diff --git a/src/auto-reply/reply.triggers.group-intro-prompts.test.ts b/src/auto-reply/reply.triggers.group-intro-prompts.test.ts index 04b9feabb..9bfb463c3 100644 --- a/src/auto-reply/reply.triggers.group-intro-prompts.test.ts +++ b/src/auto-reply/reply.triggers.group-intro-prompts.test.ts @@ -2,30 +2,30 @@ import { beforeAll, describe, expect, it } from "vitest"; import { getRunEmbeddedPiAgentMock, installTriggerHandlingE2eTestHooks, + loadGetReplyFromConfig, makeCfg, + mockRunEmbeddedPiAgentOk, withTempHome, } from "./reply.triggers.trigger-handling.test-harness.js"; let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; beforeAll(async () => { - ({ getReplyFromConfig } = await import("./reply.js")); + getReplyFromConfig = await loadGetReplyFromConfig(); }); installTriggerHandlingE2eTestHooks(); +function getLastExtraSystemPrompt() { + return getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; +} + describe("group intro prompts", () => { const groupParticipationNote = "Be a good group participant: mostly lurk and follow the conversation; reply only when directly addressed or you can add clear value. Emoji reactions are welcome when available. Write like a human. Avoid Markdown tables. Don't type literal \\n sequences; use real line breaks sparingly."; it("labels Discord groups using the surface metadata", async () => { await withTempHome(async (home) => { - getRunEmbeddedPiAgentMock().mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); + mockRunEmbeddedPiAgentOk(); await getReplyFromConfig( { @@ -42,8 +42,7 @@ describe("group intro prompts", () => { ); expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); - const extraSystemPrompt = - getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; + const extraSystemPrompt = getLastExtraSystemPrompt(); expect(extraSystemPrompt).toContain('"channel": "discord"'); expect(extraSystemPrompt).toContain( `You are in the Discord group chat "Release Squad". Participants: Alice, Bob.`, @@ -55,13 +54,7 @@ describe("group intro prompts", () => { }); it("keeps WhatsApp labeling for WhatsApp group chats", async () => { await withTempHome(async (home) => { - getRunEmbeddedPiAgentMock().mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); + mockRunEmbeddedPiAgentOk(); await getReplyFromConfig( { @@ -77,8 +70,7 @@ describe("group intro prompts", () => { ); expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); - const extraSystemPrompt = - getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; + const extraSystemPrompt = getLastExtraSystemPrompt(); expect(extraSystemPrompt).toContain('"channel": "whatsapp"'); expect(extraSystemPrompt).toContain(`You are in the WhatsApp group chat "Ops".`); expect(extraSystemPrompt).toContain( @@ -91,13 +83,7 @@ describe("group intro prompts", () => { }); it("labels Telegram groups using their own surface", async () => { await withTempHome(async (home) => { - getRunEmbeddedPiAgentMock().mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); + mockRunEmbeddedPiAgentOk(); await getReplyFromConfig( { @@ -113,8 +99,7 @@ describe("group intro prompts", () => { ); expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); - const extraSystemPrompt = - getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; + const extraSystemPrompt = getLastExtraSystemPrompt(); expect(extraSystemPrompt).toContain('"channel": "telegram"'); expect(extraSystemPrompt).toContain(`You are in the Telegram group chat "Dev Chat".`); expect(extraSystemPrompt).toContain( diff --git a/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts b/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts index ec25ca423..8276a3cd8 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts @@ -3,6 +3,7 @@ import { createBlockReplyCollector, getRunEmbeddedPiAgentMock, installTriggerHandlingE2eTestHooks, + loadGetReplyFromConfig, makeCfg, mockRunEmbeddedPiAgentOk, withTempHome, @@ -10,11 +11,40 @@ import { let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; beforeAll(async () => { - ({ getReplyFromConfig } = await import("./reply.js")); + getReplyFromConfig = await loadGetReplyFromConfig(); }); installTriggerHandlingE2eTestHooks(); +async function expectUnauthorizedCommandDropped(home: string, body: "/status" | "/whoami") { + const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + const baseCfg = makeCfg(home); + const cfg = { + ...baseCfg, + channels: { + ...baseCfg.channels, + whatsapp: { + allowFrom: ["+1000"], + }, + }, + }; + + const res = await getReplyFromConfig( + { + Body: body, + From: "+2001", + To: "+2000", + Provider: "whatsapp", + SenderE164: "+2001", + }, + {}, + cfg, + ); + + expect(res).toBeUndefined(); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); +} + describe("trigger handling", () => { it("handles inline /commands and strips it before the agent", async () => { await withTempHome(async (home) => { @@ -69,63 +99,13 @@ describe("trigger handling", () => { it("drops /status for unauthorized senders", async () => { await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - const baseCfg = makeCfg(home); - const cfg = { - ...baseCfg, - channels: { - ...baseCfg.channels, - whatsapp: { - allowFrom: ["+1000"], - }, - }, - }; - - const res = await getReplyFromConfig( - { - Body: "/status", - From: "+2001", - To: "+2000", - Provider: "whatsapp", - SenderE164: "+2001", - }, - {}, - cfg, - ); - - expect(res).toBeUndefined(); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + await expectUnauthorizedCommandDropped(home, "/status"); }); }); it("drops /whoami for unauthorized senders", async () => { await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - const baseCfg = makeCfg(home); - const cfg = { - ...baseCfg, - channels: { - ...baseCfg.channels, - whatsapp: { - allowFrom: ["+1000"], - }, - }, - }; - - const res = await getReplyFromConfig( - { - Body: "/whoami", - From: "+2001", - To: "+2000", - Provider: "whatsapp", - SenderE164: "+2001", - }, - {}, - cfg, - ); - - expect(res).toBeUndefined(); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + await expectUnauthorizedCommandDropped(home, "/whoami"); }); }); }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.test.ts b/src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.test.ts index 115bf7e77..385df13e6 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.test.ts @@ -6,13 +6,15 @@ import { getCompactEmbeddedPiSessionMock, getRunEmbeddedPiAgentMock, installTriggerHandlingE2eTestHooks, + loadGetReplyFromConfig, makeCfg, + mockRunEmbeddedPiAgentOk, withTempHome, } from "./reply.triggers.trigger-handling.test-harness.js"; let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; beforeAll(async () => { - ({ getReplyFromConfig } = await import("./reply.js")); + getReplyFromConfig = await loadGetReplyFromConfig(); }); installTriggerHandlingE2eTestHooks(); @@ -92,13 +94,7 @@ describe("trigger handling", () => { }); it("ignores think directives that only appear in the context wrapper", async () => { await withTempHome(async (home) => { - getRunEmbeddedPiAgentMock().mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); + mockRunEmbeddedPiAgentOk(); const res = await getReplyFromConfig( { @@ -127,13 +123,7 @@ describe("trigger handling", () => { }); it("does not emit directive acks for heartbeats with /think", async () => { await withTempHome(async (home) => { - getRunEmbeddedPiAgentMock().mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); + mockRunEmbeddedPiAgentOk(); const res = await getReplyFromConfig( { diff --git a/src/auto-reply/reply/abort.test.ts b/src/auto-reply/reply/abort.test.ts index c9ef99828..f5bca4b67 100644 --- a/src/auto-reply/reply/abort.test.ts +++ b/src/auto-reply/reply/abort.test.ts @@ -42,6 +42,39 @@ vi.mock("../../agents/subagent-registry.js", () => ({ })); describe("abort detection", () => { + async function writeSessionStore( + storePath: string, + sessionIdsByKey: Record, + nowMs = Date.now(), + ) { + const storeEntries = Object.fromEntries( + Object.entries(sessionIdsByKey).map(([key, sessionId]) => [ + key, + { sessionId, updatedAt: nowMs }, + ]), + ); + await fs.writeFile(storePath, JSON.stringify(storeEntries, null, 2)); + } + + async function createAbortConfig(params?: { + commandsTextEnabled?: boolean; + sessionIdsByKey?: Record; + nowMs?: number; + }) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-abort-")); + const storePath = path.join(root, "sessions.json"); + const cfg = { + session: { store: storePath }, + ...(typeof params?.commandsTextEnabled === "boolean" + ? { commands: { text: params.commandsTextEnabled } } + : {}), + } as OpenClawConfig; + if (params?.sessionIdsByKey) { + await writeSessionStore(storePath, params.sessionIdsByKey, params.nowMs); + } + return { root, storePath, cfg }; + } + async function runStopCommand(params: { cfg: OpenClawConfig; sessionKey: string; @@ -142,9 +175,7 @@ describe("abort detection", () => { }); it("fast-aborts even when text commands are disabled", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-abort-")); - const storePath = path.join(root, "sessions.json"); - const cfg = { session: { store: storePath }, commands: { text: false } } as OpenClawConfig; + const { cfg } = await createAbortConfig({ commandsTextEnabled: false }); const result = await runStopCommand({ cfg, @@ -157,24 +188,11 @@ describe("abort detection", () => { }); it("fast-abort clears queued followups and session lane", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-abort-")); - const storePath = path.join(root, "sessions.json"); - const cfg = { session: { store: storePath } } as OpenClawConfig; const sessionKey = "telegram:123"; const sessionId = "session-123"; - await fs.writeFile( - storePath, - JSON.stringify( - { - [sessionKey]: { - sessionId, - updatedAt: Date.now(), - }, - }, - null, - 2, - ), - ); + const { root, cfg } = await createAbortConfig({ + sessionIdsByKey: { [sessionKey]: sessionId }, + }); const followupRun: FollowupRun = { prompt: "queued", enqueuedAt: Date.now(), @@ -215,30 +233,16 @@ describe("abort detection", () => { }); it("fast-abort stops active subagent runs for requester session", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-abort-")); - const storePath = path.join(root, "sessions.json"); - const cfg = { session: { store: storePath } } as OpenClawConfig; const sessionKey = "telegram:parent"; const childKey = "agent:main:subagent:child-1"; const sessionId = "session-parent"; const childSessionId = "session-child"; - await fs.writeFile( - storePath, - JSON.stringify( - { - [sessionKey]: { - sessionId, - updatedAt: Date.now(), - }, - [childKey]: { - sessionId: childSessionId, - updatedAt: Date.now(), - }, - }, - null, - 2, - ), - ); + const { cfg } = await createAbortConfig({ + sessionIdsByKey: { + [sessionKey]: sessionId, + [childKey]: childSessionId, + }, + }); subagentRegistryMocks.listSubagentRunsForRequester.mockReturnValueOnce([ { @@ -264,36 +268,19 @@ describe("abort detection", () => { }); it("cascade stop kills depth-2 children when stopping depth-1 agent", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-abort-")); - const storePath = path.join(root, "sessions.json"); - const cfg = { session: { store: storePath } } as OpenClawConfig; const sessionKey = "telegram:parent"; const depth1Key = "agent:main:subagent:child-1"; const depth2Key = "agent:main:subagent:child-1:subagent:grandchild-1"; const sessionId = "session-parent"; const depth1SessionId = "session-child"; const depth2SessionId = "session-grandchild"; - await fs.writeFile( - storePath, - JSON.stringify( - { - [sessionKey]: { - sessionId, - updatedAt: Date.now(), - }, - [depth1Key]: { - sessionId: depth1SessionId, - updatedAt: Date.now(), - }, - [depth2Key]: { - sessionId: depth2SessionId, - updatedAt: Date.now(), - }, - }, - null, - 2, - ), - ); + const { cfg } = await createAbortConfig({ + sessionIdsByKey: { + [sessionKey]: sessionId, + [depth1Key]: depth1SessionId, + [depth2Key]: depth2SessionId, + }, + }); // First call: main session lists depth-1 children // Second call (cascade): depth-1 session lists depth-2 children @@ -339,34 +326,18 @@ describe("abort detection", () => { it("cascade stop traverses ended depth-1 parents to stop active depth-2 children", async () => { subagentRegistryMocks.listSubagentRunsForRequester.mockClear(); subagentRegistryMocks.markSubagentRunTerminated.mockClear(); - const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-abort-")); - const storePath = path.join(root, "sessions.json"); - const cfg = { session: { store: storePath } } as OpenClawConfig; const sessionKey = "telegram:parent"; const depth1Key = "agent:main:subagent:child-ended"; const depth2Key = "agent:main:subagent:child-ended:subagent:grandchild-active"; const now = Date.now(); - await fs.writeFile( - storePath, - JSON.stringify( - { - [sessionKey]: { - sessionId: "session-parent", - updatedAt: now, - }, - [depth1Key]: { - sessionId: "session-child-ended", - updatedAt: now, - }, - [depth2Key]: { - sessionId: "session-grandchild-active", - updatedAt: now, - }, - }, - null, - 2, - ), - ); + const { cfg } = await createAbortConfig({ + nowMs: now, + sessionIdsByKey: { + [sessionKey]: "session-parent", + [depth1Key]: "session-child-ended", + [depth2Key]: "session-grandchild-active", + }, + }); // main -> ended depth-1 parent // depth-1 parent -> active depth-2 child diff --git a/src/auto-reply/reply/commands-allowlist.ts b/src/auto-reply/reply/commands-allowlist.ts index 7024dcd1f..8bc5efb51 100644 --- a/src/auto-reply/reply/commands-allowlist.ts +++ b/src/auto-reply/reply/commands-allowlist.ts @@ -175,6 +175,22 @@ function formatEntryList(entries: string[], resolved?: Map): str .join(", "); } +function extractConfigAllowlist(account: { + config?: { + allowFrom?: Array; + groupAllowFrom?: Array; + dmPolicy?: string; + groupPolicy?: string; + }; +}) { + return { + dmAllowFrom: (account.config?.allowFrom ?? []).map(String), + groupAllowFrom: (account.config?.groupAllowFrom ?? []).map(String), + dmPolicy: account.config?.dmPolicy, + groupPolicy: account.config?.groupPolicy, + }; +} + function resolveAccountTarget( parsed: Record, channelId: ChannelId, @@ -363,10 +379,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo if (channelId === "telegram") { const account = resolveTelegramAccount({ cfg: params.cfg, accountId }); - dmAllowFrom = (account.config.allowFrom ?? []).map(String); - groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String); - dmPolicy = account.config.dmPolicy; - groupPolicy = account.config.groupPolicy; + ({ dmAllowFrom, groupAllowFrom, dmPolicy, groupPolicy } = extractConfigAllowlist(account)); const groups = account.config.groups ?? {}; for (const [groupId, groupCfg] of Object.entries(groups)) { const entries = (groupCfg?.allowFrom ?? []).map(String).filter(Boolean); @@ -389,16 +402,10 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo groupPolicy = account.groupPolicy; } else if (channelId === "signal") { const account = resolveSignalAccount({ cfg: params.cfg, accountId }); - dmAllowFrom = (account.config.allowFrom ?? []).map(String); - groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String); - dmPolicy = account.config.dmPolicy; - groupPolicy = account.config.groupPolicy; + ({ dmAllowFrom, groupAllowFrom, dmPolicy, groupPolicy } = extractConfigAllowlist(account)); } else if (channelId === "imessage") { const account = resolveIMessageAccount({ cfg: params.cfg, accountId }); - dmAllowFrom = (account.config.allowFrom ?? []).map(String); - groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String); - dmPolicy = account.config.dmPolicy; - groupPolicy = account.config.groupPolicy; + ({ dmAllowFrom, groupAllowFrom, dmPolicy, groupPolicy } = extractConfigAllowlist(account)); } else if (channelId === "slack") { const account = resolveSlackAccount({ cfg: params.cfg, accountId }); dmAllowFrom = (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String); diff --git a/src/auto-reply/reply/commands-subagents-focus.test.ts b/src/auto-reply/reply/commands-subagents-focus.test.ts index 34183c752..8ecad26cd 100644 --- a/src/auto-reply/reply/commands-subagents-focus.test.ts +++ b/src/auto-reply/reply/commands-subagents-focus.test.ts @@ -4,6 +4,7 @@ import { resetSubagentRegistryForTests, } from "../../agents/subagent-registry.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { installSubagentsCommandCoreMocks } from "./commands-subagents.test-mocks.js"; const hoisted = vi.hoisted(() => { const callGatewayMock = vi.fn(); @@ -29,18 +30,7 @@ vi.mock("../../discord/monitor/thread-bindings.js", async (importOriginal) => { }; }); -vi.mock("../../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => ({}), - }; -}); - -// Prevent transitive import chain from reaching discord/monitor which needs https-proxy-agent. -vi.mock("../../discord/monitor/gateway-plugin.js", () => ({ - createDiscordGatewayPlugin: () => ({}), -})); +installSubagentsCommandCoreMocks(); const { handleSubagentsCommand } = await import("./commands-subagents.js"); const { buildCommandTestParams } = await import("./commands-spawn.test-harness.js"); @@ -59,6 +49,25 @@ type FakeBinding = { boundAt: number; }; +function createFakeBinding( + overrides: Pick & + Partial, +): FakeBinding { + return { + accountId: "default", + channelId: "parent-1", + boundBy: "user-1", + boundAt: Date.now(), + ...overrides, + }; +} + +function expectAgentListContainsThreadBinding(text: string, label: string, threadId: string): void { + expect(text).toContain("agents:"); + expect(text).toContain(label); + expect(text).toContain(`thread:${threadId}`); +} + function createFakeThreadBindingManager(initialBindings: FakeBinding[] = []) { const byThread = new Map( initialBindings.map((binding) => [binding.threadId, binding]), @@ -222,39 +231,27 @@ describe("/focus, /unfocus, /agents", () => { }); const fake = createFakeThreadBindingManager([ - { - accountId: "default", - channelId: "parent-1", + createFakeBinding({ threadId: "thread-1", targetKind: "subagent", targetSessionKey: "agent:main:subagent:child-1", agentId: "main", label: "child-1", - boundBy: "user-1", - boundAt: Date.now(), - }, - { - accountId: "default", - channelId: "parent-1", + }), + createFakeBinding({ threadId: "thread-2", targetKind: "acp", targetSessionKey: "agent:main:main", agentId: "codex-acp", label: "main-session", - boundBy: "user-1", - boundAt: Date.now(), - }, - { - accountId: "default", - channelId: "parent-1", + }), + createFakeBinding({ threadId: "thread-3", targetKind: "acp", targetSessionKey: "agent:codex-acp:session-2", agentId: "codex-acp", label: "codex-acp", - boundBy: "user-1", - boundAt: Date.now(), - }, + }), ]); hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager); @@ -284,17 +281,13 @@ describe("/focus, /unfocus, /agents", () => { }); const fake = createFakeThreadBindingManager([ - { - accountId: "default", - channelId: "parent-1", + createFakeBinding({ threadId: "thread-persistent-1", targetKind: "subagent", targetSessionKey: "agent:main:subagent:persistent-1", agentId: "main", label: "persistent-1", - boundBy: "user-1", - boundAt: Date.now(), - }, + }), ]); hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager); @@ -302,9 +295,7 @@ describe("/focus, /unfocus, /agents", () => { const result = await handleSubagentsCommand(params, true); const text = result?.reply?.text ?? ""; - expect(text).toContain("agents:"); - expect(text).toContain("persistent-1"); - expect(text).toContain("thread:thread-persistent-1"); + expectAgentListContainsThreadBinding(text, "persistent-1", "thread-persistent-1"); }); it("/focus is discord-only", async () => { diff --git a/src/auto-reply/reply/commands-subagents-spawn.test.ts b/src/auto-reply/reply/commands-subagents-spawn.test.ts index a339cd15b..36609bca8 100644 --- a/src/auto-reply/reply/commands-subagents-spawn.test.ts +++ b/src/auto-reply/reply/commands-subagents-spawn.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { resetSubagentRegistryForTests } from "../../agents/subagent-registry.js"; import type { SpawnSubagentResult } from "../../agents/subagent-spawn.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { installSubagentsCommandCoreMocks } from "./commands-subagents.test-mocks.js"; const hoisted = vi.hoisted(() => { const spawnSubagentDirectMock = vi.fn(); @@ -18,18 +19,7 @@ vi.mock("../../gateway/call.js", () => ({ callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), })); -vi.mock("../../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => ({}), - }; -}); - -// Prevent transitive import chain from reaching discord/monitor which needs https-proxy-agent. -vi.mock("../../discord/monitor/gateway-plugin.js", () => ({ - createDiscordGatewayPlugin: () => ({}), -})); +installSubagentsCommandCoreMocks(); // Dynamic import to ensure mocks are installed first. const { handleSubagentsCommand } = await import("./commands-subagents.js"); @@ -64,6 +54,41 @@ describe("/subagents spawn command", () => { hoisted.callGatewayMock.mockClear(); }); + async function runSpawnWithFlag( + flagSegment: string, + result: SpawnSubagentResult = acceptedResult(), + ) { + spawnSubagentDirectMock.mockResolvedValue(result); + const params = buildCommandTestParams( + `/subagents spawn beta do the thing ${flagSegment}`, + baseCfg, + ); + const commandResult = await handleSubagentsCommand(params, true); + expect(commandResult).not.toBeNull(); + expect(commandResult?.reply?.text).toContain("Spawned subagent beta"); + const [spawnParams] = spawnSubagentDirectMock.mock.calls[0]; + return spawnParams as { model?: string; thinking?: string; task?: string }; + } + + async function runSuccessfulSpawn(params?: { + commandText?: string; + context?: Record; + mutateParams?: (commandParams: ReturnType) => void; + }) { + spawnSubagentDirectMock.mockResolvedValue(acceptedResult()); + const commandParams = buildCommandTestParams( + params?.commandText ?? "/subagents spawn beta do the thing", + baseCfg, + params?.context, + ); + params?.mutateParams?.(commandParams); + const result = await handleSubagentsCommand(commandParams, true); + expect(result).not.toBeNull(); + expect(result?.reply?.text).toContain("Spawned subagent beta"); + const [spawnParams, spawnCtx] = spawnSubagentDirectMock.mock.calls[0]; + return { spawnParams, spawnCtx, commandParams, commandResult: result }; + } + it("shows usage when agentId is missing", async () => { const params = buildCommandTestParams("/subagents spawn", baseCfg); const result = await handleSubagentsCommand(params, true); @@ -82,16 +107,10 @@ describe("/subagents spawn command", () => { }); it("spawns subagent and confirms reply text and child session key", async () => { - spawnSubagentDirectMock.mockResolvedValue(acceptedResult()); - const params = buildCommandTestParams("/subagents spawn beta do the thing", baseCfg); - const result = await handleSubagentsCommand(params, true); - expect(result).not.toBeNull(); - expect(result?.reply?.text).toContain("Spawned subagent beta"); - expect(result?.reply?.text).toContain("agent:beta:subagent:test-uuid"); - expect(result?.reply?.text).toContain("run-spaw"); - + const { spawnParams, spawnCtx, commandResult } = await runSuccessfulSpawn(); + expect(commandResult?.reply?.text).toContain("agent:beta:subagent:test-uuid"); + expect(commandResult?.reply?.text).toContain("run-spaw"); expect(spawnSubagentDirectMock).toHaveBeenCalledOnce(); - const [spawnParams, spawnCtx] = spawnSubagentDirectMock.mock.calls[0]; expect(spawnParams.task).toBe("do the thing"); expect(spawnParams.agentId).toBe("beta"); expect(spawnParams.mode).toBe("run"); @@ -101,50 +120,32 @@ describe("/subagents spawn command", () => { }); it("spawns with --model flag and passes model to spawnSubagentDirect", async () => { - spawnSubagentDirectMock.mockResolvedValue(acceptedResult({ modelApplied: true })); - const params = buildCommandTestParams( - "/subagents spawn beta do the thing --model openai/gpt-4o", - baseCfg, + const spawnParams = await runSpawnWithFlag( + "--model openai/gpt-4o", + acceptedResult({ modelApplied: true }), ); - const result = await handleSubagentsCommand(params, true); - expect(result).not.toBeNull(); - expect(result?.reply?.text).toContain("Spawned subagent beta"); - - const [spawnParams] = spawnSubagentDirectMock.mock.calls[0]; expect(spawnParams.model).toBe("openai/gpt-4o"); expect(spawnParams.task).toBe("do the thing"); }); it("spawns with --thinking flag and passes thinking to spawnSubagentDirect", async () => { - spawnSubagentDirectMock.mockResolvedValue(acceptedResult()); - const params = buildCommandTestParams( - "/subagents spawn beta do the thing --thinking high", - baseCfg, - ); - const result = await handleSubagentsCommand(params, true); - expect(result).not.toBeNull(); - expect(result?.reply?.text).toContain("Spawned subagent beta"); - - const [spawnParams] = spawnSubagentDirectMock.mock.calls[0]; + const spawnParams = await runSpawnWithFlag("--thinking high"); expect(spawnParams.thinking).toBe("high"); expect(spawnParams.task).toBe("do the thing"); }); it("passes group context from session entry to spawnSubagentDirect", async () => { - spawnSubagentDirectMock.mockResolvedValue(acceptedResult()); - const params = buildCommandTestParams("/subagents spawn beta do the thing", baseCfg); - params.sessionEntry = { - sessionId: "session-main", - updatedAt: Date.now(), - groupId: "group-1", - groupChannel: "#group-channel", - space: "workspace-1", - }; - const result = await handleSubagentsCommand(params, true); - expect(result).not.toBeNull(); - expect(result?.reply?.text).toContain("Spawned subagent beta"); - - const [, spawnCtx] = spawnSubagentDirectMock.mock.calls[0]; + const { spawnCtx } = await runSuccessfulSpawn({ + mutateParams: (commandParams) => { + commandParams.sessionEntry = { + sessionId: "session-main", + updatedAt: Date.now(), + groupId: "group-1", + groupChannel: "#group-channel", + space: "workspace-1", + }; + }, + }); expect(spawnCtx).toMatchObject({ agentGroupId: "group-1", agentGroupChannel: "#group-channel", @@ -153,38 +154,32 @@ describe("/subagents spawn command", () => { }); it("prefers CommandTargetSessionKey for native /subagents spawn", async () => { - spawnSubagentDirectMock.mockResolvedValue(acceptedResult()); - const params = buildCommandTestParams("/subagents spawn beta do the thing", baseCfg, { - CommandSource: "native", - CommandTargetSessionKey: "agent:main:main", - OriginatingChannel: "discord", - OriginatingTo: "channel:12345", + const { spawnCtx } = await runSuccessfulSpawn({ + context: { + CommandSource: "native", + CommandTargetSessionKey: "agent:main:main", + OriginatingChannel: "discord", + OriginatingTo: "channel:12345", + }, + mutateParams: (commandParams) => { + commandParams.sessionKey = "agent:main:slack:slash:u1"; + }, }); - params.sessionKey = "agent:main:slack:slash:u1"; - - const result = await handleSubagentsCommand(params, true); - - expect(result).not.toBeNull(); - expect(result?.reply?.text).toContain("Spawned subagent beta"); - const [, spawnCtx] = spawnSubagentDirectMock.mock.calls[0]; expect(spawnCtx.agentSessionKey).toBe("agent:main:main"); expect(spawnCtx.agentChannel).toBe("discord"); expect(spawnCtx.agentTo).toBe("channel:12345"); }); it("falls back to OriginatingTo for agentTo when command.to is missing", async () => { - spawnSubagentDirectMock.mockResolvedValue(acceptedResult()); - const params = buildCommandTestParams("/subagents spawn beta do the thing", baseCfg, { - OriginatingTo: "channel:manual", - To: "channel:fallback-from-to", + const { spawnCtx } = await runSuccessfulSpawn({ + context: { + OriginatingTo: "channel:manual", + To: "channel:fallback-from-to", + }, + mutateParams: (commandParams) => { + commandParams.command.to = undefined; + }, }); - params.command.to = undefined; - - const result = await handleSubagentsCommand(params, true); - expect(result).not.toBeNull(); - expect(result?.reply?.text).toContain("Spawned subagent beta"); - - const [, spawnCtx] = spawnSubagentDirectMock.mock.calls[0]; expect(spawnCtx).toMatchObject({ agentTo: "channel:manual" }); }); it("returns forbidden for unauthorized cross-agent spawn", async () => { @@ -199,11 +194,8 @@ describe("/subagents spawn command", () => { }); it("allows cross-agent spawn when in allowlist", async () => { - spawnSubagentDirectMock.mockResolvedValue(acceptedResult()); - const params = buildCommandTestParams("/subagents spawn beta do the thing", baseCfg); - const result = await handleSubagentsCommand(params, true); - expect(result).not.toBeNull(); - expect(result?.reply?.text).toContain("Spawned subagent beta"); + await runSuccessfulSpawn(); + expect(spawnSubagentDirectMock).toHaveBeenCalledOnce(); }); it("ignores unauthorized sender (silent, no reply)", async () => { diff --git a/src/auto-reply/reply/commands-subagents.test-mocks.ts b/src/auto-reply/reply/commands-subagents.test-mocks.ts new file mode 100644 index 000000000..da70d449b --- /dev/null +++ b/src/auto-reply/reply/commands-subagents.test-mocks.ts @@ -0,0 +1,16 @@ +import { vi } from "vitest"; + +export function installSubagentsCommandCoreMocks() { + vi.mock("../../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => ({}), + }; + }); + + // Prevent transitive import chain from reaching discord/monitor which needs https-proxy-agent. + vi.mock("../../discord/monitor/gateway-plugin.js", () => ({ + createDiscordGatewayPlugin: () => ({}), + })); +} diff --git a/src/auto-reply/reply/reply-state.test.ts b/src/auto-reply/reply/reply-state.test.ts index 5135428ce..75cc40252 100644 --- a/src/auto-reply/reply/reply-state.test.ts +++ b/src/auto-reply/reply/reply-state.test.ts @@ -53,6 +53,15 @@ async function createCompactionSessionFixture(entry: SessionEntry) { } describe("history helpers", () => { + function createHistoryMapWithTwoEntries() { + const historyMap = new Map(); + historyMap.set("group", [ + { sender: "A", body: "one" }, + { sender: "B", body: "two" }, + ]); + return historyMap; + } + it("returns current message when history is empty", () => { const result = buildHistoryContext({ historyText: " ", @@ -104,11 +113,7 @@ describe("history helpers", () => { }); it("builds context from map and appends entry", () => { - const historyMap = new Map(); - historyMap.set("group", [ - { sender: "A", body: "one" }, - { sender: "B", body: "two" }, - ]); + const historyMap = createHistoryMapWithTwoEntries(); const result = buildHistoryContextFromMap({ historyMap, @@ -127,11 +132,7 @@ describe("history helpers", () => { }); it("builds context from pending map without appending", () => { - const historyMap = new Map(); - historyMap.set("group", [ - { sender: "A", body: "one" }, - { sender: "B", body: "two" }, - ]); + const historyMap = createHistoryMapWithTwoEntries(); const result = buildPendingHistoryContextFromMap({ historyMap, diff --git a/src/line/template-messages.ts b/src/line/template-messages.ts index b6e9bd2fc..a544535d5 100644 --- a/src/line/template-messages.ts +++ b/src/line/template-messages.ts @@ -17,6 +17,23 @@ type CarouselColumn = messagingApi.CarouselColumn; type ImageCarouselTemplate = messagingApi.ImageCarouselTemplate; type ImageCarouselColumn = messagingApi.ImageCarouselColumn; +type TemplatePayloadAction = { + type?: "uri" | "postback" | "message"; + uri?: string; + data?: string; + label: string; +}; + +function buildTemplatePayloadAction(action: TemplatePayloadAction): Action { + if (action.type === "uri" && action.uri) { + return uriAction(action.label, action.uri); + } + if (action.type === "postback" && action.data) { + return postbackAction(action.label, action.data, action.label); + } + return messageAction(action.label, action.data ?? action.label); +} + /** * Create a confirm template (yes/no style dialog) */ @@ -293,16 +310,9 @@ export function buildTemplateMessageFromPayload( } case "buttons": { - const actions: Action[] = payload.actions.slice(0, 4).map((action) => { - if (action.type === "uri" && action.uri) { - return uriAction(action.label, action.uri); - } - if (action.type === "postback" && action.data) { - return postbackAction(action.label, action.data, action.label); - } - // Default to message action - return messageAction(action.label, action.data ?? action.label); - }); + const actions: Action[] = payload.actions + .slice(0, 4) + .map((action) => buildTemplatePayloadAction(action)); return createButtonTemplate(payload.title, payload.text, actions, { thumbnailImageUrl: payload.thumbnailImageUrl, @@ -312,15 +322,9 @@ export function buildTemplateMessageFromPayload( case "carousel": { const columns: CarouselColumn[] = payload.columns.slice(0, 10).map((col) => { - const colActions: Action[] = col.actions.slice(0, 3).map((action) => { - if (action.type === "uri" && action.uri) { - return uriAction(action.label, action.uri); - } - if (action.type === "postback" && action.data) { - return postbackAction(action.label, action.data, action.label); - } - return messageAction(action.label, action.data ?? action.label); - }); + const colActions: Action[] = col.actions + .slice(0, 3) + .map((action) => buildTemplatePayloadAction(action)); return createCarouselColumn({ title: col.title, diff --git a/src/signal/format.chunking.test.ts b/src/signal/format.chunking.test.ts index ded30c842..5c17ef581 100644 --- a/src/signal/format.chunking.test.ts +++ b/src/signal/format.chunking.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from "vitest"; import { markdownToSignalTextChunks } from "./format.js"; +function expectChunkStyleRangesInBounds(chunks: ReturnType) { + for (const chunk of chunks) { + for (const style of chunk.styles) { + expect(style.start).toBeGreaterThanOrEqual(0); + expect(style.start + style.length).toBeLessThanOrEqual(chunk.text.length); + expect(style.length).toBeGreaterThan(0); + } + } +} + describe("splitSignalFormattedText", () => { // We test the internal chunking behavior via markdownToSignalTextChunks with // pre-rendered SignalFormattedText. The helper is not exported, so we test @@ -145,13 +155,7 @@ describe("splitSignalFormattedText", () => { expect(chunks.length).toBeGreaterThan(1); // Verify all style ranges are valid within their respective chunks - for (const chunk of chunks) { - for (const style of chunk.styles) { - expect(style.start).toBeGreaterThanOrEqual(0); - expect(style.start + style.length).toBeLessThanOrEqual(chunk.text.length); - expect(style.length).toBeGreaterThan(0); - } - } + expectChunkStyleRangesInBounds(chunks); // Collect all styles across chunks const allStyles = chunks.flatMap((c) => c.styles.map((s) => s.style)); @@ -330,13 +334,7 @@ describe("markdownToSignalTextChunks", () => { expect(chunks.length).toBeGreaterThan(1); // All style ranges should be valid within their chunks - for (const chunk of chunks) { - for (const style of chunk.styles) { - expect(style.start).toBeGreaterThanOrEqual(0); - expect(style.start + style.length).toBeLessThanOrEqual(chunk.text.length); - expect(style.length).toBeGreaterThan(0); - } - } + expectChunkStyleRangesInBounds(chunks); // Verify styles exist somewhere const allStyles = chunks.flatMap((c) => c.styles.map((s) => s.style)); diff --git a/src/web/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts b/src/web/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts index eab7a5f1e..a4a1d38bf 100644 --- a/src/web/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts +++ b/src/web/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import sharp from "sharp"; import { describe, expect, it, vi } from "vitest"; import { monitorWebChannel } from "./auto-reply.js"; @@ -63,21 +64,106 @@ describe("web auto-reply", () => { }; } - it("honors mediaMaxMb from config", async () => { - setLoadConfigMock(() => ({ agents: { defaults: { mediaMaxMb: 1 } } })); - const sendMedia = vi.fn(); - const { reply, dispatch } = await setupSingleInboundMessage({ - resolverValue: { - text: "hi", - mediaUrl: "https://example.com/big.png", - }, - sendMedia, - }); + async function withMediaCap(mediaMaxMb: number, run: () => Promise): Promise { + setLoadConfigMock(() => ({ agents: { defaults: { mediaMaxMb } } })); + try { + return await run(); + } finally { + resetLoadConfigMock(); + } + } + function mockFetchMediaBuffer(buffer: Buffer, mime: string) { + return vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + body: true, + arrayBuffer: async () => + buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength), + headers: { get: () => mime }, + status: 200, + } as unknown as Response); + } + + async function expectCompressedImageWithinCap(params: { + mediaUrl: string; + mime: string; + image: Buffer; + messageId: string; + mediaMaxMb?: number; + }) { + await withMediaCap(params.mediaMaxMb ?? 1, async () => { + const sendMedia = vi.fn(); + const { reply, dispatch } = await setupSingleInboundMessage({ + resolverValue: { text: "hi", mediaUrl: params.mediaUrl }, + sendMedia, + }); + const fetchMock = mockFetchMediaBuffer(params.image, params.mime); + + await dispatch(params.messageId); + + const payload = getSingleImagePayload(sendMedia); + expect(payload.image.length).toBeLessThanOrEqual((params.mediaMaxMb ?? 1) * 1024 * 1024); + expect(payload.mimetype).toBe("image/jpeg"); + expect(reply).not.toHaveBeenCalled(); + fetchMock.mockRestore(); + }); + } + + it("compresses common formats to jpeg under the cap", { timeout: 45_000 }, async () => { + const formats = [ + { + name: "png", + mime: "image/png", + make: (buf: Buffer, opts: { width: number; height: number }) => + sharp(buf, { + raw: { width: opts.width, height: opts.height, channels: 3 }, + }) + .png({ compressionLevel: 0 }) + .toBuffer(), + }, + { + name: "jpeg", + mime: "image/jpeg", + make: (buf: Buffer, opts: { width: number; height: number }) => + sharp(buf, { + raw: { width: opts.width, height: opts.height, channels: 3 }, + }) + .jpeg({ quality: 90 }) + .toBuffer(), + }, + { + name: "webp", + mime: "image/webp", + make: (buf: Buffer, opts: { width: number; height: number }) => + sharp(buf, { + raw: { width: opts.width, height: opts.height, channels: 3 }, + }) + .webp({ quality: 100 }) + .toBuffer(), + }, + ] as const; + + const width = 1150; + const height = 1150; + const sharedRaw = crypto.randomBytes(width * height * 3); + + for (const fmt of formats) { + const big = await fmt.make(sharedRaw, { width, height }); + expect(big.length).toBeGreaterThan(1 * 1024 * 1024); + await expectCompressedImageWithinCap({ + mediaUrl: `https://example.com/big.${fmt.name}`, + mime: fmt.mime, + image: big, + messageId: `msg-${fmt.name}`, + }); + } + }); + + it("honors mediaMaxMb from config", async () => { const bigPng = await sharp({ create: { - width: 900, - height: 900, + width: 1200, + height: 1200, channels: 3, background: { r: 0, g: 0, b: 255 }, }, @@ -85,25 +171,12 @@ describe("web auto-reply", () => { .png({ compressionLevel: 0 }) .toBuffer(); expect(bigPng.length).toBeGreaterThan(1 * 1024 * 1024); - - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - body: true, - arrayBuffer: async () => - bigPng.buffer.slice(bigPng.byteOffset, bigPng.byteOffset + bigPng.byteLength), - headers: { get: () => "image/png" }, - status: 200, - } as unknown as Response); - - await dispatch("msg-big"); - - const payload = getSingleImagePayload(sendMedia); - expect(payload.image.length).toBeLessThanOrEqual(1 * 1024 * 1024); - expect(payload.mimetype).toBe("image/jpeg"); - expect(reply).not.toHaveBeenCalled(); - - fetchMock.mockRestore(); - resetLoadConfigMock(); + await expectCompressedImageWithinCap({ + mediaUrl: "https://example.com/big.png", + mime: "image/png", + image: bigPng, + messageId: "msg1", + }); }); it("falls back to text when media is unsupported", async () => { const sendMedia = vi.fn();