From a48a3dbdda2f711e3165576bf0e643458036ac92 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 3 Mar 2026 01:05:24 +0000 Subject: [PATCH] refactor(tests): dedupe tool, projector, and delivery fixtures --- src/agents/tools/image-tool.test.ts | 257 +++++++++++---------- src/agents/tools/pdf-tool.test.ts | 245 ++++++++++---------- src/auto-reply/reply/acp-projector.test.ts | 214 ++++++++--------- src/auto-reply/reply/reply-utils.test.ts | 136 ++++++----- src/gateway/server.cron.test.ts | 148 +++++------- src/infra/outbound/deliver.test.ts | 151 ++++++------ 6 files changed, 552 insertions(+), 599 deletions(-) diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index 238237d3ffb..66f985c1cac 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -64,6 +64,21 @@ function stubMinimaxOkFetch() { return fetch; } +function stubMinimaxFetch(baseResp: { status_code: number; status_msg: string }, content = "ok") { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers(), + json: async () => ({ + content, + base_resp: baseResp, + }), + }); + global.fetch = withFetchPreconnect(fetch); + return fetch; +} + function stubOpenAiCompletionsOkFetch(text = "ok") { const fetch = vi.fn().mockResolvedValue( new Response( @@ -120,6 +135,13 @@ function createMinimaxImageConfig(): OpenClawConfig { }; } +function createDefaultImageFallbackExpectation(primary: string) { + return { + primary, + fallbacks: ["openai/gpt-5-mini", "anthropic/claude-opus-4-5"], + }; +} + function makeModelDefinition(id: string, input: Array<"text" | "image">): ModelDefinitionConfig { return { id, @@ -156,6 +178,36 @@ function requireImageTool(tool: T | null | undefined): T { return tool; } +function createRequiredImageTool(args: Parameters[0]) { + return requireImageTool(createImageTool(args)); +} + +type ImageToolInstance = ReturnType; + +async function withTempSandboxState( + run: (ctx: { stateDir: string; agentDir: string; sandboxRoot: string }) => Promise, +) { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-sandbox-")); + const agentDir = path.join(stateDir, "agent"); + const sandboxRoot = path.join(stateDir, "sandbox"); + await fs.mkdir(agentDir, { recursive: true }); + await fs.mkdir(sandboxRoot, { recursive: true }); + try { + await run({ stateDir, agentDir, sandboxRoot }); + } finally { + await fs.rm(stateDir, { recursive: true, force: true }); + } +} + +async function withMinimaxImageToolFromTempAgentDir( + run: (tool: ImageToolInstance) => Promise, +) { + await withTempAgentDir(async (agentDir) => { + const cfg = createMinimaxImageConfig(); + await run(createRequiredImageTool({ config: cfg, agentDir })); + }); +} + function findSchemaUnionKeywords(schema: unknown, path = "root"): string[] { if (!schema || typeof schema !== "object") { return []; @@ -214,10 +266,9 @@ describe("image tool implicit imageModel config", () => { const cfg: OpenClawConfig = { agents: { defaults: { model: { primary: "minimax/MiniMax-M2.5" } } }, }; - expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({ - primary: "minimax/MiniMax-VL-01", - fallbacks: ["openai/gpt-5-mini", "anthropic/claude-opus-4-5"], - }); + expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual( + createDefaultImageFallbackExpectation("minimax/MiniMax-VL-01"), + ); expect(createImageTool({ config: cfg, agentDir })).not.toBeNull(); }); }); @@ -230,10 +281,9 @@ describe("image tool implicit imageModel config", () => { const cfg: OpenClawConfig = { agents: { defaults: { model: { primary: "zai/glm-4.7" } } }, }; - expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({ - primary: "zai/glm-4.6v", - fallbacks: ["openai/gpt-5-mini", "anthropic/claude-opus-4-5"], - }); + expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual( + createDefaultImageFallbackExpectation("zai/glm-4.6v"), + ); expect(createImageTool({ config: cfg, agentDir })).not.toBeNull(); }); }); @@ -383,11 +433,7 @@ describe("image tool implicit imageModel config", () => { }); it("exposes an Anthropic-safe image schema without union keywords", async () => { - const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-")); - try { - const cfg = createMinimaxImageConfig(); - const tool = requireImageTool(createImageTool({ config: cfg, agentDir })); - + await withMinimaxImageToolFromTempAgentDir(async (tool) => { const violations = findSchemaUnionKeywords(tool.parameters, "image.parameters"); expect(violations).toEqual([]); @@ -403,17 +449,11 @@ describe("image tool implicit imageModel config", () => { expect(imageSchema?.type).toBe("string"); expect(imagesSchema?.type).toBe("array"); expect(imageItems?.type).toBe("string"); - } finally { - await fs.rm(agentDir, { recursive: true, force: true }); - } + }); }); it("keeps an Anthropic-safe image schema snapshot", async () => { - const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-")); - try { - const cfg = createMinimaxImageConfig(); - const tool = requireImageTool(createImageTool({ config: cfg, agentDir })); - + await withMinimaxImageToolFromTempAgentDir(async (tool) => { expect(JSON.parse(JSON.stringify(tool.parameters))).toEqual({ type: "object", properties: { @@ -429,19 +469,16 @@ describe("image tool implicit imageModel config", () => { maxImages: { type: "number" }, }, }); - } finally { - await fs.rm(agentDir, { recursive: true, force: true }); - } + }); }); it("allows workspace images outside default local media roots", async () => { await withTempWorkspacePng(async ({ workspaceDir, imagePath }) => { const fetch = stubMinimaxOkFetch(); - const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-")); - try { + await withTempAgentDir(async (agentDir) => { const cfg = createMinimaxImageConfig(); - const withoutWorkspace = requireImageTool(createImageTool({ config: cfg, agentDir })); + const withoutWorkspace = createRequiredImageTool({ config: cfg, agentDir }); await expect( withoutWorkspace.execute("t0", { prompt: "Describe the image.", @@ -449,34 +486,27 @@ describe("image tool implicit imageModel config", () => { }), ).rejects.toThrow(/Local media path is not under an allowed directory/i); - const withWorkspace = requireImageTool( - createImageTool({ config: cfg, agentDir, workspaceDir }), - ); + const withWorkspace = createRequiredImageTool({ config: cfg, agentDir, workspaceDir }); await expectImageToolExecOk(withWorkspace, imagePath); expect(fetch).toHaveBeenCalledTimes(1); - } finally { - await fs.rm(agentDir, { recursive: true, force: true }); - } + }); }); }); it("respects fsPolicy.workspaceOnly for non-sandbox image paths", async () => { await withTempWorkspacePng(async ({ workspaceDir, imagePath }) => { const fetch = stubMinimaxOkFetch(); - const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-")); - try { + await withTempAgentDir(async (agentDir) => { const cfg = createMinimaxImageConfig(); - const tool = requireImageTool( - createImageTool({ - config: cfg, - agentDir, - workspaceDir, - fsPolicy: { workspaceOnly: true }, - }), - ); + const tool = createRequiredImageTool({ + config: cfg, + agentDir, + workspaceDir, + fsPolicy: { workspaceOnly: true }, + }); // File inside workspace is allowed. await expectImageToolExecOk(tool, imagePath); @@ -493,17 +523,14 @@ describe("image tool implicit imageModel config", () => { } finally { await fs.rm(outsideDir, { recursive: true, force: true }); } - } finally { - await fs.rm(agentDir, { recursive: true, force: true }); - } + }); }); }); it("allows workspace images via createOpenClawCodingTools default workspace root", async () => { await withTempWorkspacePng(async ({ imagePath }) => { const fetch = stubMinimaxOkFetch(); - const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-")); - try { + await withTempAgentDir(async (agentDir) => { const cfg = createMinimaxImageConfig(); const tools = createOpenClawCodingTools({ config: cfg, agentDir }); @@ -512,52 +539,44 @@ describe("image tool implicit imageModel config", () => { await expectImageToolExecOk(tool, imagePath); expect(fetch).toHaveBeenCalledTimes(1); - } finally { - await fs.rm(agentDir, { recursive: true, force: true }); - } + }); }); }); it("sandboxes image paths like the read tool", async () => { - const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-sandbox-")); - const agentDir = path.join(stateDir, "agent"); - const sandboxRoot = path.join(stateDir, "sandbox"); - await fs.mkdir(agentDir, { recursive: true }); - await fs.mkdir(sandboxRoot, { recursive: true }); - await fs.writeFile(path.join(sandboxRoot, "img.png"), "fake", "utf8"); - const sandbox = { root: sandboxRoot, bridge: createHostSandboxFsBridge(sandboxRoot) }; + await withTempSandboxState(async ({ agentDir, sandboxRoot }) => { + await fs.writeFile(path.join(sandboxRoot, "img.png"), "fake", "utf8"); + const sandbox = { root: sandboxRoot, bridge: createHostSandboxFsBridge(sandboxRoot) }; - vi.stubEnv("OPENAI_API_KEY", "openai-test"); - const cfg: OpenClawConfig = { - agents: { defaults: { model: { primary: "minimax/MiniMax-M2.5" } } }, - }; - const tool = requireImageTool(createImageTool({ config: cfg, agentDir, sandbox })); + vi.stubEnv("OPENAI_API_KEY", "openai-test"); + const cfg: OpenClawConfig = { + agents: { defaults: { model: { primary: "minimax/MiniMax-M2.5" } } }, + }; + const tool = createRequiredImageTool({ config: cfg, agentDir, sandbox }); - await expect(tool.execute("t1", { image: "https://example.com/a.png" })).rejects.toThrow( - /Sandboxed image tool does not allow remote URLs/i, - ); + await expect(tool.execute("t1", { image: "https://example.com/a.png" })).rejects.toThrow( + /Sandboxed image tool does not allow remote URLs/i, + ); - await expect(tool.execute("t2", { image: "../escape.png" })).rejects.toThrow( - /escapes sandbox root/i, - ); + await expect(tool.execute("t2", { image: "../escape.png" })).rejects.toThrow( + /escapes sandbox root/i, + ); + }); }); it("applies tools.fs.workspaceOnly to image paths in sandbox mode", async () => { - const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-sandbox-")); - const agentDir = path.join(stateDir, "agent"); - const sandboxRoot = path.join(stateDir, "sandbox"); - await fs.mkdir(agentDir, { recursive: true }); - await fs.mkdir(sandboxRoot, { recursive: true }); - await fs.writeFile(path.join(agentDir, "secret.png"), Buffer.from(ONE_PIXEL_PNG_B64, "base64")); + await withTempSandboxState(async ({ agentDir, sandboxRoot }) => { + await fs.writeFile( + path.join(agentDir, "secret.png"), + Buffer.from(ONE_PIXEL_PNG_B64, "base64"), + ); + const sandbox = createUnsafeMountedSandbox({ sandboxRoot, agentRoot: agentDir }); + const fetch = stubMinimaxOkFetch(); + const cfg: OpenClawConfig = { + ...createMinimaxImageConfig(), + tools: { fs: { workspaceOnly: true } }, + }; - const sandbox = createUnsafeMountedSandbox({ sandboxRoot, agentRoot: agentDir }); - const fetch = stubMinimaxOkFetch(); - const cfg: OpenClawConfig = { - ...createMinimaxImageConfig(), - tools: { fs: { workspaceOnly: true } }, - }; - - try { const tools = createOpenClawCodingTools({ config: cfg, agentDir, @@ -580,46 +599,40 @@ describe("image tool implicit imageModel config", () => { }), ).rejects.toThrow(/Path escapes sandbox root/i); expect(fetch).not.toHaveBeenCalled(); - } finally { - await fs.rm(stateDir, { recursive: true, force: true }); - } + }); }); it("rewrites inbound absolute paths into sandbox media/inbound", async () => { - const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-sandbox-")); - const agentDir = path.join(stateDir, "agent"); - const sandboxRoot = path.join(stateDir, "sandbox"); - await fs.mkdir(agentDir, { recursive: true }); - await fs.mkdir(path.join(sandboxRoot, "media", "inbound"), { - recursive: true, - }); - const pngB64 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; - await fs.writeFile( - path.join(sandboxRoot, "media", "inbound", "photo.png"), - Buffer.from(pngB64, "base64"), - ); + await withTempSandboxState(async ({ agentDir, sandboxRoot }) => { + await fs.mkdir(path.join(sandboxRoot, "media", "inbound"), { + recursive: true, + }); + await fs.writeFile( + path.join(sandboxRoot, "media", "inbound", "photo.png"), + Buffer.from(ONE_PIXEL_PNG_B64, "base64"), + ); - const fetch = stubMinimaxOkFetch(); + const fetch = stubMinimaxOkFetch(); - const cfg: OpenClawConfig = { - agents: { - defaults: { - model: { primary: "minimax/MiniMax-M2.5" }, - imageModel: { primary: "minimax/MiniMax-VL-01" }, + const cfg: OpenClawConfig = { + agents: { + defaults: { + model: { primary: "minimax/MiniMax-M2.5" }, + imageModel: { primary: "minimax/MiniMax-VL-01" }, + }, }, - }, - }; - const sandbox = { root: sandboxRoot, bridge: createHostSandboxFsBridge(sandboxRoot) }; - const tool = requireImageTool(createImageTool({ config: cfg, agentDir, sandbox })); + }; + const sandbox = { root: sandboxRoot, bridge: createHostSandboxFsBridge(sandboxRoot) }; + const tool = createRequiredImageTool({ config: cfg, agentDir, sandbox }); - const res = await tool.execute("t1", { - prompt: "Describe the image.", - image: "@/Users/steipete/.openclaw/media/inbound/photo.png", + const res = await tool.execute("t1", { + prompt: "Describe the image.", + image: "@/Users/steipete/.openclaw/media/inbound/photo.png", + }); + + expect(fetch).toHaveBeenCalledTimes(1); + expect((res.details as { rewrittenFrom?: string }).rewrittenFrom).toContain("photo.png"); }); - - expect(fetch).toHaveBeenCalledTimes(1); - expect((res.details as { rewrittenFrom?: string }).rewrittenFrom).toContain("photo.png"); }); }); @@ -658,24 +671,14 @@ describe("image tool MiniMax VLM routing", () => { }); async function createMinimaxVlmFixture(baseResp: { status_code: number; status_msg: string }) { - const fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - statusText: "OK", - headers: new Headers(), - json: async () => ({ - content: baseResp.status_code === 0 ? "ok" : "", - base_resp: baseResp, - }), - }); - global.fetch = withFetchPreconnect(fetch); + const fetch = stubMinimaxFetch(baseResp, baseResp.status_code === 0 ? "ok" : ""); const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-minimax-vlm-")); vi.stubEnv("MINIMAX_API_KEY", "minimax-test"); const cfg: OpenClawConfig = { agents: { defaults: { model: { primary: "minimax/MiniMax-M2.5" } } }, }; - const tool = requireImageTool(createImageTool({ config: cfg, agentDir })); + const tool = createRequiredImageTool({ config: cfg, agentDir }); return { fetch, tool }; } diff --git a/src/agents/tools/pdf-tool.test.ts b/src/agents/tools/pdf-tool.test.ts index c86d899ff9e..8a422350ed8 100644 --- a/src/agents/tools/pdf-tool.test.ts +++ b/src/agents/tools/pdf-tool.test.ts @@ -31,6 +31,7 @@ async function withTempAgentDir(run: (agentDir: string) => Promise): Promi const ANTHROPIC_PDF_MODEL = "anthropic/claude-opus-4-6"; const OPENAI_PDF_MODEL = "openai/gpt-5-mini"; +const TEST_PDF_INPUT = { base64: "dGVzdA==", filename: "doc.pdf" } as const; const FAKE_PDF_MEDIA = { kind: "document", buffer: Buffer.from("%PDF-1.4 fake"), @@ -38,6 +39,64 @@ const FAKE_PDF_MEDIA = { fileName: "doc.pdf", } as const; +function requirePdfTool(tool: ReturnType) { + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("expected pdf tool"); + } + return tool; +} + +type PdfToolInstance = ReturnType; + +async function withAnthropicPdfTool( + run: (tool: PdfToolInstance, agentDir: string) => Promise, +) { + await withTempAgentDir(async (agentDir) => { + vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test"); + const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL); + const tool = requirePdfTool(createPdfTool({ config: cfg, agentDir })); + await run(tool, agentDir); + }); +} + +function makeAnthropicAnalyzeParams( + overrides: Partial<{ + apiKey: string; + modelId: string; + prompt: string; + pdfs: Array<{ base64: string; filename: string }>; + maxTokens: number; + baseUrl: string; + }> = {}, +) { + return { + apiKey: "test-key", + modelId: "claude-opus-4-6", + prompt: "test", + pdfs: [TEST_PDF_INPUT], + ...overrides, + }; +} + +function makeGeminiAnalyzeParams( + overrides: Partial<{ + apiKey: string; + modelId: string; + prompt: string; + pdfs: Array<{ base64: string; filename: string }>; + baseUrl: string; + }> = {}, +) { + return { + apiKey: "test-key", + modelId: "gemini-2.5-pro", + prompt: "test", + pdfs: [TEST_PDF_INPUT], + ...overrides, + }; +} + function resetAuthEnv() { vi.stubEnv("OPENAI_API_KEY", ""); vi.stubEnv("ANTHROPIC_API_KEY", ""); @@ -291,35 +350,23 @@ describe("createPdfTool", () => { }); it("creates tool when auth is available", async () => { - await withTempAgentDir(async (agentDir) => { - vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test"); - const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL); - const tool = createPdfTool({ config: cfg, agentDir }); - expect(tool).not.toBeNull(); - expect(tool?.name).toBe("pdf"); - expect(tool?.label).toBe("PDF"); - expect(tool?.description).toContain("PDF documents"); + await withAnthropicPdfTool(async (tool) => { + expect(tool.name).toBe("pdf"); + expect(tool.label).toBe("PDF"); + expect(tool.description).toContain("PDF documents"); }); }); it("rejects when no pdf input provided", async () => { - await withTempAgentDir(async (agentDir) => { - vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test"); - const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL); - const tool = createPdfTool({ config: cfg, agentDir }); - expect(tool).not.toBeNull(); - await expect(tool!.execute("t1", { prompt: "test" })).rejects.toThrow("pdf required"); + await withAnthropicPdfTool(async (tool) => { + await expect(tool.execute("t1", { prompt: "test" })).rejects.toThrow("pdf required"); }); }); it("rejects too many PDFs", async () => { - await withTempAgentDir(async (agentDir) => { - vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test"); - const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL); - const tool = createPdfTool({ config: cfg, agentDir }); - expect(tool).not.toBeNull(); + await withAnthropicPdfTool(async (tool) => { const manyPdfs = Array.from({ length: 15 }, (_, i) => `/tmp/doc${i}.pdf`); - const result = await tool!.execute("t1", { prompt: "test", pdfs: manyPdfs }); + const result = await tool.execute("t1", { prompt: "test", pdfs: manyPdfs }); expect(result).toMatchObject({ details: { error: "too_many_pdfs" }, }); @@ -333,18 +380,19 @@ describe("createPdfTool", () => { const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pdf-out-")); try { const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL); - const tool = createPdfTool({ - config: cfg, - agentDir, - workspaceDir, - fsPolicy: { workspaceOnly: true }, - }); - expect(tool).not.toBeNull(); + const tool = requirePdfTool( + createPdfTool({ + config: cfg, + agentDir, + workspaceDir, + fsPolicy: { workspaceOnly: true }, + }), + ); const outsidePdf = path.join(outsideDir, "secret.pdf"); await fs.writeFile(outsidePdf, "%PDF-1.4 fake"); - await expect(tool!.execute("t1", { prompt: "test", pdf: outsidePdf })).rejects.toThrow( + await expect(tool.execute("t1", { prompt: "test", pdf: outsidePdf })).rejects.toThrow( /not under an allowed directory/i, ); } finally { @@ -355,12 +403,8 @@ describe("createPdfTool", () => { }); it("rejects unsupported scheme references", async () => { - await withTempAgentDir(async (agentDir) => { - vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test"); - const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL); - const tool = createPdfTool({ config: cfg, agentDir }); - expect(tool).not.toBeNull(); - const result = await tool!.execute("t1", { + await withAnthropicPdfTool(async (tool) => { + const result = await tool.execute("t1", { prompt: "test", pdf: "ftp://example.com/doc.pdf", }); @@ -374,11 +418,10 @@ describe("createPdfTool", () => { await withTempAgentDir(async (agentDir) => { const { loadSpy } = await stubPdfToolInfra(agentDir, { modelFound: false }); const cfg = withPdfModel(ANTHROPIC_PDF_MODEL); - const tool = createPdfTool({ config: cfg, agentDir }); - expect(tool).not.toBeNull(); + const tool = requirePdfTool(createPdfTool({ config: cfg, agentDir })); await expect( - tool!.execute("t1", { + tool.execute("t1", { prompt: "test", pdf: "/tmp/nonexistent.pdf", pdfs: ["/tmp/nonexistent.pdf"], @@ -400,10 +443,9 @@ describe("createPdfTool", () => { const extractSpy = vi.spyOn(extractModule, "extractPdfContent"); const cfg = withPdfModel(ANTHROPIC_PDF_MODEL); - const tool = createPdfTool({ config: cfg, agentDir }); - expect(tool).not.toBeNull(); + const tool = requirePdfTool(createPdfTool({ config: cfg, agentDir })); - const result = await tool!.execute("t1", { + const result = await tool.execute("t1", { prompt: "summarize", pdf: "/tmp/doc.pdf", }); @@ -420,11 +462,10 @@ describe("createPdfTool", () => { await withTempAgentDir(async (agentDir) => { await stubPdfToolInfra(agentDir, { provider: "anthropic", input: ["text", "document"] }); const cfg = withPdfModel(ANTHROPIC_PDF_MODEL); - const tool = createPdfTool({ config: cfg, agentDir }); - expect(tool).not.toBeNull(); + const tool = requirePdfTool(createPdfTool({ config: cfg, agentDir })); await expect( - tool!.execute("t1", { + tool.execute("t1", { prompt: "summarize", pdf: "/tmp/doc.pdf", pages: "1-2", @@ -452,10 +493,9 @@ describe("createPdfTool", () => { const cfg = withPdfModel(OPENAI_PDF_MODEL); - const tool = createPdfTool({ config: cfg, agentDir }); - expect(tool).not.toBeNull(); + const tool = requirePdfTool(createPdfTool({ config: cfg, agentDir })); - const result = await tool!.execute("t1", { + const result = await tool.execute("t1", { prompt: "summarize", pdf: "/tmp/doc.pdf", }); @@ -469,12 +509,8 @@ describe("createPdfTool", () => { }); it("tool parameters have correct schema shape", async () => { - await withTempAgentDir(async (agentDir) => { - vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test"); - const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL); - const tool = createPdfTool({ config: cfg, agentDir }); - expect(tool).not.toBeNull(); - const schema = tool!.parameters; + await withAnthropicPdfTool(async (tool) => { + const schema = tool.parameters; expect(schema.type).toBe("object"); expect(schema.properties).toBeDefined(); const props = schema.properties as Record; @@ -514,11 +550,11 @@ describe("native PDF provider API calls", () => { }); const result = await anthropicAnalyzePdf({ - apiKey: "test-key", - modelId: "claude-opus-4-6", - prompt: "Summarize this document", - pdfs: [{ base64: "dGVzdA==", filename: "doc.pdf" }], - maxTokens: 4096, + ...makeAnthropicAnalyzeParams({ + modelId: "claude-opus-4-6", + prompt: "Summarize this document", + maxTokens: 4096, + }), }); expect(result).toBe("Analysis of PDF"); @@ -542,14 +578,9 @@ describe("native PDF provider API calls", () => { text: async () => "invalid request", }); - await expect( - anthropicAnalyzePdf({ - apiKey: "test-key", - modelId: "claude-opus-4-6", - prompt: "test", - pdfs: [{ base64: "dGVzdA==", filename: "doc.pdf" }], - }), - ).rejects.toThrow("Anthropic PDF request failed"); + await expect(anthropicAnalyzePdf(makeAnthropicAnalyzeParams())).rejects.toThrow( + "Anthropic PDF request failed", + ); }); it("anthropicAnalyzePdf throws when response has no text", async () => { @@ -561,14 +592,9 @@ describe("native PDF provider API calls", () => { }), }); - await expect( - anthropicAnalyzePdf({ - apiKey: "test-key", - modelId: "claude-opus-4-6", - prompt: "test", - pdfs: [{ base64: "dGVzdA==", filename: "doc.pdf" }], - }), - ).rejects.toThrow("Anthropic PDF returned no text"); + await expect(anthropicAnalyzePdf(makeAnthropicAnalyzeParams())).rejects.toThrow( + "Anthropic PDF returned no text", + ); }); it("geminiAnalyzePdf sends correct request shape", async () => { @@ -585,10 +611,10 @@ describe("native PDF provider API calls", () => { }); const result = await geminiAnalyzePdf({ - apiKey: "test-key", - modelId: "gemini-2.5-pro", - prompt: "Summarize this", - pdfs: [{ base64: "dGVzdA==", filename: "doc.pdf" }], + ...makeGeminiAnalyzeParams({ + modelId: "gemini-2.5-pro", + prompt: "Summarize this", + }), }); expect(result).toBe("Gemini PDF analysis"); @@ -611,14 +637,9 @@ describe("native PDF provider API calls", () => { text: async () => "server error", }); - await expect( - geminiAnalyzePdf({ - apiKey: "test-key", - modelId: "gemini-2.5-pro", - prompt: "test", - pdfs: [{ base64: "dGVzdA==", filename: "doc.pdf" }], - }), - ).rejects.toThrow("Gemini PDF request failed"); + await expect(geminiAnalyzePdf(makeGeminiAnalyzeParams())).rejects.toThrow( + "Gemini PDF request failed", + ); }); it("geminiAnalyzePdf throws when no candidates returned", async () => { @@ -628,14 +649,9 @@ describe("native PDF provider API calls", () => { json: async () => ({ candidates: [] }), }); - await expect( - geminiAnalyzePdf({ - apiKey: "test-key", - modelId: "gemini-2.5-pro", - prompt: "test", - pdfs: [{ base64: "dGVzdA==", filename: "doc.pdf" }], - }), - ).rejects.toThrow("Gemini PDF returned no candidates"); + await expect(geminiAnalyzePdf(makeGeminiAnalyzeParams())).rejects.toThrow( + "Gemini PDF returned no candidates", + ); }); it("anthropicAnalyzePdf supports multiple PDFs", async () => { @@ -648,13 +664,14 @@ describe("native PDF provider API calls", () => { }); await anthropicAnalyzePdf({ - apiKey: "test-key", - modelId: "claude-opus-4-6", - prompt: "Compare these documents", - pdfs: [ - { base64: "cGRmMQ==", filename: "doc1.pdf" }, - { base64: "cGRmMg==", filename: "doc2.pdf" }, - ], + ...makeAnthropicAnalyzeParams({ + modelId: "claude-opus-4-6", + prompt: "Compare these documents", + pdfs: [ + { base64: "cGRmMQ==", filename: "doc1.pdf" }, + { base64: "cGRmMg==", filename: "doc2.pdf" }, + ], + }), }); const body = JSON.parse(fetchMock.mock.calls[0][1].body); @@ -675,11 +692,7 @@ describe("native PDF provider API calls", () => { }); await anthropicAnalyzePdf({ - apiKey: "test-key", - modelId: "claude-opus-4-6", - prompt: "test", - pdfs: [{ base64: "dGVzdA==", filename: "doc.pdf" }], - baseUrl: "https://custom.example.com", + ...makeAnthropicAnalyzeParams({ baseUrl: "https://custom.example.com" }), }); expect(fetchMock.mock.calls[0][0]).toContain("https://custom.example.com/v1/messages"); @@ -687,26 +700,16 @@ describe("native PDF provider API calls", () => { it("anthropicAnalyzePdf requires apiKey", async () => { const { anthropicAnalyzePdf } = await import("./pdf-native-providers.js"); - await expect( - anthropicAnalyzePdf({ - apiKey: "", - modelId: "claude-opus-4-6", - prompt: "test", - pdfs: [{ base64: "dGVzdA==", filename: "doc.pdf" }], - }), - ).rejects.toThrow("apiKey required"); + await expect(anthropicAnalyzePdf(makeAnthropicAnalyzeParams({ apiKey: "" }))).rejects.toThrow( + "apiKey required", + ); }); it("geminiAnalyzePdf requires apiKey", async () => { const { geminiAnalyzePdf } = await import("./pdf-native-providers.js"); - await expect( - geminiAnalyzePdf({ - apiKey: "", - modelId: "gemini-2.5-pro", - prompt: "test", - pdfs: [{ base64: "dGVzdA==", filename: "doc.pdf" }], - }), - ).rejects.toThrow("apiKey required"); + await expect(geminiAnalyzePdf(makeGeminiAnalyzeParams({ apiKey: "" }))).rejects.toThrow( + "apiKey required", + ); }); }); diff --git a/src/auto-reply/reply/acp-projector.test.ts b/src/auto-reply/reply/acp-projector.test.ts index c3dc0c259ff..f6667c7ff1a 100644 --- a/src/auto-reply/reply/acp-projector.test.ts +++ b/src/auto-reply/reply/acp-projector.test.ts @@ -18,6 +18,30 @@ function createProjectorHarness(cfgOverrides?: Parameters[0]) return { deliveries, projector }; } +function createLiveCfgOverrides( + streamOverrides: Record, +): Parameters[0] { + return { + acp: { + enabled: true, + stream: { + deliveryMode: "live", + ...streamOverrides, + }, + }, + } as Parameters[0]; +} + +function createHiddenBoundaryCfg( + streamOverrides: Record = {}, +): Parameters[0] { + return createLiveCfgOverrides({ + coalesceIdleMs: 0, + maxChunkChars: 256, + ...streamOverrides, + }); +} + function blockDeliveries(deliveries: Delivery[]) { return deliveries.filter((entry) => entry.kind === "block"); } @@ -92,6 +116,22 @@ function createLiveStatusAndToolLifecycleHarness(params?: { }); } +async function emitToolLifecycleEvent( + projector: ReturnType["projector"], + event: { + tag: "tool_call" | "tool_call_update"; + toolCallId: string; + status: "in_progress" | "completed"; + title?: string; + text: string; + }, +) { + await projector.onEvent({ + type: "tool_call", + ...event, + }); +} + async function runHiddenBoundaryCase(params: { cfgOverrides?: Parameters[0]; toolCallId: string; @@ -152,16 +192,12 @@ describe("createAcpReplyProjector", () => { }); it("does not suppress identical short text across terminal turn boundaries", async () => { - const { deliveries, projector } = createProjectorHarness({ - acp: { - enabled: true, - stream: { - deliveryMode: "live", - coalesceIdleMs: 0, - maxChunkChars: 64, - }, - }, - }); + const { deliveries, projector } = createProjectorHarness( + createLiveCfgOverrides({ + coalesceIdleMs: 0, + maxChunkChars: 64, + }), + ); await projector.onEvent({ type: "text_delta", text: "A", tag: "agent_message_chunk" }); await projector.onEvent({ type: "done", stopReason: "end_turn" }); @@ -177,16 +213,12 @@ describe("createAcpReplyProjector", () => { it("flushes staggered live text deltas after idle gaps", async () => { vi.useFakeTimers(); try { - const { deliveries, projector } = createProjectorHarness({ - acp: { - enabled: true, - stream: { - deliveryMode: "live", - coalesceIdleMs: 50, - maxChunkChars: 64, - }, - }, - }); + const { deliveries, projector } = createProjectorHarness( + createLiveCfgOverrides({ + coalesceIdleMs: 50, + maxChunkChars: 64, + }), + ); await projector.onEvent({ type: "text_delta", text: "A", tag: "agent_message_chunk" }); await vi.advanceTimersByTimeAsync(760); @@ -236,16 +268,12 @@ describe("createAcpReplyProjector", () => { it("does not flush short live fragments mid-phrase on idle", async () => { vi.useFakeTimers(); try { - const { deliveries, projector } = createProjectorHarness({ - acp: { - enabled: true, - stream: { - deliveryMode: "live", - coalesceIdleMs: 100, - maxChunkChars: 256, - }, - }, - }); + const { deliveries, projector } = createProjectorHarness( + createLiveCfgOverrides({ + coalesceIdleMs: 100, + maxChunkChars: 256, + }), + ); await projector.onEvent({ type: "text_delta", @@ -350,19 +378,15 @@ describe("createAcpReplyProjector", () => { }); expect(hidden).toEqual([]); - const { deliveries: shown, projector: shownProjector } = createProjectorHarness({ - acp: { - enabled: true, - stream: { - coalesceIdleMs: 0, - maxChunkChars: 64, - deliveryMode: "live", - tagVisibility: { - usage_update: true, - }, + const { deliveries: shown, projector: shownProjector } = createProjectorHarness( + createLiveCfgOverrides({ + coalesceIdleMs: 0, + maxChunkChars: 64, + tagVisibility: { + usage_update: true, }, - }, - }); + }), + ); await shownProjector.onEvent({ type: "status", @@ -406,32 +430,28 @@ describe("createAcpReplyProjector", () => { it("dedupes repeated tool lifecycle updates when repeatSuppression is enabled", async () => { const { deliveries, projector } = createLiveToolLifecycleHarness(); - await projector.onEvent({ - type: "tool_call", + await emitToolLifecycleEvent(projector, { tag: "tool_call", toolCallId: "call_1", status: "in_progress", title: "List files", text: "List files (in_progress)", }); - await projector.onEvent({ - type: "tool_call", + await emitToolLifecycleEvent(projector, { tag: "tool_call_update", toolCallId: "call_1", status: "in_progress", title: "List files", text: "List files (in_progress)", }); - await projector.onEvent({ - type: "tool_call", + await emitToolLifecycleEvent(projector, { tag: "tool_call_update", toolCallId: "call_1", status: "completed", title: "List files", text: "List files (completed)", }); - await projector.onEvent({ - type: "tool_call", + await emitToolLifecycleEvent(projector, { tag: "tool_call_update", toolCallId: "call_1", status: "completed", @@ -451,16 +471,14 @@ describe("createAcpReplyProjector", () => { const longTitle = "Run an intentionally long command title that truncates before lifecycle status is visible"; - await projector.onEvent({ - type: "tool_call", + await emitToolLifecycleEvent(projector, { tag: "tool_call", toolCallId: "call_truncated_status", status: "in_progress", title: longTitle, text: `${longTitle} (in_progress)`, }); - await projector.onEvent({ - type: "tool_call", + await emitToolLifecycleEvent(projector, { tag: "tool_call_update", toolCallId: "call_truncated_status", status: "completed", @@ -541,19 +559,15 @@ describe("createAcpReplyProjector", () => { }); it("suppresses exact duplicate status updates when repeatSuppression is enabled", async () => { - const { deliveries, projector } = createProjectorHarness({ - acp: { - enabled: true, - stream: { - coalesceIdleMs: 0, - maxChunkChars: 256, - deliveryMode: "live", - tagVisibility: { - available_commands_update: true, - }, + const { deliveries, projector } = createProjectorHarness( + createLiveCfgOverrides({ + coalesceIdleMs: 0, + maxChunkChars: 256, + tagVisibility: { + available_commands_update: true, }, - }, - }); + }), + ); await projector.onEvent({ type: "status", @@ -649,16 +663,7 @@ describe("createAcpReplyProjector", () => { it("inserts a space boundary before visible text after hidden tool updates by default", async () => { await runHiddenBoundaryCase({ - cfgOverrides: { - acp: { - enabled: true, - stream: { - coalesceIdleMs: 0, - maxChunkChars: 256, - deliveryMode: "live", - }, - }, - }, + cfgOverrides: createHiddenBoundaryCfg(), toolCallId: "call_hidden_1", expectedText: "fallback. I don't", }); @@ -666,20 +671,12 @@ describe("createAcpReplyProjector", () => { it("preserves hidden boundary across nonterminal hidden tool updates", async () => { await runHiddenBoundaryCase({ - cfgOverrides: { - acp: { - enabled: true, - stream: { - coalesceIdleMs: 0, - maxChunkChars: 256, - deliveryMode: "live", - tagVisibility: { - tool_call: false, - tool_call_update: false, - }, - }, + cfgOverrides: createHiddenBoundaryCfg({ + tagVisibility: { + tool_call: false, + tool_call_update: false, }, - }, + }), toolCallId: "hidden_boundary_1", includeNonTerminalUpdate: true, expectedText: "fallback. I don't", @@ -688,17 +685,9 @@ describe("createAcpReplyProjector", () => { it("supports hiddenBoundarySeparator=space", async () => { await runHiddenBoundaryCase({ - cfgOverrides: { - acp: { - enabled: true, - stream: { - coalesceIdleMs: 0, - maxChunkChars: 256, - deliveryMode: "live", - hiddenBoundarySeparator: "space", - }, - }, - }, + cfgOverrides: createHiddenBoundaryCfg({ + hiddenBoundarySeparator: "space", + }), toolCallId: "call_hidden_2", expectedText: "fallback. I don't", }); @@ -706,17 +695,9 @@ describe("createAcpReplyProjector", () => { it("supports hiddenBoundarySeparator=none", async () => { await runHiddenBoundaryCase({ - cfgOverrides: { - acp: { - enabled: true, - stream: { - coalesceIdleMs: 0, - maxChunkChars: 256, - deliveryMode: "live", - hiddenBoundarySeparator: "none", - }, - }, - }, + cfgOverrides: createHiddenBoundaryCfg({ + hiddenBoundarySeparator: "none", + }), toolCallId: "call_hidden_3", expectedText: "fallback.I don't", }); @@ -724,16 +705,7 @@ describe("createAcpReplyProjector", () => { it("does not duplicate newlines when previous visible text already ends with newline", async () => { await runHiddenBoundaryCase({ - cfgOverrides: { - acp: { - enabled: true, - stream: { - coalesceIdleMs: 0, - maxChunkChars: 256, - deliveryMode: "live", - }, - }, - }, + cfgOverrides: createHiddenBoundaryCfg(), toolCallId: "call_hidden_4", firstText: "fallback.\n", expectedText: "fallback.\nI don't", diff --git a/src/auto-reply/reply/reply-utils.test.ts b/src/auto-reply/reply/reply-utils.test.ts index 00c5f02e90f..c1e76e50403 100644 --- a/src/auto-reply/reply/reply-utils.test.ts +++ b/src/auto-reply/reply/reply-utils.test.ts @@ -157,6 +157,27 @@ describe("typing controller", () => { vi.useRealTimers(); }); + function createTestTypingController() { + const onReplyStart = vi.fn(); + const typing = createTypingController({ + onReplyStart, + typingIntervalSeconds: 1, + typingTtlMs: 30_000, + }); + return { typing, onReplyStart }; + } + + function markTypingState( + typing: ReturnType, + state: "run" | "idle", + ) { + if (state === "run") { + typing.markRunComplete(); + return; + } + typing.markDispatchIdle(); + } + it("stops only after both run completion and dispatcher idle are set (any order)", async () => { vi.useFakeTimers(); const cases = [ @@ -165,12 +186,7 @@ describe("typing controller", () => { ] as const; for (const testCase of cases) { - const onReplyStart = vi.fn(); - const typing = createTypingController({ - onReplyStart, - typingIntervalSeconds: 1, - typingTtlMs: 30_000, - }); + const { typing, onReplyStart } = createTestTypingController(); await typing.startTypingLoop(); expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(1); @@ -178,19 +194,11 @@ describe("typing controller", () => { await vi.advanceTimersByTimeAsync(2_000); expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(3); - if (testCase.first === "run") { - typing.markRunComplete(); - } else { - typing.markDispatchIdle(); - } + markTypingState(typing, testCase.first); await vi.advanceTimersByTimeAsync(2_000); expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(testCase.first === "run" ? 3 : 5); - if (testCase.second === "run") { - typing.markRunComplete(); - } else { - typing.markDispatchIdle(); - } + markTypingState(typing, testCase.second); await vi.advanceTimersByTimeAsync(2_000); expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(testCase.first === "run" ? 3 : 5); } @@ -198,12 +206,7 @@ describe("typing controller", () => { it("does not start typing after run completion", async () => { vi.useFakeTimers(); - const onReplyStart = vi.fn(); - const typing = createTypingController({ - onReplyStart, - typingIntervalSeconds: 1, - typingTtlMs: 30_000, - }); + const { typing, onReplyStart } = createTestTypingController(); typing.markRunComplete(); await typing.startTypingOnText("late text"); @@ -213,12 +216,7 @@ describe("typing controller", () => { it("does not restart typing after it has stopped", async () => { vi.useFakeTimers(); - const onReplyStart = vi.fn(); - const typing = createTypingController({ - onReplyStart, - typingIntervalSeconds: 1, - typingTtlMs: 30_000, - }); + const { typing, onReplyStart } = createTestTypingController(); await typing.startTypingLoop(); expect(onReplyStart).toHaveBeenCalledTimes(1); @@ -358,6 +356,21 @@ describe("parseAudioTag", () => { }); describe("resolveResponsePrefixTemplate", () => { + function expectResolvedTemplateCases< + T extends ReadonlyArray<{ + name: string; + template: string | undefined; + values: Parameters[1]; + expected: string | undefined; + }>, + >(cases: T) { + for (const testCase of cases) { + expect(resolveResponsePrefixTemplate(testCase.template, testCase.values), testCase.name).toBe( + testCase.expected, + ); + } + } + it("resolves known variables, aliases, and case-insensitive tokens", () => { const cases = [ { @@ -420,11 +433,7 @@ describe("resolveResponsePrefixTemplate", () => { 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, - ); - } + expectResolvedTemplateCases(cases); }); it("preserves unresolved/unknown placeholders and handles static inputs", () => { @@ -450,11 +459,7 @@ describe("resolveResponsePrefixTemplate", () => { expected: "[gpt-5.2 | {provider}]", }, ] as const; - for (const testCase of cases) { - expect(resolveResponsePrefixTemplate(testCase.template, testCase.values), testCase.name).toBe( - testCase.expected, - ); - } + expectResolvedTemplateCases(cases); }); }); @@ -556,16 +561,32 @@ describe("block reply coalescer", () => { vi.useRealTimers(); }); - it("coalesces chunks within the idle window", async () => { - vi.useFakeTimers(); + function createBlockCoalescerHarness(config: { + minChars: number; + maxChars: number; + idleMs: number; + joiner: string; + flushOnEnqueue?: boolean; + }) { const flushes: string[] = []; const coalescer = createBlockReplyCoalescer({ - config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " }, + config, shouldAbort: () => false, onFlush: (payload) => { flushes.push(payload.text ?? ""); }, }); + return { flushes, coalescer }; + } + + it("coalesces chunks within the idle window", async () => { + vi.useFakeTimers(); + const { flushes, coalescer } = createBlockCoalescerHarness({ + minChars: 1, + maxChars: 200, + idleMs: 100, + joiner: " ", + }); coalescer.enqueue({ text: "Hello" }); coalescer.enqueue({ text: "world" }); @@ -577,13 +598,11 @@ describe("block reply coalescer", () => { it("waits until minChars before idle flush", async () => { vi.useFakeTimers(); - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, + const { flushes, coalescer } = createBlockCoalescerHarness({ + minChars: 10, + maxChars: 200, + idleMs: 50, + joiner: " ", }); coalescer.enqueue({ text: "short" }); @@ -598,13 +617,11 @@ describe("block reply coalescer", () => { it("still accumulates when flushOnEnqueue is not set (default)", async () => { vi.useFakeTimers(); - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 1, maxChars: 2000, idleMs: 100, joiner: "\n\n" }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, + const { flushes, coalescer } = createBlockCoalescerHarness({ + minChars: 1, + maxChars: 2000, + idleMs: 100, + joiner: "\n\n", }); coalescer.enqueue({ text: "First paragraph" }); @@ -630,14 +647,7 @@ describe("block reply coalescer", () => { ] as const; for (const testCase of cases) { - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: testCase.config, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, - }); + const { flushes, coalescer } = createBlockCoalescerHarness(testCase.config); for (const input of testCase.inputs) { coalescer.enqueue({ text: input }); } diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 74979352ba0..68fe435dd86 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -130,6 +130,43 @@ async function setupCronTestRun(params: { return { prevSkipCron, dir }; } +function expectCronJobIdFromResponse(response: { ok?: unknown; payload?: unknown }) { + expect(response.ok).toBe(true); + const value = (response.payload as { id?: unknown } | null)?.id; + const id = typeof value === "string" ? value : ""; + expect(id.length > 0).toBe(true); + return id; +} + +async function addMainSystemEventCronJob(params: { ws: unknown; name: string; text?: string }) { + const response = await rpcReq(params.ws, "cron.add", { + name: params.name, + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: params.text ?? "hello" }, + }); + return expectCronJobIdFromResponse(response); +} + +function getWebhookCall(index: number) { + const [args] = fetchWithSsrFGuardMock.mock.calls[index] as unknown as [ + { + url?: string; + init?: { + method?: string; + headers?: Record; + body?: string; + }; + }, + ]; + const url = args.url ?? ""; + const init = args.init ?? {}; + const body = JSON.parse(init.body ?? "{}") as Record; + return { url, init, body }; +} + describe("gateway server cron", () => { afterAll(async () => { if (!cronSuiteTempRootPromise) { @@ -215,18 +252,7 @@ describe("gateway server cron", () => { expect(wrappedPayload?.wakeMode).toBe("now"); expect((wrappedPayload?.schedule as { kind?: unknown } | undefined)?.kind).toBe("at"); - const patchRes = await rpcReq(ws, "cron.add", { - name: "patch test", - enabled: true, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "hello" }, - }); - expect(patchRes.ok).toBe(true); - const patchJobIdValue = (patchRes.payload as { id?: unknown } | null)?.id; - const patchJobId = typeof patchJobIdValue === "string" ? patchJobIdValue : ""; - expect(patchJobId.length > 0).toBe(true); + const patchJobId = await addMainSystemEventCronJob({ ws, name: "patch test" }); const atMs = Date.now() + 1_000; const updateRes = await rpcReq(ws, "cron.update", { @@ -344,18 +370,7 @@ describe("gateway server cron", () => { expect(legacyDeliveryPatched?.delivery?.to).toBe("+15550001111"); expect(legacyDeliveryPatched?.delivery?.bestEffort).toBe(true); - const rejectRes = await rpcReq(ws, "cron.add", { - name: "patch reject", - enabled: true, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "hello" }, - }); - expect(rejectRes.ok).toBe(true); - const rejectJobIdValue = (rejectRes.payload as { id?: unknown } | null)?.id; - const rejectJobId = typeof rejectJobIdValue === "string" ? rejectJobIdValue : ""; - expect(rejectJobId.length > 0).toBe(true); + const rejectJobId = await addMainSystemEventCronJob({ ws, name: "patch reject" }); const rejectUpdateRes = await rpcReq(ws, "cron.update", { id: rejectJobId, @@ -365,18 +380,7 @@ describe("gateway server cron", () => { }); expect(rejectUpdateRes.ok).toBe(false); - const jobIdRes = await rpcReq(ws, "cron.add", { - name: "jobId test", - enabled: true, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "hello" }, - }); - expect(jobIdRes.ok).toBe(true); - const jobIdValue = (jobIdRes.payload as { id?: unknown } | null)?.id; - const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; - expect(jobId.length > 0).toBe(true); + const jobId = await addMainSystemEventCronJob({ ws, name: "jobId test" }); const jobIdUpdateRes = await rpcReq(ws, "cron.update", { jobId, @@ -387,18 +391,7 @@ describe("gateway server cron", () => { }); expect(jobIdUpdateRes.ok).toBe(true); - const disableRes = await rpcReq(ws, "cron.add", { - name: "disable test", - enabled: true, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "hello" }, - }); - expect(disableRes.ok).toBe(true); - const disableJobIdValue = (disableRes.payload as { id?: unknown } | null)?.id; - const disableJobId = typeof disableJobIdValue === "string" ? disableJobIdValue : ""; - expect(disableJobId.length > 0).toBe(true); + const disableJobId = await addMainSystemEventCronJob({ ws, name: "disable test" }); const disableUpdateRes = await rpcReq(ws, "cron.update", { id: disableJobId, @@ -601,23 +594,12 @@ describe("gateway server cron", () => { () => fetchWithSsrFGuardMock.mock.calls.length === 1, CRON_WAIT_TIMEOUT_MS, ); - const [notifyArgs] = fetchWithSsrFGuardMock.mock.calls[0] as unknown as [ - { - url?: string; - init?: { - method?: string; - headers?: Record; - body?: string; - }; - }, - ]; - const notifyUrl = notifyArgs.url ?? ""; - const notifyInit = notifyArgs.init ?? {}; - expect(notifyUrl).toBe("https://example.invalid/cron-finished"); - expect(notifyInit.method).toBe("POST"); - expect(notifyInit.headers?.Authorization).toBe("Bearer cron-webhook-token"); - expect(notifyInit.headers?.["Content-Type"]).toBe("application/json"); - const notifyBody = JSON.parse(notifyInit.body ?? "{}"); + const notifyCall = getWebhookCall(0); + expect(notifyCall.url).toBe("https://example.invalid/cron-finished"); + expect(notifyCall.init.method).toBe("POST"); + expect(notifyCall.init.headers?.Authorization).toBe("Bearer cron-webhook-token"); + expect(notifyCall.init.headers?.["Content-Type"]).toBe("application/json"); + const notifyBody = notifyCall.body; expect(notifyBody.action).toBe("finished"); expect(notifyBody.jobId).toBe(notifyJobId); @@ -632,22 +614,11 @@ describe("gateway server cron", () => { () => fetchWithSsrFGuardMock.mock.calls.length === 2, CRON_WAIT_TIMEOUT_MS, ); - const [legacyArgs] = fetchWithSsrFGuardMock.mock.calls[1] as unknown as [ - { - url?: string; - init?: { - method?: string; - headers?: Record; - body?: string; - }; - }, - ]; - const legacyUrl = legacyArgs.url ?? ""; - const legacyInit = legacyArgs.init ?? {}; - expect(legacyUrl).toBe("https://legacy.example.invalid/cron-finished"); - expect(legacyInit.method).toBe("POST"); - expect(legacyInit.headers?.Authorization).toBe("Bearer cron-webhook-token"); - const legacyBody = JSON.parse(legacyInit.body ?? "{}"); + const legacyCall = getWebhookCall(1); + expect(legacyCall.url).toBe("https://legacy.example.invalid/cron-finished"); + expect(legacyCall.init.method).toBe("POST"); + expect(legacyCall.init.headers?.Authorization).toBe("Bearer cron-webhook-token"); + const legacyBody = legacyCall.body; expect(legacyBody.action).toBe("finished"); expect(legacyBody.jobId).toBe("legacy-notify-job"); @@ -706,18 +677,9 @@ describe("gateway server cron", () => { () => fetchWithSsrFGuardMock.mock.calls.length === 1, CRON_WAIT_TIMEOUT_MS, ); - const [failureDestArgs] = fetchWithSsrFGuardMock.mock.calls[0] as unknown as [ - { - url?: string; - init?: { - method?: string; - headers?: Record; - body?: string; - }; - }, - ]; - expect(failureDestArgs.url).toBe("https://example.invalid/failure-destination"); - const failureDestBody = JSON.parse(failureDestArgs.init?.body ?? "{}"); + const failureDestCall = getWebhookCall(0); + expect(failureDestCall.url).toBe("https://example.invalid/failure-destination"); + const failureDestBody = failureDestCall.body; expect(failureDestBody.message).toBe( 'Cron job "failure destination webhook" failed: unknown error', ); diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 17034a85284..ca6652b41b1 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -79,6 +79,10 @@ const whatsappChunkConfig: OpenClawConfig = { channels: { whatsapp: { textChunkLimit: 4000 } }, }; +type DeliverOutboundArgs = Parameters[0]; +type DeliverOutboundPayload = DeliverOutboundArgs["payloads"][number]; +type DeliverSession = DeliverOutboundArgs["session"]; + async function deliverWhatsAppPayload(params: { sendWhatsApp: NonNullable< NonNullable[0]["deps"]>["sendWhatsApp"] @@ -95,6 +99,24 @@ async function deliverWhatsAppPayload(params: { }); } +async function deliverTelegramPayload(params: { + sendTelegram: NonNullable["sendTelegram"]>; + payload: DeliverOutboundPayload; + cfg?: OpenClawConfig; + accountId?: string; + session?: DeliverSession; +}) { + return deliverOutboundPayloads({ + cfg: params.cfg ?? telegramChunkConfig, + channel: "telegram", + to: "123", + payloads: [params.payload], + deps: { sendTelegram: params.sendTelegram }, + ...(params.accountId ? { accountId: params.accountId } : {}), + ...(params.session ? { session: params.session } : {}), + }); +} + async function runChunkedWhatsAppDelivery(params?: { mirror?: Parameters[0]["mirror"]; }) { @@ -128,6 +150,42 @@ async function deliverSingleWhatsAppForHookTest(params?: { sessionKey?: string } }); } +async function runBestEffortPartialFailureDelivery() { + const sendWhatsApp = vi + .fn() + .mockRejectedValueOnce(new Error("fail")) + .mockResolvedValueOnce({ messageId: "w2", toJid: "jid" }); + const onError = vi.fn(); + const cfg: OpenClawConfig = {}; + const results = await deliverOutboundPayloads({ + cfg, + channel: "whatsapp", + to: "+1555", + payloads: [{ text: "a" }, { text: "b" }], + deps: { sendWhatsApp }, + bestEffort: true, + onError, + }); + return { sendWhatsApp, onError, results }; +} + +function expectSuccessfulWhatsAppInternalHookPayload( + expected: Partial<{ + content: string; + messageId: string; + isGroup: boolean; + groupId: string; + }>, +) { + return expect.objectContaining({ + to: "+1555", + success: true, + channelId: "whatsapp", + conversationId: "+1555", + ...expected, + }); +} + describe("deliverOutboundPayloads", () => { beforeEach(() => { setActivePluginRegistry(defaultRegistry); @@ -217,13 +275,10 @@ describe("deliverOutboundPayloads", () => { it("passes explicit accountId to sendTelegram", async () => { const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" }); - await deliverOutboundPayloads({ - cfg: telegramChunkConfig, - channel: "telegram", - to: "123", + await deliverTelegramPayload({ + sendTelegram, accountId: "default", - payloads: [{ text: "hi" }], - deps: { sendTelegram }, + payload: { text: "hi" }, }); expect(sendTelegram).toHaveBeenCalledWith( @@ -236,17 +291,12 @@ describe("deliverOutboundPayloads", () => { it("preserves HTML text for telegram sendPayload channelData path", async () => { const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" }); - await deliverOutboundPayloads({ - cfg: telegramChunkConfig, - channel: "telegram", - to: "123", - payloads: [ - { - text: "hello", - channelData: { telegram: { buttons: [] } }, - }, - ], - deps: { sendTelegram }, + await deliverTelegramPayload({ + sendTelegram, + payload: { + text: "hello", + channelData: { telegram: { buttons: [] } }, + }, }); expect(sendTelegram).toHaveBeenCalledTimes(1); @@ -260,13 +310,10 @@ describe("deliverOutboundPayloads", () => { it("scopes media local roots to the active agent workspace when agentId is provided", async () => { const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" }); - await deliverOutboundPayloads({ - cfg: telegramChunkConfig, - channel: "telegram", - to: "123", + await deliverTelegramPayload({ + sendTelegram, session: { agentId: "work" }, - payloads: [{ text: "hi", mediaUrl: "file:///tmp/f.png" }], - deps: { sendTelegram }, + payload: { text: "hi", mediaUrl: "file:///tmp/f.png" }, }); expect(sendTelegram).toHaveBeenCalledWith( @@ -282,12 +329,9 @@ describe("deliverOutboundPayloads", () => { it("includes OpenClaw tmp root in telegram mediaLocalRoots", async () => { const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" }); - await deliverOutboundPayloads({ - cfg: telegramChunkConfig, - channel: "telegram", - to: "123", - payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }], - deps: { sendTelegram }, + await deliverTelegramPayload({ + sendTelegram, + payload: { text: "hi", mediaUrl: "https://example.com/x.png" }, }); expect(sendTelegram).toHaveBeenCalledWith( @@ -613,22 +657,7 @@ describe("deliverOutboundPayloads", () => { }); it("continues on errors when bestEffort is enabled", async () => { - const sendWhatsApp = vi - .fn() - .mockRejectedValueOnce(new Error("fail")) - .mockResolvedValueOnce({ messageId: "w2", toJid: "jid" }); - const onError = vi.fn(); - const cfg: OpenClawConfig = {}; - - const results = await deliverOutboundPayloads({ - cfg, - channel: "whatsapp", - to: "+1555", - payloads: [{ text: "a" }, { text: "b" }], - deps: { sendWhatsApp }, - bestEffort: true, - onError, - }); + const { sendWhatsApp, onError, results } = await runBestEffortPartialFailureDelivery(); expect(sendWhatsApp).toHaveBeenCalledTimes(2); expect(onError).toHaveBeenCalledTimes(1); @@ -650,12 +679,8 @@ describe("deliverOutboundPayloads", () => { "message", "sent", "agent:main:main", - expect.objectContaining({ - to: "+1555", + expectSuccessfulWhatsAppInternalHookPayload({ content: "abcd", - success: true, - channelId: "whatsapp", - conversationId: "+1555", messageId: "w2", isGroup: true, groupId: "whatsapp:group:123", @@ -679,14 +704,7 @@ describe("deliverOutboundPayloads", () => { "message", "sent", "agent:main:main", - expect.objectContaining({ - to: "+1555", - content: "hello", - success: true, - channelId: "whatsapp", - conversationId: "+1555", - messageId: "w1", - }), + expectSuccessfulWhatsAppInternalHookPayload({ content: "hello", messageId: "w1" }), ); expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1); }); @@ -711,22 +729,7 @@ describe("deliverOutboundPayloads", () => { }); it("calls failDelivery instead of ackDelivery on bestEffort partial failure", async () => { - const sendWhatsApp = vi - .fn() - .mockRejectedValueOnce(new Error("fail")) - .mockResolvedValueOnce({ messageId: "w2", toJid: "jid" }); - const onError = vi.fn(); - const cfg: OpenClawConfig = {}; - - await deliverOutboundPayloads({ - cfg, - channel: "whatsapp", - to: "+1555", - payloads: [{ text: "a" }, { text: "b" }], - deps: { sendWhatsApp }, - bestEffort: true, - onError, - }); + const { onError } = await runBestEffortPartialFailureDelivery(); // onError was called for the first payload's failure. expect(onError).toHaveBeenCalledTimes(1);