From 59a8eecd7ec768b9c30fe1df0f54541996842135 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 02:19:35 +0000 Subject: [PATCH] test: speed up test suite --- ...models-json-into-provided-agentdir.test.ts | 258 ------ ...ipt.test.ts => pi-embedded-runner.test.ts} | 201 +++-- src/cli/gateway.sigterm.test.ts | 145 ++- ...board-non-interactive.gateway-auth.test.ts | 213 ----- ...> onboard-non-interactive.gateway.test.ts} | 235 +++-- .../onboard-non-interactive.remote.test.ts | 113 --- src/gateway/gateway.e2e.test.ts | 269 ++++++ .../gateway.tool-calling.mock-openai.test.ts | 367 -------- src/gateway/gateway.wizard.e2e.test.ts | 255 ------ src/gateway/openresponses-http.e2e.test.ts | 587 ++++--------- .../server.agent.gateway-server-agent.test.ts | 285 +++--- src/gateway/server.auth.test.ts | 4 +- .../server.chat.gateway-server-chat-b.test.ts | 823 ++++++++++-------- .../server.chat.gateway-server-chat-c.test.ts | 318 ------- .../server.chat.gateway-server-chat.test.ts | 630 ++++---------- src/gateway/server.cron.test.ts | 748 ++++++---------- src/gateway/server.nodes.allowlist.test.ts | 263 +++--- src/gateway/server.roles.test.ts | 11 +- src/gateway/test-helpers.e2e.ts | 133 +++ src/gateway/test-helpers.openai-mock.ts | 198 +++++ src/memory/manager.batch.test.ts | 8 +- src/slack/send.ts | 6 +- ...s-media-file-path-no-file-download.test.ts | 43 +- vitest.config.ts | 3 +- 24 files changed, 2393 insertions(+), 3723 deletions(-) delete mode 100644 src/agents/pi-embedded-runner.run-embedded-pi-agent.writes-models-json-into-provided-agentdir.test.ts rename src/agents/{pi-embedded-runner.run-embedded-pi-agent.appends-new-user-assistant-after-existing-transcript.test.ts => pi-embedded-runner.test.ts} (68%) delete mode 100644 src/commands/onboard-non-interactive.gateway-auth.test.ts rename src/commands/{onboard-non-interactive.lan-auto-token.test.ts => onboard-non-interactive.gateway.test.ts} (52%) delete mode 100644 src/commands/onboard-non-interactive.remote.test.ts create mode 100644 src/gateway/gateway.e2e.test.ts delete mode 100644 src/gateway/gateway.tool-calling.mock-openai.test.ts delete mode 100644 src/gateway/gateway.wizard.e2e.test.ts delete mode 100644 src/gateway/server.chat.gateway-server-chat-c.test.ts create mode 100644 src/gateway/test-helpers.e2e.ts create mode 100644 src/gateway/test-helpers.openai-mock.ts diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.writes-models-json-into-provided-agentdir.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.writes-models-json-into-provided-agentdir.test.ts deleted file mode 100644 index d49e38567..000000000 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.writes-models-json-into-provided-agentdir.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { beforeAll, describe, expect, it, vi } from "vitest"; -import type { ClawdbotConfig } from "../config/config.js"; -import { ensureClawdbotModelsJson } from "./models-config.js"; - -const buildAssistantMessage = (model: { api: string; provider: string; id: string }) => ({ - role: "assistant" as const, - content: [{ type: "text" as const, text: "ok" }], - stopReason: "stop" as const, - api: model.api, - provider: model.provider, - model: model.id, - usage: { - input: 1, - output: 1, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 2, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, - }, - timestamp: Date.now(), -}); - -const buildAssistantErrorMessage = (model: { api: string; provider: string; id: string }) => ({ - role: "assistant" as const, - content: [] as const, - stopReason: "error" as const, - errorMessage: "boom", - api: model.api, - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, - }, - timestamp: Date.now(), -}); - -const mockPiAi = () => { - vi.doMock("@mariozechner/pi-ai", async () => { - const actual = - await vi.importActual("@mariozechner/pi-ai"); - return { - ...actual, - complete: async (model: { api: string; provider: string; id: string }) => { - if (model.id === "mock-error") return buildAssistantErrorMessage(model); - return buildAssistantMessage(model); - }, - completeSimple: async (model: { api: string; provider: string; id: string }) => { - if (model.id === "mock-error") return buildAssistantErrorMessage(model); - return buildAssistantMessage(model); - }, - streamSimple: (model: { api: string; provider: string; id: string }) => { - const stream = new actual.AssistantMessageEventStream(); - queueMicrotask(() => { - stream.push({ - type: "done", - reason: "stop", - message: - model.id === "mock-error" - ? buildAssistantErrorMessage(model) - : buildAssistantMessage(model), - }); - stream.end(); - }); - return stream; - }, - }; - }); -}; - -let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent; - -beforeAll(async () => { - vi.useRealTimers(); - mockPiAi(); - ({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js")); -}, 20_000); - -const makeOpenAiConfig = (modelIds: string[]) => - ({ - models: { - providers: { - openai: { - api: "openai-responses", - apiKey: "sk-test", - baseUrl: "https://example.com", - models: modelIds.map((id) => ({ - id, - name: `Mock ${id}`, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 16_000, - maxTokens: 2048, - })), - }, - }, - }, - }) satisfies ClawdbotConfig; - -const ensureModels = (cfg: ClawdbotConfig, agentDir: string) => - ensureClawdbotModelsJson(cfg, agentDir); - -const testSessionKey = "agent:test:embedded-models"; -const immediateEnqueue = async (task: () => Promise) => task(); - -const textFromContent = (content: unknown) => { - if (typeof content === "string") return content; - if (Array.isArray(content) && content[0]?.type === "text") { - return (content[0] as { text?: string }).text; - } - return undefined; -}; - -const readSessionMessages = async (sessionFile: string) => { - const raw = await fs.readFile(sessionFile, "utf-8"); - return raw - .split(/\r?\n/) - .filter(Boolean) - .map( - (line) => - JSON.parse(line) as { - type?: string; - message?: { role?: string; content?: unknown }; - }, - ) - .filter((entry) => entry.type === "message") - .map((entry) => entry.message as { role?: string; content?: unknown }); -}; - -describe("runEmbeddedPiAgent", () => { - it("writes models.json into the provided agentDir", async () => { - const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-")); - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-")); - const sessionFile = path.join(workspaceDir, "session.jsonl"); - - const cfg = { - models: { - providers: { - minimax: { - baseUrl: "https://api.minimax.io/anthropic", - api: "anthropic-messages", - apiKey: "sk-minimax-test", - models: [ - { - id: "MiniMax-M2.1", - name: "MiniMax M2.1", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, - maxTokens: 8192, - }, - ], - }, - }, - }, - } satisfies ClawdbotConfig; - - await expect( - runEmbeddedPiAgent({ - sessionId: "session:test", - sessionKey: testSessionKey, - sessionFile, - workspaceDir, - config: cfg, - prompt: "hi", - provider: "definitely-not-a-provider", - model: "definitely-not-a-model", - timeoutMs: 1, - agentDir, - enqueue: immediateEnqueue, - }), - ).rejects.toThrow(/Unknown model:/); - - await expect(fs.stat(path.join(agentDir, "models.json"))).resolves.toBeTruthy(); - }); - it("persists the first user message before assistant output", { timeout: 60_000 }, async () => { - const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-")); - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-")); - const sessionFile = path.join(workspaceDir, "session.jsonl"); - - const cfg = makeOpenAiConfig(["mock-1"]); - await ensureModels(cfg, agentDir); - - 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 messages = await readSessionMessages(sessionFile); - const firstUserIndex = messages.findIndex( - (message) => message?.role === "user" && textFromContent(message.content) === "hello", - ); - const firstAssistantIndex = messages.findIndex((message) => message?.role === "assistant"); - expect(firstUserIndex).toBeGreaterThanOrEqual(0); - if (firstAssistantIndex !== -1) { - expect(firstUserIndex).toBeLessThan(firstAssistantIndex); - } - }); - it("persists the user message when prompt fails before assistant output", async () => { - const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-")); - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-")); - const sessionFile = path.join(workspaceDir, "session.jsonl"); - - const cfg = makeOpenAiConfig(["mock-error"]); - await ensureModels(cfg, agentDir); - - const result = await runEmbeddedPiAgent({ - sessionId: "session:test", - sessionKey: testSessionKey, - sessionFile, - workspaceDir, - config: cfg, - prompt: "boom", - provider: "openai", - model: "mock-error", - timeoutMs: 5_000, - agentDir, - enqueue: immediateEnqueue, - }); - expect(result.payloads[0]?.isError).toBe(true); - - const messages = await readSessionMessages(sessionFile); - const userIndex = messages.findIndex( - (message) => message?.role === "user" && textFromContent(message.content) === "boom", - ); - expect(userIndex).toBeGreaterThanOrEqual(0); - }); -}); diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.appends-new-user-assistant-after-existing-transcript.test.ts b/src/agents/pi-embedded-runner.test.ts similarity index 68% rename from src/agents/pi-embedded-runner.run-embedded-pi-agent.appends-new-user-assistant-after-existing-transcript.test.ts rename to src/agents/pi-embedded-runner.test.ts index c2067b99b..8ab12c6c1 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.appends-new-user-assistant-after-existing-transcript.test.ts +++ b/src/agents/pi-embedded-runner.test.ts @@ -1,7 +1,8 @@ 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 { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig } from "../config/config.js"; import { ensureClawdbotModelsJson } from "./models-config.js"; @@ -86,10 +87,25 @@ vi.mock("@mariozechner/pi-ai", async () => { }); let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent; +let tempRoot: string | undefined; +let agentDir: string; +let workspaceDir: string; +let sessionCounter = 0; -beforeEach(async () => { +beforeAll(async () => { vi.useRealTimers(); ({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js")); + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-embedded-agent-")); + agentDir = path.join(tempRoot, "agent"); + workspaceDir = path.join(tempRoot, "workspace"); + await fs.mkdir(agentDir, { recursive: true }); + await fs.mkdir(workspaceDir, { recursive: true }); +}, 20_000); + +afterAll(async () => { + if (!tempRoot) return; + await fs.rm(tempRoot, { recursive: true, force: true }); + tempRoot = undefined; }); const makeOpenAiConfig = (modelIds: string[]) => @@ -114,10 +130,14 @@ const makeOpenAiConfig = (modelIds: string[]) => }, }) satisfies ClawdbotConfig; -const ensureModels = (cfg: ClawdbotConfig, agentDir: string) => - ensureClawdbotModelsJson(cfg, agentDir); +const ensureModels = (cfg: ClawdbotConfig) => ensureClawdbotModelsJson(cfg, agentDir); -const testSessionKey = "agent:test:embedded-ordering"; +const nextSessionFile = () => { + sessionCounter += 1; + return path.join(workspaceDir, `session-${sessionCounter}.jsonl`); +}; + +const testSessionKey = "agent:test:embedded"; const immediateEnqueue = async (task: () => Promise) => task(); const textFromContent = (content: unknown) => { @@ -145,15 +165,114 @@ const readSessionMessages = async (sessionFile: string) => { }; describe("runEmbeddedPiAgent", () => { + it("writes models.json into the provided agentDir", async () => { + const sessionFile = nextSessionFile(); + + const cfg = { + models: { + providers: { + minimax: { + baseUrl: "https://api.minimax.io/anthropic", + api: "anthropic-messages", + apiKey: "sk-minimax-test", + models: [ + { + id: "MiniMax-M2.1", + name: "MiniMax M2.1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + }, + ], + }, + }, + }, + } satisfies ClawdbotConfig; + + await expect( + runEmbeddedPiAgent({ + sessionId: "session:test", + sessionKey: testSessionKey, + sessionFile, + workspaceDir, + config: cfg, + prompt: "hi", + provider: "definitely-not-a-provider", + model: "definitely-not-a-model", + timeoutMs: 1, + agentDir, + enqueue: immediateEnqueue, + }), + ).rejects.toThrow(/Unknown model:/); + + await expect(fs.stat(path.join(agentDir, "models.json"))).resolves.toBeTruthy(); + }); + + it("persists the first user message before assistant output", { timeout: 60_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, + }); + + const messages = await readSessionMessages(sessionFile); + const firstUserIndex = messages.findIndex( + (message) => message?.role === "user" && textFromContent(message.content) === "hello", + ); + const firstAssistantIndex = messages.findIndex((message) => message?.role === "assistant"); + expect(firstUserIndex).toBeGreaterThanOrEqual(0); + if (firstAssistantIndex !== -1) { + expect(firstUserIndex).toBeLessThan(firstAssistantIndex); + } + }); + + it("persists the user message when prompt fails before assistant output", async () => { + const sessionFile = nextSessionFile(); + const cfg = makeOpenAiConfig(["mock-error"]); + await ensureModels(cfg); + + const result = await runEmbeddedPiAgent({ + sessionId: "session:test", + sessionKey: testSessionKey, + sessionFile, + workspaceDir, + config: cfg, + prompt: "boom", + provider: "openai", + model: "mock-error", + timeoutMs: 5_000, + agentDir, + enqueue: immediateEnqueue, + }); + expect(result.payloads[0]?.isError).toBe(true); + + const messages = await readSessionMessages(sessionFile); + const userIndex = messages.findIndex( + (message) => message?.role === "user" && textFromContent(message.content) === "boom", + ); + expect(userIndex).toBeGreaterThanOrEqual(0); + }); + it( "appends new user + assistant after existing transcript entries", { timeout: 90_000 }, async () => { const { SessionManager } = await import("@mariozechner/pi-coding-agent"); - - const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-")); - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-")); - const sessionFile = path.join(workspaceDir, "session.jsonl"); + const sessionFile = nextSessionFile(); const sessionManager = SessionManager.open(sessionFile); sessionManager.appendMessage({ @@ -185,7 +304,7 @@ describe("runEmbeddedPiAgent", () => { }); const cfg = makeOpenAiConfig(["mock-1"]); - await ensureModels(cfg, agentDir); + await ensureModels(cfg); await runEmbeddedPiAgent({ sessionId: "session:test", @@ -221,13 +340,11 @@ describe("runEmbeddedPiAgent", () => { expect(newAssistantIndex).toBeGreaterThan(newUserIndex); }, ); - it("persists multi-turn user/assistant ordering across runs", async () => { - const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-")); - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-")); - const sessionFile = path.join(workspaceDir, "session.jsonl"); + it("persists multi-turn user/assistant ordering across runs", async () => { + const sessionFile = nextSessionFile(); const cfg = makeOpenAiConfig(["mock-1"]); - await ensureModels(cfg, agentDir); + await ensureModels(cfg); await runEmbeddedPiAgent({ sessionId: "session:test", @@ -265,58 +382,33 @@ describe("runEmbeddedPiAgent", () => { (message, index) => index > firstUserIndex && message?.role === "assistant", ); const secondUserIndex = messages.findIndex( - (message) => message?.role === "user" && textFromContent(message.content) === "second", + (message, index) => + index > firstAssistantIndex && + message?.role === "user" && + textFromContent(message.content) === "second", ); const secondAssistantIndex = messages.findIndex( (message, index) => index > secondUserIndex && message?.role === "assistant", ); + expect(firstUserIndex).toBeGreaterThanOrEqual(0); expect(firstAssistantIndex).toBeGreaterThan(firstUserIndex); expect(secondUserIndex).toBeGreaterThan(firstAssistantIndex); expect(secondAssistantIndex).toBeGreaterThan(secondUserIndex); - }, 90_000); + }); + it("repairs orphaned user messages and continues", async () => { const { SessionManager } = await import("@mariozechner/pi-coding-agent"); - - const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-")); - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-")); - const sessionFile = path.join(workspaceDir, "session.jsonl"); + const sessionFile = nextSessionFile(); const sessionManager = SessionManager.open(sessionFile); sessionManager.appendMessage({ role: "user", - content: [{ type: "text", text: "seed user 1" }], - }); - sessionManager.appendMessage({ - role: "assistant", - content: [{ type: "text", text: "seed assistant" }], - stopReason: "stop", - api: "openai-responses", - provider: "openai", - model: "mock-1", - usage: { - input: 1, - output: 1, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 2, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, - }, - timestamp: Date.now(), - }); - sessionManager.appendMessage({ - role: "user", - content: [{ type: "text", text: "seed user 2" }], + content: [{ type: "text", text: "orphaned user" }], }); const cfg = makeOpenAiConfig(["mock-1"]); - await ensureModels(cfg, agentDir); + await ensureModels(cfg); const result = await runEmbeddedPiAgent({ sessionId: "session:test", @@ -338,19 +430,16 @@ describe("runEmbeddedPiAgent", () => { it("repairs orphaned single-user sessions and continues", async () => { const { SessionManager } = await import("@mariozechner/pi-coding-agent"); - - const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-")); - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-")); - const sessionFile = path.join(workspaceDir, "session.jsonl"); + const sessionFile = nextSessionFile(); const sessionManager = SessionManager.open(sessionFile); sessionManager.appendMessage({ role: "user", - content: [{ type: "text", text: "seed user only" }], + content: [{ type: "text", text: "solo user" }], }); const cfg = makeOpenAiConfig(["mock-1"]); - await ensureModels(cfg, agentDir); + await ensureModels(cfg); const result = await runEmbeddedPiAgent({ sessionId: "session:test", diff --git a/src/cli/gateway.sigterm.test.ts b/src/cli/gateway.sigterm.test.ts index 96e1923d5..5ba698599 100644 --- a/src/cli/gateway.sigterm.test.ts +++ b/src/cli/gateway.sigterm.test.ts @@ -1,66 +1,66 @@ import { spawn } from "node:child_process"; import fs from "node:fs"; -import net from "node:net"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; -const waitForPortOpen = async ( +const waitForReady = async ( proc: ReturnType, chunksOut: string[], chunksErr: string[], - port: number, timeoutMs: number, ) => { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - if (proc.exitCode !== null) { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { const stdout = chunksOut.join(""); const stderr = chunksErr.join(""); - throw new Error( - `gateway exited before listening (code=${String(proc.exitCode)} signal=${String(proc.signalCode)})\n` + - `--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`, + cleanup(); + reject( + new Error( + `timeout waiting for gateway to start\n` + + `--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`, + ), ); - } + }, timeoutMs); - try { - await new Promise((resolve, reject) => { - const socket = net.connect({ host: "127.0.0.1", port }); - socket.once("connect", () => { - socket.destroy(); - resolve(); - }); - socket.once("error", (err) => { - socket.destroy(); - reject(err); - }); - }); - return; - } catch { - // keep polling - } + const cleanup = () => { + clearTimeout(timer); + proc.off("exit", onExit); + proc.off("message", onMessage); + proc.stdout?.off("data", onStdout); + }; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - const stdout = chunksOut.join(""); - const stderr = chunksErr.join(""); - throw new Error( - `timeout waiting for gateway to listen on port ${port}\n` + - `--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`, - ); -}; + const onExit = () => { + const stdout = chunksOut.join(""); + const stderr = chunksErr.join(""); + cleanup(); + reject( + new Error( + `gateway exited before ready (code=${String(proc.exitCode)} signal=${String(proc.signalCode)})\n` + + `--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`, + ), + ); + }; -const getFreePort = async () => { - const srv = net.createServer(); - await new Promise((resolve) => srv.listen(0, "127.0.0.1", resolve)); - const addr = srv.address(); - if (!addr || typeof addr === "string") { - srv.close(); - throw new Error("failed to bind ephemeral port"); - } - await new Promise((resolve) => srv.close(() => resolve())); - return addr.port; + const onMessage = (msg: unknown) => { + if (msg && typeof msg === "object" && "ready" in msg) { + cleanup(); + resolve(); + } + }; + + const onStdout = (chunk: unknown) => { + if (String(chunk).includes("READY")) { + cleanup(); + resolve(); + } + }; + + proc.once("exit", onExit); + proc.on("message", onMessage); + proc.stdout?.on("data", onStdout); + }); }; describe("gateway SIGTERM", () => { @@ -77,67 +77,50 @@ describe("gateway SIGTERM", () => { }); it("exits 0 on SIGTERM", { timeout: 180_000 }, async () => { - const port = await getFreePort(); const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-gateway-test-")); - const configPath = path.join(stateDir, "clawdbot.json"); - fs.writeFileSync( - configPath, - JSON.stringify({ gateway: { mode: "local", port } }, null, 2), - "utf8", - ); const out: string[] = []; const err: string[] = []; const nodeBin = process.execPath; - const entryArgs = [ - "gateway", - "--port", - String(port), - "--bind", - "loopback", - "--allow-unconfigured", - ]; const env = { ...process.env, CLAWDBOT_NO_RESPAWN: "1", CLAWDBOT_STATE_DIR: stateDir, - CLAWDBOT_CONFIG_PATH: configPath, CLAWDBOT_SKIP_CHANNELS: "1", + CLAWDBOT_SKIP_GMAIL_WATCHER: "1", + CLAWDBOT_SKIP_CRON: "1", CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1", CLAWDBOT_SKIP_CANVAS_HOST: "1", - // Avoid port collisions with other test processes that may also start a gateway server. - CLAWDBOT_BRIDGE_HOST: "127.0.0.1", - CLAWDBOT_BRIDGE_PORT: "0", }; const bootstrapPath = path.join(stateDir, "clawdbot-entry-bootstrap.mjs"); - const runMainPath = path.resolve("src/cli/run-main.ts"); + const runLoopPath = path.resolve("src/cli/gateway-cli/run-loop.ts"); + const runtimePath = path.resolve("src/runtime.ts"); fs.writeFileSync( bootstrapPath, [ 'import { pathToFileURL } from "node:url";', - 'const rawArgs = process.env.CLAWDBOT_ENTRY_ARGS ?? "[]";', - "let entryArgs = [];", - "try {", - " entryArgs = JSON.parse(rawArgs);", - "} catch (err) {", - ' console.error("Failed to parse CLAWDBOT_ENTRY_ARGS", err);', - " process.exit(1);", - "}", - "if (!Array.isArray(entryArgs)) entryArgs = [];", - 'entryArgs = entryArgs.filter((arg) => typeof arg === "string" && !arg.toLowerCase().includes("node.exe"));', - `const runMainUrl = ${JSON.stringify(pathToFileURL(runMainPath).href)};`, - "const { runCli } = await import(runMainUrl);", - 'await runCli(["node", "clawdbot", ...entryArgs]);', + `const runLoopUrl = ${JSON.stringify(pathToFileURL(runLoopPath).href)};`, + `const runtimeUrl = ${JSON.stringify(pathToFileURL(runtimePath).href)};`, + "const { runGatewayLoop } = await import(runLoopUrl);", + "const { defaultRuntime } = await import(runtimeUrl);", + "await runGatewayLoop({", + " start: async () => {", + ' process.stdout.write("READY\\\\n");', + " if (process.send) process.send({ ready: true });", + " const keepAlive = setInterval(() => {}, 1000);", + " return { close: async () => clearInterval(keepAlive) };", + " },", + " runtime: defaultRuntime,", + "});", ].join("\n"), "utf8", ); const childArgs = ["--import", "tsx", bootstrapPath]; - env.CLAWDBOT_ENTRY_ARGS = JSON.stringify(entryArgs); child = spawn(nodeBin, childArgs, { cwd: process.cwd(), env, - stdio: ["ignore", "pipe", "pipe"], + stdio: ["ignore", "pipe", "pipe", "ipc"], }); const proc = child; @@ -148,7 +131,7 @@ describe("gateway SIGTERM", () => { child.stdout?.on("data", (d) => out.push(String(d))); child.stderr?.on("data", (d) => err.push(String(d))); - await waitForPortOpen(proc, out, err, port, 150_000); + await waitForReady(proc, out, err, 150_000); proc.kill("SIGTERM"); diff --git a/src/commands/onboard-non-interactive.gateway-auth.test.ts b/src/commands/onboard-non-interactive.gateway-auth.test.ts deleted file mode 100644 index 4180b29ad..000000000 --- a/src/commands/onboard-non-interactive.gateway-auth.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import fs from "node:fs/promises"; -import { createServer } from "node:net"; -import os from "node:os"; -import path from "node:path"; - -import { describe, expect, it, vi } from "vitest"; -import { WebSocket } from "ws"; - -import { - loadOrCreateDeviceIdentity, - publicKeyRawBase64UrlFromPem, - signDevicePayload, -} from "../infra/device-identity.js"; -import { buildDeviceAuthPayload } from "../gateway/device-auth.js"; -import { PROTOCOL_VERSION } from "../gateway/protocol/index.js"; -import { rawDataToString } from "../infra/ws.js"; -import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; - -async function getFreePort(): Promise { - return await new Promise((resolve, reject) => { - const srv = createServer(); - srv.on("error", reject); - 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); - }); - }); - }); -} - -async function onceMessage( - ws: WebSocket, - filter: (obj: unknown) => boolean, - timeoutMs = 5000, -): Promise { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs); - const closeHandler = (code: number, reason: Buffer) => { - clearTimeout(timer); - ws.off("message", handler); - reject(new Error(`closed ${code}: ${rawDataToString(reason)}`)); - }; - const handler = (data: WebSocket.RawData) => { - const obj = JSON.parse(rawDataToString(data)); - if (!filter(obj)) return; - clearTimeout(timer); - ws.off("message", handler); - ws.off("close", closeHandler); - resolve(obj as T); - }; - ws.on("message", handler); - ws.once("close", closeHandler); - }); -} - -async function connectReq(params: { url: string; token?: string }) { - const ws = new WebSocket(params.url); - await new Promise((resolve) => ws.once("open", resolve)); - const identity = loadOrCreateDeviceIdentity(); - const signedAtMs = Date.now(); - const payload = buildDeviceAuthPayload({ - deviceId: identity.deviceId, - clientId: GATEWAY_CLIENT_NAMES.TEST, - clientMode: GATEWAY_CLIENT_MODES.TEST, - role: "operator", - scopes: [], - signedAtMs, - token: params.token ?? null, - }); - const device = { - id: identity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), - signature: signDevicePayload(identity.privateKeyPem, payload), - signedAt: signedAtMs, - }; - ws.send( - JSON.stringify({ - type: "req", - id: "c1", - method: "connect", - params: { - minProtocol: PROTOCOL_VERSION, - maxProtocol: PROTOCOL_VERSION, - client: { - id: GATEWAY_CLIENT_NAMES.TEST, - displayName: "vitest", - version: "dev", - platform: process.platform, - mode: GATEWAY_CLIENT_MODES.TEST, - }, - caps: [], - auth: params.token ? { token: params.token } : undefined, - device, - }, - }), - ); - const res = await onceMessage<{ - type: "res"; - id: string; - ok: boolean; - error?: { message?: string }; - }>(ws, (o) => { - const obj = o as { type?: unknown; id?: unknown } | undefined; - return obj?.type === "res" && obj?.id === "c1"; - }); - ws.close(); - return res; -} - -describe("onboard (non-interactive): gateway auth", () => { - it("writes gateway token auth into config and gateway enforces it", async () => { - const prev = { - home: process.env.HOME, - stateDir: process.env.CLAWDBOT_STATE_DIR, - configPath: process.env.CLAWDBOT_CONFIG_PATH, - skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS, - skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER, - skipCron: process.env.CLAWDBOT_SKIP_CRON, - skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST, - token: process.env.CLAWDBOT_GATEWAY_TOKEN, - }; - - process.env.CLAWDBOT_SKIP_CHANNELS = "1"; - process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; - process.env.CLAWDBOT_SKIP_CRON = "1"; - process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; - delete process.env.CLAWDBOT_GATEWAY_TOKEN; - - const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-noninteractive-")); - process.env.HOME = tempHome; - delete process.env.CLAWDBOT_STATE_DIR; - delete process.env.CLAWDBOT_CONFIG_PATH; - vi.resetModules(); - - const token = "tok_test_123"; - const workspace = path.join(tempHome, "clawd"); - - const runtime = { - log: () => {}, - error: (msg: string) => { - throw new Error(msg); - }, - exit: (code: number) => { - throw new Error(`exit:${code}`); - }, - }; - - const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); - await runNonInteractiveOnboarding( - { - nonInteractive: true, - mode: "local", - workspace, - authChoice: "skip", - skipSkills: true, - skipHealth: true, - installDaemon: false, - gatewayBind: "loopback", - gatewayAuth: "token", - gatewayToken: token, - }, - runtime, - ); - - const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js"); - const cfg = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8")) as { - gateway?: { auth?: { mode?: string; token?: string } }; - agents?: { defaults?: { workspace?: string } }; - }; - - expect(cfg?.agents?.defaults?.workspace).toBe(workspace); - expect(cfg?.gateway?.auth?.mode).toBe("token"); - expect(cfg?.gateway?.auth?.token).toBe(token); - - const { startGatewayServer } = await import("../gateway/server.js"); - const port = await getFreePort(); - const server = await startGatewayServer(port, { - bind: "loopback", - controlUiEnabled: false, - }); - try { - const resNoToken = await connectReq({ url: `ws://127.0.0.1:${port}` }); - expect(resNoToken.ok).toBe(false); - expect(resNoToken.error?.message ?? "").toContain("unauthorized"); - - const resToken = await connectReq({ - url: `ws://127.0.0.1:${port}`, - token, - }); - expect(resToken.ok).toBe(true); - } finally { - await server.close({ reason: "non-interactive onboard auth test" }); - } - - await fs.rm(tempHome, { recursive: true, force: true }); - process.env.HOME = prev.home; - process.env.CLAWDBOT_STATE_DIR = prev.stateDir; - process.env.CLAWDBOT_CONFIG_PATH = prev.configPath; - process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels; - process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail; - process.env.CLAWDBOT_SKIP_CRON = prev.skipCron; - process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas; - process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token; - }, 60_000); -}); diff --git a/src/commands/onboard-non-interactive.lan-auto-token.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts similarity index 52% rename from src/commands/onboard-non-interactive.lan-auto-token.test.ts rename to src/commands/onboard-non-interactive.gateway.test.ts index 904664f12..4535c1d25 100644 --- a/src/commands/onboard-non-interactive.lan-auto-token.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -3,7 +3,7 @@ import { createServer } from "node:net"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { WebSocket } from "ws"; import { @@ -13,33 +13,32 @@ import { } from "../infra/device-identity.js"; import { buildDeviceAuthPayload } from "../gateway/device-auth.js"; import { PROTOCOL_VERSION } from "../gateway/protocol/index.js"; -import { getFreePort as getFreeTestPort } from "../gateway/test-helpers.js"; import { rawDataToString } from "../infra/ws.js"; +import { getDeterministicFreePortBlock } from "../test-utils/ports.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; -async function isPortFree(port: number): Promise { - if (!Number.isFinite(port) || port <= 0 || port > 65535) return false; - return await new Promise((resolve) => { +async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { const srv = createServer(); - srv.once("error", () => resolve(false)); - srv.listen(port, "127.0.0.1", () => { - srv.close(() => resolve(true)); + srv.on("error", reject); + 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); + }); }); }); } async function getFreeGatewayPort(): Promise { - // Gateway uses derived ports (bridge/browser/canvas). Avoid flaky collisions by - // ensuring the common derived offsets are free too. - for (let attempt = 0; attempt < 25; attempt += 1) { - const port = await getFreeTestPort(); - const candidates = [port, port + 1, port + 2, port + 4]; - const ok = (await Promise.all(candidates.map((candidate) => isPortFree(candidate)))).every( - Boolean, - ); - if (ok) return port; - } - throw new Error("failed to acquire a free gateway port block"); + return await getDeterministicFreePortBlock({ offsets: [0, 1, 2, 4] }); } async function onceMessage( @@ -121,47 +120,177 @@ async function connectReq(params: { url: string; token?: string }) { return res; } -describe("onboard (non-interactive): lan bind auto-token", () => { - it("auto-enables token auth when binding LAN and persists the token", async () => { - if (process.platform === "win32") { - // Windows runner occasionally drops the temp config write in this flow; skip to keep CI green. - return; - } - const prev = { - home: process.env.HOME, - stateDir: process.env.CLAWDBOT_STATE_DIR, - configPath: process.env.CLAWDBOT_CONFIG_PATH, - skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS, - skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER, - skipCron: process.env.CLAWDBOT_SKIP_CRON, - skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST, - token: process.env.CLAWDBOT_GATEWAY_TOKEN, - }; +const runtime = { + log: () => {}, + error: (msg: string) => { + throw new Error(msg); + }, + exit: (code: number) => { + throw new Error(`exit:${code}`); + }, +}; +describe("onboard (non-interactive): gateway and remote auth", () => { + const prev = { + home: process.env.HOME, + stateDir: process.env.CLAWDBOT_STATE_DIR, + configPath: process.env.CLAWDBOT_CONFIG_PATH, + skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS, + skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER, + skipCron: process.env.CLAWDBOT_SKIP_CRON, + skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST, + token: process.env.CLAWDBOT_GATEWAY_TOKEN, + password: process.env.CLAWDBOT_GATEWAY_PASSWORD, + }; + let tempHome: string | undefined; + + const initStateDir = async (prefix: string) => { + if (!tempHome) { + throw new Error("temp home not initialized"); + } + const stateDir = await fs.mkdtemp(path.join(tempHome, prefix)); + process.env.CLAWDBOT_STATE_DIR = stateDir; + delete process.env.CLAWDBOT_CONFIG_PATH; + vi.resetModules(); + return stateDir; + }; + + beforeAll(async () => { process.env.CLAWDBOT_SKIP_CHANNELS = "1"; process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; process.env.CLAWDBOT_SKIP_CRON = "1"; process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; delete process.env.CLAWDBOT_GATEWAY_TOKEN; + delete process.env.CLAWDBOT_GATEWAY_PASSWORD; - const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-lan-")); + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-")); process.env.HOME = tempHome; - const stateDir = path.join(tempHome, ".clawdbot"); + }); + + afterAll(async () => { + if (tempHome) { + await fs.rm(tempHome, { recursive: true, force: true }); + } + process.env.HOME = prev.home; + process.env.CLAWDBOT_STATE_DIR = prev.stateDir; + process.env.CLAWDBOT_CONFIG_PATH = prev.configPath; + process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels; + process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail; + process.env.CLAWDBOT_SKIP_CRON = prev.skipCron; + process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas; + process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token; + process.env.CLAWDBOT_GATEWAY_PASSWORD = prev.password; + }); + + it("writes gateway token auth into config and gateway enforces it", async () => { + const stateDir = await initStateDir("state-noninteractive-"); + const token = "tok_test_123"; + const workspace = path.join(stateDir, "clawd"); + + const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); + await runNonInteractiveOnboarding( + { + nonInteractive: true, + mode: "local", + workspace, + authChoice: "skip", + skipSkills: true, + skipHealth: true, + installDaemon: false, + gatewayBind: "loopback", + gatewayAuth: "token", + gatewayToken: token, + }, + runtime, + ); + + const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js"); + const cfg = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8")) as { + gateway?: { auth?: { mode?: string; token?: string } }; + agents?: { defaults?: { workspace?: string } }; + }; + + expect(cfg?.agents?.defaults?.workspace).toBe(workspace); + expect(cfg?.gateway?.auth?.mode).toBe("token"); + expect(cfg?.gateway?.auth?.token).toBe(token); + + const { startGatewayServer } = await import("../gateway/server.js"); + const port = await getFreePort(); + const server = await startGatewayServer(port, { + bind: "loopback", + controlUiEnabled: false, + }); + try { + const resNoToken = await connectReq({ url: `ws://127.0.0.1:${port}` }); + expect(resNoToken.ok).toBe(false); + expect(resNoToken.error?.message ?? "").toContain("unauthorized"); + + const resToken = await connectReq({ + url: `ws://127.0.0.1:${port}`, + token, + }); + expect(resToken.ok).toBe(true); + } finally { + await server.close({ reason: "non-interactive onboard auth test" }); + } + + await fs.rm(stateDir, { recursive: true, force: true }); + }, 60_000); + + it("writes gateway.remote url/token and callGateway uses them", async () => { + const stateDir = await initStateDir("state-remote-"); + const port = await getFreePort(); + const token = "tok_remote_123"; + const { startGatewayServer } = await import("../gateway/server.js"); + const server = await startGatewayServer(port, { + bind: "loopback", + auth: { mode: "token", token }, + controlUiEnabled: false, + }); + + try { + const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); + await runNonInteractiveOnboarding( + { + nonInteractive: true, + mode: "remote", + remoteUrl: `ws://127.0.0.1:${port}`, + remoteToken: token, + authChoice: "skip", + json: true, + }, + runtime, + ); + + const { resolveConfigPath } = await import("../config/config.js"); + const cfg = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8")) as { + gateway?: { mode?: string; remote?: { url?: string; token?: string } }; + }; + + expect(cfg.gateway?.mode).toBe("remote"); + expect(cfg.gateway?.remote?.url).toBe(`ws://127.0.0.1:${port}`); + expect(cfg.gateway?.remote?.token).toBe(token); + + const { callGateway } = await import("../gateway/call.js"); + const health = await callGateway<{ ok?: boolean }>({ method: "health" }); + expect(health?.ok).toBe(true); + } finally { + await server.close({ reason: "non-interactive remote test complete" }); + await fs.rm(stateDir, { recursive: true, force: true }); + } + }, 60_000); + + it("auto-enables token auth when binding LAN and persists the token", async () => { + if (process.platform === "win32") { + // Windows runner occasionally drops the temp config write in this flow; skip to keep CI green. + return; + } + const stateDir = await initStateDir("state-lan-"); process.env.CLAWDBOT_STATE_DIR = stateDir; process.env.CLAWDBOT_CONFIG_PATH = path.join(stateDir, "clawdbot.json"); const port = await getFreeGatewayPort(); - const workspace = path.join(tempHome, "clawd"); - - const runtime = { - log: () => {}, - error: (msg: string) => { - throw new Error(msg); - }, - exit: (code: number) => { - throw new Error(`exit:${code}`); - }, - }; + const workspace = path.join(stateDir, "clawd"); // Other test files mock ../config/config.js. This onboarding flow needs the real // implementation so it can persist the config and then read it back (Windows CI @@ -226,14 +355,6 @@ describe("onboard (non-interactive): lan bind auto-token", () => { await server.close({ reason: "lan auto-token test complete" }); } - await fs.rm(tempHome, { recursive: true, force: true }); - process.env.HOME = prev.home; - process.env.CLAWDBOT_STATE_DIR = prev.stateDir; - process.env.CLAWDBOT_CONFIG_PATH = prev.configPath; - process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels; - process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail; - process.env.CLAWDBOT_SKIP_CRON = prev.skipCron; - process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas; - process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token; + await fs.rm(stateDir, { recursive: true, force: true }); }, 60_000); }); diff --git a/src/commands/onboard-non-interactive.remote.test.ts b/src/commands/onboard-non-interactive.remote.test.ts deleted file mode 100644 index bd4d24e57..000000000 --- a/src/commands/onboard-non-interactive.remote.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import fs from "node:fs/promises"; -import { createServer } from "node:net"; -import os from "node:os"; -import path from "node:path"; - -import { describe, expect, it } from "vitest"; - -async function getFreePort(): Promise { - return await new Promise((resolve, reject) => { - const srv = createServer(); - srv.on("error", reject); - 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); - }); - }); - }); -} - -describe("onboard (non-interactive): remote gateway config", () => { - it("writes gateway.remote url/token and callGateway uses them", async () => { - const prev = { - home: process.env.HOME, - stateDir: process.env.CLAWDBOT_STATE_DIR, - configPath: process.env.CLAWDBOT_CONFIG_PATH, - skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS, - skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER, - skipCron: process.env.CLAWDBOT_SKIP_CRON, - skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST, - token: process.env.CLAWDBOT_GATEWAY_TOKEN, - password: process.env.CLAWDBOT_GATEWAY_PASSWORD, - }; - - process.env.CLAWDBOT_SKIP_CHANNELS = "1"; - process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; - process.env.CLAWDBOT_SKIP_CRON = "1"; - process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; - delete process.env.CLAWDBOT_GATEWAY_TOKEN; - delete process.env.CLAWDBOT_GATEWAY_PASSWORD; - - const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-remote-")); - process.env.HOME = tempHome; - delete process.env.CLAWDBOT_STATE_DIR; - delete process.env.CLAWDBOT_CONFIG_PATH; - - const port = await getFreePort(); - const token = "tok_remote_123"; - const { startGatewayServer } = await import("../gateway/server.js"); - const server = await startGatewayServer(port, { - bind: "loopback", - auth: { mode: "token", token }, - controlUiEnabled: false, - }); - - const runtime = { - log: () => {}, - error: (msg: string) => { - throw new Error(msg); - }, - exit: (code: number) => { - throw new Error(`exit:${code}`); - }, - }; - - try { - const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); - await runNonInteractiveOnboarding( - { - nonInteractive: true, - mode: "remote", - remoteUrl: `ws://127.0.0.1:${port}`, - remoteToken: token, - authChoice: "skip", - json: true, - }, - runtime, - ); - - const { resolveConfigPath } = await import("../config/config.js"); - const cfg = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8")) as { - gateway?: { mode?: string; remote?: { url?: string; token?: string } }; - }; - - expect(cfg.gateway?.mode).toBe("remote"); - expect(cfg.gateway?.remote?.url).toBe(`ws://127.0.0.1:${port}`); - expect(cfg.gateway?.remote?.token).toBe(token); - - const { callGateway } = await import("../gateway/call.js"); - const health = await callGateway<{ ok?: boolean }>({ method: "health" }); - expect(health?.ok).toBe(true); - } finally { - await server.close({ reason: "non-interactive remote test complete" }); - await fs.rm(tempHome, { recursive: true, force: true }); - process.env.HOME = prev.home; - process.env.CLAWDBOT_STATE_DIR = prev.stateDir; - process.env.CLAWDBOT_CONFIG_PATH = prev.configPath; - process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels; - process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail; - process.env.CLAWDBOT_SKIP_CRON = prev.skipCron; - process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas; - process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token; - process.env.CLAWDBOT_GATEWAY_PASSWORD = prev.password; - } - }, 60_000); -}); diff --git a/src/gateway/gateway.e2e.test.ts b/src/gateway/gateway.e2e.test.ts new file mode 100644 index 000000000..336f8a204 --- /dev/null +++ b/src/gateway/gateway.e2e.test.ts @@ -0,0 +1,269 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + connectDeviceAuthReq, + connectGatewayClient, + getFreeGatewayPort, +} from "./test-helpers.e2e.js"; +import { installOpenAiResponsesMock } from "./test-helpers.openai-mock.js"; +import { startGatewayServer } from "./server.js"; + +function extractPayloadText(result: unknown): string { + const record = result as Record; + const payloads = Array.isArray(record.payloads) ? record.payloads : []; + const texts = payloads + .map((p) => (p && typeof p === "object" ? (p as Record).text : undefined)) + .filter((t): t is string => typeof t === "string" && t.trim().length > 0); + return texts.join("\n").trim(); +} + +describe("gateway e2e", () => { + it( + "runs a mock OpenAI tool call end-to-end via gateway agent loop", + { timeout: 90_000 }, + async () => { + const prev = { + home: process.env.HOME, + configPath: process.env.CLAWDBOT_CONFIG_PATH, + token: process.env.CLAWDBOT_GATEWAY_TOKEN, + skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS, + skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER, + skipCron: process.env.CLAWDBOT_SKIP_CRON, + skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST, + }; + + const { baseUrl: openaiBaseUrl, restore } = installOpenAiResponsesMock(); + + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-mock-home-")); + process.env.HOME = tempHome; + process.env.CLAWDBOT_SKIP_CHANNELS = "1"; + process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; + process.env.CLAWDBOT_SKIP_CRON = "1"; + process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; + + const token = `test-${randomUUID()}`; + process.env.CLAWDBOT_GATEWAY_TOKEN = token; + + const workspaceDir = path.join(tempHome, "clawd"); + await fs.mkdir(workspaceDir, { recursive: true }); + + const nonceA = randomUUID(); + const nonceB = randomUUID(); + const toolProbePath = path.join(workspaceDir, `.clawdbot-tool-probe.${nonceA}.txt`); + await fs.writeFile(toolProbePath, `nonceA=${nonceA}\nnonceB=${nonceB}\n`); + + const configDir = path.join(tempHome, ".clawdbot"); + await fs.mkdir(configDir, { recursive: true }); + const configPath = path.join(configDir, "clawdbot.json"); + + const cfg = { + agents: { defaults: { workspace: workspaceDir } }, + models: { + mode: "replace", + providers: { + openai: { + baseUrl: openaiBaseUrl, + 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, + }, + ], + }, + }, + }, + gateway: { auth: { token } }, + }; + + await fs.writeFile(configPath, `${JSON.stringify(cfg, null, 2)}\n`); + process.env.CLAWDBOT_CONFIG_PATH = configPath; + + const port = await getFreeGatewayPort(); + const server = await startGatewayServer(port, { + bind: "loopback", + auth: { mode: "token", token }, + controlUiEnabled: false, + }); + + const client = await connectGatewayClient({ + url: `ws://127.0.0.1:${port}`, + token, + clientDisplayName: "vitest-mock-openai", + }); + + try { + const sessionKey = "agent:dev:mock-openai"; + + await client.request>("sessions.patch", { + key: sessionKey, + model: "openai/gpt-5.2", + }); + + const runId = randomUUID(); + const payload = await client.request<{ + status?: unknown; + result?: unknown; + }>( + "agent", + { + sessionKey, + idempotencyKey: `idem-${runId}`, + message: + `Call the read tool on "${toolProbePath}". ` + + `Then reply with exactly: ${nonceA} ${nonceB}. No extra text.`, + deliver: false, + }, + { expectFinal: true }, + ); + + expect(payload?.status).toBe("ok"); + const text = extractPayloadText(payload?.result); + expect(text).toContain(nonceA); + expect(text).toContain(nonceB); + } finally { + client.stop(); + await server.close({ reason: "mock openai test complete" }); + await fs.rm(tempHome, { recursive: true, force: true }); + restore(); + process.env.HOME = prev.home; + process.env.CLAWDBOT_CONFIG_PATH = prev.configPath; + process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token; + process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels; + process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail; + process.env.CLAWDBOT_SKIP_CRON = prev.skipCron; + process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas; + } + }, + ); + + it("runs wizard over ws and writes auth token config", { timeout: 90_000 }, async () => { + const prev = { + home: process.env.HOME, + stateDir: process.env.CLAWDBOT_STATE_DIR, + configPath: process.env.CLAWDBOT_CONFIG_PATH, + token: process.env.CLAWDBOT_GATEWAY_TOKEN, + skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS, + skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER, + skipCron: process.env.CLAWDBOT_SKIP_CRON, + skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST, + }; + + process.env.CLAWDBOT_SKIP_CHANNELS = "1"; + process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; + process.env.CLAWDBOT_SKIP_CRON = "1"; + process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-wizard-home-")); + process.env.HOME = tempHome; + delete process.env.CLAWDBOT_STATE_DIR; + delete process.env.CLAWDBOT_CONFIG_PATH; + + const wizardToken = `wiz-${randomUUID()}`; + const port = await getFreeGatewayPort(); + const server = await startGatewayServer(port, { + bind: "loopback", + auth: { mode: "none" }, + controlUiEnabled: false, + wizardRunner: async (_opts, _runtime, prompter) => { + await prompter.intro("Wizard E2E"); + await prompter.note("write token"); + const token = await prompter.text({ message: "token" }); + const { writeConfigFile } = await import("../config/config.js"); + await writeConfigFile({ + gateway: { auth: { mode: "token", token: String(token) } }, + }); + await prompter.outro("ok"); + }, + }); + + const client = await connectGatewayClient({ + url: `ws://127.0.0.1:${port}`, + clientDisplayName: "vitest-wizard", + }); + + try { + const start = await client.request<{ + sessionId?: string; + done: boolean; + status: "running" | "done" | "cancelled" | "error"; + step?: { + id: string; + type: "note" | "select" | "text" | "confirm" | "multiselect" | "progress"; + }; + error?: string; + }>("wizard.start", { mode: "local" }); + const sessionId = start.sessionId; + expect(typeof sessionId).toBe("string"); + + let next = start; + let didSendToken = false; + while (!next.done) { + const step = next.step; + if (!step) throw new Error("wizard missing step"); + const value = step.type === "text" ? wizardToken : null; + if (step.type === "text") didSendToken = true; + next = await client.request("wizard.next", { + sessionId, + answer: { stepId: step.id, value }, + }); + } + + expect(didSendToken).toBe(true); + expect(next.status).toBe("done"); + + const { resolveConfigPath } = await import("../config/config.js"); + const parsed = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8")); + const token = (parsed as Record)?.gateway as + | Record + | undefined; + expect((token?.auth as { token?: string } | undefined)?.token).toBe(wizardToken); + } finally { + client.stop(); + await server.close({ reason: "wizard e2e complete" }); + } + + const port2 = await getFreeGatewayPort(); + const server2 = await startGatewayServer(port2, { + bind: "loopback", + controlUiEnabled: false, + }); + try { + const resNoToken = await connectDeviceAuthReq({ + url: `ws://127.0.0.1:${port2}`, + }); + expect(resNoToken.ok).toBe(false); + expect(resNoToken.error?.message ?? "").toContain("unauthorized"); + + const resToken = await connectDeviceAuthReq({ + url: `ws://127.0.0.1:${port2}`, + token: wizardToken, + }); + expect(resToken.ok).toBe(true); + } finally { + await server2.close({ reason: "wizard auth verify" }); + await fs.rm(tempHome, { recursive: true, force: true }); + process.env.HOME = prev.home; + process.env.CLAWDBOT_STATE_DIR = prev.stateDir; + process.env.CLAWDBOT_CONFIG_PATH = prev.configPath; + process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token; + process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels; + process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail; + process.env.CLAWDBOT_SKIP_CRON = prev.skipCron; + process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas; + } + }); +}); diff --git a/src/gateway/gateway.tool-calling.mock-openai.test.ts b/src/gateway/gateway.tool-calling.mock-openai.test.ts deleted file mode 100644 index c90f88175..000000000 --- a/src/gateway/gateway.tool-calling.mock-openai.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -import { randomUUID } from "node:crypto"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -import { describe, expect, it } from "vitest"; -import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; -import { getDeterministicFreePortBlock } from "../test-utils/ports.js"; - -import { GatewayClient } from "./client.js"; -import { startGatewayServer } from "./server.js"; - -type OpenAIResponsesParams = { - input?: unknown[]; -}; - -type OpenAIResponseStreamEvent = - | { type: "response.output_item.added"; item: Record } - | { type: "response.function_call_arguments.delta"; delta: string } - | { type: "response.output_item.done"; item: Record } - | { - type: "response.completed"; - response: { - status: "completed"; - usage: { - input_tokens: number; - output_tokens: number; - total_tokens: number; - input_tokens_details?: { cached_tokens?: number }; - }; - }; - }; - -function extractLastUserText(input: unknown[]): string { - for (let i = input.length - 1; i >= 0; i -= 1) { - const item = input[i] as Record | undefined; - if (!item || item.role !== "user") continue; - const content = item.content; - if (Array.isArray(content)) { - const text = content - .filter( - (c): c is { type: "input_text"; text: string } => - !!c && - typeof c === "object" && - (c as { type?: unknown }).type === "input_text" && - typeof (c as { text?: unknown }).text === "string", - ) - .map((c) => c.text) - .join("\n") - .trim(); - if (text) return text; - } - } - return ""; -} - -function extractToolOutput(input: unknown[]): string { - for (const itemRaw of input) { - const item = itemRaw as Record | undefined; - if (!item || item.type !== "function_call_output") continue; - return typeof item.output === "string" ? item.output : ""; - } - return ""; -} - -async function* fakeOpenAIResponsesStream( - params: OpenAIResponsesParams, -): AsyncGenerator { - const input = Array.isArray(params.input) ? params.input : []; - const toolOutput = extractToolOutput(input); - - // Turn 1: return a tool call to `read`. - if (!toolOutput) { - const prompt = extractLastUserText(input); - const quoted = /"([^"]+)"/.exec(prompt)?.[1]; - const toolPath = quoted ?? "package.json"; - const argsJson = JSON.stringify({ path: toolPath }); - - yield { - type: "response.output_item.added", - item: { - type: "function_call", - id: "fc_test_1", - call_id: "call_test_1", - name: "read", - arguments: "", - }, - }; - yield { type: "response.function_call_arguments.delta", delta: argsJson }; - yield { - type: "response.output_item.done", - item: { - type: "function_call", - id: "fc_test_1", - call_id: "call_test_1", - name: "read", - arguments: argsJson, - }, - }; - yield { - type: "response.completed", - response: { - status: "completed", - usage: { input_tokens: 10, output_tokens: 10, total_tokens: 20 }, - }, - }; - return; - } - - // Turn 2: echo the nonces extracted from the Read tool output. - const nonceA = /nonceA=([^\s]+)/.exec(toolOutput)?.[1] ?? ""; - const nonceB = /nonceB=([^\s]+)/.exec(toolOutput)?.[1] ?? ""; - const reply = `${nonceA} ${nonceB}`.trim(); - - yield { - type: "response.output_item.added", - item: { - type: "message", - id: "msg_test_1", - role: "assistant", - content: [], - status: "in_progress", - }, - }; - yield { - type: "response.output_item.done", - item: { - type: "message", - id: "msg_test_1", - role: "assistant", - status: "completed", - content: [{ type: "output_text", text: reply, annotations: [] }], - }, - }; - yield { - type: "response.completed", - response: { - status: "completed", - usage: { input_tokens: 10, output_tokens: 10, total_tokens: 20 }, - }, - }; -} - -function decodeBodyText(body: unknown): string { - if (!body) return ""; - if (typeof body === "string") return body; - if (body instanceof Uint8Array) return Buffer.from(body).toString("utf8"); - if (body instanceof ArrayBuffer) return Buffer.from(new Uint8Array(body)).toString("utf8"); - return ""; -} - -async function buildOpenAIResponsesSse(params: OpenAIResponsesParams): Promise { - const events: OpenAIResponseStreamEvent[] = []; - for await (const event of fakeOpenAIResponsesStream(params)) { - events.push(event); - } - - const sse = `${events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("")}data: [DONE]\n\n`; - const encoder = new TextEncoder(); - const body = new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(sse)); - controller.close(); - }, - }); - return new Response(body, { - status: 200, - headers: { "content-type": "text/event-stream" }, - }); -} - -async function getFreeGatewayPort(): Promise { - return await getDeterministicFreePortBlock({ offsets: [0, 1, 2, 3, 4] }); -} - -function extractPayloadText(result: unknown): string { - const record = result as Record; - const payloads = Array.isArray(record.payloads) ? record.payloads : []; - const texts = payloads - .map((p) => (p && typeof p === "object" ? (p as Record).text : undefined)) - .filter((t): t is string => typeof t === "string" && t.trim().length > 0); - 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, - token: params.token, - clientName: GATEWAY_CLIENT_NAMES.TEST, - clientDisplayName: "vitest-mock-openai", - 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(); - }); -} - -describe("gateway (mock openai): tool calling", () => { - it("runs a Read tool call end-to-end via gateway agent loop", { timeout: 90_000 }, async () => { - const prev = { - home: process.env.HOME, - configPath: process.env.CLAWDBOT_CONFIG_PATH, - token: process.env.CLAWDBOT_GATEWAY_TOKEN, - skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS, - skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER, - skipCron: process.env.CLAWDBOT_SKIP_CRON, - skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST, - }; - - const originalFetch = globalThis.fetch; - const openaiBaseUrl = "https://api.openai.com/v1"; - const openaiResponsesUrl = `${openaiBaseUrl}/responses`; - const isOpenAIResponsesRequest = (url: string) => - url === openaiResponsesUrl || - url.startsWith(`${openaiResponsesUrl}/`) || - url.startsWith(`${openaiResponsesUrl}?`); - const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - - if (isOpenAIResponsesRequest(url)) { - const bodyText = - typeof (init as { body?: unknown } | undefined)?.body !== "undefined" - ? decodeBodyText((init as { body?: unknown }).body) - : input instanceof Request - ? await input.clone().text() - : ""; - - const parsed = bodyText ? (JSON.parse(bodyText) as Record) : {}; - const inputItems = Array.isArray(parsed.input) ? parsed.input : []; - return await buildOpenAIResponsesSse({ input: inputItems }); - } - if (url.startsWith(openaiBaseUrl)) { - throw new Error(`unexpected OpenAI request in mock test: ${url}`); - } - - if (!originalFetch) { - throw new Error(`fetch is not available (url=${url})`); - } - return await originalFetch(input, init); - }; - // TypeScript: Bun's fetch typing includes extra properties; keep this test portable. - (globalThis as unknown as { fetch: unknown }).fetch = fetchImpl; - - const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-mock-home-")); - process.env.HOME = tempHome; - process.env.CLAWDBOT_SKIP_CHANNELS = "1"; - process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; - process.env.CLAWDBOT_SKIP_CRON = "1"; - process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; - - const token = `test-${randomUUID()}`; - process.env.CLAWDBOT_GATEWAY_TOKEN = token; - - const workspaceDir = path.join(tempHome, "clawd"); - await fs.mkdir(workspaceDir, { recursive: true }); - - const nonceA = randomUUID(); - const nonceB = randomUUID(); - const toolProbePath = path.join(workspaceDir, `.clawdbot-tool-probe.${nonceA}.txt`); - await fs.writeFile(toolProbePath, `nonceA=${nonceA}\nnonceB=${nonceB}\n`); - - const configDir = path.join(tempHome, ".clawdbot"); - await fs.mkdir(configDir, { recursive: true }); - const configPath = path.join(configDir, "clawdbot.json"); - - const cfg = { - agents: { defaults: { workspace: workspaceDir } }, - models: { - mode: "replace", - providers: { - openai: { - baseUrl: openaiBaseUrl, - 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, - }, - ], - }, - }, - }, - gateway: { auth: { token } }, - }; - - await fs.writeFile(configPath, `${JSON.stringify(cfg, null, 2)}\n`); - process.env.CLAWDBOT_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}`, - token, - }); - - try { - const sessionKey = "agent:dev:mock-openai"; - - await client.request>("sessions.patch", { - key: sessionKey, - model: "openai/gpt-5.2", - }); - - const runId = randomUUID(); - const payload = await client.request<{ - status?: unknown; - result?: unknown; - }>( - "agent", - { - sessionKey, - idempotencyKey: `idem-${runId}`, - message: - `Call the read tool on "${toolProbePath}". ` + - `Then reply with exactly: ${nonceA} ${nonceB}. No extra text.`, - deliver: false, - }, - { expectFinal: true }, - ); - - expect(payload?.status).toBe("ok"); - const text = extractPayloadText(payload?.result); - expect(text).toContain(nonceA); - expect(text).toContain(nonceB); - } finally { - client.stop(); - await server.close({ reason: "mock openai test complete" }); - await fs.rm(tempHome, { recursive: true, force: true }); - (globalThis as unknown as { fetch: unknown }).fetch = originalFetch; - process.env.HOME = prev.home; - process.env.CLAWDBOT_CONFIG_PATH = prev.configPath; - process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token; - process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels; - process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail; - process.env.CLAWDBOT_SKIP_CRON = prev.skipCron; - process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas; - } - }); -}); diff --git a/src/gateway/gateway.wizard.e2e.test.ts b/src/gateway/gateway.wizard.e2e.test.ts deleted file mode 100644 index 5c23ff002..000000000 --- a/src/gateway/gateway.wizard.e2e.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { randomUUID } from "node:crypto"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -import { describe, expect, it } from "vitest"; -import { WebSocket } from "ws"; - -import { - loadOrCreateDeviceIdentity, - publicKeyRawBase64UrlFromPem, - signDevicePayload, -} from "../infra/device-identity.js"; -import { rawDataToString } from "../infra/ws.js"; -import { getDeterministicFreePortBlock } from "../test-utils/ports.js"; -import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; -import { buildDeviceAuthPayload } from "./device-auth.js"; -import { PROTOCOL_VERSION } from "./protocol/index.js"; - -async function getFreeGatewayPort(): Promise { - return await getDeterministicFreePortBlock({ offsets: [0, 1, 2, 3, 4] }); -} - -async function onceMessage( - ws: WebSocket, - filter: (obj: unknown) => boolean, - timeoutMs = 5000, -): Promise { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs); - const closeHandler = (code: number, reason: Buffer) => { - clearTimeout(timer); - ws.off("message", handler); - reject(new Error(`closed ${code}: ${rawDataToString(reason)}`)); - }; - const handler = (data: WebSocket.RawData) => { - const obj = JSON.parse(rawDataToString(data)); - if (!filter(obj)) return; - clearTimeout(timer); - ws.off("message", handler); - ws.off("close", closeHandler); - resolve(obj as T); - }; - ws.on("message", handler); - ws.once("close", closeHandler); - }); -} - -async function connectReq(params: { url: string; token?: string }) { - const ws = new WebSocket(params.url); - await new Promise((resolve) => ws.once("open", resolve)); - const identity = loadOrCreateDeviceIdentity(); - const signedAtMs = Date.now(); - const payload = buildDeviceAuthPayload({ - deviceId: identity.deviceId, - clientId: GATEWAY_CLIENT_NAMES.TEST, - clientMode: GATEWAY_CLIENT_MODES.TEST, - role: "operator", - scopes: [], - signedAtMs, - token: params.token ?? null, - }); - const device = { - id: identity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), - signature: signDevicePayload(identity.privateKeyPem, payload), - signedAt: signedAtMs, - }; - ws.send( - JSON.stringify({ - type: "req", - id: "c1", - method: "connect", - params: { - minProtocol: PROTOCOL_VERSION, - maxProtocol: PROTOCOL_VERSION, - client: { - id: GATEWAY_CLIENT_NAMES.TEST, - displayName: "vitest", - version: "dev", - platform: process.platform, - mode: GATEWAY_CLIENT_MODES.TEST, - }, - caps: [], - auth: params.token ? { token: params.token } : undefined, - device, - }, - }), - ); - const res = await onceMessage<{ - type: "res"; - id: string; - ok: boolean; - error?: { message?: string }; - }>(ws, (o) => { - const obj = o as { type?: unknown; id?: unknown } | undefined; - return obj?.type === "res" && obj?.id === "c1"; - }); - ws.close(); - return res; -} - -async function connectClient(params: { url: string; token?: string }) { - const { GatewayClient } = await import("./client.js"); - 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, - token: params.token, - clientName: GATEWAY_CLIENT_NAMES.TEST, - clientDisplayName: "vitest-wizard", - 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(); - }); -} - -type WizardStep = { - id: string; - type: "note" | "select" | "text" | "confirm" | "multiselect" | "progress"; -}; - -type WizardNextPayload = { - sessionId?: string; - done: boolean; - status: "running" | "done" | "cancelled" | "error"; - step?: WizardStep; - error?: string; -}; - -describe("gateway wizard (e2e)", () => { - it("runs wizard over ws and writes auth token config", async () => { - const prev = { - home: process.env.HOME, - stateDir: process.env.CLAWDBOT_STATE_DIR, - configPath: process.env.CLAWDBOT_CONFIG_PATH, - token: process.env.CLAWDBOT_GATEWAY_TOKEN, - skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS, - skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER, - skipCron: process.env.CLAWDBOT_SKIP_CRON, - skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST, - }; - - process.env.CLAWDBOT_SKIP_CHANNELS = "1"; - process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; - process.env.CLAWDBOT_SKIP_CRON = "1"; - process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; - delete process.env.CLAWDBOT_GATEWAY_TOKEN; - - const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-wizard-home-")); - process.env.HOME = tempHome; - delete process.env.CLAWDBOT_STATE_DIR; - delete process.env.CLAWDBOT_CONFIG_PATH; - - const wizardToken = `wiz-${randomUUID()}`; - const port = await getFreeGatewayPort(); - const { startGatewayServer } = await import("./server.js"); - const server = await startGatewayServer(port, { - bind: "loopback", - auth: { mode: "none" }, - controlUiEnabled: false, - wizardRunner: async (_opts, _runtime, prompter) => { - await prompter.intro("Wizard E2E"); - await prompter.note("write token"); - const token = await prompter.text({ message: "token" }); - const { writeConfigFile } = await import("../config/config.js"); - await writeConfigFile({ - gateway: { auth: { mode: "token", token: String(token) } }, - }); - await prompter.outro("ok"); - }, - }); - - const client = await connectClient({ url: `ws://127.0.0.1:${port}` }); - - try { - const start = await client.request("wizard.start", { - mode: "local", - }); - const sessionId = start.sessionId; - expect(typeof sessionId).toBe("string"); - - let next: WizardNextPayload = start; - let didSendToken = false; - while (!next.done) { - const step = next.step; - if (!step) throw new Error("wizard missing step"); - const value = step.type === "text" ? wizardToken : null; - if (step.type === "text") didSendToken = true; - next = await client.request("wizard.next", { - sessionId, - answer: { stepId: step.id, value }, - }); - } - - expect(didSendToken).toBe(true); - expect(next.status).toBe("done"); - - const { resolveConfigPath } = await import("../config/config.js"); - const parsed = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8")); - const token = (parsed as Record)?.gateway as - | Record - | undefined; - expect((token?.auth as { token?: string } | undefined)?.token).toBe(wizardToken); - } finally { - client.stop(); - await server.close({ reason: "wizard e2e complete" }); - } - - const port2 = await getFreeGatewayPort(); - const { startGatewayServer: startGatewayServer2 } = await import("./server.js"); - const server2 = await startGatewayServer2(port2, { - bind: "loopback", - controlUiEnabled: false, - }); - try { - const resNoToken = await connectReq({ - url: `ws://127.0.0.1:${port2}`, - }); - expect(resNoToken.ok).toBe(false); - expect(resNoToken.error?.message ?? "").toContain("unauthorized"); - - const resToken = await connectReq({ - url: `ws://127.0.0.1:${port2}`, - token: wizardToken, - }); - expect(resToken.ok).toBe(true); - } finally { - await server2.close({ reason: "wizard auth verify" }); - await fs.rm(tempHome, { recursive: true, force: true }); - process.env.HOME = prev.home; - process.env.CLAWDBOT_STATE_DIR = prev.stateDir; - process.env.CLAWDBOT_CONFIG_PATH = prev.configPath; - process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token; - process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels; - process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail; - process.env.CLAWDBOT_SKIP_CRON = prev.skipCron; - process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas; - } - }, 90_000); -}); diff --git a/src/gateway/openresponses-http.e2e.test.ts b/src/gateway/openresponses-http.e2e.test.ts index 061c2e19f..782c0addb 100644 --- a/src/gateway/openresponses-http.e2e.test.ts +++ b/src/gateway/openresponses-http.e2e.test.ts @@ -70,7 +70,7 @@ async function ensureResponseConsumed(res: Response) { } describe("OpenResponses HTTP API (e2e)", () => { - it("is disabled by default (requires config)", { timeout: 120_000 }, async () => { + it("rejects when disabled (default + config)", { timeout: 120_000 }, async () => { const port = await getFreePort(); const server = await startServerWithDefaultConfig(port); try { @@ -83,201 +83,112 @@ describe("OpenResponses HTTP API (e2e)", () => { } finally { await server.close({ reason: "test done" }); } - }); - it("can be disabled via config (404)", async () => { - const port = await getFreePort(); - const server = await startServer(port, { + const disabledPort = await getFreePort(); + const disabledServer = await startServer(disabledPort, { openResponsesEnabled: false, }); try { - const res = await postResponses(port, { + const res = await postResponses(disabledPort, { model: "clawdbot", input: "hi", }); expect(res.status).toBe(404); await ensureResponseConsumed(res); } finally { - await server.close({ reason: "test done" }); + await disabledServer.close({ reason: "test done" }); } }); - it("rejects non-POST", async () => { + it("handles OpenResponses request parsing and validation", async () => { const port = await getFreePort(); const server = await startServer(port); + const mockAgentOnce = (payloads: Array<{ text: string }>, meta?: unknown) => { + agentCommand.mockReset(); + agentCommand.mockResolvedValueOnce({ payloads, meta } as never); + }; + try { - const res = await fetch(`http://127.0.0.1:${port}/v1/responses`, { + const resNonPost = await fetch(`http://127.0.0.1:${port}/v1/responses`, { method: "GET", headers: { authorization: "Bearer secret" }, }); - expect(res.status).toBe(405); - await ensureResponseConsumed(res); - } finally { - await server.close({ reason: "test done" }); - } - }); + expect(resNonPost.status).toBe(405); + await ensureResponseConsumed(resNonPost); - it("rejects missing auth", async () => { - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await fetch(`http://127.0.0.1:${port}/v1/responses`, { + const resMissingAuth = await fetch(`http://127.0.0.1:${port}/v1/responses`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "clawdbot", input: "hi" }), }); - expect(res.status).toBe(401); - await ensureResponseConsumed(res); - } finally { - await server.close({ reason: "test done" }); - } - }); + expect(resMissingAuth.status).toBe(401); + await ensureResponseConsumed(resMissingAuth); - it("rejects invalid request body (missing model)", async () => { - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postResponses(port, { input: "hi" }); - expect(res.status).toBe(400); - const json = (await res.json()) as Record; - expect((json.error as Record | undefined)?.type).toBe( + const resMissingModel = await postResponses(port, { input: "hi" }); + expect(resMissingModel.status).toBe(400); + const missingModelJson = (await resMissingModel.json()) as Record; + expect((missingModelJson.error as Record | undefined)?.type).toBe( "invalid_request_error", ); - await ensureResponseConsumed(res); - } finally { - await server.close({ reason: "test done" }); - } - }); + await ensureResponseConsumed(resMissingModel); - it("routes to a specific agent via header", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "hello" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postResponses( + mockAgentOnce([{ text: "hello" }]); + const resHeader = await postResponses( port, { model: "clawdbot", input: "hi" }, { "x-clawdbot-agent-id": "beta" }, ); - expect(res.status).toBe(200); - - expect(agentCommand).toHaveBeenCalledTimes(1); - const [opts] = agentCommand.mock.calls[0] ?? []; - expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch( + expect(resHeader.status).toBe(200); + const [optsHeader] = agentCommand.mock.calls[0] ?? []; + expect((optsHeader as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch( /^agent:beta:/, ); - await ensureResponseConsumed(res); - } finally { - await server.close({ reason: "test done" }); - } - }); + await ensureResponseConsumed(resHeader); - it("routes to a specific agent via model (no custom headers)", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "hello" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postResponses(port, { - model: "clawdbot:beta", - input: "hi", - }); - expect(res.status).toBe(200); - - expect(agentCommand).toHaveBeenCalledTimes(1); - const [opts] = agentCommand.mock.calls[0] ?? []; - expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch( + mockAgentOnce([{ text: "hello" }]); + const resModel = await postResponses(port, { model: "clawdbot:beta", input: "hi" }); + expect(resModel.status).toBe(200); + const [optsModel] = agentCommand.mock.calls[0] ?? []; + expect((optsModel as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch( /^agent:beta:/, ); - await ensureResponseConsumed(res); - } finally { - await server.close({ reason: "test done" }); - } - }); + await ensureResponseConsumed(resModel); - it("uses OpenResponses user for a stable session key", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "hello" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postResponses(port, { + mockAgentOnce([{ text: "hello" }]); + const resUser = await postResponses(port, { user: "alice", model: "clawdbot", input: "hi", }); - expect(res.status).toBe(200); - - const [opts] = agentCommand.mock.calls[0] ?? []; - expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain( + expect(resUser.status).toBe(200); + const [optsUser] = agentCommand.mock.calls[0] ?? []; + expect((optsUser as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain( "openresponses-user:alice", ); - await ensureResponseConsumed(res); - } finally { - await server.close({ reason: "test done" }); - } - }); + await ensureResponseConsumed(resUser); - it("accepts string input", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "hello" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postResponses(port, { + mockAgentOnce([{ text: "hello" }]); + const resString = await postResponses(port, { model: "clawdbot", input: "hello world", }); - expect(res.status).toBe(200); + expect(resString.status).toBe(200); + const [optsString] = agentCommand.mock.calls[0] ?? []; + expect((optsString as { message?: string } | undefined)?.message).toBe("hello world"); + await ensureResponseConsumed(resString); - const [opts] = agentCommand.mock.calls[0] ?? []; - expect((opts as { message?: string } | undefined)?.message).toBe("hello world"); - await ensureResponseConsumed(res); - } finally { - await server.close({ reason: "test done" }); - } - }); - - it("accepts array input with message items", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "hello" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postResponses(port, { + mockAgentOnce([{ text: "hello" }]); + const resArray = await postResponses(port, { model: "clawdbot", input: [{ type: "message", role: "user", content: "hello there" }], }); - expect(res.status).toBe(200); + expect(resArray.status).toBe(200); + const [optsArray] = agentCommand.mock.calls[0] ?? []; + expect((optsArray as { message?: string } | undefined)?.message).toBe("hello there"); + await ensureResponseConsumed(resArray); - const [opts] = agentCommand.mock.calls[0] ?? []; - expect((opts as { message?: string } | undefined)?.message).toBe("hello there"); - await ensureResponseConsumed(res); - } finally { - await server.close({ reason: "test done" }); - } - }); - - it("extracts system and developer messages as extraSystemPrompt", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "hello" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postResponses(port, { + mockAgentOnce([{ text: "hello" }]); + const resSystemDeveloper = await postResponses(port, { model: "clawdbot", input: [ { type: "message", role: "system", content: "You are a helpful assistant." }, @@ -285,53 +196,30 @@ describe("OpenResponses HTTP API (e2e)", () => { { type: "message", role: "user", content: "Hello" }, ], }); - expect(res.status).toBe(200); - - const [opts] = agentCommand.mock.calls[0] ?? []; + expect(resSystemDeveloper.status).toBe(200); + const [optsSystemDeveloper] = agentCommand.mock.calls[0] ?? []; const extraSystemPrompt = - (opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? ""; + (optsSystemDeveloper as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? + ""; expect(extraSystemPrompt).toContain("You are a helpful assistant."); expect(extraSystemPrompt).toContain("Be concise."); - await ensureResponseConsumed(res); - } finally { - await server.close({ reason: "test done" }); - } - }); + await ensureResponseConsumed(resSystemDeveloper); - it("includes instructions in extraSystemPrompt", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "hello" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postResponses(port, { + mockAgentOnce([{ text: "hello" }]); + const resInstructions = await postResponses(port, { model: "clawdbot", input: "hi", instructions: "Always respond in French.", }); - expect(res.status).toBe(200); + expect(resInstructions.status).toBe(200); + const [optsInstructions] = agentCommand.mock.calls[0] ?? []; + const instructionPrompt = + (optsInstructions as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? ""; + expect(instructionPrompt).toContain("Always respond in French."); + await ensureResponseConsumed(resInstructions); - const [opts] = agentCommand.mock.calls[0] ?? []; - const extraSystemPrompt = - (opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? ""; - expect(extraSystemPrompt).toContain("Always respond in French."); - await ensureResponseConsumed(res); - } finally { - await server.close({ reason: "test done" }); - } - }); - - it("includes conversation history when multiple messages are provided", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "I am Claude" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postResponses(port, { + mockAgentOnce([{ text: "I am Claude" }]); + const resHistory = await postResponses(port, { model: "clawdbot", input: [ { type: "message", role: "system", content: "You are a helpful assistant." }, @@ -340,56 +228,33 @@ describe("OpenResponses HTTP API (e2e)", () => { { type: "message", role: "user", content: "What did I just ask you?" }, ], }); - expect(res.status).toBe(200); + expect(resHistory.status).toBe(200); + const [optsHistory] = agentCommand.mock.calls[0] ?? []; + const historyMessage = (optsHistory as { message?: string } | undefined)?.message ?? ""; + expect(historyMessage).toContain(HISTORY_CONTEXT_MARKER); + expect(historyMessage).toContain("User: Hello, who are you?"); + expect(historyMessage).toContain("Assistant: I am Claude."); + expect(historyMessage).toContain(CURRENT_MESSAGE_MARKER); + expect(historyMessage).toContain("User: What did I just ask you?"); + await ensureResponseConsumed(resHistory); - const [opts] = agentCommand.mock.calls[0] ?? []; - const message = (opts as { message?: string } | undefined)?.message ?? ""; - expect(message).toContain(HISTORY_CONTEXT_MARKER); - expect(message).toContain("User: Hello, who are you?"); - expect(message).toContain("Assistant: I am Claude."); - expect(message).toContain(CURRENT_MESSAGE_MARKER); - expect(message).toContain("User: What did I just ask you?"); - await ensureResponseConsumed(res); - } finally { - await server.close({ reason: "test done" }); - } - }); - - it("includes function_call_output when it is the latest item", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "ok" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postResponses(port, { + mockAgentOnce([{ text: "ok" }]); + const resFunctionOutput = await postResponses(port, { model: "clawdbot", input: [ { type: "message", role: "user", content: "What's the weather?" }, { type: "function_call_output", call_id: "call_1", output: "Sunny, 70F." }, ], }); - expect(res.status).toBe(200); + expect(resFunctionOutput.status).toBe(200); + const [optsFunctionOutput] = agentCommand.mock.calls[0] ?? []; + const functionOutputMessage = + (optsFunctionOutput as { message?: string } | undefined)?.message ?? ""; + expect(functionOutputMessage).toContain("Sunny, 70F."); + await ensureResponseConsumed(resFunctionOutput); - const [opts] = agentCommand.mock.calls[0] ?? []; - const message = (opts as { message?: string } | undefined)?.message ?? ""; - expect(message).toContain("Sunny, 70F."); - await ensureResponseConsumed(res); - } finally { - await server.close({ reason: "test done" }); - } - }); - - it("moves input_file content into extraSystemPrompt", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "ok" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postResponses(port, { + mockAgentOnce([{ text: "ok" }]); + const resInputFile = await postResponses(port, { model: "clawdbot", input: [ { @@ -410,29 +275,17 @@ describe("OpenResponses HTTP API (e2e)", () => { }, ], }); - expect(res.status).toBe(200); + expect(resInputFile.status).toBe(200); + const [optsInputFile] = agentCommand.mock.calls[0] ?? []; + const inputFileMessage = (optsInputFile as { message?: string } | undefined)?.message ?? ""; + const inputFilePrompt = + (optsInputFile as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? ""; + expect(inputFileMessage).toBe("read this"); + expect(inputFilePrompt).toContain(''); + await ensureResponseConsumed(resInputFile); - const [opts] = agentCommand.mock.calls[0] ?? []; - const message = (opts as { message?: string } | undefined)?.message ?? ""; - const extraSystemPrompt = - (opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? ""; - expect(message).toBe("read this"); - expect(extraSystemPrompt).toContain(''); - await ensureResponseConsumed(res); - } finally { - await server.close({ reason: "test done" }); - } - }); - - it("applies tool_choice=none by dropping tools", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "ok" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postResponses(port, { + mockAgentOnce([{ text: "ok" }]); + const resToolNone = await postResponses(port, { model: "clawdbot", input: "hi", tools: [ @@ -443,25 +296,15 @@ describe("OpenResponses HTTP API (e2e)", () => { ], tool_choice: "none", }); - expect(res.status).toBe(200); + expect(resToolNone.status).toBe(200); + const [optsToolNone] = agentCommand.mock.calls[0] ?? []; + expect( + (optsToolNone as { clientTools?: unknown[] } | undefined)?.clientTools, + ).toBeUndefined(); + await ensureResponseConsumed(resToolNone); - const [opts] = agentCommand.mock.calls[0] ?? []; - expect((opts as { clientTools?: unknown[] } | undefined)?.clientTools).toBeUndefined(); - await ensureResponseConsumed(res); - } finally { - await server.close({ reason: "test done" }); - } - }); - - it("applies tool_choice to a specific tool", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "ok" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postResponses(port, { + mockAgentOnce([{ text: "ok" }]); + const resToolChoice = await postResponses(port, { model: "clawdbot", input: "hi", tools: [ @@ -476,24 +319,16 @@ describe("OpenResponses HTTP API (e2e)", () => { ], tool_choice: { type: "function", function: { name: "get_time" } }, }); - expect(res.status).toBe(200); - - const [opts] = agentCommand.mock.calls[0] ?? []; + expect(resToolChoice.status).toBe(200); + const [optsToolChoice] = agentCommand.mock.calls[0] ?? []; const clientTools = - (opts as { clientTools?: Array<{ function?: { name?: string } }> })?.clientTools ?? []; + (optsToolChoice as { clientTools?: Array<{ function?: { name?: string } }> }) + ?.clientTools ?? []; expect(clientTools).toHaveLength(1); expect(clientTools[0]?.function?.name).toBe("get_time"); - await ensureResponseConsumed(res); - } finally { - await server.close({ reason: "test done" }); - } - }); + await ensureResponseConsumed(resToolChoice); - it("rejects tool_choice that references an unknown tool", async () => { - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postResponses(port, { + const resUnknownTool = await postResponses(port, { model: "clawdbot", input: "hi", tools: [ @@ -504,85 +339,51 @@ describe("OpenResponses HTTP API (e2e)", () => { ], tool_choice: { type: "function", function: { name: "unknown_tool" } }, }); - expect(res.status).toBe(400); - await ensureResponseConsumed(res); - } finally { - await server.close({ reason: "test done" }); - } - }); + expect(resUnknownTool.status).toBe(400); + await ensureResponseConsumed(resUnknownTool); - it("passes max_output_tokens through to the agent stream params", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "ok" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postResponses(port, { + mockAgentOnce([{ text: "ok" }]); + const resMaxTokens = await postResponses(port, { model: "clawdbot", input: "hi", max_output_tokens: 123, }); - expect(res.status).toBe(200); - - const [opts] = agentCommand.mock.calls[0] ?? []; + expect(resMaxTokens.status).toBe(200); + const [optsMaxTokens] = agentCommand.mock.calls[0] ?? []; expect( - (opts as { streamParams?: { maxTokens?: number } } | undefined)?.streamParams?.maxTokens, + (optsMaxTokens as { streamParams?: { maxTokens?: number } } | undefined)?.streamParams + ?.maxTokens, ).toBe(123); - await ensureResponseConsumed(res); - } finally { - await server.close({ reason: "test done" }); - } - }); + await ensureResponseConsumed(resMaxTokens); - it("returns usage when available", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "ok" }], - meta: { + mockAgentOnce([{ text: "ok" }], { agentMeta: { usage: { input: 3, output: 5, cacheRead: 1, cacheWrite: 1 }, }, - }, - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postResponses(port, { + }); + const resUsage = await postResponses(port, { stream: false, model: "clawdbot", input: "hi", }); - expect(res.status).toBe(200); - const json = (await res.json()) as Record; - expect(json.usage).toEqual({ input_tokens: 3, output_tokens: 5, total_tokens: 10 }); - await ensureResponseConsumed(res); - } finally { - await server.close({ reason: "test done" }); - } - }); + expect(resUsage.status).toBe(200); + const usageJson = (await resUsage.json()) as Record; + expect(usageJson.usage).toEqual({ input_tokens: 3, output_tokens: 5, total_tokens: 10 }); + await ensureResponseConsumed(resUsage); - it("returns a non-streaming response with correct shape", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "hello" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postResponses(port, { + mockAgentOnce([{ text: "hello" }]); + const resShape = await postResponses(port, { stream: false, model: "clawdbot", input: "hi", }); - expect(res.status).toBe(200); - const json = (await res.json()) as Record; - expect(json.object).toBe("response"); - expect(json.status).toBe("completed"); - expect(Array.isArray(json.output)).toBe(true); + expect(resShape.status).toBe(200); + const shapeJson = (await resShape.json()) as Record; + expect(shapeJson.object).toBe("response"); + expect(shapeJson.status).toBe("completed"); + expect(Array.isArray(shapeJson.output)).toBe(true); - const output = json.output as Array>; + const output = shapeJson.output as Array>; expect(output.length).toBe(1); const item = output[0] ?? {}; expect(item.type).toBe("message"); @@ -592,55 +393,48 @@ describe("OpenResponses HTTP API (e2e)", () => { expect(content.length).toBe(1); expect(content[0]?.type).toBe("output_text"); expect(content[0]?.text).toBe("hello"); - await ensureResponseConsumed(res); - } finally { - await server.close({ reason: "test done" }); - } - }); + await ensureResponseConsumed(resShape); - it("requires a user message in input", async () => { - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postResponses(port, { + const resNoUser = await postResponses(port, { model: "clawdbot", input: [{ type: "message", role: "system", content: "yo" }], }); - expect(res.status).toBe(400); - const json = (await res.json()) as Record; - expect((json.error as Record | undefined)?.type).toBe( + expect(resNoUser.status).toBe(400); + const noUserJson = (await resNoUser.json()) as Record; + expect((noUserJson.error as Record | undefined)?.type).toBe( "invalid_request_error", ); - await ensureResponseConsumed(res); + await ensureResponseConsumed(resNoUser); } finally { await server.close({ reason: "test done" }); } }); - it("streams SSE events when stream=true (delta events)", async () => { - agentCommand.mockImplementationOnce(async (opts: unknown) => { - const runId = (opts as { runId?: string } | undefined)?.runId ?? ""; - emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } }); - emitAgentEvent({ runId, stream: "assistant", data: { delta: "llo" } }); - return { payloads: [{ text: "hello" }] } as never; - }); - + it("streams OpenResponses SSE events", async () => { const port = await getFreePort(); const server = await startServer(port); + try { - const res = await postResponses(port, { + agentCommand.mockReset(); + agentCommand.mockImplementationOnce(async (opts: unknown) => { + const runId = (opts as { runId?: string } | undefined)?.runId ?? ""; + emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } }); + emitAgentEvent({ runId, stream: "assistant", data: { delta: "llo" } }); + return { payloads: [{ text: "hello" }] } as never; + }); + + const resDelta = await postResponses(port, { stream: true, model: "clawdbot", input: "hi", }); - expect(res.status).toBe(200); - expect(res.headers.get("content-type") ?? "").toContain("text/event-stream"); + expect(resDelta.status).toBe(200); + expect(resDelta.headers.get("content-type") ?? "").toContain("text/event-stream"); - const text = await res.text(); - const events = parseSseEvents(text); + const deltaText = await resDelta.text(); + const deltaEvents = parseSseEvents(deltaText); - // Check for required event types - const eventTypes = events.map((e) => e.event).filter(Boolean); + const eventTypes = deltaEvents.map((e) => e.event).filter(Boolean); expect(eventTypes).toContain("response.created"); expect(eventTypes).toContain("response.output_item.added"); expect(eventTypes).toContain("response.in_progress"); @@ -649,72 +443,51 @@ describe("OpenResponses HTTP API (e2e)", () => { expect(eventTypes).toContain("response.output_text.done"); expect(eventTypes).toContain("response.content_part.done"); expect(eventTypes).toContain("response.completed"); + expect(deltaEvents.some((e) => e.data === "[DONE]")).toBe(true); - // Check for [DONE] terminal event - expect(events.some((e) => e.data === "[DONE]")).toBe(true); - - // Verify delta content - const deltaEvents = events.filter((e) => e.event === "response.output_text.delta"); - const allDeltas = deltaEvents + const deltas = deltaEvents + .filter((e) => e.event === "response.output_text.delta") .map((e) => { const parsed = JSON.parse(e.data) as { delta?: string }; return parsed.delta ?? ""; }) .join(""); - expect(allDeltas).toBe("hello"); - await ensureResponseConsumed(res); - } finally { - await server.close({ reason: "test done" }); - } - }); + expect(deltas).toBe("hello"); - it("streams SSE events when stream=true (fallback when no deltas)", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "hello" }], - } as never); + agentCommand.mockReset(); + agentCommand.mockResolvedValueOnce({ + payloads: [{ text: "hello" }], + } as never); - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postResponses(port, { + const resFallback = await postResponses(port, { stream: true, model: "clawdbot", input: "hi", }); - expect(res.status).toBe(200); - const text = await res.text(); - expect(text).toContain("[DONE]"); - expect(text).toContain("hello"); - await ensureResponseConsumed(res); - } finally { - await server.close({ reason: "test done" }); - } - }); + expect(resFallback.status).toBe(200); + const fallbackText = await resFallback.text(); + expect(fallbackText).toContain("[DONE]"); + expect(fallbackText).toContain("hello"); - it("event type matches JSON type field", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "hello" }], - } as never); + agentCommand.mockReset(); + agentCommand.mockResolvedValueOnce({ + payloads: [{ text: "hello" }], + } as never); - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postResponses(port, { + const resTypeMatch = await postResponses(port, { stream: true, model: "clawdbot", input: "hi", }); - expect(res.status).toBe(200); + expect(resTypeMatch.status).toBe(200); - const text = await res.text(); - const events = parseSseEvents(text); - - for (const event of events) { + const typeText = await resTypeMatch.text(); + const typeEvents = parseSseEvents(typeText); + for (const event of typeEvents) { if (event.data === "[DONE]") continue; const parsed = JSON.parse(event.data) as { type?: string }; expect(event.event).toBe(parsed.type); } - await ensureResponseConsumed(res); } finally { await server.close({ reason: "test done" }); } diff --git a/src/gateway/server.agent.gateway-server-agent.test.ts b/src/gateway/server.agent.gateway-server-agent.test.ts index 8fde4ad7c..d5cdc5b48 100644 --- a/src/gateway/server.agent.gateway-server-agent.test.ts +++ b/src/gateway/server.agent.gateway-server-agent.test.ts @@ -25,46 +25,7 @@ function _expectChannels(call: Record, channel: string) { } describe("gateway server agent", () => { - test("agent events include sessionKey in agent payloads", async () => { - const { server, ws } = await startServerWithClient(); - await connectOk(ws, { - client: { - id: GATEWAY_CLIENT_NAMES.WEBCHAT, - version: "1.0.0", - platform: "test", - mode: GATEWAY_CLIENT_MODES.WEBCHAT, - }, - }); - - registerAgentRunContext("run-tool-1", { - sessionKey: "main", - verboseLevel: "on", - }); - - const agentEvtP = onceMessage( - ws, - (o) => o.type === "event" && o.event === "agent" && o.payload?.runId === "run-tool-1", - 8000, - ); - - emitAgentEvent({ - runId: "run-tool-1", - stream: "tool", - data: { phase: "start", name: "read", toolCallId: "tool-1" }, - }); - - const evt = await agentEvtP; - const payload = - evt.payload && typeof evt.payload === "object" - ? (evt.payload as Record) - : {}; - expect(payload.sessionKey).toBe("main"); - - ws.close(); - await server.close(); - }); - - test("suppresses tool stream events when verbose is off", async () => { + test("agent events include sessionKey and agent.wait covers lifecycle flows", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); testState.sessionStorePath = path.join(dir, "sessions.json"); await writeSessionStore({ @@ -87,153 +48,153 @@ describe("gateway server agent", () => { }, }); - registerAgentRunContext("run-tool-off", { sessionKey: "agent:main:main" }); - - emitAgentEvent({ - runId: "run-tool-off", - stream: "tool", - data: { phase: "start", name: "read", toolCallId: "tool-1" }, - }); - emitAgentEvent({ - runId: "run-tool-off", - stream: "assistant", - data: { text: "hello" }, + registerAgentRunContext("run-tool-1", { + sessionKey: "main", + verboseLevel: "on", }); - const evt = await onceMessage( - ws, - (o) => o.type === "event" && o.event === "agent" && o.payload?.runId === "run-tool-off", - 8000, - ); - const payload = - evt.payload && typeof evt.payload === "object" - ? (evt.payload as Record) - : {}; - expect(payload.stream).toBe("assistant"); + { + const agentEvtP = onceMessage( + ws, + (o) => o.type === "event" && o.event === "agent" && o.payload?.runId === "run-tool-1", + 8000, + ); - ws.close(); - await server.close(); - }); - - test("agent.wait resolves after lifecycle end", async () => { - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const waitP = rpcReq(ws, "agent.wait", { - runId: "run-wait-1", - timeoutMs: 1000, - }); - - setTimeout(() => { emitAgentEvent({ + runId: "run-tool-1", + stream: "tool", + data: { phase: "start", name: "read", toolCallId: "tool-1" }, + }); + + const evt = await agentEvtP; + const payload = + evt.payload && typeof evt.payload === "object" + ? (evt.payload as Record) + : {}; + expect(payload.sessionKey).toBe("main"); + } + + { + registerAgentRunContext("run-tool-off", { sessionKey: "agent:main:main" }); + + emitAgentEvent({ + runId: "run-tool-off", + stream: "tool", + data: { phase: "start", name: "read", toolCallId: "tool-1" }, + }); + emitAgentEvent({ + runId: "run-tool-off", + stream: "assistant", + data: { text: "hello" }, + }); + + const evt = await onceMessage( + ws, + (o) => o.type === "event" && o.event === "agent" && o.payload?.runId === "run-tool-off", + 8000, + ); + const payload = + evt.payload && typeof evt.payload === "object" + ? (evt.payload as Record) + : {}; + expect(payload.stream).toBe("assistant"); + } + + { + const waitP = rpcReq(ws, "agent.wait", { runId: "run-wait-1", - stream: "lifecycle", - data: { phase: "end", startedAt: 200, endedAt: 210 }, + timeoutMs: 1000, }); - }, 10); - const res = await waitP; - expect(res.ok).toBe(true); - expect(res.payload.status).toBe("ok"); - expect(res.payload.startedAt).toBe(200); + setTimeout(() => { + emitAgentEvent({ + runId: "run-wait-1", + stream: "lifecycle", + data: { phase: "end", startedAt: 200, endedAt: 210 }, + }); + }, 5); - ws.close(); - await server.close(); - }); + const res = await waitP; + expect(res.ok).toBe(true); + expect(res.payload.status).toBe("ok"); + expect(res.payload.startedAt).toBe(200); + } - test("agent.wait resolves when lifecycle ended before wait call", async () => { - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - emitAgentEvent({ - runId: "run-wait-early", - stream: "lifecycle", - data: { phase: "end", startedAt: 50, endedAt: 55 }, - }); - - const res = await rpcReq(ws, "agent.wait", { - runId: "run-wait-early", - timeoutMs: 1000, - }); - expect(res.ok).toBe(true); - expect(res.payload.status).toBe("ok"); - expect(res.payload.startedAt).toBe(50); - - ws.close(); - await server.close(); - }); - - test("agent.wait times out when no lifecycle ends", async () => { - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const res = await rpcReq(ws, "agent.wait", { - runId: "run-wait-3", - timeoutMs: 20, - }); - expect(res.ok).toBe(true); - expect(res.payload.status).toBe("timeout"); - - ws.close(); - await server.close(); - }); - - test("agent.wait returns error on lifecycle error", async () => { - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const waitP = rpcReq(ws, "agent.wait", { - runId: "run-wait-err", - timeoutMs: 1000, - }); - - setTimeout(() => { + { emitAgentEvent({ - runId: "run-wait-err", + runId: "run-wait-early", stream: "lifecycle", - data: { phase: "error", error: "boom" }, + data: { phase: "end", startedAt: 50, endedAt: 55 }, }); - }, 10); - const res = await waitP; - expect(res.ok).toBe(true); - expect(res.payload.status).toBe("error"); - expect(res.payload.error).toBe("boom"); + const res = await rpcReq(ws, "agent.wait", { + runId: "run-wait-early", + timeoutMs: 1000, + }); + expect(res.ok).toBe(true); + expect(res.payload.status).toBe("ok"); + expect(res.payload.startedAt).toBe(50); + } - ws.close(); - await server.close(); - }); + { + const res = await rpcReq(ws, "agent.wait", { + runId: "run-wait-3", + timeoutMs: 30, + }); + expect(res.ok).toBe(true); + expect(res.payload.status).toBe("timeout"); + } - test("agent.wait uses lifecycle start timestamp when end omits it", async () => { - const { server, ws } = await startServerWithClient(); - await connectOk(ws); + { + const waitP = rpcReq(ws, "agent.wait", { + runId: "run-wait-err", + timeoutMs: 1000, + }); - const waitP = rpcReq(ws, "agent.wait", { - runId: "run-wait-start", - timeoutMs: 1000, - }); + setTimeout(() => { + emitAgentEvent({ + runId: "run-wait-err", + stream: "lifecycle", + data: { phase: "error", error: "boom" }, + }); + }, 5); - emitAgentEvent({ - runId: "run-wait-start", - stream: "lifecycle", - data: { phase: "start", startedAt: 123 }, - }); + const res = await waitP; + expect(res.ok).toBe(true); + expect(res.payload.status).toBe("error"); + expect(res.payload.error).toBe("boom"); + } + + { + const waitP = rpcReq(ws, "agent.wait", { + runId: "run-wait-start", + timeoutMs: 1000, + }); - setTimeout(() => { emitAgentEvent({ runId: "run-wait-start", stream: "lifecycle", - data: { phase: "end", endedAt: 456 }, + data: { phase: "start", startedAt: 123 }, }); - }, 10); - const res = await waitP; - expect(res.ok).toBe(true); - expect(res.payload.status).toBe("ok"); - expect(res.payload.startedAt).toBe(123); - expect(res.payload.endedAt).toBe(456); + setTimeout(() => { + emitAgentEvent({ + runId: "run-wait-start", + stream: "lifecycle", + data: { phase: "end", endedAt: 456 }, + }); + }, 5); + + const res = await waitP; + expect(res.ok).toBe(true); + expect(res.payload.status).toBe("ok"); + expect(res.payload.startedAt).toBe(123); + expect(res.payload.endedAt).toBe(456); + } ws.close(); await server.close(); + await fs.rm(dir, { recursive: true, force: true }); + testState.sessionStorePath = undefined; }); }); diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index 0bc7b8374..cb240a0e3 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -30,11 +30,11 @@ describe("gateway server auth/connect", () => { test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => { vi.useRealTimers(); const prevHandshakeTimeout = process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS; - process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "250"; + process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "50"; try { const { server, ws } = await startServerWithClient(); const handshakeTimeoutMs = getHandshakeTimeoutMs(); - const closed = await waitForWsClose(ws, handshakeTimeoutMs + 2_000); + const closed = await waitForWsClose(ws, handshakeTimeoutMs + 250); expect(closed).toBe(true); await server.close(); } finally { diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 60bbce89f..7886ed242 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, test, vi } from "vitest"; +import { emitAgentEvent } from "../infra/agent-events.js"; import { agentCommand, connectOk, @@ -13,9 +14,7 @@ import { testState, writeSessionStore, } from "./test-helpers.js"; - installGatewayTestHooks(); - async function waitFor(condition: () => boolean, timeoutMs = 1500) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { @@ -24,106 +23,300 @@ async function waitFor(condition: () => boolean, timeoutMs = 1500) { } throw new Error("timeout waiting for condition"); } - +const sendReq = ( + ws: { send: (payload: string) => void }, + id: string, + method: string, + params: unknown, +) => { + ws.send( + JSON.stringify({ + type: "req", + id, + method, + params, + }), + ); +}; +const withSessionStore = async ( + tempDirs: string[], + entries: Record< + string, + { sessionId: string; updatedAt: number; lastChannel?: string; lastTo?: string } + >, + fn: (dir: string) => Promise, +): Promise => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); + tempDirs.push(dir); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await writeSessionStore({ entries }); + try { + return await fn(dir); + } finally { + testState.sessionStorePath = undefined; + } +}; describe("gateway server chat", () => { - test("chat.history caps payload bytes", { timeout: 60_000 }, async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); - testState.sessionStorePath = path.join(dir, "sessions.json"); - await writeSessionStore({ - entries: { - main: { - sessionId: "sess-main", - updatedAt: Date.now(), - }, - }, - }); - + test("handles history, abort, idempotency, and ordering flows", { timeout: 60_000 }, async () => { + const tempDirs: string[] = []; const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const bigText = "x".repeat(200_000); - const largeLines: string[] = []; - for (let i = 0; i < 40; i += 1) { - largeLines.push( - JSON.stringify({ - message: { - role: "user", - content: [{ type: "text", text: `${i}:${bigText}` }], - timestamp: Date.now() + i, - }, - }), - ); - } - await fs.writeFile(path.join(dir, "sess-main.jsonl"), largeLines.join("\n"), "utf-8"); - - const cappedRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", { - sessionKey: "main", - limit: 1000, - }); - expect(cappedRes.ok).toBe(true); - const cappedMsgs = cappedRes.payload?.messages ?? []; - const bytes = Buffer.byteLength(JSON.stringify(cappedMsgs), "utf8"); - expect(bytes).toBeLessThanOrEqual(6 * 1024 * 1024); - expect(cappedMsgs.length).toBeLessThan(60); - - ws.close(); - await server.close(); - }); - - test("chat.send does not overwrite last delivery route", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); - testState.sessionStorePath = path.join(dir, "sessions.json"); - await writeSessionStore({ - entries: { - main: { - sessionId: "sess-main", - updatedAt: Date.now(), - lastChannel: "whatsapp", - lastTo: "+1555", - }, - }, - }); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const res = await rpcReq(ws, "chat.send", { - sessionKey: "main", - message: "hello", - idempotencyKey: "idem-route", - }); - expect(res.ok).toBe(true); - - const stored = JSON.parse(await fs.readFile(testState.sessionStorePath, "utf-8")) as Record< - string, - { lastChannel?: string; lastTo?: string } | undefined - >; - expect(stored["agent:main:main"]?.lastChannel).toBe("whatsapp"); - expect(stored["agent:main:main"]?.lastTo).toBe("+1555"); - - ws.close(); - await server.close(); - }); - - test("chat.abort cancels an in-flight chat.send", { timeout: 60_000 }, async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); - testState.sessionStorePath = path.join(dir, "sessions.json"); - await writeSessionStore({ - entries: { - main: { - sessionId: "sess-main", - updatedAt: Date.now(), - }, - }, - }); - - const { server, ws } = await startServerWithClient(); - let inFlight: Promise | undefined; + const spy = vi.mocked(agentCommand); + const resetSpy = () => { + spy.mockReset(); + spy.mockResolvedValue(undefined); + }; try { await connectOk(ws); - - const spy = vi.mocked(agentCommand); - const callsBefore = spy.mock.calls.length; + await withSessionStore( + tempDirs, + { main: { sessionId: "sess-main", updatedAt: Date.now() } }, + async (historyDir) => { + const bigText = "x".repeat(200_000); + const largeLines: string[] = []; + for (let i = 0; i < 40; i += 1) { + largeLines.push( + JSON.stringify({ + message: { + role: "user", + content: [{ type: "text", text: `${i}:${bigText}` }], + timestamp: Date.now() + i, + }, + }), + ); + } + await fs.writeFile( + path.join(historyDir, "sess-main.jsonl"), + largeLines.join("\n"), + "utf-8", + ); + const cappedRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", { + sessionKey: "main", + limit: 1000, + }); + expect(cappedRes.ok).toBe(true); + const cappedMsgs = cappedRes.payload?.messages ?? []; + const bytes = Buffer.byteLength(JSON.stringify(cappedMsgs), "utf8"); + expect(bytes).toBeLessThanOrEqual(6 * 1024 * 1024); + expect(cappedMsgs.length).toBeLessThan(60); + }, + ); + await withSessionStore( + tempDirs, + { + main: { + sessionId: "sess-main", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastTo: "+1555", + }, + }, + async () => { + const routeRes = await rpcReq(ws, "chat.send", { + sessionKey: "main", + message: "hello", + idempotencyKey: "idem-route", + }); + expect(routeRes.ok).toBe(true); + const stored = JSON.parse( + await fs.readFile(testState.sessionStorePath as string, "utf-8"), + ) as Record; + expect(stored["agent:main:main"]?.lastChannel).toBe("whatsapp"); + expect(stored["agent:main:main"]?.lastTo).toBe("+1555"); + }, + ); + await withSessionStore( + tempDirs, + { main: { sessionId: "sess-main", updatedAt: Date.now() } }, + async () => { + resetSpy(); + let abortInFlight: Promise | undefined; + try { + const callsBefore = spy.mock.calls.length; + spy.mockImplementationOnce(async (opts) => { + const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; + await new Promise((resolve) => { + if (!signal) return resolve(); + if (signal.aborted) return resolve(); + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + }); + const sendResP = onceMessage( + ws, + (o) => o.type === "res" && o.id === "send-abort-1", + 8000, + ); + const abortResP = onceMessage(ws, (o) => o.type === "res" && o.id === "abort-1", 8000); + const abortedEventP = onceMessage( + ws, + (o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted", + 8000, + ); + abortInFlight = Promise.allSettled([sendResP, abortResP, abortedEventP]); + sendReq(ws, "send-abort-1", "chat.send", { + sessionKey: "main", + message: "hello", + idempotencyKey: "idem-abort-1", + timeoutMs: 30_000, + }); + const sendRes = await sendResP; + expect(sendRes.ok).toBe(true); + await new Promise((resolve, reject) => { + const deadline = Date.now() + 1000; + const tick = () => { + if (spy.mock.calls.length > callsBefore) return resolve(); + if (Date.now() > deadline) + return reject(new Error("timeout waiting for agentCommand")); + setTimeout(tick, 5); + }; + tick(); + }); + sendReq(ws, "abort-1", "chat.abort", { + sessionKey: "main", + runId: "idem-abort-1", + }); + const abortRes = await abortResP; + expect(abortRes.ok).toBe(true); + const evt = await abortedEventP; + expect(evt.payload?.runId).toBe("idem-abort-1"); + expect(evt.payload?.sessionKey).toBe("main"); + } finally { + await abortInFlight; + } + }, + ); + await withSessionStore( + tempDirs, + { main: { sessionId: "sess-main", updatedAt: Date.now() } }, + async () => { + sessionStoreSaveDelayMs.value = 120; + resetSpy(); + try { + spy.mockImplementationOnce(async (opts) => { + const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; + await new Promise((resolve) => { + if (!signal) return resolve(); + if (signal.aborted) return resolve(); + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + }); + const abortedEventP = onceMessage( + ws, + (o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted", + ); + const sendResP = onceMessage( + ws, + (o) => o.type === "res" && o.id === "send-abort-save-1", + ); + sendReq(ws, "send-abort-save-1", "chat.send", { + sessionKey: "main", + message: "hello", + idempotencyKey: "idem-abort-save-1", + timeoutMs: 30_000, + }); + const abortResP = onceMessage(ws, (o) => o.type === "res" && o.id === "abort-save-1"); + sendReq(ws, "abort-save-1", "chat.abort", { + sessionKey: "main", + runId: "idem-abort-save-1", + }); + const abortRes = await abortResP; + expect(abortRes.ok).toBe(true); + const sendRes = await sendResP; + expect(sendRes.ok).toBe(true); + const evt = await abortedEventP; + expect(evt.payload?.runId).toBe("idem-abort-save-1"); + expect(evt.payload?.sessionKey).toBe("main"); + } finally { + sessionStoreSaveDelayMs.value = 0; + } + }, + ); + await withSessionStore( + tempDirs, + { main: { sessionId: "sess-main", updatedAt: Date.now() } }, + async () => { + resetSpy(); + const callsBeforeStop = spy.mock.calls.length; + spy.mockImplementationOnce(async (opts) => { + const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; + await new Promise((resolve) => { + if (!signal) return resolve(); + if (signal.aborted) return resolve(); + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + }); + const stopSendResP = onceMessage( + ws, + (o) => o.type === "res" && o.id === "send-stop-1", + 8000, + ); + sendReq(ws, "send-stop-1", "chat.send", { + sessionKey: "main", + message: "hello", + idempotencyKey: "idem-stop-run", + }); + const stopSendRes = await stopSendResP; + expect(stopSendRes.ok).toBe(true); + await waitFor(() => spy.mock.calls.length > callsBeforeStop); + const abortedStopEventP = onceMessage( + ws, + (o) => + o.type === "event" && + o.event === "chat" && + o.payload?.state === "aborted" && + o.payload?.runId === "idem-stop-run", + 8000, + ); + const stopResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-stop-2", 8000); + sendReq(ws, "send-stop-2", "chat.send", { + sessionKey: "main", + message: "/stop", + idempotencyKey: "idem-stop-req", + }); + const stopRes = await stopResP; + expect(stopRes.ok).toBe(true); + const stopEvt = await abortedStopEventP; + expect(stopEvt.payload?.sessionKey).toBe("main"); + expect(spy.mock.calls.length).toBe(callsBeforeStop + 1); + }, + ); + resetSpy(); + let resolveRun: (() => void) | undefined; + const runDone = new Promise((resolve) => { + resolveRun = resolve; + }); + spy.mockImplementationOnce(async () => { + await runDone; + }); + const started = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", { + sessionKey: "main", + message: "hello", + idempotencyKey: "idem-status-1", + }); + expect(started.ok).toBe(true); + expect(started.payload?.status).toBe("started"); + const inFlightRes = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", { + sessionKey: "main", + message: "hello", + idempotencyKey: "idem-status-1", + }); + expect(inFlightRes.ok).toBe(true); + expect(inFlightRes.payload?.status).toBe("in_flight"); + resolveRun?.(); + let completed = false; + for (let i = 0; i < 50; i++) { + const again = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", { + sessionKey: "main", + message: "hello", + idempotencyKey: "idem-status-1", + }); + if (again.ok && again.payload?.status === "ok") { + completed = true; + break; + } + await new Promise((r) => setTimeout(r, 10)); + } + expect(completed).toBe(true); + resetSpy(); spy.mockImplementationOnce(async (opts) => { const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; await new Promise((resolve) => { @@ -132,260 +325,198 @@ describe("gateway server chat", () => { signal.addEventListener("abort", () => resolve(), { once: true }); }); }); - - const sendResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-abort-1", 8000); - const abortResP = onceMessage(ws, (o) => o.type === "res" && o.id === "abort-1", 8000); const abortedEventP = onceMessage( ws, - (o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted", - 8000, + (o) => + o.type === "event" && + o.event === "chat" && + o.payload?.state === "aborted" && + o.payload?.runId === "idem-abort-all-1", ); - inFlight = Promise.allSettled([sendResP, abortResP, abortedEventP]); - - ws.send( - JSON.stringify({ - type: "req", - id: "send-abort-1", - method: "chat.send", - params: { - sessionKey: "main", - message: "hello", - idempotencyKey: "idem-abort-1", - timeoutMs: 30_000, - }, - }), - ); - - const sendRes = await sendResP; - expect(sendRes.ok).toBe(true); - - await new Promise((resolve, reject) => { - const deadline = Date.now() + 1000; - const tick = () => { - if (spy.mock.calls.length > callsBefore) return resolve(); - if (Date.now() > deadline) return reject(new Error("timeout waiting for agentCommand")); - setTimeout(tick, 5); - }; - tick(); - }); - - ws.send( - JSON.stringify({ - type: "req", - id: "abort-1", - method: "chat.abort", - params: { sessionKey: "main", runId: "idem-abort-1" }, - }), - ); - - const abortRes = await abortResP; - expect(abortRes.ok).toBe(true); - - const evt = await abortedEventP; - expect(evt.payload?.runId).toBe("idem-abort-1"); - expect(evt.payload?.sessionKey).toBe("main"); - } finally { - ws.close(); - await inFlight; - await server.close(); - } - }); - - test("chat.abort cancels while saving the session store", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); - testState.sessionStorePath = path.join(dir, "sessions.json"); - await writeSessionStore({ - entries: { - main: { - sessionId: "sess-main", - updatedAt: Date.now(), - }, - }, - }); - - sessionStoreSaveDelayMs.value = 120; - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const spy = vi.mocked(agentCommand); - spy.mockImplementationOnce(async (opts) => { - const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; - await new Promise((resolve) => { - if (!signal) return resolve(); - if (signal.aborted) return resolve(); - signal.addEventListener("abort", () => resolve(), { once: true }); - }); - }); - - const abortedEventP = onceMessage( - ws, - (o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted", - ); - - const sendResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-abort-save-1"); - - ws.send( - JSON.stringify({ - type: "req", - id: "send-abort-save-1", - method: "chat.send", - params: { - sessionKey: "main", - message: "hello", - idempotencyKey: "idem-abort-save-1", - timeoutMs: 30_000, - }, - }), - ); - - const abortResP = onceMessage(ws, (o) => o.type === "res" && o.id === "abort-save-1"); - ws.send( - JSON.stringify({ - type: "req", - id: "abort-save-1", - method: "chat.abort", - params: { sessionKey: "main", runId: "idem-abort-save-1" }, - }), - ); - - const abortRes = await abortResP; - expect(abortRes.ok).toBe(true); - - const sendRes = await sendResP; - expect(sendRes.ok).toBe(true); - - const evt = await abortedEventP; - expect(evt.payload?.runId).toBe("idem-abort-save-1"); - expect(evt.payload?.sessionKey).toBe("main"); - - ws.close(); - await server.close(); - }); - - test("chat.send treats /stop as an out-of-band abort", { timeout: 60_000 }, async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); - testState.sessionStorePath = path.join(dir, "sessions.json"); - await writeSessionStore({ - entries: { - main: { sessionId: "sess-main", updatedAt: Date.now() }, - }, - }); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const spy = vi.mocked(agentCommand); - const callsBefore = spy.mock.calls.length; - spy.mockImplementationOnce(async (opts) => { - const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; - await new Promise((resolve) => { - if (!signal) return resolve(); - if (signal.aborted) return resolve(); - signal.addEventListener("abort", () => resolve(), { once: true }); - }); - }); - - const sendResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-stop-1", 8000); - ws.send( - JSON.stringify({ - type: "req", - id: "send-stop-1", - method: "chat.send", - params: { - sessionKey: "main", - message: "hello", - idempotencyKey: "idem-stop-run", - }, - }), - ); - const sendRes = await sendResP; - expect(sendRes.ok).toBe(true); - - await waitFor(() => spy.mock.calls.length > callsBefore); - - const abortedEventP = onceMessage( - ws, - (o) => - o.type === "event" && - o.event === "chat" && - o.payload?.state === "aborted" && - o.payload?.runId === "idem-stop-run", - 8000, - ); - - const stopResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-stop-2", 8000); - ws.send( - JSON.stringify({ - type: "req", - id: "send-stop-2", - method: "chat.send", - params: { - sessionKey: "main", - message: "/stop", - idempotencyKey: "idem-stop-req", - }, - }), - ); - const stopRes = await stopResP; - expect(stopRes.ok).toBe(true); - - const evt = await abortedEventP; - expect(evt.payload?.sessionKey).toBe("main"); - - expect(spy.mock.calls.length).toBe(callsBefore + 1); - - ws.close(); - await server.close(); - }); - - test("chat.send idempotency returns started → in_flight → ok", async () => { - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const spy = vi.mocked(agentCommand); - let resolveRun: (() => void) | undefined; - const runDone = new Promise((resolve) => { - resolveRun = resolve; - }); - spy.mockImplementationOnce(async () => { - await runDone; - }); - - const started = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", { - sessionKey: "main", - message: "hello", - idempotencyKey: "idem-status-1", - }); - expect(started.ok).toBe(true); - expect(started.payload?.status).toBe("started"); - - const inFlight = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", { - sessionKey: "main", - message: "hello", - idempotencyKey: "idem-status-1", - }); - expect(inFlight.ok).toBe(true); - expect(inFlight.payload?.status).toBe("in_flight"); - - resolveRun?.(); - - let completed = false; - for (let i = 0; i < 50; i++) { - const again = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", { + const startedAbortAll = await rpcReq(ws, "chat.send", { sessionKey: "main", message: "hello", - idempotencyKey: "idem-status-1", + idempotencyKey: "idem-abort-all-1", }); - if (again.ok && again.payload?.status === "ok") { - completed = true; - break; - } - await new Promise((r) => setTimeout(r, 10)); + expect(startedAbortAll.ok).toBe(true); + const abortRes = await rpcReq<{ + ok?: boolean; + aborted?: boolean; + runIds?: string[]; + }>(ws, "chat.abort", { sessionKey: "main" }); + expect(abortRes.ok).toBe(true); + expect(abortRes.payload?.aborted).toBe(true); + expect(abortRes.payload?.runIds ?? []).toContain("idem-abort-all-1"); + await abortedEventP; + const noDeltaP = onceMessage( + ws, + (o) => + o.type === "event" && + o.event === "chat" && + (o.payload?.state === "delta" || o.payload?.state === "final") && + o.payload?.runId === "idem-abort-all-1", + 250, + ); + emitAgentEvent({ + runId: "idem-abort-all-1", + stream: "assistant", + data: { text: "should be suppressed" }, + }); + emitAgentEvent({ + runId: "idem-abort-all-1", + stream: "lifecycle", + data: { phase: "end" }, + }); + await expect(noDeltaP).rejects.toThrow(/timeout/i); + await withSessionStore(tempDirs, {}, async () => { + const abortUnknown = await rpcReq<{ + ok?: boolean; + aborted?: boolean; + }>(ws, "chat.abort", { sessionKey: "main", runId: "missing-run" }); + expect(abortUnknown.ok).toBe(true); + expect(abortUnknown.payload?.aborted).toBe(false); + }); + await withSessionStore( + tempDirs, + { main: { sessionId: "sess-main", updatedAt: Date.now() } }, + async () => { + resetSpy(); + let agentStartedResolve: (() => void) | undefined; + const agentStartedP = new Promise((resolve) => { + agentStartedResolve = resolve; + }); + spy.mockImplementationOnce(async (opts) => { + agentStartedResolve?.(); + const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; + await new Promise((resolve) => { + if (!signal) return resolve(); + if (signal.aborted) return resolve(); + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + }); + const sendResP = onceMessage( + ws, + (o) => o.type === "res" && o.id === "send-mismatch-1", + 10_000, + ); + sendReq(ws, "send-mismatch-1", "chat.send", { + sessionKey: "main", + message: "hello", + idempotencyKey: "idem-mismatch-1", + timeoutMs: 30_000, + }); + await agentStartedP; + const abortMismatch = await rpcReq(ws, "chat.abort", { + sessionKey: "other", + runId: "idem-mismatch-1", + }); + expect(abortMismatch.ok).toBe(false); + expect(abortMismatch.error?.code).toBe("INVALID_REQUEST"); + const abortMismatch2 = await rpcReq(ws, "chat.abort", { + sessionKey: "main", + runId: "idem-mismatch-1", + }); + expect(abortMismatch2.ok).toBe(true); + const sendRes = await sendResP; + expect(sendRes.ok).toBe(true); + }, + ); + await withSessionStore( + tempDirs, + { main: { sessionId: "sess-main", updatedAt: Date.now() } }, + async () => { + resetSpy(); + spy.mockResolvedValueOnce(undefined); + sendReq(ws, "send-complete-1", "chat.send", { + sessionKey: "main", + message: "hello", + idempotencyKey: "idem-complete-1", + timeoutMs: 30_000, + }); + const sendCompleteRes = await onceMessage( + ws, + (o) => o.type === "res" && o.id === "send-complete-1", + ); + expect(sendCompleteRes.ok).toBe(true); + let completedRun = false; + for (let i = 0; i < 50; i++) { + const again = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", { + sessionKey: "main", + message: "hello", + idempotencyKey: "idem-complete-1", + timeoutMs: 30_000, + }); + if (again.ok && again.payload?.status === "ok") { + completedRun = true; + break; + } + await new Promise((r) => setTimeout(r, 10)); + } + expect(completedRun).toBe(true); + const abortCompleteRes = await rpcReq(ws, "chat.abort", { + sessionKey: "main", + runId: "idem-complete-1", + }); + expect(abortCompleteRes.ok).toBe(true); + expect(abortCompleteRes.payload?.aborted).toBe(false); + }, + ); + await withSessionStore( + tempDirs, + { main: { sessionId: "sess-main", updatedAt: Date.now() } }, + async () => { + const res1 = await rpcReq(ws, "chat.send", { + sessionKey: "main", + message: "first", + idempotencyKey: "idem-1", + }); + expect(res1.ok).toBe(true); + const res2 = await rpcReq(ws, "chat.send", { + sessionKey: "main", + message: "second", + idempotencyKey: "idem-2", + }); + expect(res2.ok).toBe(true); + const final1P = onceMessage( + ws, + (o) => o.type === "event" && o.event === "chat" && o.payload?.state === "final", + 8000, + ); + emitAgentEvent({ + runId: "idem-1", + stream: "lifecycle", + data: { phase: "end" }, + }); + const final1 = await final1P; + const run1 = + final1.payload && typeof final1.payload === "object" + ? (final1.payload as { runId?: string }).runId + : undefined; + expect(run1).toBe("idem-1"); + const final2P = onceMessage( + ws, + (o) => o.type === "event" && o.event === "chat" && o.payload?.state === "final", + 8000, + ); + emitAgentEvent({ + runId: "idem-2", + stream: "lifecycle", + data: { phase: "end" }, + }); + const final2 = await final2P; + const run2 = + final2.payload && typeof final2.payload === "object" + ? (final2.payload as { runId?: string }).runId + : undefined; + expect(run2).toBe("idem-2"); + }, + ); + } finally { + testState.sessionStorePath = undefined; + sessionStoreSaveDelayMs.value = 0; + ws.close(); + await server.close(); + await Promise.all(tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true }))); } - expect(completed).toBe(true); - - ws.close(); - await server.close(); }); }); diff --git a/src/gateway/server.chat.gateway-server-chat-c.test.ts b/src/gateway/server.chat.gateway-server-chat-c.test.ts deleted file mode 100644 index 1a55a2488..000000000 --- a/src/gateway/server.chat.gateway-server-chat-c.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, test, vi } from "vitest"; -import { emitAgentEvent } from "../infra/agent-events.js"; -import { - agentCommand, - connectOk, - installGatewayTestHooks, - onceMessage, - rpcReq, - startServerWithClient, - testState, - writeSessionStore, -} from "./test-helpers.js"; - -installGatewayTestHooks(); - -async function _waitFor(condition: () => boolean, timeoutMs = 1500) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (condition()) return; - await new Promise((r) => setTimeout(r, 5)); - } - throw new Error("timeout waiting for condition"); -} - -describe("gateway server chat", () => { - test("chat.abort without runId aborts active runs and suppresses chat events after abort", async () => { - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const spy = vi.mocked(agentCommand); - spy.mockImplementationOnce(async (opts) => { - const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; - await new Promise((resolve) => { - if (!signal) return resolve(); - if (signal.aborted) return resolve(); - signal.addEventListener("abort", () => resolve(), { once: true }); - }); - }); - - const abortedEventP = onceMessage( - ws, - (o) => - o.type === "event" && - o.event === "chat" && - o.payload?.state === "aborted" && - o.payload?.runId === "idem-abort-all-1", - ); - - const started = await rpcReq(ws, "chat.send", { - sessionKey: "main", - message: "hello", - idempotencyKey: "idem-abort-all-1", - }); - expect(started.ok).toBe(true); - - const abortRes = await rpcReq<{ - ok?: boolean; - aborted?: boolean; - runIds?: string[]; - }>(ws, "chat.abort", { sessionKey: "main" }); - expect(abortRes.ok).toBe(true); - expect(abortRes.payload?.aborted).toBe(true); - expect(abortRes.payload?.runIds ?? []).toContain("idem-abort-all-1"); - - await abortedEventP; - - const noDeltaP = onceMessage( - ws, - (o) => - o.type === "event" && - o.event === "chat" && - (o.payload?.state === "delta" || o.payload?.state === "final") && - o.payload?.runId === "idem-abort-all-1", - 250, - ); - - emitAgentEvent({ - runId: "idem-abort-all-1", - stream: "assistant", - data: { text: "should be suppressed" }, - }); - emitAgentEvent({ - runId: "idem-abort-all-1", - stream: "lifecycle", - data: { phase: "end" }, - }); - - await expect(noDeltaP).rejects.toThrow(/timeout/i); - - ws.close(); - await server.close(); - }); - - test("chat.abort returns aborted=false for unknown runId", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); - testState.sessionStorePath = path.join(dir, "sessions.json"); - await writeSessionStore({ entries: {} }); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const abortRes = await rpcReq<{ - ok?: boolean; - aborted?: boolean; - }>(ws, "chat.abort", { sessionKey: "main", runId: "missing-run" }); - - expect(abortRes.ok).toBe(true); - expect(abortRes.payload?.aborted).toBe(false); - - ws.close(); - await server.close(); - }); - - test("chat.abort rejects mismatched sessionKey", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); - testState.sessionStorePath = path.join(dir, "sessions.json"); - await writeSessionStore({ - entries: { - main: { - sessionId: "sess-main", - updatedAt: Date.now(), - }, - }, - }); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const spy = vi.mocked(agentCommand); - let agentStartedResolve: (() => void) | undefined; - const agentStartedP = new Promise((resolve) => { - agentStartedResolve = resolve; - }); - spy.mockImplementationOnce(async (opts) => { - agentStartedResolve?.(); - const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; - await new Promise((resolve) => { - if (!signal) return resolve(); - if (signal.aborted) return resolve(); - signal.addEventListener("abort", () => resolve(), { once: true }); - }); - }); - - const sendResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-mismatch-1", 10_000); - ws.send( - JSON.stringify({ - type: "req", - id: "send-mismatch-1", - method: "chat.send", - params: { - sessionKey: "main", - message: "hello", - idempotencyKey: "idem-mismatch-1", - timeoutMs: 30_000, - }, - }), - ); - - await agentStartedP; - - const abortRes = await rpcReq(ws, "chat.abort", { - sessionKey: "other", - runId: "idem-mismatch-1", - }); - expect(abortRes.ok).toBe(false); - expect(abortRes.error?.code).toBe("INVALID_REQUEST"); - - const abortRes2 = await rpcReq(ws, "chat.abort", { - sessionKey: "main", - runId: "idem-mismatch-1", - }); - expect(abortRes2.ok).toBe(true); - - const sendRes = await sendResP; - expect(sendRes.ok).toBe(true); - - ws.close(); - await server.close(); - }, 15_000); - - test("chat.abort is a no-op after chat.send completes", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); - testState.sessionStorePath = path.join(dir, "sessions.json"); - await writeSessionStore({ - entries: { - main: { - sessionId: "sess-main", - updatedAt: Date.now(), - }, - }, - }); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const spy = vi.mocked(agentCommand); - spy.mockResolvedValueOnce(undefined); - - ws.send( - JSON.stringify({ - type: "req", - id: "send-complete-1", - method: "chat.send", - params: { - sessionKey: "main", - message: "hello", - idempotencyKey: "idem-complete-1", - timeoutMs: 30_000, - }, - }), - ); - - const sendRes = await onceMessage(ws, (o) => o.type === "res" && o.id === "send-complete-1"); - expect(sendRes.ok).toBe(true); - - // chat.send returns before the run ends; wait until dedupe is populated - // (meaning the run completed and the abort controller was cleared). - let completed = false; - for (let i = 0; i < 50; i++) { - const again = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", { - sessionKey: "main", - message: "hello", - idempotencyKey: "idem-complete-1", - timeoutMs: 30_000, - }); - if (again.ok && again.payload?.status === "ok") { - completed = true; - break; - } - await new Promise((r) => setTimeout(r, 10)); - } - expect(completed).toBe(true); - - const abortRes = await rpcReq(ws, "chat.abort", { - sessionKey: "main", - runId: "idem-complete-1", - }); - expect(abortRes.ok).toBe(true); - expect(abortRes.payload?.aborted).toBe(false); - - ws.close(); - await server.close(); - }); - - test("chat.send preserves run ordering for queued runs", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); - testState.sessionStorePath = path.join(dir, "sessions.json"); - await writeSessionStore({ - entries: { - main: { - sessionId: "sess-main", - updatedAt: Date.now(), - }, - }, - }); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const res1 = await rpcReq(ws, "chat.send", { - sessionKey: "main", - message: "first", - idempotencyKey: "idem-1", - }); - expect(res1.ok).toBe(true); - - const res2 = await rpcReq(ws, "chat.send", { - sessionKey: "main", - message: "second", - idempotencyKey: "idem-2", - }); - expect(res2.ok).toBe(true); - - const final1P = onceMessage( - ws, - (o) => o.type === "event" && o.event === "chat" && o.payload?.state === "final", - 8000, - ); - - emitAgentEvent({ - runId: "idem-1", - stream: "lifecycle", - data: { phase: "end" }, - }); - - const final1 = await final1P; - const run1 = - final1.payload && typeof final1.payload === "object" - ? (final1.payload as { runId?: string }).runId - : undefined; - expect(run1).toBe("idem-1"); - - const final2P = onceMessage( - ws, - (o) => o.type === "event" && o.event === "chat" && o.payload?.state === "final", - 8000, - ); - - emitAgentEvent({ - runId: "idem-2", - stream: "lifecycle", - data: { phase: "end" }, - }); - - const final2 = await final2P; - const run2 = - final2.payload && typeof final2.payload === "object" - ? (final2.payload as { runId?: string }).runId - : undefined; - expect(run2).toBe("idem-2"); - - ws.close(); - await server.close(); - }); -}); diff --git a/src/gateway/server.chat.gateway-server-chat.test.ts b/src/gateway/server.chat.gateway-server-chat.test.ts index 766212b74..7bddfa695 100644 --- a/src/gateway/server.chat.gateway-server-chat.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.test.ts @@ -2,13 +2,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, test, vi } from "vitest"; +import { WebSocket } from "ws"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { agentCommand, connectOk, installGatewayTestHooks, onceMessage, - piSdkMock, rpcReq, startServerWithClient, testState, @@ -27,468 +27,222 @@ async function waitFor(condition: () => boolean, timeoutMs = 1500) { } describe("gateway server chat", () => { - test("webchat can chat.send without a mobile node", async () => { - const { server, ws } = await startServerWithClient(); - await connectOk(ws, { - client: { - id: GATEWAY_CLIENT_NAMES.CONTROL_UI, - version: "dev", - platform: "web", - mode: GATEWAY_CLIENT_MODES.WEBCHAT, - }, - }); + test("handles chat send and history flows", async () => { + const tempDirs: string[] = []; + const { server, ws, port } = await startServerWithClient(); + let webchatWs: WebSocket | undefined; - const res = await rpcReq(ws, "chat.send", { - sessionKey: "main", - message: "hello", - idempotencyKey: "idem-webchat-1", - }); - expect(res.ok).toBe(true); + try { + await connectOk(ws); - ws.close(); - await server.close(); - }); - - test("chat.send defaults to agent timeout config", async () => { - testState.agentConfig = { timeoutSeconds: 123 }; - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const spy = vi.mocked(agentCommand); - const callsBefore = spy.mock.calls.length; - const res = await rpcReq(ws, "chat.send", { - sessionKey: "main", - message: "hello", - idempotencyKey: "idem-timeout-1", - }); - expect(res.ok).toBe(true); - - await waitFor(() => spy.mock.calls.length > callsBefore); - const call = spy.mock.calls.at(-1)?.[0] as { timeout?: string } | undefined; - expect(call?.timeout).toBe("123"); - - ws.close(); - await server.close(); - }); - - test("chat.send forwards sessionKey to agentCommand", async () => { - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const spy = vi.mocked(agentCommand); - const callsBefore = spy.mock.calls.length; - const res = await rpcReq(ws, "chat.send", { - sessionKey: "agent:main:subagent:abc", - message: "hello", - idempotencyKey: "idem-session-key-1", - }); - expect(res.ok).toBe(true); - - await waitFor(() => spy.mock.calls.length > callsBefore); - const call = spy.mock.calls.at(-1)?.[0] as { sessionKey?: string } | undefined; - expect(call?.sessionKey).toBe("agent:main:subagent:abc"); - - ws.close(); - await server.close(); - }); - - test("chat.send blocked by send policy", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); - testState.sessionStorePath = path.join(dir, "sessions.json"); - testState.sessionConfig = { - sendPolicy: { - default: "allow", - rules: [ - { - action: "deny", - match: { channel: "discord", chatType: "group" }, - }, - ], - }, - }; - - await writeSessionStore({ - entries: { - "discord:group:dev": { - sessionId: "sess-discord", - updatedAt: Date.now(), - chatType: "group", - channel: "discord", + webchatWs = new WebSocket(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => webchatWs?.once("open", resolve)); + await connectOk(webchatWs, { + client: { + id: GATEWAY_CLIENT_NAMES.CONTROL_UI, + version: "dev", + platform: "web", + mode: GATEWAY_CLIENT_MODES.WEBCHAT, }, - }, - }); + }); - const { server, ws } = await startServerWithClient(); - await connectOk(ws); + const webchatRes = await rpcReq(webchatWs, "chat.send", { + sessionKey: "main", + message: "hello", + idempotencyKey: "idem-webchat-1", + }); + expect(webchatRes.ok).toBe(true); - const res = await rpcReq(ws, "chat.send", { - sessionKey: "discord:group:dev", - message: "hello", - idempotencyKey: "idem-1", - }); - expect(res.ok).toBe(false); - expect((res.error as { message?: string } | undefined)?.message ?? "").toMatch(/send blocked/i); + webchatWs.close(); + webchatWs = undefined; - ws.close(); - await server.close(); - }); + const spy = vi.mocked(agentCommand); + spy.mockClear(); + testState.agentConfig = { timeoutSeconds: 123 }; + const callsBeforeTimeout = spy.mock.calls.length; + const timeoutRes = await rpcReq(ws, "chat.send", { + sessionKey: "main", + message: "hello", + idempotencyKey: "idem-timeout-1", + }); + expect(timeoutRes.ok).toBe(true); - test("agent blocked by send policy for sessionKey", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); - testState.sessionStorePath = path.join(dir, "sessions.json"); - testState.sessionConfig = { - sendPolicy: { - default: "allow", - rules: [{ action: "deny", match: { keyPrefix: "cron:" } }], - }, - }; + await waitFor(() => spy.mock.calls.length > callsBeforeTimeout); + const timeoutCall = spy.mock.calls.at(-1)?.[0] as { timeout?: string } | undefined; + expect(timeoutCall?.timeout).toBe("123"); + testState.agentConfig = undefined; - await writeSessionStore({ - entries: { - "cron:job-1": { - sessionId: "sess-cron", - updatedAt: Date.now(), - }, - }, - }); + spy.mockClear(); + const callsBeforeSession = spy.mock.calls.length; + const sessionRes = await rpcReq(ws, "chat.send", { + sessionKey: "agent:main:subagent:abc", + message: "hello", + idempotencyKey: "idem-session-key-1", + }); + expect(sessionRes.ok).toBe(true); - const { server, ws } = await startServerWithClient(); - await connectOk(ws); + await waitFor(() => spy.mock.calls.length > callsBeforeSession); + const sessionCall = spy.mock.calls.at(-1)?.[0] as { sessionKey?: string } | undefined; + expect(sessionCall?.sessionKey).toBe("agent:main:subagent:abc"); - const res = await rpcReq(ws, "agent", { - sessionKey: "cron:job-1", - message: "hi", - idempotencyKey: "idem-2", - }); - expect(res.ok).toBe(false); - expect((res.error as { message?: string } | undefined)?.message ?? "").toMatch(/send blocked/i); - - ws.close(); - await server.close(); - }); - test("chat.send accepts image attachment", { timeout: 12000 }, async () => { - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const spy = vi.mocked(agentCommand); - const callsBefore = spy.mock.calls.length; - - const pngB64 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; - - const reqId = "chat-img"; - ws.send( - JSON.stringify({ - type: "req", - id: reqId, - method: "chat.send", - params: { - sessionKey: "main", - message: "see image", - idempotencyKey: "idem-img", - attachments: [ + const sendPolicyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); + tempDirs.push(sendPolicyDir); + testState.sessionStorePath = path.join(sendPolicyDir, "sessions.json"); + testState.sessionConfig = { + sendPolicy: { + default: "allow", + rules: [ { - type: "image", - mimeType: "image/png", - fileName: "dot.png", - content: `data:image/png;base64,${pngB64}`, + action: "deny", + match: { channel: "discord", chatType: "group" }, }, ], }, - }), - ); + }; - const res = await onceMessage(ws, (o) => o.type === "res" && o.id === reqId, 8000); - expect(res.ok).toBe(true); - expect(res.payload?.runId).toBeDefined(); - - await waitFor(() => spy.mock.calls.length > callsBefore, 8000); - const call = spy.mock.calls.at(-1)?.[0] as - | { images?: Array<{ type: string; data: string; mimeType: string }> } - | undefined; - expect(call?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]); - - ws.close(); - await server.close(); - }); - - test("chat.history caps large histories and honors limit", async () => { - const firstContentText = (msg: unknown): string | undefined => { - if (!msg || typeof msg !== "object") return undefined; - const content = (msg as { content?: unknown }).content; - if (!Array.isArray(content) || content.length === 0) return undefined; - const first = content[0]; - if (!first || typeof first !== "object") return undefined; - const text = (first as { text?: unknown }).text; - return typeof text === "string" ? text : undefined; - }; - - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); - testState.sessionStorePath = path.join(dir, "sessions.json"); - await writeSessionStore({ - entries: { - main: { - sessionId: "sess-main", - updatedAt: Date.now(), + await writeSessionStore({ + entries: { + "discord:group:dev": { + sessionId: "sess-discord", + updatedAt: Date.now(), + chatType: "group", + channel: "discord", + }, }, - }, - }); + }); - const lines: string[] = []; - for (let i = 0; i < 300; i += 1) { - lines.push( + const blockedRes = await rpcReq(ws, "chat.send", { + sessionKey: "discord:group:dev", + message: "hello", + idempotencyKey: "idem-1", + }); + expect(blockedRes.ok).toBe(false); + expect((blockedRes.error as { message?: string } | undefined)?.message ?? "").toMatch( + /send blocked/i, + ); + + testState.sessionStorePath = undefined; + testState.sessionConfig = undefined; + + const agentBlockedDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); + tempDirs.push(agentBlockedDir); + testState.sessionStorePath = path.join(agentBlockedDir, "sessions.json"); + testState.sessionConfig = { + sendPolicy: { + default: "allow", + rules: [{ action: "deny", match: { keyPrefix: "cron:" } }], + }, + }; + + await writeSessionStore({ + entries: { + "cron:job-1": { + sessionId: "sess-cron", + updatedAt: Date.now(), + }, + }, + }); + + const agentBlockedRes = await rpcReq(ws, "agent", { + sessionKey: "cron:job-1", + message: "hi", + idempotencyKey: "idem-2", + }); + expect(agentBlockedRes.ok).toBe(false); + expect((agentBlockedRes.error as { message?: string } | undefined)?.message ?? "").toMatch( + /send blocked/i, + ); + + testState.sessionStorePath = undefined; + testState.sessionConfig = undefined; + + spy.mockClear(); + const callsBeforeImage = spy.mock.calls.length; + const pngB64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; + + const reqId = "chat-img"; + ws.send( JSON.stringify({ - message: { - role: "user", - content: [{ type: "text", text: `m${i}` }], - timestamp: Date.now() + i, + type: "req", + id: reqId, + method: "chat.send", + params: { + sessionKey: "main", + message: "see image", + idempotencyKey: "idem-img", + attachments: [ + { + type: "image", + mimeType: "image/png", + fileName: "dot.png", + content: `data:image/png;base64,${pngB64}`, + }, + ], }, }), ); - } - await fs.writeFile(path.join(dir, "sess-main.jsonl"), lines.join("\n"), "utf-8"); - const { server, ws } = await startServerWithClient(); - await connectOk(ws); + const imgRes = await onceMessage(ws, (o) => o.type === "res" && o.id === reqId, 8000); + expect(imgRes.ok).toBe(true); + expect(imgRes.payload?.runId).toBeDefined(); - const defaultRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", { - sessionKey: "main", - }); - expect(defaultRes.ok).toBe(true); - const defaultMsgs = defaultRes.payload?.messages ?? []; - expect(defaultMsgs.length).toBe(200); - expect(firstContentText(defaultMsgs[0])).toBe("m100"); + await waitFor(() => spy.mock.calls.length > callsBeforeImage, 8000); + const imgCall = spy.mock.calls.at(-1)?.[0] as + | { images?: Array<{ type: string; data: string; mimeType: string }> } + | undefined; + expect(imgCall?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]); - const limitedRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", { - sessionKey: "main", - limit: 5, - }); - expect(limitedRes.ok).toBe(true); - const limitedMsgs = limitedRes.payload?.messages ?? []; - expect(limitedMsgs.length).toBe(5); - expect(firstContentText(limitedMsgs[0])).toBe("m295"); - - const largeLines: string[] = []; - for (let i = 0; i < 1500; i += 1) { - largeLines.push( - JSON.stringify({ - message: { - role: "user", - content: [{ type: "text", text: `b${i}` }], - timestamp: Date.now() + i, + const historyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); + tempDirs.push(historyDir); + testState.sessionStorePath = path.join(historyDir, "sessions.json"); + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-main", + updatedAt: Date.now(), }, - }), - ); + }, + }); + + const lines: string[] = []; + for (let i = 0; i < 300; i += 1) { + lines.push( + JSON.stringify({ + message: { + role: "user", + content: [{ type: "text", text: `m${i}` }], + timestamp: Date.now() + i, + }, + }), + ); + } + await fs.writeFile(path.join(historyDir, "sess-main.jsonl"), lines.join("\n"), "utf-8"); + + const defaultRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", { + sessionKey: "main", + }); + expect(defaultRes.ok).toBe(true); + const defaultMsgs = defaultRes.payload?.messages ?? []; + const firstContentText = (msg: unknown): string | undefined => { + if (!msg || typeof msg !== "object") return undefined; + const content = (msg as { content?: unknown }).content; + if (!Array.isArray(content) || content.length === 0) return undefined; + const first = content[0]; + if (!first || typeof first !== "object") return undefined; + const text = (first as { text?: unknown }).text; + return typeof text === "string" ? text : undefined; + }; + expect(defaultMsgs.length).toBe(200); + expect(firstContentText(defaultMsgs[0])).toBe("m100"); + } finally { + testState.agentConfig = undefined; + testState.sessionStorePath = undefined; + testState.sessionConfig = undefined; + if (webchatWs) webchatWs.close(); + ws.close(); + await server.close(); + await Promise.all(tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true }))); } - await fs.writeFile(path.join(dir, "sess-main.jsonl"), largeLines.join("\n"), "utf-8"); - - const cappedRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", { - sessionKey: "main", - }); - expect(cappedRes.ok).toBe(true); - const cappedMsgs = cappedRes.payload?.messages ?? []; - expect(cappedMsgs.length).toBe(200); - expect(firstContentText(cappedMsgs[0])).toBe("b1300"); - - const maxRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", { - sessionKey: "main", - limit: 1000, - }); - expect(maxRes.ok).toBe(true); - const maxMsgs = maxRes.payload?.messages ?? []; - expect(maxMsgs.length).toBe(1000); - expect(firstContentText(maxMsgs[0])).toBe("b500"); - - ws.close(); - await server.close(); - }); - - test("chat.history strips inbound envelopes for user messages", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); - testState.sessionStorePath = path.join(dir, "sessions.json"); - await writeSessionStore({ - entries: { - main: { - sessionId: "sess-main", - updatedAt: Date.now(), - }, - }, - }); - - const enveloped = "[WebChat agent:main:main +2m 2026-01-19 09:29 UTC] hello world"; - await fs.writeFile( - path.join(dir, "sess-main.jsonl"), - JSON.stringify({ - message: { - role: "user", - content: [{ type: "text", text: enveloped }], - timestamp: Date.now(), - }, - }), - "utf-8", - ); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const res = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", { - sessionKey: "main", - }); - expect(res.ok).toBe(true); - const message = (res.payload?.messages ?? [])[0] as - | { content?: Array<{ text?: string }> } - | undefined; - expect(message?.content?.[0]?.text).toBe("hello world"); - - ws.close(); - await server.close(); - }); - - test("chat.history prefers sessionFile when set", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); - testState.sessionStorePath = path.join(dir, "sessions.json"); - - const forkedPath = path.join(dir, "sess-forked.jsonl"); - await fs.writeFile( - forkedPath, - JSON.stringify({ - message: { - role: "user", - content: [{ type: "text", text: "from-fork" }], - timestamp: Date.now(), - }, - }), - "utf-8", - ); - - await fs.writeFile( - path.join(dir, "sess-main.jsonl"), - JSON.stringify({ - message: { - role: "user", - content: [{ type: "text", text: "from-default" }], - timestamp: Date.now(), - }, - }), - "utf-8", - ); - - await writeSessionStore({ - entries: { - main: { - sessionId: "sess-main", - sessionFile: forkedPath, - updatedAt: Date.now(), - }, - }, - }); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const res = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", { - sessionKey: "main", - }); - expect(res.ok).toBe(true); - const messages = res.payload?.messages ?? []; - expect(messages.length).toBe(1); - const first = messages[0] as { content?: { text?: string }[] }; - expect(first.content?.[0]?.text).toBe("from-fork"); - - ws.close(); - await server.close(); - }); - - test("chat.inject appends to the session transcript", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); - testState.sessionStorePath = path.join(dir, "sessions.json"); - const transcriptPath = path.join(dir, "sess-main.jsonl"); - - await fs.writeFile( - transcriptPath, - `${JSON.stringify({ - type: "message", - id: "m1", - timestamp: new Date().toISOString(), - message: { role: "user", content: [{ type: "text", text: "seed" }], timestamp: Date.now() }, - })}\n`, - "utf-8", - ); - - await writeSessionStore({ - entries: { - main: { - sessionId: "sess-main", - updatedAt: Date.now(), - }, - }, - }); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const res = await rpcReq<{ messageId?: string }>(ws, "chat.inject", { - sessionKey: "main", - message: "injected text", - label: "note", - }); - expect(res.ok).toBe(true); - - const raw = await fs.readFile(transcriptPath, "utf-8"); - const lines = raw.split(/\r?\n/).filter(Boolean); - expect(lines.length).toBe(2); - const last = JSON.parse(lines[1]) as { - message?: { role?: string; content?: Array<{ text?: string }> }; - }; - expect(last.message?.role).toBe("assistant"); - expect(last.message?.content?.[0]?.text).toContain("injected text"); - - ws.close(); - await server.close(); - }); - - test("chat.history defaults thinking to low for reasoning-capable models", async () => { - piSdkMock.enabled = true; - piSdkMock.models = [ - { - id: "claude-opus-4-5", - name: "Opus 4.5", - provider: "anthropic", - reasoning: true, - }, - ]; - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); - testState.sessionStorePath = path.join(dir, "sessions.json"); - await writeSessionStore({ - entries: { - main: { - sessionId: "sess-main", - updatedAt: Date.now(), - }, - }, - }); - await fs.writeFile( - path.join(dir, "sess-main.jsonl"), - JSON.stringify({ - message: { - role: "user", - content: [{ type: "text", text: "hello" }], - timestamp: Date.now(), - }, - }), - "utf-8", - ); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const res = await rpcReq<{ thinkingLevel?: string }>(ws, "chat.history", { - sessionKey: "main", - }); - expect(res.ok).toBe(true); - expect(res.payload?.thinkingLevel).toBe("low"); - - ws.close(); - await server.close(); }); }); diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index bec6091de..7121f7521 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -1,10 +1,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { connectOk, installGatewayTestHooks, + onceMessage, rpcReq, startServerWithClient, testState, @@ -35,246 +36,35 @@ async function rmTempDir(dir: string) { await fs.rm(dir, { recursive: true, force: true }); } +async function waitForCronFinished(ws: { send: (data: string) => void }, jobId: string) { + await onceMessage( + ws as never, + (o) => + o.type === "event" && + o.event === "cron" && + o.payload?.action === "finished" && + o.payload?.jobId === jobId, + 10_000, + ); +} + +async function waitForNonEmptyFile(pathname: string, timeoutMs = 2000) { + const deadline = Date.now() + timeoutMs; + for (;;) { + const raw = await fs.readFile(pathname, "utf-8").catch(() => ""); + if (raw.trim().length > 0) return raw; + if (Date.now() >= deadline) { + throw new Error(`timeout waiting for file ${pathname}`); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + describe("gateway server cron", () => { - test("supports cron.add and cron.list", { timeout: 120_000 }, async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-")); - testState.cronStorePath = path.join(dir, "cron", "jobs.json"); - await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true }); - await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] })); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const addRes = await rpcReq(ws, "cron.add", { - name: "daily", - enabled: true, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "hello" }, - }); - expect(addRes.ok).toBe(true); - expect(typeof (addRes.payload as { id?: unknown } | null)?.id).toBe("string"); - - const listRes = await rpcReq(ws, "cron.list", { - includeDisabled: true, - }); - expect(listRes.ok).toBe(true); - const jobs = (listRes.payload as { jobs?: unknown } | null)?.jobs; - expect(Array.isArray(jobs)).toBe(true); - expect((jobs as unknown[]).length).toBe(1); - expect(((jobs as Array<{ name?: unknown }>)[0]?.name as string) ?? "").toBe("daily"); - - ws.close(); - await server.close(); - await rmTempDir(dir); - testState.cronStorePath = undefined; - }); - - test("enqueues main cron system events to the resolved main session key", async () => { + test("handles cron CRUD, normalization, and patch semantics", { timeout: 120_000 }, async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-")); testState.cronStorePath = path.join(dir, "cron", "jobs.json"); testState.sessionConfig = { mainKey: "primary" }; - await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true }); - await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] })); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const atMs = Date.now() - 1; - const addRes = await rpcReq(ws, "cron.add", { - name: "route test", - enabled: true, - schedule: { kind: "at", atMs }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "cron route check" }, - }); - expect(addRes.ok).toBe(true); - const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id; - const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; - expect(jobId.length > 0).toBe(true); - - const runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" }, 20_000); - expect(runRes.ok).toBe(true); - - const events = await waitForSystemEvent(); - expect(events.some((event) => event.includes("cron route check"))).toBe(true); - - ws.close(); - await server.close(); - await rmTempDir(dir); - testState.cronStorePath = undefined; - testState.sessionConfig = undefined; - }); - - test("normalizes wrapped cron.add payloads", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-")); - testState.cronStorePath = path.join(dir, "cron", "jobs.json"); - await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true }); - await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] })); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const atMs = Date.now() + 1000; - const addRes = await rpcReq(ws, "cron.add", { - data: { - name: "wrapped", - schedule: { atMs }, - payload: { kind: "systemEvent", text: "hello" }, - }, - }); - expect(addRes.ok).toBe(true); - const payload = addRes.payload as - | { schedule?: unknown; sessionTarget?: unknown; wakeMode?: unknown } - | undefined; - expect(payload?.sessionTarget).toBe("main"); - expect(payload?.wakeMode).toBe("next-heartbeat"); - expect((payload?.schedule as { kind?: unknown } | undefined)?.kind).toBe("at"); - - ws.close(); - await server.close(); - await rmTempDir(dir); - testState.cronStorePath = undefined; - }); - - test("normalizes cron.update patch payloads", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-")); - testState.cronStorePath = path.join(dir, "cron", "jobs.json"); - await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true }); - await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] })); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const addRes = await rpcReq(ws, "cron.add", { - name: "patch test", - enabled: true, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "hello" }, - }); - expect(addRes.ok).toBe(true); - const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id; - const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; - expect(jobId.length > 0).toBe(true); - - const atMs = Date.now() + 1_000; - const updateRes = await rpcReq(ws, "cron.update", { - id: jobId, - patch: { - schedule: { atMs }, - payload: { kind: "systemEvent", text: "updated" }, - }, - }); - expect(updateRes.ok).toBe(true); - const updated = updateRes.payload as - | { schedule?: { kind?: unknown }; payload?: { kind?: unknown } } - | undefined; - expect(updated?.schedule?.kind).toBe("at"); - expect(updated?.payload?.kind).toBe("systemEvent"); - - ws.close(); - await server.close(); - await rmTempDir(dir); - testState.cronStorePath = undefined; - }); - - test("merges agentTurn payload patches", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-")); - testState.cronStorePath = path.join(dir, "cron", "jobs.json"); - await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true }); - await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] })); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const addRes = await rpcReq(ws, "cron.add", { - name: "patch merge", - enabled: true, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "isolated", - wakeMode: "next-heartbeat", - payload: { kind: "agentTurn", message: "hello", model: "opus" }, - }); - expect(addRes.ok).toBe(true); - const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id; - const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; - expect(jobId.length > 0).toBe(true); - - const updateRes = await rpcReq(ws, "cron.update", { - id: jobId, - patch: { - payload: { kind: "agentTurn", deliver: true, channel: "telegram", to: "19098680" }, - }, - }); - expect(updateRes.ok).toBe(true); - const updated = updateRes.payload as - | { - payload?: { - kind?: unknown; - message?: unknown; - model?: unknown; - deliver?: unknown; - channel?: unknown; - to?: unknown; - }; - } - | undefined; - expect(updated?.payload?.kind).toBe("agentTurn"); - expect(updated?.payload?.message).toBe("hello"); - expect(updated?.payload?.model).toBe("opus"); - expect(updated?.payload?.deliver).toBe(true); - expect(updated?.payload?.channel).toBe("telegram"); - expect(updated?.payload?.to).toBe("19098680"); - - ws.close(); - await server.close(); - await rmTempDir(dir); - testState.cronStorePath = undefined; - }); - - test("rejects payload kind changes without required fields", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-")); - testState.cronStorePath = path.join(dir, "cron", "jobs.json"); - await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true }); - await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] })); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const addRes = await rpcReq(ws, "cron.add", { - name: "patch reject", - enabled: true, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "hello" }, - }); - expect(addRes.ok).toBe(true); - const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id; - const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; - expect(jobId.length > 0).toBe(true); - - const updateRes = await rpcReq(ws, "cron.update", { - id: jobId, - patch: { - payload: { kind: "agentTurn", deliver: true }, - }, - }); - expect(updateRes.ok).toBe(false); - - ws.close(); - await server.close(); - await rmTempDir(dir); - testState.cronStorePath = undefined; - }); - - test("accepts jobId for cron.update", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-")); - testState.cronStorePath = path.join(dir, "cron", "jobs.json"); testState.cronEnabled = false; await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true }); await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] })); @@ -282,218 +72,256 @@ describe("gateway server cron", () => { const { server, ws } = await startServerWithClient(); await connectOk(ws); - const addRes = await rpcReq(ws, "cron.add", { - name: "jobId test", - enabled: true, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "hello" }, - }); - expect(addRes.ok).toBe(true); - const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id; - const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; - expect(jobId.length > 0).toBe(true); + try { + const addRes = await rpcReq(ws, "cron.add", { + name: "daily", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "hello" }, + }); + expect(addRes.ok).toBe(true); + expect(typeof (addRes.payload as { id?: unknown } | null)?.id).toBe("string"); - const atMs = Date.now() + 2_000; - const updateRes = await rpcReq(ws, "cron.update", { - jobId, - patch: { - schedule: { atMs }, - payload: { kind: "systemEvent", text: "updated" }, - }, - }); - expect(updateRes.ok).toBe(true); + const listRes = await rpcReq(ws, "cron.list", { + includeDisabled: true, + }); + expect(listRes.ok).toBe(true); + const jobs = (listRes.payload as { jobs?: unknown } | null)?.jobs; + expect(Array.isArray(jobs)).toBe(true); + expect((jobs as unknown[]).length).toBe(1); + expect(((jobs as Array<{ name?: unknown }>)[0]?.name as string) ?? "").toBe("daily"); - ws.close(); - await server.close(); - await rmTempDir(dir); - testState.cronStorePath = undefined; - testState.cronEnabled = undefined; + const routeAtMs = Date.now() - 1; + const routeRes = await rpcReq(ws, "cron.add", { + name: "route test", + enabled: true, + schedule: { kind: "at", atMs: routeAtMs }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "cron route check" }, + }); + expect(routeRes.ok).toBe(true); + const routeJobIdValue = (routeRes.payload as { id?: unknown } | null)?.id; + const routeJobId = typeof routeJobIdValue === "string" ? routeJobIdValue : ""; + expect(routeJobId.length > 0).toBe(true); + + const runRes = await rpcReq(ws, "cron.run", { id: routeJobId, mode: "force" }, 20_000); + expect(runRes.ok).toBe(true); + const events = await waitForSystemEvent(); + expect(events.some((event) => event.includes("cron route check"))).toBe(true); + + const wrappedAtMs = Date.now() + 1000; + const wrappedRes = await rpcReq(ws, "cron.add", { + data: { + name: "wrapped", + schedule: { atMs: wrappedAtMs }, + payload: { kind: "systemEvent", text: "hello" }, + }, + }); + expect(wrappedRes.ok).toBe(true); + const wrappedPayload = wrappedRes.payload as + | { schedule?: unknown; sessionTarget?: unknown; wakeMode?: unknown } + | undefined; + expect(wrappedPayload?.sessionTarget).toBe("main"); + expect(wrappedPayload?.wakeMode).toBe("next-heartbeat"); + expect((wrappedPayload?.schedule as { kind?: unknown } | undefined)?.kind).toBe("at"); + + const patchRes = await rpcReq(ws, "cron.add", { + name: "patch test", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "hello" }, + }); + expect(patchRes.ok).toBe(true); + const patchJobIdValue = (patchRes.payload as { id?: unknown } | null)?.id; + const patchJobId = typeof patchJobIdValue === "string" ? patchJobIdValue : ""; + expect(patchJobId.length > 0).toBe(true); + + const atMs = Date.now() + 1_000; + const updateRes = await rpcReq(ws, "cron.update", { + id: patchJobId, + patch: { + schedule: { atMs }, + payload: { kind: "systemEvent", text: "updated" }, + }, + }); + expect(updateRes.ok).toBe(true); + const updated = updateRes.payload as + | { schedule?: { kind?: unknown }; payload?: { kind?: unknown } } + | undefined; + expect(updated?.schedule?.kind).toBe("at"); + expect(updated?.payload?.kind).toBe("systemEvent"); + + const mergeRes = await rpcReq(ws, "cron.add", { + name: "patch merge", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "hello", model: "opus" }, + }); + expect(mergeRes.ok).toBe(true); + const mergeJobIdValue = (mergeRes.payload as { id?: unknown } | null)?.id; + const mergeJobId = typeof mergeJobIdValue === "string" ? mergeJobIdValue : ""; + expect(mergeJobId.length > 0).toBe(true); + + const mergeUpdateRes = await rpcReq(ws, "cron.update", { + id: mergeJobId, + patch: { + payload: { kind: "agentTurn", deliver: true, channel: "telegram", to: "19098680" }, + }, + }); + expect(mergeUpdateRes.ok).toBe(true); + const merged = mergeUpdateRes.payload as + | { + payload?: { + kind?: unknown; + message?: unknown; + model?: unknown; + deliver?: unknown; + channel?: unknown; + to?: unknown; + }; + } + | undefined; + expect(merged?.payload?.kind).toBe("agentTurn"); + expect(merged?.payload?.message).toBe("hello"); + expect(merged?.payload?.model).toBe("opus"); + expect(merged?.payload?.deliver).toBe(true); + expect(merged?.payload?.channel).toBe("telegram"); + expect(merged?.payload?.to).toBe("19098680"); + + const rejectRes = await rpcReq(ws, "cron.add", { + name: "patch reject", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "hello" }, + }); + expect(rejectRes.ok).toBe(true); + const rejectJobIdValue = (rejectRes.payload as { id?: unknown } | null)?.id; + const rejectJobId = typeof rejectJobIdValue === "string" ? rejectJobIdValue : ""; + expect(rejectJobId.length > 0).toBe(true); + + const rejectUpdateRes = await rpcReq(ws, "cron.update", { + id: rejectJobId, + patch: { + payload: { kind: "agentTurn", deliver: true }, + }, + }); + expect(rejectUpdateRes.ok).toBe(false); + + const jobIdRes = await rpcReq(ws, "cron.add", { + name: "jobId test", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "hello" }, + }); + expect(jobIdRes.ok).toBe(true); + const jobIdValue = (jobIdRes.payload as { id?: unknown } | null)?.id; + const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; + expect(jobId.length > 0).toBe(true); + + const jobIdUpdateRes = await rpcReq(ws, "cron.update", { + jobId, + patch: { + schedule: { atMs: Date.now() + 2_000 }, + payload: { kind: "systemEvent", text: "updated" }, + }, + }); + expect(jobIdUpdateRes.ok).toBe(true); + + const disableRes = await rpcReq(ws, "cron.add", { + name: "disable test", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "hello" }, + }); + expect(disableRes.ok).toBe(true); + const disableJobIdValue = (disableRes.payload as { id?: unknown } | null)?.id; + const disableJobId = typeof disableJobIdValue === "string" ? disableJobIdValue : ""; + expect(disableJobId.length > 0).toBe(true); + + const disableUpdateRes = await rpcReq(ws, "cron.update", { + id: disableJobId, + patch: { enabled: false }, + }); + expect(disableUpdateRes.ok).toBe(true); + const disabled = disableUpdateRes.payload as { enabled?: unknown } | undefined; + expect(disabled?.enabled).toBe(false); + } finally { + ws.close(); + await server.close(); + await rmTempDir(dir); + testState.cronStorePath = undefined; + testState.sessionConfig = undefined; + testState.cronEnabled = undefined; + } }); - test("disables cron jobs via enabled:false patches", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-")); - testState.cronStorePath = path.join(dir, "cron", "jobs.json"); - await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true }); - await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] })); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const addRes = await rpcReq(ws, "cron.add", { - name: "disable test", - enabled: true, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "hello" }, - }); - expect(addRes.ok).toBe(true); - const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id; - const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; - expect(jobId.length > 0).toBe(true); - - const updateRes = await rpcReq(ws, "cron.update", { - id: jobId, - patch: { enabled: false }, - }); - expect(updateRes.ok).toBe(true); - const updated = updateRes.payload as { enabled?: unknown } | undefined; - expect(updated?.enabled).toBe(false); - - ws.close(); - await server.close(); - await fs.rm(dir, { recursive: true, force: true }); - testState.cronStorePath = undefined; - }); - - test("writes cron run history to runs/.jsonl", async () => { + test("writes cron run history and auto-runs due jobs", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-log-")); testState.cronStorePath = path.join(dir, "cron", "jobs.json"); + testState.cronEnabled = undefined; await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true }); await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] })); const { server, ws } = await startServerWithClient(); await connectOk(ws); - const atMs = Date.now() - 1; - const addRes = await rpcReq(ws, "cron.add", { - name: "log test", - enabled: true, - schedule: { kind: "at", atMs }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "hello" }, - }); - expect(addRes.ok).toBe(true); - const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id; - const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; - expect(jobId.length > 0).toBe(true); - - // Full-suite runs can starve the event loop; give cron.run extra time to respond. - const runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" }, 20_000); - expect(runRes.ok).toBe(true); - - const logPath = path.join(dir, "cron", "runs", `${jobId}.jsonl`); - const waitForLog = async () => { - for (let i = 0; i < 200; i += 1) { - const raw = await fs.readFile(logPath, "utf-8").catch(() => ""); - if (raw.trim().length > 0) return raw; - await yieldToEventLoop(); - } - throw new Error("timeout waiting for cron run log"); - }; - - const raw = await waitForLog(); - const line = raw - .split("\n") - .map((l) => l.trim()) - .filter(Boolean) - .at(-1); - const last = JSON.parse(line ?? "{}") as { - jobId?: unknown; - action?: unknown; - status?: unknown; - summary?: unknown; - }; - expect(last.action).toBe("finished"); - expect(last.jobId).toBe(jobId); - expect(last.status).toBe("ok"); - expect(last.summary).toBe("hello"); - - const runsRes = await rpcReq(ws, "cron.runs", { id: jobId, limit: 50 }); - expect(runsRes.ok).toBe(true); - const entries = (runsRes.payload as { entries?: unknown } | null)?.entries; - expect(Array.isArray(entries)).toBe(true); - expect((entries as Array<{ jobId?: unknown }>).at(-1)?.jobId).toBe(jobId); - expect((entries as Array<{ summary?: unknown }>).at(-1)?.summary).toBe("hello"); - - ws.close(); - await server.close(); - await fs.rm(dir, { recursive: true, force: true }); - testState.cronStorePath = undefined; - }); - - test("writes cron run history to per-job runs/ when store is jobs.json", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-log-jobs-")); - const cronDir = path.join(dir, "cron"); - testState.cronStorePath = path.join(cronDir, "jobs.json"); - await fs.mkdir(cronDir, { recursive: true }); - await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] })); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const atMs = Date.now() - 1; - const addRes = await rpcReq(ws, "cron.add", { - name: "log test (jobs.json)", - enabled: true, - schedule: { kind: "at", atMs }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "hello" }, - }); - - expect(addRes.ok).toBe(true); - const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id; - const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; - expect(jobId.length > 0).toBe(true); - - const runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" }); - expect(runRes.ok).toBe(true); - - const logPath = path.join(cronDir, "runs", `${jobId}.jsonl`); - const waitForLog = async () => { - for (let i = 0; i < 200; i += 1) { - const raw = await fs.readFile(logPath, "utf-8").catch(() => ""); - if (raw.trim().length > 0) return raw; - await yieldToEventLoop(); - } - throw new Error("timeout waiting for per-job cron run log"); - }; - - const raw = await waitForLog(); - const line = raw - .split("\n") - .map((l) => l.trim()) - .filter(Boolean) - .at(-1); - const last = JSON.parse(line ?? "{}") as { - jobId?: unknown; - action?: unknown; - summary?: unknown; - }; - expect(last.action).toBe("finished"); - expect(last.jobId).toBe(jobId); - expect(last.summary).toBe("hello"); - - const runsRes = await rpcReq(ws, "cron.runs", { id: jobId, limit: 20 }, 20_000); - expect(runsRes.ok).toBe(true); - const entries = (runsRes.payload as { entries?: unknown } | null)?.entries; - expect(Array.isArray(entries)).toBe(true); - expect((entries as Array<{ jobId?: unknown }>).at(-1)?.jobId).toBe(jobId); - expect((entries as Array<{ summary?: unknown }>).at(-1)?.summary).toBe("hello"); - - ws.close(); - await server.close(); - await fs.rm(dir, { recursive: true, force: true }); - testState.cronStorePath = undefined; - }); - - test("enables cron scheduler by default and runs due jobs automatically", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-default-on-")); - testState.cronStorePath = path.join(dir, "cron", "jobs.json"); - testState.cronEnabled = undefined; - try { - await fs.mkdir(path.dirname(testState.cronStorePath), { - recursive: true, + const atMs = Date.now() - 1; + const addRes = await rpcReq(ws, "cron.add", { + name: "log test", + enabled: true, + schedule: { kind: "at", atMs }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "hello" }, }); - await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] })); + expect(addRes.ok).toBe(true); + const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id; + const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; + expect(jobId.length > 0).toBe(true); - const { server, ws } = await startServerWithClient(); - await connectOk(ws); + const finishedP = waitForCronFinished(ws, jobId); + const runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" }, 20_000); + expect(runRes.ok).toBe(true); + await finishedP; + + const logPath = path.join(dir, "cron", "runs", `${jobId}.jsonl`); + const raw = await waitForNonEmptyFile(logPath); + const line = raw + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .at(-1); + const last = JSON.parse(line ?? "{}") as { + jobId?: unknown; + action?: unknown; + status?: unknown; + summary?: unknown; + }; + expect(last.action).toBe("finished"); + expect(last.jobId).toBe(jobId); + expect(last.status).toBe("ok"); + expect(last.summary).toBe("hello"); + + const runsRes = await rpcReq(ws, "cron.runs", { id: jobId, limit: 50 }); + expect(runsRes.ok).toBe(true); + const entries = (runsRes.payload as { entries?: unknown } | null)?.entries; + expect(Array.isArray(entries)).toBe(true); + expect((entries as Array<{ jobId?: unknown }>).at(-1)?.jobId).toBe(jobId); + expect((entries as Array<{ summary?: unknown }>).at(-1)?.summary).toBe("hello"); const statusRes = await rpcReq(ws, "cron.status", {}); expect(statusRes.ok).toBe(true); @@ -504,45 +332,41 @@ describe("gateway server cron", () => { const storePath = typeof statusPayload?.storePath === "string" ? statusPayload.storePath : ""; expect(storePath).toContain("jobs.json"); - // Keep the job due immediately; we poll run logs instead of relying on - // the cron finished event to avoid timing races under heavy load. - const atMs = Date.now() - 10; - const addRes = await rpcReq(ws, "cron.add", { + const autoRes = await rpcReq(ws, "cron.add", { name: "auto run test", enabled: true, - schedule: { kind: "at", atMs }, + schedule: { kind: "at", atMs: Date.now() - 10 }, sessionTarget: "main", wakeMode: "next-heartbeat", payload: { kind: "systemEvent", text: "auto" }, }); - expect(addRes.ok).toBe(true); - const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id; - const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; - expect(jobId.length > 0).toBe(true); + expect(autoRes.ok).toBe(true); + const autoJobIdValue = (autoRes.payload as { id?: unknown } | null)?.id; + const autoJobId = typeof autoJobIdValue === "string" ? autoJobIdValue : ""; + expect(autoJobId.length > 0).toBe(true); - const waitForRuns = async () => { - for (let i = 0; i < 500; i += 1) { - const runsRes = await rpcReq(ws, "cron.runs", { - id: jobId, - limit: 10, - }); - expect(runsRes.ok).toBe(true); - const entries = (runsRes.payload as { entries?: unknown } | null)?.entries; - if (Array.isArray(entries) && entries.length > 0) return entries; - await yieldToEventLoop(); - } - throw new Error("timeout waiting for cron.runs entries"); - }; - - const entries = (await waitForRuns()) as Array<{ jobId?: unknown }>; - expect(entries.at(-1)?.jobId).toBe(jobId); + vi.useFakeTimers(); + try { + const autoFinishedP = waitForCronFinished(ws, autoJobId); + await vi.advanceTimersByTimeAsync(1000); + await autoFinishedP; + } finally { + vi.useRealTimers(); + } + await waitForNonEmptyFile(path.join(dir, "cron", "runs", `${autoJobId}.jsonl`)); + const autoEntries = (await rpcReq(ws, "cron.runs", { id: autoJobId, limit: 10 })).payload as + | { entries?: Array<{ jobId?: unknown }> } + | undefined; + expect(Array.isArray(autoEntries?.entries)).toBe(true); + const runs = autoEntries?.entries ?? []; + expect(runs.at(-1)?.jobId).toBe(autoJobId); + } finally { ws.close(); await server.close(); - } finally { - testState.cronEnabled = false; - testState.cronStorePath = undefined; await rmTempDir(dir); + testState.cronStorePath = undefined; + testState.cronEnabled = undefined; } }, 45_000); }); diff --git a/src/gateway/server.nodes.allowlist.test.ts b/src/gateway/server.nodes.allowlist.test.ts index b0aae5ab7..cce162c8d 100644 --- a/src/gateway/server.nodes.allowlist.test.ts +++ b/src/gateway/server.nodes.allowlist.test.ts @@ -66,161 +66,132 @@ const connectNodeClient = async (params: { }; describe("gateway node command allowlist", () => { - test("rejects commands outside platform allowlist", async () => { + test("enforces command allowlists across node clients", async () => { const { server, ws, port } = await startServerWithClient(); await connectOk(ws); - const nodeClient = await connectNodeClient({ - port, - commands: ["system.run"], - }); + const waitForConnectedCount = async (count: number) => { + await expect + .poll( + async () => { + const listRes = await rpcReq<{ + nodes?: Array<{ nodeId: string; connected?: boolean }>; + }>(ws, "node.list", {}); + const nodes = listRes.payload?.nodes ?? []; + return nodes.filter((node) => node.connected).length; + }, + { timeout: 2_000 }, + ) + .toBe(count); + }; - const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string }> }>(ws, "node.list", {}); - const nodeId = listRes.payload?.nodes?.[0]?.nodeId ?? ""; - expect(nodeId).toBeTruthy(); + const getConnectedNodeId = async () => { + const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string; connected?: boolean }> }>( + ws, + "node.list", + {}, + ); + const nodeId = listRes.payload?.nodes?.find((node) => node.connected)?.nodeId ?? ""; + expect(nodeId).toBeTruthy(); + return nodeId; + }; - const res = await rpcReq(ws, "node.invoke", { - nodeId, - command: "system.run", - params: { command: "echo hi" }, - idempotencyKey: "allowlist-1", - }); - expect(res.ok).toBe(false); - expect(res.error?.message).toContain("node command not allowed"); + try { + const systemClient = await connectNodeClient({ + port, + commands: ["system.run"], + instanceId: "node-system-run", + displayName: "node-system-run", + }); + const systemNodeId = await getConnectedNodeId(); + const disallowedRes = await rpcReq(ws, "node.invoke", { + nodeId: systemNodeId, + command: "system.run", + params: { command: "echo hi" }, + idempotencyKey: "allowlist-1", + }); + expect(disallowedRes.ok).toBe(false); + expect(disallowedRes.error?.message).toContain("node command not allowed"); + systemClient.stop(); + await waitForConnectedCount(0); - nodeClient.stop(); - ws.close(); - await server.close(); - }); + const emptyClient = await connectNodeClient({ + port, + commands: [], + instanceId: "node-empty", + displayName: "node-empty", + }); + const emptyNodeId = await getConnectedNodeId(); + const missingRes = await rpcReq(ws, "node.invoke", { + nodeId: emptyNodeId, + command: "canvas.snapshot", + params: {}, + idempotencyKey: "allowlist-2", + }); + expect(missingRes.ok).toBe(false); + expect(missingRes.error?.message).toContain("node command not allowed"); + emptyClient.stop(); + await waitForConnectedCount(0); - test("rejects commands not declared by node", async () => { - const { server, ws, port } = await startServerWithClient(); - await connectOk(ws); + let resolveInvoke: ((payload: { id?: string; nodeId?: string }) => void) | null = null; + const waitForInvoke = () => + new Promise<{ id?: string; nodeId?: string }>((resolve) => { + resolveInvoke = resolve; + }); + const allowedClient = await connectNodeClient({ + port, + commands: ["canvas.snapshot"], + instanceId: "node-allowed", + displayName: "node-allowed", + onEvent: (evt) => { + if (evt.event === "node.invoke.request") { + const payload = evt.payload as { id?: string; nodeId?: string }; + resolveInvoke?.(payload); + } + }, + }); + const allowedNodeId = await getConnectedNodeId(); - const nodeClient = await connectNodeClient({ - port, - commands: [], - instanceId: "node-empty", - displayName: "node-empty", - }); + const invokeResP = rpcReq(ws, "node.invoke", { + nodeId: allowedNodeId, + command: "canvas.snapshot", + params: { format: "png" }, + idempotencyKey: "allowlist-3", + }); + const payload = await waitForInvoke(); + const requestId = payload?.id ?? ""; + const nodeIdFromReq = payload?.nodeId ?? "node-allowed"; + await allowedClient.request("node.invoke.result", { + id: requestId, + nodeId: nodeIdFromReq, + ok: true, + payloadJSON: JSON.stringify({ ok: true }), + }); + const invokeRes = await invokeResP; + expect(invokeRes.ok).toBe(true); - const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string }> }>(ws, "node.list", {}); - const nodeId = listRes.payload?.nodes?.find((entry) => entry.nodeId)?.nodeId ?? ""; - expect(nodeId).toBeTruthy(); + const invokeNullResP = rpcReq(ws, "node.invoke", { + nodeId: allowedNodeId, + command: "canvas.snapshot", + params: { format: "png" }, + idempotencyKey: "allowlist-null-payloadjson", + }); + const payloadNull = await waitForInvoke(); + const requestIdNull = payloadNull?.id ?? ""; + const nodeIdNull = payloadNull?.nodeId ?? "node-allowed"; + await allowedClient.request("node.invoke.result", { + id: requestIdNull, + nodeId: nodeIdNull, + ok: true, + payloadJSON: null, + }); + const invokeNullRes = await invokeNullResP; + expect(invokeNullRes.ok).toBe(true); - const res = await rpcReq(ws, "node.invoke", { - nodeId, - command: "canvas.snapshot", - params: {}, - idempotencyKey: "allowlist-2", - }); - expect(res.ok).toBe(false); - expect(res.error?.message).toContain("node command not allowed"); - - nodeClient.stop(); - ws.close(); - await server.close(); - }); - - test("allows declared command within allowlist", async () => { - const { server, ws, port } = await startServerWithClient(); - await connectOk(ws); - - let resolveInvoke: ((payload: { id?: string; nodeId?: string }) => void) | null = null; - const invokeReqP = new Promise<{ id?: string; nodeId?: string }>((resolve) => { - resolveInvoke = resolve; - }); - const nodeClient = await connectNodeClient({ - port, - commands: ["canvas.snapshot"], - instanceId: "node-allowed", - displayName: "node-allowed", - onEvent: (evt) => { - if (evt.event === "node.invoke.request") { - const payload = evt.payload as { id?: string; nodeId?: string }; - resolveInvoke?.(payload); - } - }, - }); - - const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string }> }>(ws, "node.list", {}); - const nodeId = listRes.payload?.nodes?.[0]?.nodeId ?? ""; - expect(nodeId).toBeTruthy(); - - const invokeResP = rpcReq(ws, "node.invoke", { - nodeId, - command: "canvas.snapshot", - params: { format: "png" }, - idempotencyKey: "allowlist-3", - }); - - const payload = await invokeReqP; - const requestId = payload?.id ?? ""; - const nodeIdFromReq = payload?.nodeId ?? "node-allowed"; - - await nodeClient.request("node.invoke.result", { - id: requestId, - nodeId: nodeIdFromReq, - ok: true, - payloadJSON: JSON.stringify({ ok: true }), - }); - - const invokeRes = await invokeResP; - expect(invokeRes.ok).toBe(true); - - nodeClient.stop(); - ws.close(); - await server.close(); - }); - - test("accepts node invoke result with null payloadJSON", async () => { - const { server, ws, port } = await startServerWithClient(); - await connectOk(ws); - - let resolveInvoke: ((payload: { id?: string; nodeId?: string }) => void) | null = null; - const invokeReqP = new Promise<{ id?: string; nodeId?: string }>((resolve) => { - resolveInvoke = resolve; - }); - const nodeClient = await connectNodeClient({ - port, - commands: ["canvas.snapshot"], - instanceId: "node-null-payloadjson", - displayName: "node-null-payloadjson", - onEvent: (evt) => { - if (evt.event === "node.invoke.request") { - const payload = evt.payload as { id?: string; nodeId?: string }; - resolveInvoke?.(payload); - } - }, - }); - - const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string }> }>(ws, "node.list", {}); - const nodeId = listRes.payload?.nodes?.[0]?.nodeId ?? ""; - expect(nodeId).toBeTruthy(); - - const invokeResP = rpcReq(ws, "node.invoke", { - nodeId, - command: "canvas.snapshot", - params: { format: "png" }, - idempotencyKey: "allowlist-null-payloadjson", - }); - - const payload = await invokeReqP; - const requestId = payload?.id ?? ""; - const nodeIdFromReq = payload?.nodeId ?? "node-null-payloadjson"; - - await nodeClient.request("node.invoke.result", { - id: requestId, - nodeId: nodeIdFromReq, - ok: true, - payloadJSON: null, - }); - - const invokeRes = await invokeResP; - expect(invokeRes.ok).toBe(true); - - nodeClient.stop(); - ws.close(); - await server.close(); + allowedClient.stop(); + } finally { + ws.close(); + await server.close(); + } }); }); diff --git a/src/gateway/server.roles.test.ts b/src/gateway/server.roles.test.ts index ba6b17638..b47eea012 100644 --- a/src/gateway/server.roles.test.ts +++ b/src/gateway/server.roles.test.ts @@ -12,8 +12,8 @@ import { installGatewayTestHooks(); describe("gateway role enforcement", () => { - test("operator cannot send node events or invoke results", async () => { - const { server, ws } = await startServerWithClient(); + test("enforces operator and node permissions", async () => { + const { server, ws, port } = await startServerWithClient(); await connectOk(ws); const eventRes = await rpcReq(ws, "node.event", { event: "test", payload: { ok: true } }); @@ -28,12 +28,6 @@ describe("gateway role enforcement", () => { expect(invokeRes.ok).toBe(false); expect(invokeRes.error?.message ?? "").toContain("unauthorized role"); - ws.close(); - await server.close(); - }); - - test("node can fetch skills bins but not control plane methods", async () => { - const { server, port } = await startServerWithClient(); const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`); await new Promise((resolve) => nodeWs.once("open", resolve)); await connectOk(nodeWs, { @@ -56,6 +50,7 @@ describe("gateway role enforcement", () => { expect(statusRes.error?.message ?? "").toContain("unauthorized role"); nodeWs.close(); + ws.close(); await server.close(); }); }); diff --git a/src/gateway/test-helpers.e2e.ts b/src/gateway/test-helpers.e2e.ts new file mode 100644 index 000000000..ab124a83b --- /dev/null +++ b/src/gateway/test-helpers.e2e.ts @@ -0,0 +1,133 @@ +import { WebSocket } from "ws"; + +import { + loadOrCreateDeviceIdentity, + publicKeyRawBase64UrlFromPem, + signDevicePayload, +} from "../infra/device-identity.js"; +import { rawDataToString } from "../infra/ws.js"; +import { getDeterministicFreePortBlock } from "../test-utils/ports.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, + type GatewayClientMode, + type GatewayClientName, +} from "../utils/message-channel.js"; + +import { GatewayClient } from "./client.js"; +import { buildDeviceAuthPayload } from "./device-auth.js"; +import { PROTOCOL_VERSION } from "./protocol/index.js"; + +export async function getFreeGatewayPort(): Promise { + return await getDeterministicFreePortBlock({ offsets: [0, 1, 2, 3, 4] }); +} + +export async function connectGatewayClient(params: { + url: string; + token?: string; + clientName?: GatewayClientName; + clientDisplayName?: string; + clientVersion?: string; + mode?: GatewayClientMode; +}) { + 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, + token: params.token, + clientName: params.clientName ?? GATEWAY_CLIENT_NAMES.TEST, + clientDisplayName: params.clientDisplayName ?? "vitest", + clientVersion: params.clientVersion ?? "dev", + mode: params.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(); + }); +} + +export async function connectDeviceAuthReq(params: { url: string; token?: string }) { + const ws = new WebSocket(params.url); + await new Promise((resolve) => ws.once("open", resolve)); + const identity = loadOrCreateDeviceIdentity(); + const signedAtMs = Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + role: "operator", + scopes: [], + signedAtMs, + token: params.token ?? null, + }); + const device = { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + }; + ws.send( + JSON.stringify({ + type: "req", + id: "c1", + method: "connect", + params: { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: GATEWAY_CLIENT_NAMES.TEST, + displayName: "vitest", + version: "dev", + platform: process.platform, + mode: GATEWAY_CLIENT_MODES.TEST, + }, + caps: [], + auth: params.token ? { token: params.token } : undefined, + device, + }, + }), + ); + const res = await new Promise<{ + type: "res"; + id: string; + ok: boolean; + error?: { message?: string }; + }>((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timeout")), 5000); + const closeHandler = (code: number, reason: Buffer) => { + clearTimeout(timer); + ws.off("message", handler); + reject(new Error(`closed ${code}: ${rawDataToString(reason)}`)); + }; + const handler = (data: WebSocket.RawData) => { + const obj = JSON.parse(rawDataToString(data)) as { type?: unknown; id?: unknown }; + if (obj?.type !== "res" || obj?.id !== "c1") return; + clearTimeout(timer); + ws.off("message", handler); + ws.off("close", closeHandler); + resolve( + obj as { + type: "res"; + id: string; + ok: boolean; + error?: { message?: string }; + }, + ); + }; + ws.on("message", handler); + ws.once("close", closeHandler); + }); + ws.close(); + return res; +} diff --git a/src/gateway/test-helpers.openai-mock.ts b/src/gateway/test-helpers.openai-mock.ts new file mode 100644 index 000000000..ea5977c04 --- /dev/null +++ b/src/gateway/test-helpers.openai-mock.ts @@ -0,0 +1,198 @@ +type OpenAIResponsesParams = { + input?: unknown[]; +}; + +type OpenAIResponseStreamEvent = + | { type: "response.output_item.added"; item: Record } + | { type: "response.function_call_arguments.delta"; delta: string } + | { type: "response.output_item.done"; item: Record } + | { + type: "response.completed"; + response: { + status: "completed"; + usage: { + input_tokens: number; + output_tokens: number; + total_tokens: number; + input_tokens_details?: { cached_tokens?: number }; + }; + }; + }; + +function extractLastUserText(input: unknown[]): string { + for (let i = input.length - 1; i >= 0; i -= 1) { + const item = input[i] as Record | undefined; + if (!item || item.role !== "user") continue; + const content = item.content; + if (Array.isArray(content)) { + const text = content + .filter( + (c): c is { type: "input_text"; text: string } => + !!c && + typeof c === "object" && + (c as { type?: unknown }).type === "input_text" && + typeof (c as { text?: unknown }).text === "string", + ) + .map((c) => c.text) + .join("\n") + .trim(); + if (text) return text; + } + } + return ""; +} + +function extractToolOutput(input: unknown[]): string { + for (const itemRaw of input) { + const item = itemRaw as Record | undefined; + if (!item || item.type !== "function_call_output") continue; + return typeof item.output === "string" ? item.output : ""; + } + return ""; +} + +async function* fakeOpenAIResponsesStream( + params: OpenAIResponsesParams, +): AsyncGenerator { + const input = Array.isArray(params.input) ? params.input : []; + const toolOutput = extractToolOutput(input); + + if (!toolOutput) { + const prompt = extractLastUserText(input); + const quoted = /"([^"]+)"/.exec(prompt)?.[1]; + const toolPath = quoted ?? "package.json"; + const argsJson = JSON.stringify({ path: toolPath }); + + yield { + type: "response.output_item.added", + item: { + type: "function_call", + id: "fc_test_1", + call_id: "call_test_1", + name: "read", + arguments: "", + }, + }; + yield { type: "response.function_call_arguments.delta", delta: argsJson }; + yield { + type: "response.output_item.done", + item: { + type: "function_call", + id: "fc_test_1", + call_id: "call_test_1", + name: "read", + arguments: argsJson, + }, + }; + yield { + type: "response.completed", + response: { + status: "completed", + usage: { input_tokens: 10, output_tokens: 10, total_tokens: 20 }, + }, + }; + return; + } + + const nonceA = /nonceA=([^\s]+)/.exec(toolOutput)?.[1] ?? ""; + const nonceB = /nonceB=([^\s]+)/.exec(toolOutput)?.[1] ?? ""; + const reply = `${nonceA} ${nonceB}`.trim(); + + yield { + type: "response.output_item.added", + item: { + type: "message", + id: "msg_test_1", + role: "assistant", + content: [], + status: "in_progress", + }, + }; + yield { + type: "response.output_item.done", + item: { + type: "message", + id: "msg_test_1", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: reply, annotations: [] }], + }, + }; + yield { + type: "response.completed", + response: { + status: "completed", + usage: { input_tokens: 10, output_tokens: 10, total_tokens: 20 }, + }, + }; +} + +function decodeBodyText(body: unknown): string { + if (!body) return ""; + if (typeof body === "string") return body; + if (body instanceof Uint8Array) return Buffer.from(body).toString("utf8"); + if (body instanceof ArrayBuffer) return Buffer.from(new Uint8Array(body)).toString("utf8"); + return ""; +} + +async function buildOpenAIResponsesSse(params: OpenAIResponsesParams): Promise { + const events: OpenAIResponseStreamEvent[] = []; + for await (const event of fakeOpenAIResponsesStream(params)) { + events.push(event); + } + + const sse = `${events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("")}data: [DONE]\n\n`; + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(sse)); + controller.close(); + }, + }); + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +export function installOpenAiResponsesMock(params?: { baseUrl?: string }) { + const originalFetch = globalThis.fetch; + const baseUrl = params?.baseUrl ?? "https://api.openai.com/v1"; + const responsesUrl = `${baseUrl}/responses`; + const isResponsesRequest = (url: string) => + url === responsesUrl || + url.startsWith(`${responsesUrl}/`) || + url.startsWith(`${responsesUrl}?`); + const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (isResponsesRequest(url)) { + const bodyText = + typeof (init as { body?: unknown } | undefined)?.body !== "undefined" + ? decodeBodyText((init as { body?: unknown }).body) + : input instanceof Request + ? await input.clone().text() + : ""; + + const parsed = bodyText ? (JSON.parse(bodyText) as Record) : {}; + const inputItems = Array.isArray(parsed.input) ? parsed.input : []; + return await buildOpenAIResponsesSse({ input: inputItems }); + } + if (url.startsWith(baseUrl)) { + throw new Error(`unexpected OpenAI request in mock test: ${url}`); + } + + if (!originalFetch) { + throw new Error(`fetch is not available (url=${url})`); + } + return await originalFetch(input, init); + }; + (globalThis as unknown as { fetch: unknown }).fetch = fetchImpl; + return { + baseUrl, + restore: () => { + (globalThis as unknown as { fetch: unknown }).fetch = originalFetch; + }, + }; +} diff --git a/src/memory/manager.batch.test.ts b/src/memory/manager.batch.test.ts index a3f48936e..643f39baf 100644 --- a/src/memory/manager.batch.test.ts +++ b/src/memory/manager.batch.test.ts @@ -126,7 +126,7 @@ describe("memory indexing with OpenAI batches", () => { store: { path: indexPath }, sync: { watch: false, onSessionStart: false, onSearch: false }, query: { minScore: 0 }, - remote: { batch: { enabled: true, wait: true } }, + remote: { batch: { enabled: true, wait: true, pollIntervalMs: 1 } }, }, }, list: [{ id: "main", default: true }], @@ -232,7 +232,7 @@ describe("memory indexing with OpenAI batches", () => { store: { path: indexPath }, sync: { watch: false, onSessionStart: false, onSearch: false }, query: { minScore: 0 }, - remote: { batch: { enabled: true, wait: true } }, + remote: { batch: { enabled: true, wait: true, pollIntervalMs: 1 } }, }, }, list: [{ id: "main", default: true }], @@ -329,7 +329,7 @@ describe("memory indexing with OpenAI batches", () => { store: { path: indexPath }, sync: { watch: false, onSessionStart: false, onSearch: false }, query: { minScore: 0 }, - remote: { batch: { enabled: true, wait: true } }, + remote: { batch: { enabled: true, wait: true, pollIntervalMs: 1 } }, }, }, list: [{ id: "main", default: true }], @@ -426,7 +426,7 @@ describe("memory indexing with OpenAI batches", () => { store: { path: indexPath }, sync: { watch: false, onSessionStart: false, onSearch: false }, query: { minScore: 0 }, - remote: { batch: { enabled: true, wait: true } }, + remote: { batch: { enabled: true, wait: true, pollIntervalMs: 1 } }, }, }, list: [{ id: "main", default: true }], diff --git a/src/slack/send.ts b/src/slack/send.ts index a2ba695c2..a3d61cdef 100644 --- a/src/slack/send.ts +++ b/src/slack/send.ts @@ -88,7 +88,11 @@ async function uploadSlackFile(params: { threadTs?: string; maxBytes?: number; }): Promise { - const { buffer, fileName } = await loadWebMedia(params.mediaUrl, params.maxBytes); + const { + buffer, + contentType: _contentType, + fileName, + } = await loadWebMedia(params.mediaUrl, params.maxBytes); const basePayload = { channel_id: params.channelId, file: buffer, diff --git a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts index aa2653cb3..77e980344 100644 --- a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts +++ b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts @@ -1,5 +1,6 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; +import { MEDIA_GROUP_TIMEOUT_MS } from "./bot-updates.js"; const useSpy = vi.fn(); const middlewareUseSpy = vi.fn(); @@ -253,23 +254,15 @@ describe("telegram inbound media", () => { describe("telegram media groups", () => { beforeEach(() => { - // These tests rely on real setTimeout aggregation; guard against leaked fake timers. + vi.useFakeTimers(); + }); + + afterEach(() => { vi.useRealTimers(); }); - const MEDIA_GROUP_POLL_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 15_000; const MEDIA_GROUP_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 20_000; - - const waitForMediaGroupProcessing = async ( - replySpy: ReturnType, - expectedCalls: number, - ) => { - await expect - .poll(() => replySpy.mock.calls.length, { - timeout: MEDIA_GROUP_POLL_TIMEOUT_MS, - }) - .toBe(expectedCalls); - }; + const MEDIA_GROUP_FLUSH_MS = MEDIA_GROUP_TIMEOUT_MS + 25; it( "buffers messages with same media_group_id and processes them together", @@ -334,7 +327,7 @@ describe("telegram media groups", () => { await second; expect(replySpy).not.toHaveBeenCalled(); - await waitForMediaGroupProcessing(replySpy, 1); + await vi.advanceTimersByTimeAsync(MEDIA_GROUP_FLUSH_MS); expect(runtimeError).not.toHaveBeenCalled(); expect(replySpy).toHaveBeenCalledTimes(1); @@ -400,7 +393,7 @@ describe("telegram media groups", () => { await Promise.all([first, second]); expect(replySpy).not.toHaveBeenCalled(); - await waitForMediaGroupProcessing(replySpy, 2); + await vi.advanceTimersByTimeAsync(MEDIA_GROUP_FLUSH_MS); expect(replySpy).toHaveBeenCalledTimes(2); @@ -412,21 +405,15 @@ describe("telegram media groups", () => { describe("telegram text fragments", () => { beforeEach(() => { - // These tests rely on real setTimeout aggregation; guard against leaked fake timers. + vi.useFakeTimers(); + }); + + afterEach(() => { vi.useRealTimers(); }); - const TEXT_FRAGMENT_POLL_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 15_000; const TEXT_FRAGMENT_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 20_000; - - const waitForFragmentProcessing = async ( - replySpy: ReturnType, - expectedCalls: number, - ) => { - await expect - .poll(() => replySpy.mock.calls.length, { timeout: TEXT_FRAGMENT_POLL_TIMEOUT_MS }) - .toBe(expectedCalls); - }; + const TEXT_FRAGMENT_FLUSH_MS = 1600; it( "buffers near-limit text and processes sequential parts as one message", @@ -470,7 +457,7 @@ describe("telegram text fragments", () => { }); expect(replySpy).not.toHaveBeenCalled(); - await waitForFragmentProcessing(replySpy, 1); + await vi.advanceTimersByTimeAsync(TEXT_FRAGMENT_FLUSH_MS); expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0] as { RawBody?: string; Body?: string }; diff --git a/vitest.config.ts b/vitest.config.ts index 2bf5b8aac..99b45b51d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,3 +1,4 @@ +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; @@ -5,7 +6,7 @@ import { defineConfig } from "vitest/config"; const repoRoot = path.dirname(fileURLToPath(import.meta.url)); const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; const isWindows = process.platform === "win32"; -const localWorkers = 4; +const localWorkers = Math.max(4, Math.min(8, os.cpus().length)); const ciWorkers = isWindows ? 2 : 3; export default defineConfig({