diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index d9e9b1593e5..f6ae74d909d 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -154,56 +154,48 @@ describe("chunkMarkdownText", () => { expectFencesBalanced(chunks); }); - it("reopens fenced blocks when forced to split inside them", () => { - const text = `\`\`\`txt\n${"a".repeat(500)}\n\`\`\``; - const limit = 120; - const chunks = chunkMarkdownText(text, limit); - expect(chunks.length).toBeGreaterThan(1); - for (const chunk of chunks) { - expect(chunk.length).toBeLessThanOrEqual(limit); - expect(chunk.startsWith("```txt\n")).toBe(true); - expect(chunk.trimEnd().endsWith("```")).toBe(true); - } - expectFencesBalanced(chunks); - }); + it("handles multiple fence marker styles when splitting inside fences", () => { + const cases = [ + { + name: "backtick fence", + text: `\`\`\`txt\n${"a".repeat(500)}\n\`\`\``, + limit: 120, + expectedPrefix: "```txt\n", + expectedSuffix: "```", + }, + { + name: "tilde fence", + text: `~~~sh\n${"x".repeat(600)}\n~~~`, + limit: 140, + expectedPrefix: "~~~sh\n", + expectedSuffix: "~~~", + }, + { + name: "long backtick fence", + text: `\`\`\`\`md\n${"y".repeat(600)}\n\`\`\`\``, + limit: 140, + expectedPrefix: "````md\n", + expectedSuffix: "````", + }, + { + name: "indented fence", + text: ` \`\`\`js\n ${"z".repeat(600)}\n \`\`\``, + limit: 160, + expectedPrefix: " ```js\n", + expectedSuffix: " ```", + }, + ] as const; - it("supports tilde fences", () => { - const text = `~~~sh\n${"x".repeat(600)}\n~~~`; - const limit = 140; - const chunks = chunkMarkdownText(text, limit); - expect(chunks.length).toBeGreaterThan(1); - for (const chunk of chunks) { - expect(chunk.length).toBeLessThanOrEqual(limit); - expect(chunk.startsWith("~~~sh\n")).toBe(true); - expect(chunk.trimEnd().endsWith("~~~")).toBe(true); + for (const testCase of cases) { + const chunks = chunkMarkdownText(testCase.text, testCase.limit); + expect(chunks.length, testCase.name).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.length, testCase.name).toBeLessThanOrEqual(testCase.limit); + expect(chunk.startsWith(testCase.expectedPrefix), testCase.name).toBe(true); + expect(chunk.trimEnd().endsWith(testCase.expectedSuffix), testCase.name).toBe(true); + } + expectFencesBalanced(chunks); } - expectFencesBalanced(chunks); - }); - - it("supports longer fence markers for close", () => { - const text = `\`\`\`\`md\n${"y".repeat(600)}\n\`\`\`\``; - const limit = 140; - const chunks = chunkMarkdownText(text, limit); - expect(chunks.length).toBeGreaterThan(1); - for (const chunk of chunks) { - expect(chunk.length).toBeLessThanOrEqual(limit); - expect(chunk.startsWith("````md\n")).toBe(true); - expect(chunk.trimEnd().endsWith("````")).toBe(true); - } - expectFencesBalanced(chunks); - }); - - it("preserves indentation for indented fences", () => { - const text = ` \`\`\`js\n ${"z".repeat(600)}\n \`\`\``; - const limit = 160; - const chunks = chunkMarkdownText(text, limit); - expect(chunks.length).toBeGreaterThan(1); - for (const chunk of chunks) { - expect(chunk.length).toBeLessThanOrEqual(limit); - expect(chunk.startsWith(" ```js\n")).toBe(true); - expect(chunk.trimEnd().endsWith(" ```")).toBe(true); - } - expectFencesBalanced(chunks); }); it("never produces an empty fenced chunk when splitting", () => { @@ -269,12 +261,10 @@ describe("chunkByNewline", () => { expect(chunks).toEqual([text]); }); - it("returns empty array for empty input", () => { - expect(chunkByNewline("", 100)).toEqual([]); - }); - - it("returns empty array for whitespace-only input", () => { - expect(chunkByNewline(" \n\n ", 100)).toEqual([]); + it("returns empty array for empty and whitespace-only input", () => { + for (const text of ["", " \n\n "]) { + expect(chunkByNewline(text, 100)).toEqual([]); + } }); it("preserves trailing blank lines on the last chunk", () => { @@ -291,83 +281,107 @@ describe("chunkByNewline", () => { }); describe("chunkTextWithMode", () => { - it("uses length-based chunking for length mode", () => { - const text = "Line one\nLine two"; - const chunks = chunkTextWithMode(text, 1000, "length"); - expect(chunks).toEqual(["Line one\nLine two"]); - }); + it("applies mode-specific chunking behavior", () => { + const cases = [ + { + name: "length mode", + text: "Line one\nLine two", + mode: "length" as const, + expected: ["Line one\nLine two"], + }, + { + name: "newline mode (single paragraph)", + text: "Line one\nLine two", + mode: "newline" as const, + expected: ["Line one\nLine two"], + }, + { + name: "newline mode (blank-line split)", + text: "Para one\n\nPara two", + mode: "newline" as const, + expected: ["Para one", "Para two"], + }, + ] as const; - it("uses paragraph-based chunking for newline mode", () => { - const text = "Line one\nLine two"; - const chunks = chunkTextWithMode(text, 1000, "newline"); - expect(chunks).toEqual(["Line one\nLine two"]); - }); - - it("splits on blank lines for newline mode", () => { - const text = "Para one\n\nPara two"; - const chunks = chunkTextWithMode(text, 1000, "newline"); - expect(chunks).toEqual(["Para one", "Para two"]); + for (const testCase of cases) { + const chunks = chunkTextWithMode(testCase.text, 1000, testCase.mode); + expect(chunks, testCase.name).toEqual(testCase.expected); + } }); }); describe("chunkMarkdownTextWithMode", () => { - it("uses markdown-aware chunking for length mode", () => { - const text = "Line one\nLine two"; - expect(chunkMarkdownTextWithMode(text, 1000, "length")).toEqual(chunkMarkdownText(text, 1000)); + it("applies markdown/newline mode behavior", () => { + const cases = [ + { + name: "length mode uses markdown-aware chunker", + text: "Line one\nLine two", + mode: "length" as const, + expected: chunkMarkdownText("Line one\nLine two", 1000), + }, + { + name: "newline mode keeps single paragraph", + text: "Line one\nLine two", + mode: "newline" as const, + expected: ["Line one\nLine two"], + }, + { + name: "newline mode splits by blank line", + text: "Para one\n\nPara two", + mode: "newline" as const, + expected: ["Para one", "Para two"], + }, + ] as const; + for (const testCase of cases) { + expect(chunkMarkdownTextWithMode(testCase.text, 1000, testCase.mode), testCase.name).toEqual( + testCase.expected, + ); + } }); - it("uses paragraph-based chunking for newline mode", () => { - const text = "Line one\nLine two"; - expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual(["Line one\nLine two"]); - }); - - it("splits on blank lines for newline mode", () => { - const text = "Para one\n\nPara two"; - expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual(["Para one", "Para two"]); - }); - - it("does not split single-newline code fences in newline mode", () => { - const text = "```js\nconst a = 1;\nconst b = 2;\n```\nAfter"; - expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual([text]); - }); - - it("defers long markdown paragraphs to markdown chunking in newline mode", () => { - const text = `\`\`\`js\n${"const a = 1;\n".repeat(20)}\`\`\``; - expect(chunkMarkdownTextWithMode(text, 40, "newline")).toEqual(chunkMarkdownText(text, 40)); - }); - - it("does not split on blank lines inside a fenced code block", () => { - const text = "```python\ndef my_function():\n x = 1\n\n y = 2\n return x + y\n```"; - expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual([text]); - }); - - it("splits on blank lines between a code fence and following paragraph", () => { + it("handles newline mode fence splitting rules", () => { const fence = "```python\ndef my_function():\n x = 1\n\n y = 2\n return x + y\n```"; - const text = `${fence}\n\nAfter`; - expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual([fence, "After"]); + const longFence = `\`\`\`js\n${"const a = 1;\n".repeat(20)}\`\`\``; + const cases = [ + { + name: "keeps single-newline fence+paragraph together", + text: "```js\nconst a = 1;\nconst b = 2;\n```\nAfter", + limit: 1000, + expected: ["```js\nconst a = 1;\nconst b = 2;\n```\nAfter"], + }, + { + name: "keeps blank lines inside fence together", + text: fence, + limit: 1000, + expected: [fence], + }, + { + name: "splits between fence and following paragraph", + text: `${fence}\n\nAfter`, + limit: 1000, + expected: [fence, "After"], + }, + { + name: "defers long markdown blocks to markdown chunker", + text: longFence, + limit: 40, + expected: chunkMarkdownText(longFence, 40), + }, + ] as const; + + for (const testCase of cases) { + expect( + chunkMarkdownTextWithMode(testCase.text, testCase.limit, "newline"), + testCase.name, + ).toEqual(testCase.expected); + } }); }); describe("resolveChunkMode", () => { - it("returns length as default", () => { - expect(resolveChunkMode(undefined, "telegram")).toBe("length"); - expect(resolveChunkMode({}, "discord")).toBe("length"); - expect(resolveChunkMode(undefined, "bluebubbles")).toBe("length"); - }); - - it("returns length for internal channel", () => { - const cfg = { channels: { bluebubbles: { chunkMode: "newline" as const } } }; - expect(resolveChunkMode(cfg, "__internal__")).toBe("length"); - }); - - it("supports provider-level overrides for slack", () => { - const cfg = { channels: { slack: { chunkMode: "newline" as const } } }; - expect(resolveChunkMode(cfg, "slack")).toBe("newline"); - expect(resolveChunkMode(cfg, "discord")).toBe("length"); - }); - - it("supports account-level overrides for slack", () => { - const cfg = { + it("resolves default, provider, account, and internal channel modes", () => { + const providerCfg = { channels: { slack: { chunkMode: "newline" as const } } }; + const accountCfg = { channels: { slack: { chunkMode: "length" as const, @@ -377,7 +391,21 @@ describe("resolveChunkMode", () => { }, }, }; - expect(resolveChunkMode(cfg, "slack", "primary")).toBe("newline"); - expect(resolveChunkMode(cfg, "slack", "other")).toBe("length"); + const cases = [ + { cfg: undefined, provider: "telegram", accountId: undefined, expected: "length" }, + { cfg: {}, provider: "discord", accountId: undefined, expected: "length" }, + { cfg: undefined, provider: "bluebubbles", accountId: undefined, expected: "length" }, + { cfg: providerCfg, provider: "__internal__", accountId: undefined, expected: "length" }, + { cfg: providerCfg, provider: "slack", accountId: undefined, expected: "newline" }, + { cfg: providerCfg, provider: "discord", accountId: undefined, expected: "length" }, + { cfg: accountCfg, provider: "slack", accountId: "primary", expected: "newline" }, + { cfg: accountCfg, provider: "slack", accountId: "other", expected: "length" }, + ] as const; + + for (const testCase of cases) { + expect(resolveChunkMode(testCase.cfg as never, testCase.provider, testCase.accountId)).toBe( + testCase.expected, + ); + } }); }); diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts index cc43bdc0744..a56248e7327 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts @@ -31,6 +31,9 @@ const state = vi.hoisted(() => ({ runCliAgentMock: vi.fn(), })); +let modelFallbackModule: typeof import("../../agents/model-fallback.js"); +let onAgentEvent: typeof import("../../infra/agent-events.js").onAgentEvent; + let runReplyAgentPromise: | Promise<(typeof import("./agent-runner.js"))["runReplyAgent"]> | undefined; @@ -75,6 +78,8 @@ vi.mock("./queue.js", () => ({ beforeAll(async () => { // Avoid attributing the initial agent-runner import cost to the first test case. + modelFallbackModule = await import("../../agents/model-fallback.js"); + ({ onAgentEvent } = await import("../../infra/agent-events.js")); await getRunReplyAgent(); }); @@ -629,83 +634,70 @@ describe("runReplyAgent typing (heartbeat)", () => { }); }); - it("announces model fallback in verbose mode", async () => { - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - }; - const sessionStore = { main: sessionEntry }; - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ payloads: [{ text: "final" }], meta: {} }); - const modelFallback = await import("../../agents/model-fallback.js"); - vi.spyOn(modelFallback, "runWithModelFallback").mockImplementationOnce( - async ({ run }: { run: (provider: string, model: string) => Promise }) => ({ - result: await run("deepinfra", "moonshotai/Kimi-K2.5"), - provider: "deepinfra", - model: "moonshotai/Kimi-K2.5", - attempts: [ - { - provider: "fireworks", - model: "fireworks/minimax-m2p5", - error: "Provider fireworks is in cooldown (all profiles unavailable)", - reason: "rate_limit", - }, - ], - }), - ); + it("announces model fallback only when verbose mode is enabled", async () => { + const cases = [ + { name: "verbose on", verbose: "on" as const, expectNotice: true }, + { name: "verbose off", verbose: "off" as const, expectNotice: false }, + ] as const; + for (const testCase of cases) { + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + }; + const sessionStore = { main: sessionEntry }; + state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "final" }], + meta: {}, + }); + vi.spyOn(modelFallbackModule, "runWithModelFallback").mockImplementationOnce( + async ({ run }: { run: (provider: string, model: string) => Promise }) => ({ + result: await run("deepinfra", "moonshotai/Kimi-K2.5"), + provider: "deepinfra", + model: "moonshotai/Kimi-K2.5", + attempts: [ + { + provider: "fireworks", + model: "fireworks/minimax-m2p5", + error: "Provider fireworks is in cooldown (all profiles unavailable)", + reason: "rate_limit", + }, + ], + }), + ); - const { run } = createMinimalRun({ - resolvedVerboseLevel: "on", - sessionEntry, - sessionStore, - sessionKey: "main", - }); - const res = await run(); - expect(Array.isArray(res)).toBe(true); - const payloads = res as { text?: string }[]; - expect(payloads[0]?.text).toContain("Model Fallback:"); - expect(payloads[0]?.text).toContain("deepinfra/moonshotai/Kimi-K2.5"); - expect(sessionEntry.fallbackNoticeReason).toBe("rate limit"); - }); - - it("does not announce model fallback when verbose is off", async () => { - const { onAgentEvent } = await import("../../infra/agent-events.js"); - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ payloads: [{ text: "final" }], meta: {} }); - const modelFallback = await import("../../agents/model-fallback.js"); - vi.spyOn(modelFallback, "runWithModelFallback").mockImplementationOnce( - async ({ run }: { run: (provider: string, model: string) => Promise }) => ({ - result: await run("deepinfra", "moonshotai/Kimi-K2.5"), - provider: "deepinfra", - model: "moonshotai/Kimi-K2.5", - attempts: [ - { - provider: "fireworks", - model: "fireworks/minimax-m2p5", - error: "Provider fireworks is in cooldown (all profiles unavailable)", - reason: "rate_limit", - }, - ], - }), - ); - - const { run } = createMinimalRun({ - resolvedVerboseLevel: "off", - }); - const phases: string[] = []; - const off = onAgentEvent((evt) => { - const phase = typeof evt.data?.phase === "string" ? evt.data.phase : null; - if (evt.stream === "lifecycle" && phase) { - phases.push(phase); + const { run } = createMinimalRun({ + resolvedVerboseLevel: testCase.verbose, + sessionEntry, + sessionStore, + sessionKey: "main", + }); + const phases: string[] = []; + const off = onAgentEvent((evt) => { + const phase = typeof evt.data?.phase === "string" ? evt.data.phase : null; + if (evt.stream === "lifecycle" && phase) { + phases.push(phase); + } + }); + const res = await run(); + off(); + const payload = Array.isArray(res) + ? (res[0] as { text?: string }) + : (res as { text?: string }); + if (testCase.expectNotice) { + expect(payload.text, testCase.name).toContain("Model Fallback:"); + expect(payload.text, testCase.name).toContain("deepinfra/moonshotai/Kimi-K2.5"); + expect(sessionEntry.fallbackNoticeReason, testCase.name).toBe("rate limit"); + continue; } - }); - const res = await run(); - off(); - const payload = Array.isArray(res) ? (res[0] as { text?: string }) : (res as { text?: string }); - expect(payload.text).not.toContain("Model Fallback:"); - expect(phases.filter((phase) => phase === "fallback")).toHaveLength(1); + expect(payload.text, testCase.name).not.toContain("Model Fallback:"); + expect( + phases.filter((phase) => phase === "fallback"), + testCase.name, + ).toHaveLength(1); + } }); it("announces model fallback only once per active fallback state", async () => { - const { onAgentEvent } = await import("../../infra/agent-events.js"); const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now(), @@ -716,9 +708,8 @@ describe("runReplyAgent typing (heartbeat)", () => { payloads: [{ text: "final" }], meta: {}, }); - const modelFallback = await import("../../agents/model-fallback.js"); const fallbackSpy = vi - .spyOn(modelFallback, "runWithModelFallback") + .spyOn(modelFallbackModule, "runWithModelFallback") .mockImplementation( async ({ run }: { run: (provider: string, model: string) => Promise }) => ({ result: await run("deepinfra", "moonshotai/Kimi-K2.5"), @@ -773,9 +764,8 @@ describe("runReplyAgent typing (heartbeat)", () => { payloads: [{ text: "final" }], meta: {}, }); - const modelFallback = await import("../../agents/model-fallback.js"); const fallbackSpy = vi - .spyOn(modelFallback, "runWithModelFallback") + .spyOn(modelFallbackModule, "runWithModelFallback") .mockImplementation( async ({ provider, @@ -833,7 +823,6 @@ describe("runReplyAgent typing (heartbeat)", () => { }); it("announces fallback-cleared once when runtime returns to selected model", async () => { - const { onAgentEvent } = await import("../../infra/agent-events.js"); const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now(), @@ -845,9 +834,8 @@ describe("runReplyAgent typing (heartbeat)", () => { payloads: [{ text: "final" }], meta: {}, }); - const modelFallback = await import("../../agents/model-fallback.js"); const fallbackSpy = vi - .spyOn(modelFallback, "runWithModelFallback") + .spyOn(modelFallbackModule, "runWithModelFallback") .mockImplementation( async ({ provider, @@ -915,7 +903,6 @@ describe("runReplyAgent typing (heartbeat)", () => { }); it("emits fallback lifecycle events while verbose is off", async () => { - const { onAgentEvent } = await import("../../infra/agent-events.js"); const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now(), @@ -927,9 +914,8 @@ describe("runReplyAgent typing (heartbeat)", () => { payloads: [{ text: "final" }], meta: {}, }); - const modelFallback = await import("../../agents/model-fallback.js"); const fallbackSpy = vi - .spyOn(modelFallback, "runWithModelFallback") + .spyOn(modelFallbackModule, "runWithModelFallback") .mockImplementation( async ({ provider, @@ -1008,9 +994,8 @@ describe("runReplyAgent typing (heartbeat)", () => { payloads: [{ text: "final" }], meta: {}, }); - const modelFallback = await import("../../agents/model-fallback.js"); const fallbackSpy = vi - .spyOn(modelFallback, "runWithModelFallback") + .spyOn(modelFallbackModule, "runWithModelFallback") .mockImplementation( async ({ run }: { run: (provider: string, model: string) => Promise }) => ({ result: await run("deepinfra", "moonshotai/Kimi-K2.5"), @@ -1058,9 +1043,8 @@ describe("runReplyAgent typing (heartbeat)", () => { payloads: [{ text: "final" }], meta: {}, }); - const modelFallback = await import("../../agents/model-fallback.js"); const fallbackSpy = vi - .spyOn(modelFallback, "runWithModelFallback") + .spyOn(modelFallbackModule, "runWithModelFallback") .mockImplementation( async ({ run }: { run: (provider: string, model: string) => Promise }) => ({ result: await run("deepinfra", "moonshotai/Kimi-K2.5"), diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 842aaa3ff19..3d3aca5e667 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -165,26 +165,21 @@ describe("handleCommands gating", () => { expect(result.reply?.text).toContain("elevated is not available"); }); - it("blocks /config when disabled", async () => { + it("blocks /config and /debug when disabled", async () => { const cfg = { commands: { config: false, debug: false, text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; - const params = buildParams("/config show", cfg); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("/config is disabled"); - }); - - it("blocks /debug when disabled", async () => { - const cfg = { - commands: { config: false, debug: false, text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/debug show", cfg); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("/debug is disabled"); + const cases = [ + { commandBody: "/config show", expectedText: "/config is disabled" }, + { commandBody: "/debug show", expectedText: "/debug is disabled" }, + ] as const; + for (const testCase of cases) { + const params = buildParams(testCase.commandBody, cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain(testCase.expectedText); + } }); it("does not enable gated commands from inherited command flags", async () => { @@ -266,50 +261,29 @@ describe("/approve command", () => { expect(callGatewayMock).not.toHaveBeenCalled(); }); - it("allows gateway clients with approvals scope", async () => { + it("allows gateway clients with approvals or admin scopes", async () => { const cfg = { commands: { text: true }, } as OpenClawConfig; - const params = buildParams("/approve abc allow-once", cfg, { - Provider: "webchat", - Surface: "webchat", - GatewayClientScopes: ["operator.approvals"], - }); + const scopeCases = [["operator.approvals"], ["operator.admin"]]; + for (const scopes of scopeCases) { + callGatewayMock.mockResolvedValueOnce({ ok: true }); + const params = buildParams("/approve abc allow-once", cfg, { + Provider: "webchat", + Surface: "webchat", + GatewayClientScopes: scopes, + }); - callGatewayMock.mockResolvedValueOnce({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Exec approval allow-once submitted"); - expect(callGatewayMock).toHaveBeenCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc", decision: "allow-once" }, - }), - ); - }); - - it("allows gateway clients with admin scope", async () => { - const cfg = { - commands: { text: true }, - } as OpenClawConfig; - const params = buildParams("/approve abc allow-once", cfg, { - Provider: "webchat", - Surface: "webchat", - GatewayClientScopes: ["operator.admin"], - }); - - callGatewayMock.mockResolvedValueOnce({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Exec approval allow-once submitted"); - expect(callGatewayMock).toHaveBeenCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc", decision: "allow-once" }, - }), - ); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Exec approval allow-once submitted"); + expect(callGatewayMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc", decision: "allow-once" }, + }), + ); + } }); }); @@ -420,67 +394,76 @@ describe("buildCommandsPaginationKeyboard", () => { }); describe("parseConfigCommand", () => { - it("parses show/unset", () => { - expect(parseConfigCommand("/config")).toEqual({ action: "show" }); - expect(parseConfigCommand("/config show")).toEqual({ - action: "show", - path: undefined, - }); - expect(parseConfigCommand("/config show foo.bar")).toEqual({ - action: "show", - path: "foo.bar", - }); - expect(parseConfigCommand("/config get foo.bar")).toEqual({ - action: "show", - path: "foo.bar", - }); - expect(parseConfigCommand("/config unset foo.bar")).toEqual({ - action: "unset", - path: "foo.bar", - }); - }); + it("parses config/debug command actions and JSON payloads", () => { + const cases: Array<{ + parse: (input: string) => unknown; + input: string; + expected: unknown; + }> = [ + { parse: parseConfigCommand, input: "/config", expected: { action: "show" } }, + { + parse: parseConfigCommand, + input: "/config show", + expected: { action: "show", path: undefined }, + }, + { + parse: parseConfigCommand, + input: "/config show foo.bar", + expected: { action: "show", path: "foo.bar" }, + }, + { + parse: parseConfigCommand, + input: "/config get foo.bar", + expected: { action: "show", path: "foo.bar" }, + }, + { + parse: parseConfigCommand, + input: "/config unset foo.bar", + expected: { action: "unset", path: "foo.bar" }, + }, + { + parse: parseConfigCommand, + input: '/config set foo={"a":1}', + expected: { action: "set", path: "foo", value: { a: 1 } }, + }, + { parse: parseDebugCommand, input: "/debug", expected: { action: "show" } }, + { parse: parseDebugCommand, input: "/debug show", expected: { action: "show" } }, + { parse: parseDebugCommand, input: "/debug reset", expected: { action: "reset" } }, + { + parse: parseDebugCommand, + input: "/debug unset foo.bar", + expected: { action: "unset", path: "foo.bar" }, + }, + { + parse: parseDebugCommand, + input: '/debug set foo={"a":1}', + expected: { action: "set", path: "foo", value: { a: 1 } }, + }, + ]; - it("parses set with JSON", () => { - const cmd = parseConfigCommand('/config set foo={"a":1}'); - expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } }); - }); -}); - -describe("parseDebugCommand", () => { - it("parses show/reset", () => { - expect(parseDebugCommand("/debug")).toEqual({ action: "show" }); - expect(parseDebugCommand("/debug show")).toEqual({ action: "show" }); - expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" }); - }); - - it("parses set with JSON", () => { - const cmd = parseDebugCommand('/debug set foo={"a":1}'); - expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } }); - }); - - it("parses unset", () => { - const cmd = parseDebugCommand("/debug unset foo.bar"); - expect(cmd).toEqual({ action: "unset", path: "foo.bar" }); + for (const testCase of cases) { + expect(testCase.parse(testCase.input)).toEqual(testCase.expected); + } }); }); describe("extractMessageText", () => { - it("preserves user text that looks like tool call markers", () => { - const message = { - role: "user", - content: "Here [Tool Call: foo (ID: 1)] ok", - }; - const result = extractMessageText(message); - expect(result?.text).toContain("[Tool Call: foo (ID: 1)]"); - }); + it("preserves user markers and sanitizes assistant markers", () => { + const cases = [ + { + message: { role: "user", content: "Here [Tool Call: foo (ID: 1)] ok" }, + expectedText: "Here [Tool Call: foo (ID: 1)] ok", + }, + { + message: { role: "assistant", content: "Here [Tool Call: foo (ID: 1)] ok" }, + expectedText: "Here ok", + }, + ] as const; - it("sanitizes assistant tool call markers", () => { - const message = { - role: "assistant", - content: "Here [Tool Call: foo (ID: 1)] ok", - }; - const result = extractMessageText(message); - expect(result?.text).toBe("Here ok"); + for (const testCase of cases) { + const result = extractMessageText(testCase.message); + expect(result?.text).toBe(testCase.expectedText); + } }); }); @@ -498,28 +481,18 @@ describe("handleCommands /config configWrites gating", () => { }); describe("handleCommands bash alias", () => { - it("routes !poll through the /bash handler", async () => { - resetBashChatCommandForTests(); + it("routes !poll and !stop through the /bash handler", async () => { const cfg = { commands: { bash: true, text: true }, whatsapp: { allowFrom: ["*"] }, } as OpenClawConfig; - const params = buildParams("!poll", cfg); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("No active bash job"); - }); - - it("routes !stop through the /bash handler", async () => { - resetBashChatCommandForTests(); - const cfg = { - commands: { bash: true, text: true }, - whatsapp: { allowFrom: ["*"] }, - } as OpenClawConfig; - const params = buildParams("!stop", cfg); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("No active bash job"); + for (const aliasCommand of ["!poll", "!stop"]) { + resetBashChatCommandForTests(); + const params = buildParams(aliasCommand, cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("No active bash job"); + } }); }); @@ -623,90 +596,66 @@ describe("handleCommands /allowlist", () => { expect(result.reply?.text).toContain("DM allowlist added"); }); - it("removes Slack DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { - channels: { - slack: { - allowFrom: ["U111", "U222"], - dm: { allowFrom: ["U111", "U222"] }, - configWrites: true, - }, - }, + it("removes DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => { + const cases = [ + { + provider: "slack", + removeId: "U111", + initialAllowFrom: ["U111", "U222"], + expectedAllowFrom: ["U222"], }, - }); + { + provider: "discord", + removeId: "111", + initialAllowFrom: ["111", "222"], + expectedAllowFrom: ["222"], + }, + ] as const; validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ ok: true, config, })); - const cfg = { - commands: { text: true, config: true }, - channels: { - slack: { - allowFrom: ["U111", "U222"], - dm: { allowFrom: ["U111", "U222"] }, - configWrites: true, + for (const testCase of cases) { + const previousWriteCount = writeConfigFileMock.mock.calls.length; + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { + channels: { + [testCase.provider]: { + allowFrom: testCase.initialAllowFrom, + dm: { allowFrom: testCase.initialAllowFrom }, + configWrites: true, + }, + }, }, - }, - } as OpenClawConfig; + }); - const params = buildPolicyParams("/allowlist remove dm U111", cfg, { - Provider: "slack", - Surface: "slack", - }); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(writeConfigFileMock).toHaveBeenCalledTimes(1); - const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig; - expect(written.channels?.slack?.allowFrom).toEqual(["U222"]); - expect(written.channels?.slack?.dm?.allowFrom).toBeUndefined(); - expect(result.reply?.text).toContain("channels.slack.allowFrom"); - }); - - it("removes Discord DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { + const cfg = { + commands: { text: true, config: true }, channels: { - discord: { - allowFrom: ["111", "222"], - dm: { allowFrom: ["111", "222"] }, + [testCase.provider]: { + allowFrom: testCase.initialAllowFrom, + dm: { allowFrom: testCase.initialAllowFrom }, configWrites: true, }, }, - }, - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); + } as OpenClawConfig; - const cfg = { - commands: { text: true, config: true }, - channels: { - discord: { - allowFrom: ["111", "222"], - dm: { allowFrom: ["111", "222"] }, - configWrites: true, - }, - }, - } as OpenClawConfig; + const params = buildPolicyParams(`/allowlist remove dm ${testCase.removeId}`, cfg, { + Provider: testCase.provider, + Surface: testCase.provider, + }); + const result = await handleCommands(params); - const params = buildPolicyParams("/allowlist remove dm 111", cfg, { - Provider: "discord", - Surface: "discord", - }); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(writeConfigFileMock).toHaveBeenCalledTimes(1); - const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig; - expect(written.channels?.discord?.allowFrom).toEqual(["222"]); - expect(written.channels?.discord?.dm?.allowFrom).toBeUndefined(); - expect(result.reply?.text).toContain("channels.discord.allowFrom"); + expect(result.shouldContinue).toBe(false); + expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount + 1); + const written = writeConfigFileMock.mock.calls.at(-1)?.[0] as OpenClawConfig; + const channelConfig = written.channels?.[testCase.provider]; + expect(channelConfig?.allowFrom).toEqual(testCase.expectedAllowFrom); + expect(channelConfig?.dm?.allowFrom).toBeUndefined(); + expect(result.reply?.text).toContain(`channels.${testCase.provider}.allowFrom`); + } }); }); @@ -736,44 +685,56 @@ describe("/models command", () => { expect(buttons?.length).toBeGreaterThan(0); }); - it("lists provider models with pagination hints", async () => { - // Use discord surface for text-based output tests - const params = buildPolicyParams("/models anthropic", cfg, { Surface: "discord" }); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Models (anthropic"); - expect(result.reply?.text).toContain("page 1/"); - expect(result.reply?.text).toContain("anthropic/claude-opus-4-5"); - expect(result.reply?.text).toContain("Switch: /model "); - expect(result.reply?.text).toContain("All: /models anthropic all"); - }); + it("handles provider model pagination, all mode, and unknown providers", async () => { + const cases = [ + { + name: "lists provider models with pagination hints", + command: "/models anthropic", + includes: [ + "Models (anthropic", + "page 1/", + "anthropic/claude-opus-4-5", + "Switch: /model ", + "All: /models anthropic all", + ], + excludes: [], + }, + { + name: "ignores page argument when all flag is present", + command: "/models anthropic 3 all", + includes: ["Models (anthropic", "page 1/1", "anthropic/claude-opus-4-5"], + excludes: ["Page out of range"], + }, + { + name: "errors on out-of-range pages", + command: "/models anthropic 4", + includes: ["Page out of range", "valid: 1-"], + excludes: [], + }, + { + name: "handles unknown providers", + command: "/models not-a-provider", + includes: ["Unknown provider", "Available providers"], + excludes: [], + }, + ] as const; - it("ignores page argument when all flag is present", async () => { - // Use discord surface for text-based output tests - const params = buildPolicyParams("/models anthropic 3 all", cfg, { Surface: "discord" }); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Models (anthropic"); - expect(result.reply?.text).toContain("page 1/1"); - expect(result.reply?.text).toContain("anthropic/claude-opus-4-5"); - expect(result.reply?.text).not.toContain("Page out of range"); - }); - - it("errors on out-of-range pages", async () => { - // Use discord surface for text-based output tests - const params = buildPolicyParams("/models anthropic 4", cfg, { Surface: "discord" }); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Page out of range"); - expect(result.reply?.text).toContain("valid: 1-"); - }); - - it("handles unknown providers", async () => { - const params = buildPolicyParams("/models not-a-provider", cfg); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Unknown provider"); - expect(result.reply?.text).toContain("Available providers"); + for (const testCase of cases) { + // Use discord surface for deterministic text-based output assertions. + const result = await handleCommands( + buildPolicyParams(testCase.command, cfg, { + Provider: "discord", + Surface: "discord", + }), + ); + expect(result.shouldContinue, testCase.name).toBe(false); + for (const expected of testCase.includes) { + expect(result.reply?.text, `${testCase.name}: ${expected}`).toContain(expected); + } + for (const blocked of testCase.excludes ?? []) { + expect(result.reply?.text, `${testCase.name}: !${blocked}`).not.toContain(blocked); + } + } }); it("lists configured models outside the curated catalog", async () => { @@ -867,40 +828,33 @@ describe("handleCommands hooks", () => { }); describe("handleCommands context", () => { - it("returns context help for /context", async () => { + it("returns expected details for /context commands", async () => { const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; - const params = buildParams("/context", cfg); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("/context list"); - expect(result.reply?.text).toContain("Inline shortcut"); - }); - - it("returns a per-file breakdown for /context list", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/context list", cfg); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Injected workspace files:"); - expect(result.reply?.text).toContain("AGENTS.md"); - }); - - it("returns a detailed breakdown for /context detail", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/context detail", cfg); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Context breakdown (detailed)"); - expect(result.reply?.text).toContain("Top tools (schema size):"); + const cases = [ + { + commandBody: "/context", + expectedText: ["/context list", "Inline shortcut"], + }, + { + commandBody: "/context list", + expectedText: ["Injected workspace files:", "AGENTS.md"], + }, + { + commandBody: "/context detail", + expectedText: ["Context breakdown (detailed)", "Top tools (schema size):"], + }, + ] as const; + for (const testCase of cases) { + const params = buildParams(testCase.commandBody, cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + for (const expectedText of testCase.expectedText) { + expect(result.reply?.text).toContain(expectedText); + } + } }); }); @@ -1039,30 +993,23 @@ describe("handleCommands subagents", () => { expect(result.reply?.text).not.toContain("Subagents:"); }); - it("returns help for unknown subagents action", async () => { + it("returns help/usage for invalid or incomplete subagents commands", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; - const params = buildParams("/subagents foo", cfg); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("/subagents"); - }); - - it("returns usage for subagents info without target", async () => { - resetSubagentRegistryForTests(); - callGatewayMock.mockReset(); - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/subagents info", cfg); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("/subagents info"); + const cases = [ + { commandBody: "/subagents foo", expectedText: "/subagents" }, + { commandBody: "/subagents info", expectedText: "/subagents info" }, + ] as const; + for (const testCase of cases) { + const params = buildParams(testCase.commandBody, cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain(testCase.expectedText); + } }); it("includes subagent count in /status when active", async () => { diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts index 9883d3da058..4ee28552c79 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -13,34 +13,22 @@ import { createReplyDispatcher } from "./reply-dispatcher.js"; import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js"; describe("normalizeInboundTextNewlines", () => { - it("converts CRLF to LF", () => { - expect(normalizeInboundTextNewlines("hello\r\nworld")).toBe("hello\nworld"); - }); + it("normalizes real newlines and preserves literal backslash-n sequences", () => { + const cases = [ + { input: "hello\r\nworld", expected: "hello\nworld" }, + { input: "hello\rworld", expected: "hello\nworld" }, + { input: "C:\\Work\\nxxx\\README.md", expected: "C:\\Work\\nxxx\\README.md" }, + { + input: "Please read the file at C:\\Work\\nxxx\\README.md", + expected: "Please read the file at C:\\Work\\nxxx\\README.md", + }, + { input: "C:\\new\\notes\\nested", expected: "C:\\new\\notes\\nested" }, + { input: "Line 1\r\nC:\\Work\\nxxx", expected: "Line 1\nC:\\Work\\nxxx" }, + ] as const; - it("converts CR to LF", () => { - expect(normalizeInboundTextNewlines("hello\rworld")).toBe("hello\nworld"); - }); - - it("preserves literal backslash-n sequences in Windows paths", () => { - const windowsPath = "C:\\Work\\nxxx\\README.md"; - expect(normalizeInboundTextNewlines(windowsPath)).toBe("C:\\Work\\nxxx\\README.md"); - }); - - it("preserves backslash-n in messages containing Windows paths", () => { - const message = "Please read the file at C:\\Work\\nxxx\\README.md"; - expect(normalizeInboundTextNewlines(message)).toBe( - "Please read the file at C:\\Work\\nxxx\\README.md", - ); - }); - - it("preserves multiple backslash-n sequences", () => { - const message = "C:\\new\\notes\\nested"; - expect(normalizeInboundTextNewlines(message)).toBe("C:\\new\\notes\\nested"); - }); - - it("still normalizes actual CRLF while preserving backslash-n", () => { - const message = "Line 1\r\nC:\\Work\\nxxx"; - expect(normalizeInboundTextNewlines(message)).toBe("Line 1\nC:\\Work\\nxxx"); + for (const testCase of cases) { + expect(normalizeInboundTextNewlines(testCase.input)).toBe(testCase.expected); + } }); }); @@ -205,348 +193,356 @@ const getLineData = (result: ReturnType) => (result.channelData?.line as Record | undefined) ?? {}; describe("hasLineDirectives", () => { - it("detects quick_replies directive", () => { - expect(hasLineDirectives("Here are options [[quick_replies: A, B, C]]")).toBe(true); - }); + it("matches expected detection across directive patterns", () => { + const cases: Array<{ text: string; expected: boolean }> = [ + { text: "Here are options [[quick_replies: A, B, C]]", expected: true }, + { text: "[[location: Place | Address | 35.6 | 139.7]]", expected: true }, + { text: "[[confirm: Continue? | Yes | No]]", expected: true }, + { text: "[[buttons: Menu | Choose | Opt1:data1, Opt2:data2]]", expected: true }, + { text: "Just regular text", expected: false }, + { text: "[[not_a_directive: something]]", expected: false }, + { text: "[[media_player: Song | Artist | Speaker]]", expected: true }, + { text: "[[event: Meeting | Jan 24 | 2pm]]", expected: true }, + { text: "[[agenda: Today | Meeting:9am, Lunch:12pm]]", expected: true }, + { text: "[[device: TV | Room]]", expected: true }, + { text: "[[appletv_remote: Apple TV | Playing]]", expected: true }, + ]; - it("detects location directive", () => { - expect(hasLineDirectives("[[location: Place | Address | 35.6 | 139.7]]")).toBe(true); - }); - - it("detects confirm directive", () => { - expect(hasLineDirectives("[[confirm: Continue? | Yes | No]]")).toBe(true); - }); - - it("detects buttons directive", () => { - expect(hasLineDirectives("[[buttons: Menu | Choose | Opt1:data1, Opt2:data2]]")).toBe(true); - }); - - it("returns false for regular text", () => { - expect(hasLineDirectives("Just regular text")).toBe(false); - }); - - it("returns false for similar but invalid patterns", () => { - expect(hasLineDirectives("[[not_a_directive: something]]")).toBe(false); - }); - - it("detects media_player directive", () => { - expect(hasLineDirectives("[[media_player: Song | Artist | Speaker]]")).toBe(true); - }); - - it("detects event directive", () => { - expect(hasLineDirectives("[[event: Meeting | Jan 24 | 2pm]]")).toBe(true); - }); - - it("detects agenda directive", () => { - expect(hasLineDirectives("[[agenda: Today | Meeting:9am, Lunch:12pm]]")).toBe(true); - }); - - it("detects device directive", () => { - expect(hasLineDirectives("[[device: TV | Room]]")).toBe(true); - }); - - it("detects appletv_remote directive", () => { - expect(hasLineDirectives("[[appletv_remote: Apple TV | Playing]]")).toBe(true); + for (const testCase of cases) { + expect(hasLineDirectives(testCase.text)).toBe(testCase.expected); + } }); }); describe("parseLineDirectives", () => { describe("quick_replies", () => { - it("parses quick_replies and removes from text", () => { - const result = parseLineDirectives({ - text: "Choose one:\n[[quick_replies: Option A, Option B, Option C]]", - }); + it("parses quick replies variants", () => { + const cases: Array<{ + text: string; + channelData?: { line: { quickReplies: string[] } }; + quickReplies: string[]; + outputText?: string; + }> = [ + { + text: "Choose one:\n[[quick_replies: Option A, Option B, Option C]]", + quickReplies: ["Option A", "Option B", "Option C"], + outputText: "Choose one:", + }, + { + text: "Before [[quick_replies: A, B]] After", + quickReplies: ["A", "B"], + outputText: "Before After", + }, + { + text: "Text [[quick_replies: C, D]]", + channelData: { line: { quickReplies: ["A", "B"] } }, + quickReplies: ["A", "B", "C", "D"], + outputText: "Text", + }, + ]; - expect(getLineData(result).quickReplies).toEqual(["Option A", "Option B", "Option C"]); - expect(result.text).toBe("Choose one:"); - }); - - it("handles quick_replies in middle of text", () => { - const result = parseLineDirectives({ - text: "Before [[quick_replies: A, B]] After", - }); - - expect(getLineData(result).quickReplies).toEqual(["A", "B"]); - expect(result.text).toBe("Before After"); - }); - - it("merges with existing quickReplies", () => { - const result = parseLineDirectives({ - text: "Text [[quick_replies: C, D]]", - channelData: { line: { quickReplies: ["A", "B"] } }, - }); - - expect(getLineData(result).quickReplies).toEqual(["A", "B", "C", "D"]); + for (const testCase of cases) { + const result = parseLineDirectives({ + text: testCase.text, + channelData: testCase.channelData, + }); + expect(getLineData(result).quickReplies).toEqual(testCase.quickReplies); + if (testCase.outputText !== undefined) { + expect(result.text).toBe(testCase.outputText); + } + } }); }); describe("location", () => { - it("parses location with all fields", () => { - const result = parseLineDirectives({ - text: "Here's the location:\n[[location: Tokyo Station | Tokyo, Japan | 35.6812 | 139.7671]]", - }); - - expect(getLineData(result).location).toEqual({ - title: "Tokyo Station", - address: "Tokyo, Japan", - latitude: 35.6812, - longitude: 139.7671, - }); - expect(result.text).toBe("Here's the location:"); - }); - - it("ignores invalid coordinates", () => { - const result = parseLineDirectives({ - text: "[[location: Place | Address | invalid | 139.7]]", - }); - - expect(getLineData(result).location).toBeUndefined(); - }); - - it("does not override existing location", () => { + it("parses location variants", () => { const existing = { title: "Existing", address: "Addr", latitude: 1, longitude: 2 }; - const result = parseLineDirectives({ - text: "[[location: New | New Addr | 35.6 | 139.7]]", - channelData: { line: { location: existing } }, - }); + const cases: Array<{ + text: string; + channelData?: { line: { location: typeof existing } }; + location?: typeof existing; + outputText?: string; + }> = [ + { + text: "Here's the location:\n[[location: Tokyo Station | Tokyo, Japan | 35.6812 | 139.7671]]", + location: { + title: "Tokyo Station", + address: "Tokyo, Japan", + latitude: 35.6812, + longitude: 139.7671, + }, + outputText: "Here's the location:", + }, + { + text: "[[location: Place | Address | invalid | 139.7]]", + location: undefined, + }, + { + text: "[[location: New | New Addr | 35.6 | 139.7]]", + channelData: { line: { location: existing } }, + location: existing, + }, + ]; - expect(getLineData(result).location).toEqual(existing); + for (const testCase of cases) { + const result = parseLineDirectives({ + text: testCase.text, + channelData: testCase.channelData, + }); + expect(getLineData(result).location).toEqual(testCase.location); + if (testCase.outputText !== undefined) { + expect(result.text).toBe(testCase.outputText); + } + } }); }); describe("confirm", () => { - it("parses simple confirm", () => { - const result = parseLineDirectives({ - text: "[[confirm: Delete this item? | Yes | No]]", - }); + it("parses confirm directives with default and custom action payloads", () => { + const cases = [ + { + name: "default yes/no data", + text: "[[confirm: Delete this item? | Yes | No]]", + expectedTemplate: { + type: "confirm", + text: "Delete this item?", + confirmLabel: "Yes", + confirmData: "yes", + cancelLabel: "No", + cancelData: "no", + altText: "Delete this item?", + }, + expectedText: undefined, + }, + { + name: "custom action data", + text: "[[confirm: Proceed? | OK:action=confirm | Cancel:action=cancel]]", + expectedTemplate: { + type: "confirm", + text: "Proceed?", + confirmLabel: "OK", + confirmData: "action=confirm", + cancelLabel: "Cancel", + cancelData: "action=cancel", + altText: "Proceed?", + }, + expectedText: undefined, + }, + ] as const; - expect(getLineData(result).templateMessage).toEqual({ - type: "confirm", - text: "Delete this item?", - confirmLabel: "Yes", - confirmData: "yes", - cancelLabel: "No", - cancelData: "no", - altText: "Delete this item?", - }); - // Text is undefined when directive consumes entire text - expect(result.text).toBeUndefined(); - }); - - it("parses confirm with custom data", () => { - const result = parseLineDirectives({ - text: "[[confirm: Proceed? | OK:action=confirm | Cancel:action=cancel]]", - }); - - expect(getLineData(result).templateMessage).toEqual({ - type: "confirm", - text: "Proceed?", - confirmLabel: "OK", - confirmData: "action=confirm", - cancelLabel: "Cancel", - cancelData: "action=cancel", - altText: "Proceed?", - }); + for (const testCase of cases) { + const result = parseLineDirectives({ text: testCase.text }); + expect(getLineData(result).templateMessage, testCase.name).toEqual( + testCase.expectedTemplate, + ); + expect(result.text, testCase.name).toBe(testCase.expectedText); + } }); }); describe("buttons", () => { - it("parses buttons with message actions", () => { - const result = parseLineDirectives({ - text: "[[buttons: Menu | Select an option | Help:/help, Status:/status]]", - }); + it("parses message/uri/postback button actions and enforces action caps", () => { + const cases = [ + { + name: "message actions", + text: "[[buttons: Menu | Select an option | Help:/help, Status:/status]]", + expectedTemplate: { + type: "buttons", + title: "Menu", + text: "Select an option", + actions: [ + { type: "message", label: "Help", data: "/help" }, + { type: "message", label: "Status", data: "/status" }, + ], + altText: "Menu: Select an option", + }, + }, + { + name: "uri action", + text: "[[buttons: Links | Visit us | Site:https://example.com]]", + expectedFirstAction: { + type: "uri", + label: "Site", + uri: "https://example.com", + }, + }, + { + name: "postback action", + text: "[[buttons: Actions | Choose | Select:action=select&id=1]]", + expectedFirstAction: { + type: "postback", + label: "Select", + data: "action=select&id=1", + }, + }, + { + name: "action cap", + text: "[[buttons: Menu | Text | A:a, B:b, C:c, D:d, E:e, F:f]]", + expectedActionCount: 4, + }, + ] as const; - expect(getLineData(result).templateMessage).toEqual({ - type: "buttons", - title: "Menu", - text: "Select an option", - actions: [ - { type: "message", label: "Help", data: "/help" }, - { type: "message", label: "Status", data: "/status" }, - ], - altText: "Menu: Select an option", - }); - }); - - it("parses buttons with uri actions", () => { - const result = parseLineDirectives({ - text: "[[buttons: Links | Visit us | Site:https://example.com]]", - }); - - const templateMessage = getLineData(result).templateMessage as { - type?: string; - actions?: Array>; - }; - expect(templateMessage?.type).toBe("buttons"); - if (templateMessage?.type === "buttons") { - expect(templateMessage.actions?.[0]).toEqual({ - type: "uri", - label: "Site", - uri: "https://example.com", - }); - } - }); - - it("parses buttons with postback actions", () => { - const result = parseLineDirectives({ - text: "[[buttons: Actions | Choose | Select:action=select&id=1]]", - }); - - const templateMessage = getLineData(result).templateMessage as { - type?: string; - actions?: Array>; - }; - expect(templateMessage?.type).toBe("buttons"); - if (templateMessage?.type === "buttons") { - expect(templateMessage.actions?.[0]).toEqual({ - type: "postback", - label: "Select", - data: "action=select&id=1", - }); - } - }); - - it("limits to 4 actions", () => { - const result = parseLineDirectives({ - text: "[[buttons: Menu | Text | A:a, B:b, C:c, D:d, E:e, F:f]]", - }); - - const templateMessage = getLineData(result).templateMessage as { - type?: string; - actions?: Array>; - }; - expect(templateMessage?.type).toBe("buttons"); - if (templateMessage?.type === "buttons") { - expect(templateMessage.actions?.length).toBe(4); + for (const testCase of cases) { + const result = parseLineDirectives({ text: testCase.text }); + const templateMessage = getLineData(result).templateMessage as { + type?: string; + actions?: Array>; + }; + expect(templateMessage?.type, testCase.name).toBe("buttons"); + if ("expectedTemplate" in testCase) { + expect(templateMessage, testCase.name).toEqual(testCase.expectedTemplate); + } + if ("expectedFirstAction" in testCase) { + expect(templateMessage?.actions?.[0], testCase.name).toEqual( + testCase.expectedFirstAction, + ); + } + if ("expectedActionCount" in testCase) { + expect(templateMessage?.actions?.length, testCase.name).toBe( + testCase.expectedActionCount, + ); + } } }); }); describe("media_player", () => { - it("parses media_player with all fields", () => { - const result = parseLineDirectives({ - text: "Now playing:\n[[media_player: Bohemian Rhapsody | Queen | Speaker | https://example.com/album.jpg | playing]]", - }); + it("parses media_player directives across full/minimal/paused variants", () => { + const cases = [ + { + name: "all fields", + text: "Now playing:\n[[media_player: Bohemian Rhapsody | Queen | Speaker | https://example.com/album.jpg | playing]]", + expectedAltText: "🎵 Bohemian Rhapsody - Queen", + expectedText: "Now playing:", + expectFooter: true, + }, + { + name: "minimal", + text: "[[media_player: Unknown Track]]", + expectedAltText: "🎵 Unknown Track", + expectedText: undefined, + expectFooter: false, + }, + { + name: "paused status", + text: "[[media_player: Song | Artist | Player | | paused]]", + expectedAltText: undefined, + expectedText: undefined, + expectFooter: false, + expectBodyContents: true, + }, + ] as const; - const flexMessage = getLineData(result).flexMessage as { - altText?: string; - contents?: { footer?: { contents?: unknown[] } }; - }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("🎵 Bohemian Rhapsody - Queen"); - const contents = flexMessage?.contents as { footer?: { contents?: unknown[] } }; - expect(contents.footer?.contents?.length).toBeGreaterThan(0); - expect(result.text).toBe("Now playing:"); - }); - - it("parses media_player with minimal fields", () => { - const result = parseLineDirectives({ - text: "[[media_player: Unknown Track]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("🎵 Unknown Track"); - }); - - it("handles paused status", () => { - const result = parseLineDirectives({ - text: "[[media_player: Song | Artist | Player | | paused]]", - }); - - const flexMessage = getLineData(result).flexMessage as { - contents?: { body: { contents: unknown[] } }; - }; - expect(flexMessage).toBeDefined(); - const contents = flexMessage?.contents as { body: { contents: unknown[] } }; - expect(contents).toBeDefined(); + for (const testCase of cases) { + const result = parseLineDirectives({ text: testCase.text }); + const flexMessage = getLineData(result).flexMessage as { + altText?: string; + contents?: { footer?: { contents?: unknown[] }; body?: { contents?: unknown[] } }; + }; + expect(flexMessage, testCase.name).toBeDefined(); + if (testCase.expectedAltText !== undefined) { + expect(flexMessage?.altText, testCase.name).toBe(testCase.expectedAltText); + } + if (testCase.expectedText !== undefined) { + expect(result.text, testCase.name).toBe(testCase.expectedText); + } + if (testCase.expectFooter) { + expect(flexMessage?.contents?.footer?.contents?.length, testCase.name).toBeGreaterThan(0); + } + if (testCase.expectBodyContents) { + expect(flexMessage?.contents?.body?.contents, testCase.name).toBeDefined(); + } + } }); }); describe("event", () => { - it("parses event with all fields", () => { - const result = parseLineDirectives({ - text: "[[event: Team Meeting | January 24, 2026 | 2:00 PM - 3:00 PM | Conference Room A | Discuss Q1 roadmap]]", - }); + it("parses event variants", () => { + const cases = [ + { + text: "[[event: Team Meeting | January 24, 2026 | 2:00 PM - 3:00 PM | Conference Room A | Discuss Q1 roadmap]]", + altText: "📅 Team Meeting - January 24, 2026 2:00 PM - 3:00 PM", + }, + { + text: "[[event: Birthday Party | March 15]]", + altText: "📅 Birthday Party - March 15", + }, + ]; - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("📅 Team Meeting - January 24, 2026 2:00 PM - 3:00 PM"); - }); - - it("parses event with minimal fields", () => { - const result = parseLineDirectives({ - text: "[[event: Birthday Party | March 15]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("📅 Birthday Party - March 15"); + for (const testCase of cases) { + const result = parseLineDirectives({ text: testCase.text }); + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe(testCase.altText); + } }); }); describe("agenda", () => { - it("parses agenda with multiple events", () => { - const result = parseLineDirectives({ - text: "[[agenda: Today's Schedule | Team Meeting:9:00 AM, Lunch:12:00 PM, Review:3:00 PM]]", - }); + it("parses agenda variants", () => { + const cases = [ + { + text: "[[agenda: Today's Schedule | Team Meeting:9:00 AM, Lunch:12:00 PM, Review:3:00 PM]]", + altText: "📋 Today's Schedule (3 events)", + }, + { + text: "[[agenda: Tasks | Buy groceries, Call mom, Workout]]", + altText: "📋 Tasks (3 events)", + }, + ]; - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("📋 Today's Schedule (3 events)"); - }); - - it("parses agenda with events without times", () => { - const result = parseLineDirectives({ - text: "[[agenda: Tasks | Buy groceries, Call mom, Workout]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("📋 Tasks (3 events)"); + for (const testCase of cases) { + const result = parseLineDirectives({ text: testCase.text }); + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe(testCase.altText); + } }); }); describe("device", () => { - it("parses device with controls", () => { - const result = parseLineDirectives({ - text: "[[device: TV | Streaming Box | Playing | Play/Pause:toggle, Menu:menu]]", - }); + it("parses device variants", () => { + const cases = [ + { + text: "[[device: TV | Streaming Box | Playing | Play/Pause:toggle, Menu:menu]]", + altText: "📱 TV: Playing", + }, + { + text: "[[device: Speaker]]", + altText: "📱 Speaker", + }, + ]; - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("📱 TV: Playing"); - }); - - it("parses device with minimal fields", () => { - const result = parseLineDirectives({ - text: "[[device: Speaker]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("📱 Speaker"); + for (const testCase of cases) { + const result = parseLineDirectives({ text: testCase.text }); + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe(testCase.altText); + } }); }); describe("appletv_remote", () => { - it("parses appletv_remote with status", () => { - const result = parseLineDirectives({ - text: "[[appletv_remote: Apple TV | Playing]]", - }); + it("parses appletv remote variants", () => { + const cases = [ + { + text: "[[appletv_remote: Apple TV | Playing]]", + contains: "Apple TV", + }, + { + text: "[[appletv_remote: Apple TV]]", + contains: undefined, + }, + ]; - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toContain("Apple TV"); - }); - - it("parses appletv_remote with minimal fields", () => { - const result = parseLineDirectives({ - text: "[[appletv_remote: Apple TV]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); + for (const testCase of cases) { + const result = parseLineDirectives({ text: testCase.text }); + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + if (testCase.contains) { + expect(flexMessage?.altText).toContain(testCase.contains); + } + } }); }); @@ -1205,34 +1201,15 @@ describe("createReplyDispatcher", () => { }); describe("resolveReplyToMode", () => { - it("defaults to off for Telegram", () => { - expect(resolveReplyToMode(emptyCfg, "telegram")).toBe("off"); - }); - - it("defaults to off for Discord and Slack", () => { - expect(resolveReplyToMode(emptyCfg, "discord")).toBe("off"); - expect(resolveReplyToMode(emptyCfg, "slack")).toBe("off"); - }); - - it("defaults to all when channel is unknown", () => { - expect(resolveReplyToMode(emptyCfg, undefined)).toBe("all"); - }); - - it("uses configured value when present", () => { - const cfg = { + it("resolves defaults, channel overrides, chat-type overrides, and legacy dm overrides", () => { + const configuredCfg = { channels: { telegram: { replyToMode: "all" }, discord: { replyToMode: "first" }, slack: { replyToMode: "all" }, }, } as OpenClawConfig; - expect(resolveReplyToMode(cfg, "telegram")).toBe("all"); - expect(resolveReplyToMode(cfg, "discord")).toBe("first"); - expect(resolveReplyToMode(cfg, "slack")).toBe("all"); - }); - - it("uses chat-type replyToMode overrides for Slack when configured", () => { - const cfg = { + const chatTypeCfg = { channels: { slack: { replyToMode: "off", @@ -1240,26 +1217,14 @@ describe("resolveReplyToMode", () => { }, }, } as OpenClawConfig; - expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all"); - expect(resolveReplyToMode(cfg, "slack", null, "group")).toBe("first"); - expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off"); - expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off"); - }); - - it("falls back to top-level replyToMode when no chat-type override is set", () => { - const cfg = { + const topLevelFallbackCfg = { channels: { slack: { replyToMode: "first", }, }, } as OpenClawConfig; - expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first"); - expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first"); - }); - - it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => { - const cfg = { + const legacyDmCfg = { channels: { slack: { replyToMode: "off", @@ -1267,25 +1232,63 @@ describe("resolveReplyToMode", () => { }, }, } as OpenClawConfig; - expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all"); - expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off"); + + const cases: Array<{ + cfg: OpenClawConfig; + channel?: "telegram" | "discord" | "slack"; + chatType?: "direct" | "group" | "channel"; + expected: "off" | "all" | "first"; + }> = [ + { cfg: emptyCfg, channel: "telegram", expected: "off" }, + { cfg: emptyCfg, channel: "discord", expected: "off" }, + { cfg: emptyCfg, channel: "slack", expected: "off" }, + { cfg: emptyCfg, channel: undefined, expected: "all" }, + { cfg: configuredCfg, channel: "telegram", expected: "all" }, + { cfg: configuredCfg, channel: "discord", expected: "first" }, + { cfg: configuredCfg, channel: "slack", expected: "all" }, + { cfg: chatTypeCfg, channel: "slack", chatType: "direct", expected: "all" }, + { cfg: chatTypeCfg, channel: "slack", chatType: "group", expected: "first" }, + { cfg: chatTypeCfg, channel: "slack", chatType: "channel", expected: "off" }, + { cfg: chatTypeCfg, channel: "slack", chatType: undefined, expected: "off" }, + { cfg: topLevelFallbackCfg, channel: "slack", chatType: "direct", expected: "first" }, + { cfg: topLevelFallbackCfg, channel: "slack", chatType: "channel", expected: "first" }, + { cfg: legacyDmCfg, channel: "slack", chatType: "direct", expected: "all" }, + { cfg: legacyDmCfg, channel: "slack", chatType: "channel", expected: "off" }, + ]; + for (const testCase of cases) { + expect(resolveReplyToMode(testCase.cfg, testCase.channel, null, testCase.chatType)).toBe( + testCase.expected, + ); + } }); }); describe("createReplyToModeFilter", () => { - it("drops replyToId when mode is off", () => { - const filter = createReplyToModeFilter("off"); - expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBeUndefined(); - }); - - it("keeps replyToId when mode is off and reply tags are allowed", () => { - const filter = createReplyToModeFilter("off", { allowExplicitReplyTagsWhenOff: true }); - expect(filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId).toBe("1"); - }); - - it("keeps replyToId when mode is all", () => { - const filter = createReplyToModeFilter("all"); - expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1"); + it("handles off/all mode behavior for replyToId", () => { + const cases: Array<{ + filter: ReturnType; + input: { text: string; replyToId?: string; replyToTag?: boolean }; + expectedReplyToId?: string; + }> = [ + { + filter: createReplyToModeFilter("off"), + input: { text: "hi", replyToId: "1" }, + expectedReplyToId: undefined, + }, + { + filter: createReplyToModeFilter("off", { allowExplicitReplyTagsWhenOff: true }), + input: { text: "hi", replyToId: "1", replyToTag: true }, + expectedReplyToId: "1", + }, + { + filter: createReplyToModeFilter("all"), + input: { text: "hi", replyToId: "1" }, + expectedReplyToId: "1", + }, + ]; + for (const testCase of cases) { + expect(testCase.filter(testCase.input).replyToId).toBe(testCase.expectedReplyToId); + } }); it("keeps only the first replyToId when mode is first", () => { diff --git a/src/auto-reply/reply/reply-utils.test.ts b/src/auto-reply/reply/reply-utils.test.ts index 946fb741317..4262b80db0f 100644 --- a/src/auto-reply/reply/reply-utils.test.ts +++ b/src/auto-reply/reply/reply-utils.test.ts @@ -18,56 +18,61 @@ import { createTypingController } from "./typing.js"; describe("matchesMentionWithExplicit", () => { const mentionRegexes = [/\bopenclaw\b/i]; - it("checks mentionPatterns even when explicit mention is available", () => { - const result = matchesMentionWithExplicit({ - text: "@openclaw hello", - mentionRegexes, - explicit: { - hasAnyMention: true, - isExplicitlyMentioned: false, - canResolveExplicit: true, + it("combines explicit-mention state with regex fallback rules", () => { + const cases = [ + { + name: "regex match with explicit resolver available", + text: "@openclaw hello", + mentionRegexes, + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: false, + canResolveExplicit: true, + }, + expected: true, }, - }); - expect(result).toBe(true); - }); - - it("returns false when explicit is false and no regex match", () => { - const result = matchesMentionWithExplicit({ - text: "<@999999> hello", - mentionRegexes, - explicit: { - hasAnyMention: true, - isExplicitlyMentioned: false, - canResolveExplicit: true, + { + name: "no explicit and no regex match", + text: "<@999999> hello", + mentionRegexes, + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: false, + canResolveExplicit: true, + }, + expected: false, }, - }); - expect(result).toBe(false); - }); - - it("returns true when explicitly mentioned even if regexes do not match", () => { - const result = matchesMentionWithExplicit({ - text: "<@123456>", - mentionRegexes: [], - explicit: { - hasAnyMention: true, - isExplicitlyMentioned: true, - canResolveExplicit: true, + { + name: "explicit mention even without regex", + text: "<@123456>", + mentionRegexes: [], + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: true, + canResolveExplicit: true, + }, + expected: true, }, - }); - expect(result).toBe(true); - }); - - it("falls back to regex matching when explicit mention cannot be resolved", () => { - const result = matchesMentionWithExplicit({ - text: "openclaw please", - mentionRegexes, - explicit: { - hasAnyMention: true, - isExplicitlyMentioned: false, - canResolveExplicit: false, + { + name: "falls back to regex when explicit cannot resolve", + text: "openclaw please", + mentionRegexes, + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: false, + canResolveExplicit: false, + }, + expected: true, }, - }); - expect(result).toBe(true); + ] as const; + for (const testCase of cases) { + const result = matchesMentionWithExplicit({ + text: testCase.text, + mentionRegexes: [...testCase.mentionRegexes], + explicit: testCase.explicit, + }); + expect(result, testCase.name).toBe(testCase.expected); + } }); }); @@ -89,30 +94,19 @@ describe("normalizeReplyPayload", () => { expect(normalized?.channelData).toEqual(payload.channelData); }); - it("records silent skips", () => { - const reasons: string[] = []; - const normalized = normalizeReplyPayload( - { text: SILENT_REPLY_TOKEN }, - { + it("records skip reasons for silent/empty payloads", () => { + const cases = [ + { name: "silent", payload: { text: SILENT_REPLY_TOKEN }, reason: "silent" }, + { name: "empty", payload: { text: " " }, reason: "empty" }, + ] as const; + for (const testCase of cases) { + const reasons: string[] = []; + const normalized = normalizeReplyPayload(testCase.payload, { onSkip: (reason) => reasons.push(reason), - }, - ); - - expect(normalized).toBeNull(); - expect(reasons).toEqual(["silent"]); - }); - - it("records empty skips", () => { - const reasons: string[] = []; - const normalized = normalizeReplyPayload( - { text: " " }, - { - onSkip: (reason) => reasons.push(reason), - }, - ); - - expect(normalized).toBeNull(); - expect(reasons).toEqual(["empty"]); + }); + expect(normalized, testCase.name).toBeNull(); + expect(reasons, testCase.name).toEqual([testCase.reason]); + } }); }); @@ -121,49 +115,43 @@ describe("typing controller", () => { vi.useRealTimers(); }); - it("stops after run completion and dispatcher idle", async () => { + it("stops only after both run completion and dispatcher idle are set (any order)", async () => { vi.useFakeTimers(); - const onReplyStart = vi.fn(async () => {}); - const typing = createTypingController({ - onReplyStart, - typingIntervalSeconds: 1, - typingTtlMs: 30_000, - }); + const cases = [ + { name: "run-complete first", first: "run", second: "idle" }, + { name: "dispatch-idle first", first: "idle", second: "run" }, + ] as const; - await typing.startTypingLoop(); - expect(onReplyStart).toHaveBeenCalledTimes(1); + for (const testCase of cases) { + const onReplyStart = vi.fn(async () => {}); + const typing = createTypingController({ + onReplyStart, + typingIntervalSeconds: 1, + typingTtlMs: 30_000, + }); - vi.advanceTimersByTime(2_000); - expect(onReplyStart).toHaveBeenCalledTimes(3); + await typing.startTypingLoop(); + expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(1); - typing.markRunComplete(); - vi.advanceTimersByTime(1_000); - expect(onReplyStart).toHaveBeenCalledTimes(4); + vi.advanceTimersByTime(2_000); + expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(3); - typing.markDispatchIdle(); - vi.advanceTimersByTime(2_000); - expect(onReplyStart).toHaveBeenCalledTimes(4); - }); + if (testCase.first === "run") { + typing.markRunComplete(); + } else { + typing.markDispatchIdle(); + } + vi.advanceTimersByTime(2_000); + expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(5); - it("keeps typing until both idle and run completion are set", async () => { - vi.useFakeTimers(); - const onReplyStart = vi.fn(async () => {}); - const typing = createTypingController({ - onReplyStart, - typingIntervalSeconds: 1, - typingTtlMs: 30_000, - }); - - await typing.startTypingLoop(); - expect(onReplyStart).toHaveBeenCalledTimes(1); - - typing.markDispatchIdle(); - vi.advanceTimersByTime(2_000); - expect(onReplyStart).toHaveBeenCalledTimes(3); - - typing.markRunComplete(); - vi.advanceTimersByTime(2_000); - expect(onReplyStart).toHaveBeenCalledTimes(3); + if (testCase.second === "run") { + typing.markRunComplete(); + } else { + typing.markDispatchIdle(); + } + vi.advanceTimersByTime(2_000); + expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(5); + } }); it("does not start typing after run completion", async () => { @@ -207,99 +195,228 @@ describe("typing controller", () => { }); describe("resolveTypingMode", () => { - it("defaults to instant for direct chats", () => { - expect( - resolveTypingMode({ - configured: undefined, - isGroupChat: false, - wasMentioned: false, - isHeartbeat: false, - }), - ).toBe("instant"); + it("resolves defaults, configured overrides, and heartbeat suppression", () => { + const cases = [ + { + name: "default direct chat", + input: { + configured: undefined, + isGroupChat: false, + wasMentioned: false, + isHeartbeat: false, + }, + expected: "instant", + }, + { + name: "default group chat without mention", + input: { + configured: undefined, + isGroupChat: true, + wasMentioned: false, + isHeartbeat: false, + }, + expected: "message", + }, + { + name: "default mentioned group chat", + input: { + configured: undefined, + isGroupChat: true, + wasMentioned: true, + isHeartbeat: false, + }, + expected: "instant", + }, + { + name: "configured thinking override", + input: { + configured: "thinking" as const, + isGroupChat: false, + wasMentioned: false, + isHeartbeat: false, + }, + expected: "thinking", + }, + { + name: "configured message override", + input: { + configured: "message" as const, + isGroupChat: true, + wasMentioned: true, + isHeartbeat: false, + }, + expected: "message", + }, + { + name: "heartbeat forces never", + input: { + configured: "instant" as const, + isGroupChat: false, + wasMentioned: false, + isHeartbeat: true, + }, + expected: "never", + }, + ] as const; + + for (const testCase of cases) { + expect(resolveTypingMode(testCase.input), testCase.name).toBe(testCase.expected); + } + }); +}); + +describe("parseAudioTag", () => { + it("extracts audio tag state and cleaned text", () => { + const cases = [ + { + name: "tag in sentence", + input: "Hello [[audio_as_voice]] world", + expected: { audioAsVoice: true, hadTag: true, text: "Hello world" }, + }, + { + name: "missing text", + input: undefined, + expected: { audioAsVoice: false, hadTag: false, text: "" }, + }, + { + name: "tag-only content", + input: "[[audio_as_voice]]", + expected: { audioAsVoice: true, hadTag: true, text: "" }, + }, + ] as const; + for (const testCase of cases) { + const result = parseAudioTag(testCase.input); + expect(result.audioAsVoice, testCase.name).toBe(testCase.expected.audioAsVoice); + expect(result.hadTag, testCase.name).toBe(testCase.expected.hadTag); + expect(result.text, testCase.name).toBe(testCase.expected.text); + } + }); +}); + +describe("resolveResponsePrefixTemplate", () => { + it("resolves known variables, aliases, and case-insensitive tokens", () => { + const cases = [ + { + name: "model", + template: "[{model}]", + values: { model: "gpt-5.2" }, + expected: "[gpt-5.2]", + }, + { + name: "modelFull", + template: "[{modelFull}]", + values: { modelFull: "openai-codex/gpt-5.2" }, + expected: "[openai-codex/gpt-5.2]", + }, + { + name: "provider", + template: "[{provider}]", + values: { provider: "anthropic" }, + expected: "[anthropic]", + }, + { + name: "thinkingLevel", + template: "think:{thinkingLevel}", + values: { thinkingLevel: "high" }, + expected: "think:high", + }, + { + name: "think alias", + template: "think:{think}", + values: { thinkingLevel: "low" }, + expected: "think:low", + }, + { + name: "identity.name", + template: "[{identity.name}]", + values: { identityName: "OpenClaw" }, + expected: "[OpenClaw]", + }, + { + name: "identityName alias", + template: "[{identityName}]", + values: { identityName: "OpenClaw" }, + expected: "[OpenClaw]", + }, + { + name: "case-insensitive variables", + template: "[{MODEL} | {ThinkingLevel}]", + values: { model: "gpt-5.2", thinkingLevel: "low" }, + expected: "[gpt-5.2 | low]", + }, + { + name: "all variables", + template: "[{identity.name}] {provider}/{model} (think:{thinkingLevel})", + values: { + identityName: "OpenClaw", + provider: "anthropic", + model: "claude-opus-4-5", + thinkingLevel: "high", + }, + expected: "[OpenClaw] anthropic/claude-opus-4-5 (think:high)", + }, + ] as const; + for (const testCase of cases) { + expect(resolveResponsePrefixTemplate(testCase.template, testCase.values), testCase.name).toBe( + testCase.expected, + ); + } }); - it("defaults to message for group chats without mentions", () => { - expect( - resolveTypingMode({ - configured: undefined, - isGroupChat: true, - wasMentioned: false, - isHeartbeat: false, - }), - ).toBe("message"); - }); - - it("defaults to instant for mentioned group chats", () => { - expect( - resolveTypingMode({ - configured: undefined, - isGroupChat: true, - wasMentioned: true, - isHeartbeat: false, - }), - ).toBe("instant"); - }); - - it("honors configured mode across contexts", () => { - expect( - resolveTypingMode({ - configured: "thinking", - isGroupChat: false, - wasMentioned: false, - isHeartbeat: false, - }), - ).toBe("thinking"); - expect( - resolveTypingMode({ - configured: "message", - isGroupChat: true, - wasMentioned: true, - isHeartbeat: false, - }), - ).toBe("message"); - }); - - it("forces never for heartbeat runs", () => { - expect( - resolveTypingMode({ - configured: "instant", - isGroupChat: false, - wasMentioned: false, - isHeartbeat: true, - }), - ).toBe("never"); + it("preserves unresolved/unknown placeholders and handles static inputs", () => { + const cases = [ + { name: "undefined template", template: undefined, values: {}, expected: undefined }, + { name: "no variables", template: "[Claude]", values: {}, expected: "[Claude]" }, + { + name: "unresolved known variable", + template: "[{model}]", + values: {}, + expected: "[{model}]", + }, + { + name: "unrecognized variable", + template: "[{unknownVar}]", + values: { model: "gpt-5.2" }, + expected: "[{unknownVar}]", + }, + { + name: "mixed resolved/unresolved", + template: "[{model} | {provider}]", + values: { model: "gpt-5.2" }, + expected: "[gpt-5.2 | {provider}]", + }, + ] as const; + for (const testCase of cases) { + expect(resolveResponsePrefixTemplate(testCase.template, testCase.values), testCase.name).toBe( + testCase.expected, + ); + } }); }); describe("createTypingSignaler", () => { - it("signals immediately for instant mode", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "instant", - isHeartbeat: false, - }); + it("gates run-start typing by mode", async () => { + const cases = [ + { name: "instant", mode: "instant" as const, expectedStartCalls: 1 }, + { name: "message", mode: "message" as const, expectedStartCalls: 0 }, + { name: "thinking", mode: "thinking" as const, expectedStartCalls: 0 }, + ] as const; + for (const testCase of cases) { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: testCase.mode, + isHeartbeat: false, + }); - await signaler.signalRunStart(); - - expect(typing.startTypingLoop).toHaveBeenCalled(); + await signaler.signalRunStart(); + expect(typing.startTypingLoop, testCase.name).toHaveBeenCalledTimes( + testCase.expectedStartCalls, + ); + } }); - it("signals on text for message mode", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "message", - isHeartbeat: false, - }); - - await signaler.signalTextDelta("hello"); - - expect(typing.startTypingOnText).toHaveBeenCalledWith("hello"); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - }); - - it("signals on message start for message mode", async () => { + it("signals on message-mode boundaries and text deltas", async () => { const typing = createMockTypingController(); const signaler = createTypingSignaler({ typing, @@ -312,9 +429,10 @@ describe("createTypingSignaler", () => { expect(typing.startTypingLoop).not.toHaveBeenCalled(); await signaler.signalTextDelta("hello"); expect(typing.startTypingOnText).toHaveBeenCalledWith("hello"); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); }); - it("signals on reasoning for thinking mode", async () => { + it("starts typing and refreshes ttl on text for thinking mode", async () => { const typing = createMockTypingController(); const signaler = createTypingSignaler({ typing, @@ -326,24 +444,11 @@ describe("createTypingSignaler", () => { expect(typing.startTypingLoop).not.toHaveBeenCalled(); await signaler.signalTextDelta("hi"); expect(typing.startTypingLoop).toHaveBeenCalled(); - }); - - it("refreshes ttl on text for thinking mode", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "thinking", - isHeartbeat: false, - }); - - await signaler.signalTextDelta("hi"); - - expect(typing.startTypingLoop).toHaveBeenCalled(); expect(typing.refreshTypingTtl).toHaveBeenCalled(); expect(typing.startTypingOnText).not.toHaveBeenCalled(); }); - it("starts typing on tool start before text", async () => { + it("handles tool-start typing before and after active text mode", async () => { const typing = createMockTypingController(); const signaler = createTypingSignaler({ typing, @@ -356,21 +461,8 @@ describe("createTypingSignaler", () => { expect(typing.startTypingLoop).toHaveBeenCalled(); expect(typing.refreshTypingTtl).toHaveBeenCalled(); expect(typing.startTypingOnText).not.toHaveBeenCalled(); - }); - - it("refreshes ttl on tool start when active after text", async () => { - const typing = createMockTypingController({ - isActive: vi.fn(() => true), - }); - const signaler = createTypingSignaler({ - typing, - mode: "message", - isHeartbeat: false, - }); - - await signaler.signalTextDelta("hello"); + (typing.isActive as ReturnType).mockReturnValue(true); (typing.startTypingLoop as ReturnType).mockClear(); - (typing.startTypingOnText as ReturnType).mockClear(); (typing.refreshTypingTtl as ReturnType).mockClear(); await signaler.signalToolStart(); @@ -395,28 +487,6 @@ describe("createTypingSignaler", () => { }); }); -describe("parseAudioTag", () => { - it("detects audio_as_voice and strips the tag", () => { - const result = parseAudioTag("Hello [[audio_as_voice]] world"); - expect(result.audioAsVoice).toBe(true); - expect(result.hadTag).toBe(true); - expect(result.text).toBe("Hello world"); - }); - - it("returns empty output for missing text", () => { - const result = parseAudioTag(undefined); - expect(result.audioAsVoice).toBe(false); - expect(result.hadTag).toBe(false); - expect(result.text).toBe(""); - }); - - it("removes tag-only messages", () => { - const result = parseAudioTag("[[audio_as_voice]]"); - expect(result.audioAsVoice).toBe(true); - expect(result.text).toBe(""); - }); -}); - describe("block reply coalescer", () => { afterEach(() => { vi.useRealTimers(); @@ -462,25 +532,6 @@ describe("block reply coalescer", () => { coalescer.stop(); }); - it("flushes each enqueued payload separately when flushOnEnqueue is set", async () => { - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, - }); - - coalescer.enqueue({ text: "First paragraph" }); - coalescer.enqueue({ text: "Second paragraph" }); - coalescer.enqueue({ text: "Third paragraph" }); - - await Promise.resolve(); - expect(flushes).toEqual(["First paragraph", "Second paragraph", "Third paragraph"]); - coalescer.stop(); - }); - it("still accumulates when flushOnEnqueue is not set (default)", async () => { vi.useFakeTimers(); const flushes: string[] = []; @@ -500,41 +551,36 @@ describe("block reply coalescer", () => { coalescer.stop(); }); - it("flushes short payloads immediately when flushOnEnqueue is set", async () => { - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: "\n\n", flushOnEnqueue: true }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); + it("flushes immediately per enqueue when flushOnEnqueue is set", async () => { + const cases = [ + { + config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: "\n\n", flushOnEnqueue: true }, + inputs: ["Hi"], + expected: ["Hi"], }, - }); - - coalescer.enqueue({ text: "Hi" }); - await Promise.resolve(); - expect(flushes).toEqual(["Hi"]); - coalescer.stop(); - }); - - it("resets char budget per paragraph with flushOnEnqueue", async () => { - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 1, maxChars: 30, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); + { + config: { minChars: 1, maxChars: 30, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true }, + inputs: ["12345678901234567890", "abcdefghijklmnopqrst"], + expected: ["12345678901234567890", "abcdefghijklmnopqrst"], }, - }); + ] as const; - // Each 20-char payload fits within maxChars=30 individually - coalescer.enqueue({ text: "12345678901234567890" }); - coalescer.enqueue({ text: "abcdefghijklmnopqrst" }); - - await Promise.resolve(); - // Without flushOnEnqueue, these would be joined to 40+ chars and trigger maxChars split. - // With flushOnEnqueue, each is sent independently within budget. - expect(flushes).toEqual(["12345678901234567890", "abcdefghijklmnopqrst"]); - coalescer.stop(); + for (const testCase of cases) { + const flushes: string[] = []; + const coalescer = createBlockReplyCoalescer({ + config: testCase.config, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push(payload.text ?? ""); + }, + }); + for (const input of testCase.inputs) { + coalescer.enqueue({ text: input }); + } + await Promise.resolve(); + expect(flushes).toEqual(testCase.expected); + coalescer.stop(); + } }); it("flushes buffered text before media payloads", () => { @@ -562,42 +608,36 @@ describe("block reply coalescer", () => { }); describe("createReplyReferencePlanner", () => { - it("disables references when mode is off", () => { - const planner = createReplyReferencePlanner({ + it("plans references correctly for off/first/all modes", () => { + const offPlanner = createReplyReferencePlanner({ replyToMode: "off", startId: "parent", }); - expect(planner.use()).toBeUndefined(); - }); + expect(offPlanner.use()).toBeUndefined(); - it("uses startId once when mode is first", () => { - const planner = createReplyReferencePlanner({ + const firstPlanner = createReplyReferencePlanner({ replyToMode: "first", startId: "parent", }); - expect(planner.use()).toBe("parent"); - expect(planner.hasReplied()).toBe(true); - planner.markSent(); - expect(planner.use()).toBeUndefined(); - }); + expect(firstPlanner.use()).toBe("parent"); + expect(firstPlanner.hasReplied()).toBe(true); + firstPlanner.markSent(); + expect(firstPlanner.use()).toBeUndefined(); - it("returns startId for every call when mode is all", () => { - const planner = createReplyReferencePlanner({ + const allPlanner = createReplyReferencePlanner({ replyToMode: "all", startId: "parent", }); - expect(planner.use()).toBe("parent"); - expect(planner.use()).toBe("parent"); - }); + expect(allPlanner.use()).toBe("parent"); + expect(allPlanner.use()).toBe("parent"); - it("uses existingId once when mode is first", () => { - const planner = createReplyReferencePlanner({ + const existingIdPlanner = createReplyReferencePlanner({ replyToMode: "first", existingId: "thread-1", startId: "parent", }); - expect(planner.use()).toBe("thread-1"); - expect(planner.use()).toBeUndefined(); + expect(existingIdPlanner.use()).toBe("thread-1"); + expect(existingIdPlanner.use()).toBeUndefined(); }); it("honors allowReference=false", () => { @@ -634,23 +674,13 @@ describe("createStreamingDirectiveAccumulator", () => { expect(result?.replyToCurrent).toBe(true); }); - it("propagates explicit reply ids across chunks", () => { + it("propagates explicit reply ids across current and subsequent chunks", () => { const accumulator = createStreamingDirectiveAccumulator(); expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull(); - const result = accumulator.consume("Hi"); - expect(result?.text).toBe("Hi"); - expect(result?.replyToId).toBe("abc-123"); - expect(result?.replyToTag).toBe(true); - }); - - it("keeps explicit reply ids sticky across subsequent renderable chunks", () => { - const accumulator = createStreamingDirectiveAccumulator(); - - expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull(); - - const first = accumulator.consume("test 1"); + const first = accumulator.consume("Hi"); + expect(first?.text).toBe("Hi"); expect(first?.replyToId).toBe("abc-123"); expect(first?.replyToTag).toBe(true); @@ -674,136 +704,26 @@ describe("createStreamingDirectiveAccumulator", () => { }); }); -describe("resolveResponsePrefixTemplate", () => { - it("returns undefined for undefined template", () => { - expect(resolveResponsePrefixTemplate(undefined, {})).toBeUndefined(); - }); - - it("returns template as-is when no variables present", () => { - expect(resolveResponsePrefixTemplate("[Claude]", {})).toBe("[Claude]"); - }); - - it("resolves {model} variable", () => { - const result = resolveResponsePrefixTemplate("[{model}]", { - model: "gpt-5.2", - }); - expect(result).toBe("[gpt-5.2]"); - }); - - it("resolves {modelFull} variable", () => { - const result = resolveResponsePrefixTemplate("[{modelFull}]", { - modelFull: "openai-codex/gpt-5.2", - }); - expect(result).toBe("[openai-codex/gpt-5.2]"); - }); - - it("resolves {provider} variable", () => { - const result = resolveResponsePrefixTemplate("[{provider}]", { - provider: "anthropic", - }); - expect(result).toBe("[anthropic]"); - }); - - it("resolves {thinkingLevel} variable", () => { - const result = resolveResponsePrefixTemplate("think:{thinkingLevel}", { - thinkingLevel: "high", - }); - expect(result).toBe("think:high"); - }); - - it("resolves {think} as alias for thinkingLevel", () => { - const result = resolveResponsePrefixTemplate("think:{think}", { - thinkingLevel: "low", - }); - expect(result).toBe("think:low"); - }); - - it("resolves {identity.name} variable", () => { - const result = resolveResponsePrefixTemplate("[{identity.name}]", { - identityName: "OpenClaw", - }); - expect(result).toBe("[OpenClaw]"); - }); - - it("resolves {identityName} as alias", () => { - const result = resolveResponsePrefixTemplate("[{identityName}]", { - identityName: "OpenClaw", - }); - expect(result).toBe("[OpenClaw]"); - }); - - it("leaves unresolved variables as-is", () => { - const result = resolveResponsePrefixTemplate("[{model}]", {}); - expect(result).toBe("[{model}]"); - }); - - it("leaves unrecognized variables as-is", () => { - const result = resolveResponsePrefixTemplate("[{unknownVar}]", { - model: "gpt-5.2", - }); - expect(result).toBe("[{unknownVar}]"); - }); - - it("handles case insensitivity", () => { - const result = resolveResponsePrefixTemplate("[{MODEL} | {ThinkingLevel}]", { - model: "gpt-5.2", - thinkingLevel: "low", - }); - expect(result).toBe("[gpt-5.2 | low]"); - }); - - it("handles mixed resolved and unresolved variables", () => { - const result = resolveResponsePrefixTemplate("[{model} | {provider}]", { - model: "gpt-5.2", - // provider not provided - }); - expect(result).toBe("[gpt-5.2 | {provider}]"); - }); - - it("handles complex template with all variables", () => { - const result = resolveResponsePrefixTemplate( - "[{identity.name}] {provider}/{model} (think:{thinkingLevel})", - { - identityName: "OpenClaw", - provider: "anthropic", - model: "claude-opus-4-5", - thinkingLevel: "high", - }, - ); - expect(result).toBe("[OpenClaw] anthropic/claude-opus-4-5 (think:high)"); - }); -}); - describe("extractShortModelName", () => { - it("strips provider prefix", () => { - expect(extractShortModelName("openai-codex/gpt-5.2-codex")).toBe("gpt-5.2-codex"); - }); - - it("strips date suffix", () => { - expect(extractShortModelName("claude-opus-4-5-20251101")).toBe("claude-opus-4-5"); - }); - - it("strips -latest suffix", () => { - expect(extractShortModelName("gpt-5.2-latest")).toBe("gpt-5.2"); - }); - - it("preserves version numbers that look like dates but are not", () => { - // Date suffix must be exactly 8 digits at the end - expect(extractShortModelName("model-123456789")).toBe("model-123456789"); + it("normalizes provider/date/latest suffixes while preserving other IDs", () => { + const cases = [ + ["openai-codex/gpt-5.2-codex", "gpt-5.2-codex"], + ["claude-opus-4-5-20251101", "claude-opus-4-5"], + ["gpt-5.2-latest", "gpt-5.2"], + // Date suffix must be exactly 8 digits at the end. + ["model-123456789", "model-123456789"], + ] as const; + for (const [input, expected] of cases) { + expect(extractShortModelName(input), input).toBe(expected); + } }); }); describe("hasTemplateVariables", () => { - it("returns false for empty string", () => { + it("handles empty, static, and repeated variable checks", () => { expect(hasTemplateVariables("")).toBe(false); - }); - - it("handles consecutive calls correctly (regex lastIndex reset)", () => { - // First call expect(hasTemplateVariables("[{model}]")).toBe(true); - // Second call should still work expect(hasTemplateVariables("[{model}]")).toBe(true); - // Static string should return false expect(hasTemplateVariables("[Claude]")).toBe(false); }); }); diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 4edd94febf2..32b0dc8937b 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -561,210 +561,102 @@ describe("initSessionState reset triggers in WhatsApp groups", () => { } as OpenClawConfig; } - it("Reset trigger /new works for authorized sender in WhatsApp group", async () => { - const storePath = await createStorePath("openclaw-group-reset-"); + it("applies WhatsApp group reset authorization across sender variants", async () => { const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); + const cases = [ + { + name: "authorized sender", + storePrefix: "openclaw-group-reset-", + allowFrom: ["+41796666864"], + body: `[Chat messages since your last reply - for context]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Someone: hello\\n\\n[Current message - respond to this]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Peschiño: /new\\n[from: Peschiño (+41796666864)]`, + senderName: "Peschiño", + senderE164: "+41796666864", + senderId: "41796666864:0@s.whatsapp.net", + expectedIsNewSession: true, + }, + { + name: "unauthorized sender", + storePrefix: "openclaw-group-reset-unauth-", + allowFrom: ["+41796666864"], + body: `[Context]\\n[WhatsApp ...] OtherPerson: /new\\n[from: OtherPerson (+1555123456)]`, + senderName: "OtherPerson", + senderE164: "+1555123456", + senderId: "1555123456:0@s.whatsapp.net", + expectedIsNewSession: false, + }, + { + name: "raw body clean while body wrapped", + storePrefix: "openclaw-group-rawbody-", + allowFrom: ["*"], + body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Jake: /new\n[from: Jake (+1222)]`, + senderName: undefined, + senderE164: "+1222", + senderId: undefined, + expectedIsNewSession: true, + }, + { + name: "LID sender with authorized E164", + storePrefix: "openclaw-group-reset-lid-", + allowFrom: ["+41796666864"], + body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Owner: /new\n[from: Owner (+41796666864)]`, + senderName: "Owner", + senderE164: "+41796666864", + senderId: "123@lid", + expectedIsNewSession: true, + }, + { + name: "LID sender with unauthorized E164", + storePrefix: "openclaw-group-reset-lid-unauth-", + allowFrom: ["+41796666864"], + body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Other: /new\n[from: Other (+1555123456)]`, + senderName: "Other", + senderE164: "+1555123456", + senderId: "123@lid", + expectedIsNewSession: false, + }, + ] as const; - const cfg = makeCfg({ - storePath, - allowFrom: ["+41796666864"], - }); + for (const testCase of cases) { + const storePath = await createStorePath(testCase.storePrefix); + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + const cfg = makeCfg({ + storePath, + allowFrom: testCase.allowFrom, + }); - const groupMessageCtx = { - Body: `[Chat messages since your last reply - for context]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Someone: hello\\n\\n[Current message - respond to this]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Peschiño: /new\\n[from: Peschiño (+41796666864)]`, - RawBody: "/new", - CommandBody: "/new", - From: "120363406150318674@g.us", - To: "+41779241027", - ChatType: "group", - SessionKey: sessionKey, - Provider: "whatsapp", - Surface: "whatsapp", - SenderName: "Peschiño", - SenderE164: "+41796666864", - SenderId: "41796666864:0@s.whatsapp.net", - }; + const result = await initSessionState({ + ctx: { + Body: testCase.body, + RawBody: "/new", + CommandBody: "/new", + From: "120363406150318674@g.us", + To: "+41779241027", + ChatType: "group", + SessionKey: sessionKey, + Provider: "whatsapp", + Surface: "whatsapp", + SenderName: testCase.senderName, + SenderE164: testCase.senderE164, + SenderId: testCase.senderId, + }, + cfg, + commandAuthorized: true, + }); - const result = await initSessionState({ - ctx: groupMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.triggerBodyNormalized).toBe("/new"); - expect(result.isNewSession).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.bodyStripped).toBe(""); - }); - - it("Reset trigger /new blocked for unauthorized sender in existing session", async () => { - const storePath = await createStorePath("openclaw-group-reset-unauth-"); - const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; - const existingSessionId = "existing-session-123"; - - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = makeCfg({ - storePath, - allowFrom: ["+41796666864"], - }); - - const groupMessageCtx = { - Body: `[Context]\\n[WhatsApp ...] OtherPerson: /new\\n[from: OtherPerson (+1555123456)]`, - RawBody: "/new", - CommandBody: "/new", - From: "120363406150318674@g.us", - To: "+41779241027", - ChatType: "group", - SessionKey: sessionKey, - Provider: "whatsapp", - Surface: "whatsapp", - SenderName: "OtherPerson", - SenderE164: "+1555123456", - SenderId: "1555123456:0@s.whatsapp.net", - }; - - const result = await initSessionState({ - ctx: groupMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.triggerBodyNormalized).toBe("/new"); - expect(result.sessionId).toBe(existingSessionId); - expect(result.isNewSession).toBe(false); - }); - - it("Reset trigger works when RawBody is clean but Body has wrapped context", async () => { - const storePath = await createStorePath("openclaw-group-rawbody-"); - const sessionKey = "agent:main:whatsapp:group:g1"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = makeCfg({ - storePath, - allowFrom: ["*"], - }); - - const groupMessageCtx = { - Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Jake: /new\n[from: Jake (+1222)]`, - RawBody: "/new", - CommandBody: "/new", - From: "120363406150318674@g.us", - To: "+1111", - ChatType: "group", - SessionKey: sessionKey, - Provider: "whatsapp", - SenderE164: "+1222", - }; - - const result = await initSessionState({ - ctx: groupMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.triggerBodyNormalized).toBe("/new"); - expect(result.isNewSession).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.bodyStripped).toBe(""); - }); - - it("Reset trigger /new works when SenderId is LID but SenderE164 is authorized", async () => { - const storePath = await createStorePath("openclaw-group-reset-lid-"); - const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = makeCfg({ - storePath, - allowFrom: ["+41796666864"], - }); - - const groupMessageCtx = { - Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Owner: /new\n[from: Owner (+41796666864)]`, - RawBody: "/new", - CommandBody: "/new", - From: "120363406150318674@g.us", - To: "+41779241027", - ChatType: "group", - SessionKey: sessionKey, - Provider: "whatsapp", - Surface: "whatsapp", - SenderName: "Owner", - SenderE164: "+41796666864", - SenderId: "123@lid", - }; - - const result = await initSessionState({ - ctx: groupMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.triggerBodyNormalized).toBe("/new"); - expect(result.isNewSession).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.bodyStripped).toBe(""); - }); - - it("Reset trigger /new blocked when SenderId is LID but SenderE164 is unauthorized", async () => { - const storePath = await createStorePath("openclaw-group-reset-lid-unauth-"); - const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = makeCfg({ - storePath, - allowFrom: ["+41796666864"], - }); - - const groupMessageCtx = { - Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Other: /new\n[from: Other (+1555123456)]`, - RawBody: "/new", - CommandBody: "/new", - From: "120363406150318674@g.us", - To: "+41779241027", - ChatType: "group", - SessionKey: sessionKey, - Provider: "whatsapp", - Surface: "whatsapp", - SenderName: "Other", - SenderE164: "+1555123456", - SenderId: "123@lid", - }; - - const result = await initSessionState({ - ctx: groupMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.triggerBodyNormalized).toBe("/new"); - expect(result.sessionId).toBe(existingSessionId); - expect(result.isNewSession).toBe(false); + expect(result.triggerBodyNormalized, testCase.name).toBe("/new"); + expect(result.isNewSession, testCase.name).toBe(testCase.expectedIsNewSession); + if (testCase.expectedIsNewSession) { + expect(result.sessionId, testCase.name).not.toBe(existingSessionId); + expect(result.bodyStripped, testCase.name).toBe(""); + } else { + expect(result.sessionId, testCase.name).toBe(existingSessionId); + } + } }); }); @@ -782,84 +674,59 @@ describe("initSessionState reset triggers in Slack channels", () => { }); } - it("Reset trigger /reset works when Slack message has a leading <@...> mention token", async () => { - const storePath = await createStorePath("openclaw-slack-channel-reset-"); - const sessionKey = "agent:main:slack:channel:c1"; + it("supports mention-prefixed Slack reset commands and preserves args", async () => { const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); + const cases = [ + { + name: "reset command", + storePrefix: "openclaw-slack-channel-reset-", + sessionKey: "agent:main:slack:channel:c1", + body: "<@U123> /reset", + expectedBodyStripped: "", + }, + { + name: "new command with args", + storePrefix: "openclaw-slack-channel-new-", + sessionKey: "agent:main:slack:channel:c2", + body: "<@U123> /new take notes", + expectedBodyStripped: "take notes", + }, + ] as const; - const cfg = { - session: { store: storePath, idleMinutes: 999 }, - } as OpenClawConfig; + for (const testCase of cases) { + const storePath = await createStorePath(testCase.storePrefix); + await seedSessionStore({ + storePath, + sessionKey: testCase.sessionKey, + sessionId: existingSessionId, + }); + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; - const channelMessageCtx = { - Body: "<@U123> /reset", - RawBody: "<@U123> /reset", - CommandBody: "<@U123> /reset", - From: "slack:channel:C1", - To: "channel:C1", - ChatType: "channel", - SessionKey: sessionKey, - Provider: "slack", - Surface: "slack", - SenderId: "U123", - SenderName: "Owner", - }; + const result = await initSessionState({ + ctx: { + Body: testCase.body, + RawBody: testCase.body, + CommandBody: testCase.body, + From: "slack:channel:C1", + To: "channel:C1", + ChatType: "channel", + SessionKey: testCase.sessionKey, + Provider: "slack", + Surface: "slack", + SenderId: "U123", + SenderName: "Owner", + }, + cfg, + commandAuthorized: true, + }); - const result = await initSessionState({ - ctx: channelMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.resetTriggered).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.bodyStripped).toBe(""); - }); - - it("Reset trigger /new preserves args when Slack message has a leading <@...> mention token", async () => { - const storePath = await createStorePath("openclaw-slack-channel-new-"); - const sessionKey = "agent:main:slack:channel:c2"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = { - session: { store: storePath, idleMinutes: 999 }, - } as OpenClawConfig; - - const channelMessageCtx = { - Body: "<@U123> /new take notes", - RawBody: "<@U123> /new take notes", - CommandBody: "<@U123> /new take notes", - From: "slack:channel:C2", - To: "channel:C2", - ChatType: "channel", - SessionKey: sessionKey, - Provider: "slack", - Surface: "slack", - SenderId: "U123", - SenderName: "Owner", - }; - - const result = await initSessionState({ - ctx: channelMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.resetTriggered).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.bodyStripped).toBe("take notes"); + expect(result.isNewSession, testCase.name).toBe(true); + expect(result.resetTriggered, testCase.name).toBe(true); + expect(result.sessionId, testCase.name).not.toBe(existingSessionId); + expect(result.bodyStripped, testCase.name).toBe(testCase.expectedBodyStripped); + } }); }); diff --git a/src/tts/tts.test.ts b/src/tts/tts.test.ts index 09dc90e642c..559c52bb7e3 100644 --- a/src/tts/tts.test.ts +++ b/src/tts/tts.test.ts @@ -88,50 +88,38 @@ describe("tts", () => { }); describe("isValidVoiceId", () => { - it("accepts valid ElevenLabs voice IDs", () => { - expect(isValidVoiceId("pMsXgVXv3BLzUgSXRplE")).toBe(true); - expect(isValidVoiceId("21m00Tcm4TlvDq8ikWAM")).toBe(true); - expect(isValidVoiceId("EXAVITQu4vr4xnSDxMaL")).toBe(true); - }); - - it("accepts voice IDs of varying valid lengths", () => { - expect(isValidVoiceId("a1b2c3d4e5")).toBe(true); - expect(isValidVoiceId("a".repeat(40))).toBe(true); - }); - - it("rejects too short voice IDs", () => { - expect(isValidVoiceId("")).toBe(false); - expect(isValidVoiceId("abc")).toBe(false); - expect(isValidVoiceId("123456789")).toBe(false); - }); - - it("rejects too long voice IDs", () => { - expect(isValidVoiceId("a".repeat(41))).toBe(false); - expect(isValidVoiceId("a".repeat(100))).toBe(false); - }); - - it("rejects voice IDs with invalid characters", () => { - expect(isValidVoiceId("pMsXgVXv3BLz-gSXRplE")).toBe(false); - expect(isValidVoiceId("pMsXgVXv3BLz_gSXRplE")).toBe(false); - expect(isValidVoiceId("pMsXgVXv3BLz gSXRplE")).toBe(false); - expect(isValidVoiceId("../../../etc/passwd")).toBe(false); - expect(isValidVoiceId("voice?param=value")).toBe(false); + it("validates ElevenLabs voice ID length and character rules", () => { + const cases = [ + { value: "pMsXgVXv3BLzUgSXRplE", expected: true }, + { value: "21m00Tcm4TlvDq8ikWAM", expected: true }, + { value: "EXAVITQu4vr4xnSDxMaL", expected: true }, + { value: "a1b2c3d4e5", expected: true }, + { value: "a".repeat(40), expected: true }, + { value: "", expected: false }, + { value: "abc", expected: false }, + { value: "123456789", expected: false }, + { value: "a".repeat(41), expected: false }, + { value: "a".repeat(100), expected: false }, + { value: "pMsXgVXv3BLz-gSXRplE", expected: false }, + { value: "pMsXgVXv3BLz_gSXRplE", expected: false }, + { value: "pMsXgVXv3BLz gSXRplE", expected: false }, + { value: "../../../etc/passwd", expected: false }, + { value: "voice?param=value", expected: false }, + ] as const; + for (const testCase of cases) { + expect(isValidVoiceId(testCase.value), testCase.value).toBe(testCase.expected); + } }); }); describe("isValidOpenAIVoice", () => { - it("accepts all valid OpenAI voices", () => { + it("accepts all valid OpenAI voices including newer additions", () => { for (const voice of OPENAI_TTS_VOICES) { expect(isValidOpenAIVoice(voice)).toBe(true); } - }); - - it("includes newer OpenAI voices (ballad, cedar, juniper, marin, verse) (#2393)", () => { - expect(isValidOpenAIVoice("ballad")).toBe(true); - expect(isValidOpenAIVoice("cedar")).toBe(true); - expect(isValidOpenAIVoice("juniper")).toBe(true); - expect(isValidOpenAIVoice("marin")).toBe(true); - expect(isValidOpenAIVoice("verse")).toBe(true); + for (const newerVoice of ["ballad", "cedar", "juniper", "marin", "verse"]) { + expect(isValidOpenAIVoice(newerVoice), newerVoice).toBe(true); + } }); it("rejects invalid voice names", () => { @@ -144,48 +132,56 @@ describe("tts", () => { }); describe("isValidOpenAIModel", () => { - it("accepts supported models", () => { - expect(isValidOpenAIModel("gpt-4o-mini-tts")).toBe(true); - expect(isValidOpenAIModel("tts-1")).toBe(true); - expect(isValidOpenAIModel("tts-1-hd")).toBe(true); - }); - - it("rejects unsupported models", () => { - expect(isValidOpenAIModel("invalid")).toBe(false); - expect(isValidOpenAIModel("")).toBe(false); - expect(isValidOpenAIModel("gpt-4")).toBe(false); - }); - }); - - describe("OPENAI_TTS_MODELS", () => { - it("contains supported models", () => { + it("matches the supported model set and rejects unsupported values", () => { expect(OPENAI_TTS_MODELS).toContain("gpt-4o-mini-tts"); expect(OPENAI_TTS_MODELS).toContain("tts-1"); expect(OPENAI_TTS_MODELS).toContain("tts-1-hd"); expect(OPENAI_TTS_MODELS).toHaveLength(3); - }); - - it("is a non-empty array", () => { expect(Array.isArray(OPENAI_TTS_MODELS)).toBe(true); expect(OPENAI_TTS_MODELS.length).toBeGreaterThan(0); + const cases = [ + { model: "gpt-4o-mini-tts", expected: true }, + { model: "tts-1", expected: true }, + { model: "tts-1-hd", expected: true }, + { model: "invalid", expected: false }, + { model: "", expected: false }, + { model: "gpt-4", expected: false }, + ] as const; + for (const testCase of cases) { + expect(isValidOpenAIModel(testCase.model), testCase.model).toBe(testCase.expected); + } }); }); describe("resolveOutputFormat", () => { - it("uses Opus for Telegram", () => { - const output = resolveOutputFormat("telegram"); - expect(output.openai).toBe("opus"); - expect(output.elevenlabs).toBe("opus_48000_64"); - expect(output.extension).toBe(".opus"); - expect(output.voiceCompatible).toBe(true); - }); - - it("uses MP3 for other channels", () => { - const output = resolveOutputFormat("discord"); - expect(output.openai).toBe("mp3"); - expect(output.elevenlabs).toBe("mp3_44100_128"); - expect(output.extension).toBe(".mp3"); - expect(output.voiceCompatible).toBe(false); + it("selects opus for Telegram and mp3 for other channels", () => { + const cases = [ + { + channel: "telegram", + expected: { + openai: "opus", + elevenlabs: "opus_48000_64", + extension: ".opus", + voiceCompatible: true, + }, + }, + { + channel: "discord", + expected: { + openai: "mp3", + elevenlabs: "mp3_44100_128", + extension: ".mp3", + voiceCompatible: false, + }, + }, + ] as const; + for (const testCase of cases) { + const output = resolveOutputFormat(testCase.channel); + expect(output.openai, testCase.channel).toBe(testCase.expected.openai); + expect(output.elevenlabs, testCase.channel).toBe(testCase.expected.elevenlabs); + expect(output.extension, testCase.channel).toBe(testCase.expected.extension); + expect(output.voiceCompatible, testCase.channel).toBe(testCase.expected.voiceCompatible); + } }); }); @@ -195,21 +191,30 @@ describe("tts", () => { messages: { tts: {} }, }; - it("uses default output format when edge output format is not configured", () => { - const config = resolveTtsConfig(baseCfg); - expect(resolveEdgeOutputFormat(config)).toBe("audio-24khz-48kbitrate-mono-mp3"); - }); - - it("uses configured output format when provided", () => { - const config = resolveTtsConfig({ - ...baseCfg, - messages: { - tts: { - edge: { outputFormat: "audio-24khz-96kbitrate-mono-mp3" }, - }, + it("uses default edge output format unless overridden", () => { + const cases = [ + { + name: "default", + cfg: baseCfg, + expected: "audio-24khz-48kbitrate-mono-mp3", }, - }); - expect(resolveEdgeOutputFormat(config)).toBe("audio-24khz-96kbitrate-mono-mp3"); + { + name: "override", + cfg: { + ...baseCfg, + messages: { + tts: { + edge: { outputFormat: "audio-24khz-96kbitrate-mono-mp3" }, + }, + }, + } as OpenClawConfig, + expected: "audio-24khz-96kbitrate-mono-mp3", + }, + ] as const; + for (const testCase of cases) { + const config = resolveTtsConfig(testCase.cfg); + expect(resolveEdgeOutputFormat(config), testCase.name).toBe(testCase.expected); + } }); }); @@ -318,79 +323,52 @@ describe("tts", () => { expect(resolveModel).toHaveBeenCalledWith("openai", "gpt-4.1-mini", undefined, cfg); }); - it("rejects targetLength below minimum (100)", async () => { - await expect( - summarizeText({ + it("validates targetLength bounds", async () => { + const cases = [ + { targetLength: 99, shouldThrow: true }, + { targetLength: 100, shouldThrow: false }, + { targetLength: 10000, shouldThrow: false }, + { targetLength: 10001, shouldThrow: true }, + ] as const; + for (const testCase of cases) { + const call = summarizeText({ text: "text", - targetLength: 99, + targetLength: testCase.targetLength, cfg: baseCfg, config: baseConfig, timeoutMs: 30_000, - }), - ).rejects.toThrow("Invalid targetLength: 99"); + }); + if (testCase.shouldThrow) { + await expect(call, String(testCase.targetLength)).rejects.toThrow( + `Invalid targetLength: ${testCase.targetLength}`, + ); + } else { + await expect(call, String(testCase.targetLength)).resolves.toBeDefined(); + } + } }); - it("rejects targetLength above maximum (10000)", async () => { - await expect( - summarizeText({ - text: "text", - targetLength: 10001, - cfg: baseCfg, - config: baseConfig, - timeoutMs: 30_000, - }), - ).rejects.toThrow("Invalid targetLength: 10001"); - }); - - it("accepts targetLength at boundaries", async () => { - await expect( - summarizeText({ - text: "text", - targetLength: 100, - cfg: baseCfg, - config: baseConfig, - timeoutMs: 30_000, - }), - ).resolves.toBeDefined(); - await expect( - summarizeText({ - text: "text", - targetLength: 10000, - cfg: baseCfg, - config: baseConfig, - timeoutMs: 30_000, - }), - ).resolves.toBeDefined(); - }); - - it("throws error when no summary is returned", async () => { - vi.mocked(completeSimple).mockResolvedValue(mockAssistantMessage([])); - - await expect( - summarizeText({ - text: "text", - targetLength: 500, - cfg: baseCfg, - config: baseConfig, - timeoutMs: 30_000, - }), - ).rejects.toThrow("No summary returned"); - }); - - it("throws error when summary content is empty", async () => { - vi.mocked(completeSimple).mockResolvedValue( - mockAssistantMessage([{ type: "text", text: " " }]), - ); - - await expect( - summarizeText({ - text: "text", - targetLength: 500, - cfg: baseCfg, - config: baseConfig, - timeoutMs: 30_000, - }), - ).rejects.toThrow("No summary returned"); + it("throws when summary output is missing or empty", async () => { + const cases = [ + { name: "no summary blocks", message: mockAssistantMessage([]) }, + { + name: "empty summary content", + message: mockAssistantMessage([{ type: "text", text: " " }]), + }, + ] as const; + for (const testCase of cases) { + vi.mocked(completeSimple).mockResolvedValue(testCase.message); + await expect( + summarizeText({ + text: "text", + targetLength: 500, + cfg: baseCfg, + config: baseConfig, + timeoutMs: 30_000, + }), + testCase.name, + ).rejects.toThrow("No summary returned"); + } }); }); @@ -400,49 +378,44 @@ describe("tts", () => { messages: { tts: {} }, }; - it("prefers OpenAI when no provider is configured and API key exists", () => { - withEnv( + it("selects provider based on available API keys", () => { + const cases = [ { - OPENAI_API_KEY: "test-openai-key", - ELEVENLABS_API_KEY: undefined, - XI_API_KEY: undefined, + env: { + OPENAI_API_KEY: "test-openai-key", + ELEVENLABS_API_KEY: undefined, + XI_API_KEY: undefined, + }, + prefsPath: "/tmp/tts-prefs-openai.json", + expected: "openai", }, - () => { - const config = resolveTtsConfig(baseCfg); - const provider = getTtsProvider(config, "/tmp/tts-prefs-openai.json"); - expect(provider).toBe("openai"); + { + env: { + OPENAI_API_KEY: undefined, + ELEVENLABS_API_KEY: "test-elevenlabs-key", + XI_API_KEY: undefined, + }, + prefsPath: "/tmp/tts-prefs-elevenlabs.json", + expected: "elevenlabs", }, - ); - }); + { + env: { + OPENAI_API_KEY: undefined, + ELEVENLABS_API_KEY: undefined, + XI_API_KEY: undefined, + }, + prefsPath: "/tmp/tts-prefs-edge.json", + expected: "edge", + }, + ] as const; - it("prefers ElevenLabs when OpenAI is missing and ElevenLabs key exists", () => { - withEnv( - { - OPENAI_API_KEY: undefined, - ELEVENLABS_API_KEY: "test-elevenlabs-key", - XI_API_KEY: undefined, - }, - () => { + for (const testCase of cases) { + withEnv(testCase.env, () => { const config = resolveTtsConfig(baseCfg); - const provider = getTtsProvider(config, "/tmp/tts-prefs-elevenlabs.json"); - expect(provider).toBe("elevenlabs"); - }, - ); - }); - - it("falls back to Edge when no API keys are present", () => { - withEnv( - { - OPENAI_API_KEY: undefined, - ELEVENLABS_API_KEY: undefined, - XI_API_KEY: undefined, - }, - () => { - const config = resolveTtsConfig(baseCfg); - const provider = getTtsProvider(config, "/tmp/tts-prefs-edge.json"); - expect(provider).toBe("edge"); - }, - ); + const provider = getTtsProvider(config, testCase.prefsPath); + expect(provider).toBe(testCase.expected); + }); + } }); }); @@ -485,48 +458,47 @@ describe("tts", () => { }, }; - it("skips auto-TTS when inbound audio gating is on and the message is not audio", async () => { - await withMockedAutoTtsFetch(async (fetchMock) => { - const payload = { text: "Hello world" }; - const result = await maybeApplyTtsToPayload({ - payload, - cfg: baseCfg, - kind: "final", - inboundAudio: false, - }); - - expect(result).toBe(payload); - expect(fetchMock).not.toHaveBeenCalled(); - }); - }); - - it("skips auto-TTS when markdown stripping leaves text too short", async () => { - await withMockedAutoTtsFetch(async (fetchMock) => { - const payload = { text: "### **bold**" }; - const result = await maybeApplyTtsToPayload({ - payload, - cfg: baseCfg, - kind: "final", - inboundAudio: true, - }); - - expect(result).toBe(payload); - expect(fetchMock).not.toHaveBeenCalled(); - }); - }); - - it("attempts auto-TTS when inbound audio gating is on and the message is audio", async () => { - await withMockedAutoTtsFetch(async (fetchMock) => { - const result = await maybeApplyTtsToPayload({ + it("applies inbound auto-TTS gating by audio status and cleaned text length", async () => { + const cases = [ + { + name: "inbound gating blocks non-audio", payload: { text: "Hello world" }, - cfg: baseCfg, - kind: "final", + inboundAudio: false, + expectedFetchCalls: 0, + expectSamePayload: true, + }, + { + name: "inbound gating blocks too-short cleaned text", + payload: { text: "### **bold**" }, inboundAudio: true, - }); + expectedFetchCalls: 0, + expectSamePayload: true, + }, + { + name: "inbound gating allows audio with real text", + payload: { text: "Hello world" }, + inboundAudio: true, + expectedFetchCalls: 1, + expectSamePayload: false, + }, + ] as const; - expect(result.mediaUrl).toBeDefined(); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); + for (const testCase of cases) { + await withMockedAutoTtsFetch(async (fetchMock) => { + const result = await maybeApplyTtsToPayload({ + payload: testCase.payload, + cfg: baseCfg, + kind: "final", + inboundAudio: testCase.inboundAudio, + }); + expect(fetchMock, testCase.name).toHaveBeenCalledTimes(testCase.expectedFetchCalls); + if (testCase.expectSamePayload) { + expect(result, testCase.name).toBe(testCase.payload); + } else { + expect(result.mediaUrl, testCase.name).toBeDefined(); + } + }); + } }); it("skips auto-TTS in tagged mode unless a tts tag is present", async () => {