Compare commits

..
Author SHA1 Message Date
Peter Steinberger b4aeebafda fix: cover slack open policy gating (#1563) (thanks @itsjaydesu) 2026-01-24 07:05:31 +00:00
Jay Winder 1f6badb106 fix(slack): apply open policy consistently to slash commands
Address reviewer feedback: slash commands now use the same
hasExplicitConfig check as regular messages, so unlisted
channels are allowed under groupPolicy: "open" for both
message handling and slash commands.
2026-01-24 06:56:19 +00:00
Jay Winder 285f9efcf2 fix: groupPolicy: "open" ignored when channel-specific config exists
## Summary

Fix Slack `groupPolicy: "open"` to allow unlisted channels even when `channels.slack.channels` contains custom entries.

## Problem

When `groupPolicy` is set to `"open"`, the bot should respond in **any channel** it's invited to. However, if `channels.slack.channels` contains *any* entries—even just one channel with a custom system prompt—the open policy is ignored. Only explicitly listed channels receive responses; all others get an ephemeral "This channel is not allowed" error.

### Example config

```json
{
  "channels": {
    "slack": {
      "groupPolicy": "open",
      "channels": {
        "C0123456789": { "systemPrompt": "Custom prompt for this channel" }
      }
    }
  }
}
```

With this config, the bot only responds in `C0123456789`. Messages in any other channel are blocked—even though the policy is `"open"`.

## Root Cause

In `src/slack/monitor/context.ts`, `isChannelAllowed()` has two sequential checks:

1. `isSlackChannelAllowedByPolicy()` — correctly returns `true` for open policy
2. A secondary `!channelAllowed` check — was blocking channels when `resolveSlackChannelConfig()` returned `{ allowed: false }` for unlisted channels

The second check conflated "channel not in config" with "channel explicitly denied."

## Fix

Use `matchSource` to distinguish explicit denial from absence of config:

```ts
const hasExplicitConfig = Boolean(channelConfig?.matchSource);
if (!channelAllowed && (params.groupPolicy !== "open" || hasExplicitConfig)) {
  return false;
}
```

When `matchSource` is undefined, the channel has no explicit config entry and should be allowed under open policy.

## Behavior After Fix

| Scenario | Result |
|----------|--------|
| `groupPolicy: "open"`, channel unlisted | ✅ Allowed |
| `groupPolicy: "open"`, channel explicitly denied (`allow: false`) | ❌ Blocked |
| `groupPolicy: "open"`, channel with custom config | ✅ Allowed |
| `groupPolicy: "allowlist"`, channel unlisted | ❌ Blocked |

## Test Plan

- [x] Open policy + unlisted channel → allowed
- [x] Open policy + explicitly denied channel → blocked
- [x] Allowlist policy + unlisted channel → blocked
- [x] Allowlist policy + listed channel → allowed
2026-01-24 06:56:19 +00:00
13 changed files with 402 additions and 364 deletions

No files matched your search

+1 -2
View File
@@ -19,8 +19,8 @@ Docs: https://docs.clawd.bot
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.
### Fixes
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
- Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)
- CLI: normalize Windows argv to drop duplicate node.exe entries before commands. (#1564) Thanks @Takhoffman.
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
@@ -56,7 +56,6 @@ Docs: https://docs.clawd.bot
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.
- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)
- TUI: track active run ids from chat events so tool/lifecycle updates show for non-TUI runs. (#1567) Thanks @vignesh07.
## 2026.1.22
+13 -6
View File
@@ -90,10 +90,16 @@ function safeJsonStringify(value: unknown): string | null {
}
}
function formatError(error: unknown): string {
function formatError(error: unknown): string | undefined {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
if (error instanceof Error) return error.stack ?? error.message;
return safeJsonStringify(error) ?? "[unknown error]";
if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") {
return String(error);
}
if (error && typeof error === "object") {
return safeJsonStringify(error) ?? "unknown error";
}
return undefined;
}
function digest(value: unknown): string | undefined {
@@ -178,13 +184,14 @@ export function createAnthropicPayloadLogger(params: {
const recordUsage: AnthropicPayloadLogger["recordUsage"] = (messages, error) => {
const usage = findLastAssistantUsage(messages);
const errorMessage = formatError(error);
if (!usage) {
if (error) {
if (errorMessage) {
record({
...base,
ts: new Date().toISOString(),
stage: "usage",
error: formatError(error),
error: errorMessage,
});
}
return;
@@ -194,7 +201,7 @@ export function createAnthropicPayloadLogger(params: {
ts: new Date().toISOString(),
stage: "usage",
usage,
error: error ? formatError(error) : undefined,
error: errorMessage,
});
log.info("anthropic usage", {
runId: params.runId,
+46 -2
View File
@@ -1,3 +1,4 @@
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
@@ -11,7 +12,6 @@ import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.
import { enableConsoleCapture } from "../logging.js";
import { getPrimaryCommand, hasHelpOrVersion } from "./argv.js";
import { tryRouteCli } from "./route.js";
import { normalizeWindowsArgv } from "./windows-argv.js";
export function rewriteUpdateFlagArgv(argv: string[]): string[] {
const index = argv.indexOf("--update");
@@ -23,7 +23,7 @@ export function rewriteUpdateFlagArgv(argv: string[]): string[] {
}
export async function runCli(argv: string[] = process.argv) {
const normalizedArgv = normalizeWindowsArgv(argv);
const normalizedArgv = stripWindowsNodeExec(argv);
loadDotEnv({ quiet: true });
normalizeEnv();
ensureClawdbotCliOnPath();
@@ -59,6 +59,50 @@ export async function runCli(argv: string[] = process.argv) {
await program.parseAsync(parseArgv);
}
function stripWindowsNodeExec(argv: string[]): string[] {
if (process.platform !== "win32") return argv;
const stripControlChars = (value: string): string => {
let out = "";
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i);
if (code >= 32 && code !== 127) {
out += value[i];
}
}
return out;
};
const normalizeArg = (value: string): string =>
stripControlChars(value)
.replace(/^['"]+|['"]+$/g, "")
.trim();
const normalizeCandidate = (value: string): string =>
normalizeArg(value).replace(/^\\\\\\?\\/, "");
const execPath = normalizeCandidate(process.execPath);
const execPathLower = execPath.toLowerCase();
const execBase = path.basename(execPath).toLowerCase();
const isExecPath = (value: string | undefined): boolean => {
if (!value) return false;
const lower = normalizeCandidate(value).toLowerCase();
return (
lower === execPathLower ||
path.basename(lower) === execBase ||
lower.endsWith("\\node.exe") ||
lower.endsWith("/node.exe") ||
lower.includes("node.exe")
);
};
const filtered = argv.filter((arg, index) => index === 0 || !isExecPath(arg));
if (filtered.length < 3) return filtered;
const cleaned = [...filtered];
if (isExecPath(cleaned[1])) {
cleaned.splice(1, 1);
}
if (isExecPath(cleaned[2])) {
cleaned.splice(2, 1);
}
return cleaned;
}
export function isCliMainModule(): boolean {
return isMainModule({ currentFile: fileURLToPath(import.meta.url) });
}
-42
View File
@@ -1,42 +0,0 @@
import { describe, expect, it } from "vitest";
import { normalizeWindowsArgv } from "./windows-argv.js";
describe("normalizeWindowsArgv", () => {
const execPath = "C:\\Program Files\\nodejs\\node.exe";
const scriptPath = "C:\\clawdbot\\dist\\entry.js";
it("returns argv unchanged on non-windows platforms", () => {
const argv = [execPath, scriptPath, "status"];
expect(normalizeWindowsArgv(argv, { platform: "darwin", execPath })).toBe(argv);
});
it("removes duplicate node exec at argv[1]", () => {
const argv = [execPath, execPath, scriptPath, "status"];
expect(normalizeWindowsArgv(argv, { platform: "win32", execPath })).toEqual([
execPath,
scriptPath,
"status",
]);
});
it("removes duplicate node exec at argv[2]", () => {
const argv = [execPath, scriptPath, execPath, "gateway", "run"];
expect(normalizeWindowsArgv(argv, { platform: "win32", execPath })).toEqual([
execPath,
scriptPath,
"gateway",
"run",
]);
});
it("keeps url arguments that contain node.exe", () => {
const argv = [execPath, scriptPath, "send", "https://example.com/node.exe"];
expect(normalizeWindowsArgv(argv, { platform: "win32", execPath })).toEqual(argv);
});
it("keeps node.exe paths after the command", () => {
const argv = [execPath, scriptPath, "send", "C:\\Program Files\\nodejs\\node.exe"];
expect(normalizeWindowsArgv(argv, { platform: "win32", execPath })).toEqual(argv);
});
});
-55
View File
@@ -1,55 +0,0 @@
import path from "node:path";
import process from "node:process";
type WindowsArgvOptions = {
platform?: NodeJS.Platform;
execPath?: string;
};
export function normalizeWindowsArgv(
argv: string[],
{ platform = process.platform, execPath = process.execPath }: WindowsArgvOptions = {},
): string[] {
if (platform !== "win32") return argv;
if (argv.length < 2) return argv;
const stripControlChars = (value: string): string => {
let out = "";
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i);
if (code >= 32 && code !== 127) {
out += value[i];
}
}
return out;
};
const normalizeArg = (value: string): string =>
stripControlChars(value)
.replace(/^['"]+|['"]+$/g, "")
.trim();
const normalizeCandidate = (value: string): string =>
normalizeArg(value).replace(/^\\\\\\?\\/, "");
const execPathNormalized = normalizeCandidate(execPath);
const execPathLower = execPathNormalized.toLowerCase();
const execBaseLower = path.basename(execPathLower);
const isNodeExecPath = (value: string | undefined): boolean => {
if (!value) return false;
const normalized = normalizeCandidate(value);
if (!normalized) return false;
if (normalized.includes("://")) return false;
const lower = normalized.toLowerCase();
if (lower === execPathLower || lower === execBaseLower) return true;
if (!lower.endsWith("node.exe")) return false;
return path.isAbsolute(normalized) || normalized.includes("\\") || normalized.includes("/");
};
const next = [...argv];
for (let i = 1; i <= 2 && i < next.length; ) {
if (isNodeExecPath(next[i])) {
next.splice(i, 1);
continue;
}
i += 1;
}
return next;
}
+60 -1
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import path from "node:path";
import process from "node:process";
import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js";
import { normalizeWindowsArgv } from "./cli/windows-argv.js";
import { isTruthyEnvValue } from "./infra/env.js";
import { attachChildProcessBridge } from "./process/child-process-bridge.js";
@@ -57,6 +57,65 @@ function ensureExperimentalWarningSuppressed(): boolean {
return true;
}
function normalizeWindowsArgv(argv: string[]): string[] {
if (process.platform !== "win32") return argv;
if (argv.length < 2) return argv;
const stripControlChars = (value: string): string => {
let out = "";
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i);
if (code >= 32 && code !== 127) {
out += value[i];
}
}
return out;
};
const normalizeArg = (value: string): string =>
stripControlChars(value)
.replace(/^['"]+|['"]+$/g, "")
.trim();
const normalizeCandidate = (value: string): string =>
normalizeArg(value).replace(/^\\\\\\?\\/, "");
const execPath = normalizeCandidate(process.execPath);
const execPathLower = execPath.toLowerCase();
const execBase = path.basename(execPath).toLowerCase();
const isExecPath = (value: string | undefined): boolean => {
if (!value) return false;
const lower = normalizeCandidate(value).toLowerCase();
return (
lower === execPathLower ||
path.basename(lower) === execBase ||
lower.endsWith("\\node.exe") ||
lower.endsWith("/node.exe") ||
lower.includes("node.exe")
);
};
const next = [...argv];
for (let i = 1; i <= 3 && i < next.length; ) {
if (isExecPath(next[i])) {
next.splice(i, 1);
continue;
}
i += 1;
}
const filtered = next.filter((arg, index) => index === 0 || !isExecPath(arg));
if (filtered.length < 3) return filtered;
const cleaned = [...filtered];
for (let i = 2; i < cleaned.length; ) {
const arg = cleaned[i];
if (!arg || arg.startsWith("-")) {
i += 1;
continue;
}
if (isExecPath(arg)) {
cleaned.splice(i, 1);
continue;
}
break;
}
return cleaned;
}
process.argv = normalizeWindowsArgv(process.argv);
if (!ensureExperimentalWarningSuppressed()) {
+7 -15
View File
@@ -194,22 +194,14 @@ function sanitizeArgs(args: string | undefined): string | undefined {
return args.slice(0, MAX_ARGS_LENGTH);
}
const stripControlChars = (value: string): string => {
let out = "";
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i);
if (code === 9 || code === 10) {
out += value[i];
continue;
}
if (code < 32 || code === 127) continue;
out += value[i];
}
return out;
};
// Remove control characters (except newlines and tabs which may be intentional)
return stripControlChars(args);
let sanitized = "";
for (const char of args) {
const code = char.charCodeAt(0);
const isControl = (code <= 0x1f && code !== 0x09 && code !== 0x0a) || code === 0x7f;
if (!isControl) sanitized += char;
}
return sanitized;
}
/**
+58
View File
@@ -60,3 +60,61 @@ describe("resolveSlackSystemEventSessionKey", () => {
);
});
});
describe("isChannelAllowed with groupPolicy and channelsConfig", () => {
it("allows unlisted channels when groupPolicy is open even with channelsConfig entries", () => {
// Bug fix: when groupPolicy="open" and channels has some entries,
// unlisted channels should still be allowed (not blocked)
const ctx = createSlackMonitorContext({
...baseParams(),
groupPolicy: "open",
channelsConfig: {
C_LISTED: { requireMention: true },
},
});
// Listed channel should be allowed
expect(ctx.isChannelAllowed({ channelId: "C_LISTED", channelType: "channel" })).toBe(true);
// Unlisted channel should ALSO be allowed when policy is "open"
expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(true);
});
it("blocks unlisted channels when groupPolicy is allowlist", () => {
const ctx = createSlackMonitorContext({
...baseParams(),
groupPolicy: "allowlist",
channelsConfig: {
C_LISTED: { requireMention: true },
},
});
// Listed channel should be allowed
expect(ctx.isChannelAllowed({ channelId: "C_LISTED", channelType: "channel" })).toBe(true);
// Unlisted channel should be blocked when policy is "allowlist"
expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(false);
});
it("blocks explicitly denied channels even when groupPolicy is open", () => {
const ctx = createSlackMonitorContext({
...baseParams(),
groupPolicy: "open",
channelsConfig: {
C_ALLOWED: { allow: true },
C_DENIED: { allow: false },
},
});
// Explicitly allowed channel
expect(ctx.isChannelAllowed({ channelId: "C_ALLOWED", channelType: "channel" })).toBe(true);
// Explicitly denied channel should be blocked even with open policy
expect(ctx.isChannelAllowed({ channelId: "C_DENIED", channelType: "channel" })).toBe(false);
// Unlisted channel should be allowed with open policy
expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(true);
});
it("allows all channels when groupPolicy is open and channelsConfig is empty", () => {
const ctx = createSlackMonitorContext({
...baseParams(),
groupPolicy: "open",
channelsConfig: undefined,
});
expect(ctx.isChannelAllowed({ channelId: "C_ANY", channelType: "channel" })).toBe(true);
});
});
+5 -1
View File
@@ -327,7 +327,11 @@ export function createSlackMonitorContext(params: {
);
return false;
}
if (!channelAllowed) {
// When groupPolicy is "open", only block channels that are EXPLICITLY denied
// (i.e., have a matching config entry with allow:false). Channels not in the
// config (matchSource undefined) should be allowed under open policy.
const hasExplicitConfig = Boolean(channelConfig?.matchSource);
if (!channelAllowed && (params.groupPolicy !== "open" || hasExplicitConfig)) {
logVerbose(`slack: drop channel ${p.channelId} (${channelMatchMeta})`);
return false;
}
+186
View File
@@ -0,0 +1,186 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerSlackMonitorSlashCommands } from "./slash.js";
const dispatchMock = vi.fn();
const readAllowFromStoreMock = vi.fn();
const upsertPairingRequestMock = vi.fn();
const resolveAgentRouteMock = vi.fn();
vi.mock("../../auto-reply/reply/provider-dispatcher.js", () => ({
dispatchReplyWithDispatcher: (...args: unknown[]) => dispatchMock(...args),
}));
vi.mock("../../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
}));
vi.mock("../../routing/resolve-route.js", () => ({
resolveAgentRoute: (...args: unknown[]) => resolveAgentRouteMock(...args),
}));
vi.mock("../../agents/identity.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../agents/identity.js")>();
return {
...actual,
resolveEffectiveMessagesConfig: () => ({ responsePrefix: "" }),
};
});
function createHarness(overrides?: {
groupPolicy?: "open" | "allowlist";
channelsConfig?: Record<string, { allow?: boolean; requireMention?: boolean }>;
channelId?: string;
channelName?: string;
}) {
const commands = new Map<unknown, (args: unknown) => Promise<void>>();
const postEphemeral = vi.fn().mockResolvedValue({ ok: true });
const app = {
client: { chat: { postEphemeral } },
command: (name: unknown, handler: (args: unknown) => Promise<void>) => {
commands.set(name, handler);
},
};
const channelId = overrides?.channelId ?? "C_UNLISTED";
const channelName = overrides?.channelName ?? "unlisted";
const ctx = {
cfg: { commands: { native: false } },
runtime: {},
botToken: "bot-token",
botUserId: "bot",
teamId: "T1",
allowFrom: ["*"],
dmEnabled: true,
dmPolicy: "open",
groupDmEnabled: false,
groupDmChannels: [],
defaultRequireMention: true,
groupPolicy: overrides?.groupPolicy ?? "open",
useAccessGroups: true,
channelsConfig: overrides?.channelsConfig,
slashCommand: { enabled: true, name: "clawd", ephemeral: true, sessionPrefix: "slack:slash" },
textLimit: 4000,
app,
isChannelAllowed: () => true,
resolveChannelName: async () => ({ name: channelName, type: "channel" }),
resolveUserName: async () => ({ name: "Ada" }),
} as unknown;
const account = { accountId: "acct", config: { commands: { native: false } } } as unknown;
return { commands, ctx, account, postEphemeral, channelId, channelName };
}
beforeEach(() => {
dispatchMock.mockReset().mockResolvedValue({ counts: { final: 1, tool: 0, block: 0 } });
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
resolveAgentRouteMock.mockReset().mockReturnValue({
agentId: "main",
sessionKey: "session:1",
accountId: "acct",
});
});
describe("slack slash commands channel policy", () => {
it("allows unlisted channels when groupPolicy is open", async () => {
const { commands, ctx, account, channelId, channelName } = createHarness({
groupPolicy: "open",
channelsConfig: { C_LISTED: { requireMention: true } },
channelId: "C_UNLISTED",
channelName: "unlisted",
});
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
const handler = [...commands.values()][0];
if (!handler) throw new Error("Missing slash handler");
const respond = vi.fn().mockResolvedValue(undefined);
await handler({
command: {
user_id: "U1",
user_name: "Ada",
channel_id: channelId,
channel_name: channelName,
text: "hello",
trigger_id: "t1",
},
ack: vi.fn().mockResolvedValue(undefined),
respond,
});
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(respond).not.toHaveBeenCalledWith(
expect.objectContaining({ text: "This channel is not allowed." }),
);
});
it("blocks explicitly denied channels when groupPolicy is open", async () => {
const { commands, ctx, account, channelId, channelName } = createHarness({
groupPolicy: "open",
channelsConfig: { C_DENIED: { allow: false } },
channelId: "C_DENIED",
channelName: "denied",
});
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
const handler = [...commands.values()][0];
if (!handler) throw new Error("Missing slash handler");
const respond = vi.fn().mockResolvedValue(undefined);
await handler({
command: {
user_id: "U1",
user_name: "Ada",
channel_id: channelId,
channel_name: channelName,
text: "hello",
trigger_id: "t1",
},
ack: vi.fn().mockResolvedValue(undefined),
respond,
});
expect(dispatchMock).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith({
text: "This channel is not allowed.",
response_type: "ephemeral",
});
});
it("blocks unlisted channels when groupPolicy is allowlist", async () => {
const { commands, ctx, account, channelId, channelName } = createHarness({
groupPolicy: "allowlist",
channelsConfig: { C_LISTED: { requireMention: true } },
channelId: "C_UNLISTED",
channelName: "unlisted",
});
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
const handler = [...commands.values()][0];
if (!handler) throw new Error("Missing slash handler");
const respond = vi.fn().mockResolvedValue(undefined);
await handler({
command: {
user_id: "U1",
user_name: "Ada",
channel_id: channelId,
channel_name: channelName,
text: "hello",
trigger_id: "t1",
},
ack: vi.fn().mockResolvedValue(undefined),
respond,
});
expect(dispatchMock).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith({
text: "This channel is not allowed.",
response_type: "ephemeral",
});
});
});
+12 -9
View File
@@ -262,8 +262,7 @@ export function registerSlackMonitorSlashCommands(params: {
groupPolicy: ctx.groupPolicy,
channelAllowlistConfigured,
channelAllowed,
}) ||
!channelAllowed
})
) {
await respond({
text: "This channel is not allowed.",
@@ -271,13 +270,17 @@ export function registerSlackMonitorSlashCommands(params: {
});
return;
}
}
if (ctx.useAccessGroups && channelConfig?.allowed === false) {
await respond({
text: "This channel is not allowed.",
response_type: "ephemeral",
});
return;
// When groupPolicy is "open", only block channels that are EXPLICITLY denied
// (i.e., have a matching config entry with allow:false). Channels not in the
// config (matchSource undefined) should be allowed under open policy.
const hasExplicitConfig = Boolean(channelConfig?.matchSource);
if (!channelAllowed && (ctx.groupPolicy !== "open" || hasExplicitConfig)) {
await respond({
text: "This channel is not allowed.",
response_type: "ephemeral",
});
return;
}
}
}
-187
View File
@@ -1,187 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { createEventHandlers } from "./tui-event-handlers.js";
import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
type MockChatLog = {
startTool: ReturnType<typeof vi.fn>;
updateToolResult: ReturnType<typeof vi.fn>;
addSystem: ReturnType<typeof vi.fn>;
updateAssistant: ReturnType<typeof vi.fn>;
finalizeAssistant: ReturnType<typeof vi.fn>;
};
describe("tui-event-handlers: handleAgentEvent", () => {
const makeState = (overrides?: Partial<TuiStateAccess>): TuiStateAccess => ({
agentDefaultId: "main",
sessionMainKey: "agent:main:main",
sessionScope: "global",
agents: [],
currentAgentId: "main",
currentSessionKey: "agent:main:main",
currentSessionId: "session-1",
activeChatRunId: "run-1",
historyLoaded: true,
sessionInfo: {},
initialSessionApplied: true,
isConnected: true,
autoMessageSent: false,
toolsExpanded: false,
showThinking: false,
connectionStatus: "connected",
activityStatus: "idle",
statusTimeout: null,
lastCtrlCAt: 0,
...overrides,
});
const makeContext = (state: TuiStateAccess) => {
const chatLog: MockChatLog = {
startTool: vi.fn(),
updateToolResult: vi.fn(),
addSystem: vi.fn(),
updateAssistant: vi.fn(),
finalizeAssistant: vi.fn(),
};
const tui = { requestRender: vi.fn() };
const setActivityStatus = vi.fn();
return { chatLog, tui, state, setActivityStatus };
};
it("processes tool events when runId matches activeChatRunId (even if sessionId differs)", () => {
const state = makeState({ currentSessionId: "session-xyz", activeChatRunId: "run-123" });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
// Casts are fine here: TUI runtime shape is larger than we need in unit tests.
chatLog: chatLog as any,
tui: tui as any,
state,
setActivityStatus,
});
const evt: AgentEvent = {
runId: "run-123",
stream: "tool",
data: {
phase: "start",
toolCallId: "tc1",
name: "exec",
args: { command: "echo hi" },
},
};
handleAgentEvent(evt);
expect(chatLog.startTool).toHaveBeenCalledWith("tc1", "exec", { command: "echo hi" });
expect(tui.requestRender).toHaveBeenCalledTimes(1);
});
it("ignores tool events when runId does not match activeChatRunId", () => {
const state = makeState({ activeChatRunId: "run-1" });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
chatLog: chatLog as any,
tui: tui as any,
state,
setActivityStatus,
});
const evt: AgentEvent = {
runId: "run-2",
stream: "tool",
data: { phase: "start", toolCallId: "tc1", name: "exec" },
};
handleAgentEvent(evt);
expect(chatLog.startTool).not.toHaveBeenCalled();
expect(chatLog.updateToolResult).not.toHaveBeenCalled();
expect(tui.requestRender).not.toHaveBeenCalled();
});
it("processes lifecycle events when runId matches activeChatRunId", () => {
const state = makeState({ activeChatRunId: "run-9" });
const { tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
chatLog: { startTool: vi.fn(), updateToolResult: vi.fn() } as any,
tui: tui as any,
state,
setActivityStatus,
});
const evt: AgentEvent = {
runId: "run-9",
stream: "lifecycle",
data: { phase: "start" },
};
handleAgentEvent(evt);
expect(setActivityStatus).toHaveBeenCalledWith("running");
expect(tui.requestRender).toHaveBeenCalledTimes(1);
});
it("captures runId from chat events when activeChatRunId is unset", () => {
const state = makeState({ activeChatRunId: null });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
chatLog: chatLog as any,
tui: tui as any,
state,
setActivityStatus,
});
const chatEvt: ChatEvent = {
runId: "run-42",
sessionKey: state.currentSessionKey,
state: "delta",
message: { content: "hello" },
};
handleChatEvent(chatEvt);
expect(state.activeChatRunId).toBe("run-42");
const agentEvt: AgentEvent = {
runId: "run-42",
stream: "tool",
data: { phase: "start", toolCallId: "tc1", name: "exec" },
};
handleAgentEvent(agentEvt);
expect(chatLog.startTool).toHaveBeenCalledWith("tc1", "exec", undefined);
});
it("clears run mapping when the session changes", () => {
const state = makeState({ activeChatRunId: null });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
chatLog: chatLog as any,
tui: tui as any,
state,
setActivityStatus,
});
handleChatEvent({
runId: "run-old",
sessionKey: state.currentSessionKey,
state: "delta",
message: { content: "hello" },
});
state.currentSessionKey = "agent:main:other";
state.activeChatRunId = null;
tui.requestRender.mockClear();
handleAgentEvent({
runId: "run-old",
stream: "tool",
data: { phase: "start", toolCallId: "tc2", name: "exec" },
});
expect(chatLog.startTool).not.toHaveBeenCalled();
expect(tui.requestRender).not.toHaveBeenCalled();
});
});
+14 -44
View File
@@ -15,58 +15,33 @@ type EventHandlerContext = {
export function createEventHandlers(context: EventHandlerContext) {
const { chatLog, tui, state, setActivityStatus, refreshSessionInfo } = context;
const finalizedRuns = new Map<string, number>();
const sessionRuns = new Map<string, number>();
let streamAssembler = new TuiStreamAssembler();
let lastSessionKey = state.currentSessionKey;
const pruneRunMap = (runs: Map<string, number>) => {
if (runs.size <= 200) return;
const keepUntil = Date.now() - 10 * 60 * 1000;
for (const [key, ts] of runs) {
if (runs.size <= 150) break;
if (ts < keepUntil) runs.delete(key);
}
if (runs.size > 200) {
for (const key of runs.keys()) {
runs.delete(key);
if (runs.size <= 150) break;
}
}
};
const syncSessionKey = () => {
if (state.currentSessionKey === lastSessionKey) return;
lastSessionKey = state.currentSessionKey;
finalizedRuns.clear();
sessionRuns.clear();
streamAssembler = new TuiStreamAssembler();
};
const noteSessionRun = (runId: string) => {
sessionRuns.set(runId, Date.now());
pruneRunMap(sessionRuns);
};
const streamAssembler = new TuiStreamAssembler();
const noteFinalizedRun = (runId: string) => {
finalizedRuns.set(runId, Date.now());
sessionRuns.delete(runId);
streamAssembler.drop(runId);
pruneRunMap(finalizedRuns);
if (finalizedRuns.size <= 200) return;
const keepUntil = Date.now() - 10 * 60 * 1000;
for (const [key, ts] of finalizedRuns) {
if (finalizedRuns.size <= 150) break;
if (ts < keepUntil) finalizedRuns.delete(key);
}
if (finalizedRuns.size > 200) {
for (const key of finalizedRuns.keys()) {
finalizedRuns.delete(key);
if (finalizedRuns.size <= 150) break;
}
}
};
const handleChatEvent = (payload: unknown) => {
if (!payload || typeof payload !== "object") return;
const evt = payload as ChatEvent;
syncSessionKey();
if (evt.sessionKey !== state.currentSessionKey) return;
if (finalizedRuns.has(evt.runId)) {
if (evt.state === "delta") return;
if (evt.state === "final") return;
}
noteSessionRun(evt.runId);
if (!state.activeChatRunId) {
state.activeChatRunId = evt.runId;
}
if (evt.state === "delta") {
const displayText = streamAssembler.ingestDelta(evt.runId, evt.message, state.showThinking);
if (!displayText) return;
@@ -103,7 +78,6 @@ export function createEventHandlers(context: EventHandlerContext) {
if (evt.state === "aborted") {
chatLog.addSystem("run aborted");
streamAssembler.drop(evt.runId);
sessionRuns.delete(evt.runId);
state.activeChatRunId = null;
setActivityStatus("aborted");
void refreshSessionInfo?.();
@@ -111,7 +85,6 @@ export function createEventHandlers(context: EventHandlerContext) {
if (evt.state === "error") {
chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
streamAssembler.drop(evt.runId);
sessionRuns.delete(evt.runId);
state.activeChatRunId = null;
setActivityStatus("error");
void refreshSessionInfo?.();
@@ -122,10 +95,7 @@ export function createEventHandlers(context: EventHandlerContext) {
const handleAgentEvent = (payload: unknown) => {
if (!payload || typeof payload !== "object") return;
const evt = payload as AgentEvent;
syncSessionKey();
// Agent events (tool streaming, lifecycle) are emitted per-run. Filter against the
// active chat run id, not the session id.
if (evt.runId !== state.activeChatRunId && !sessionRuns.has(evt.runId)) return;
if (!state.currentSessionId || evt.runId !== state.currentSessionId) return;
if (evt.stream === "tool") {
const data = evt.data ?? {};
const phase = asString(data.phase, "");