test: speed up test suite

This commit is contained in:
Peter Steinberger
2026-01-23 02:19:35 +00:00
parent 542cf011a0
commit 59a8eecd7e
24 changed files with 2393 additions and 3723 deletions

View File

@@ -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<typeof import("@mariozechner/pi-ai")>("@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 <T>(task: () => Promise<T>) => 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);
});
});

View File

@@ -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 <T>(task: () => Promise<T>) => 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",

View File

@@ -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<typeof spawn>,
chunksOut: string[],
chunksErr: string[],
port: number,
timeoutMs: number,
) => {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (proc.exitCode !== null) {
await new Promise<void>((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<void>((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<void>((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<void>((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");

View File

@@ -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<number> {
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<T = unknown>(
ws: WebSocket,
filter: (obj: unknown) => boolean,
timeoutMs = 5000,
): Promise<T> {
return await new Promise<T>((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<void>((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);
});

View File

@@ -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<boolean> {
if (!Number.isFinite(port) || port <= 0 || port > 65535) return false;
return await new Promise((resolve) => {
async function getFreePort(): Promise<number> {
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<number> {
// 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<T = unknown>(
@@ -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);
});

View File

@@ -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<number> {
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);
});

View File

@@ -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<string, unknown>;
const payloads = Array.isArray(record.payloads) ? record.payloads : [];
const texts = payloads
.map((p) => (p && typeof p === "object" ? (p as Record<string, unknown>).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<Record<string, unknown>>("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<string, unknown>)?.gateway as
| Record<string, unknown>
| 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;
}
});
});

View File

@@ -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<string, unknown> }
| { type: "response.function_call_arguments.delta"; delta: string }
| { type: "response.output_item.done"; item: Record<string, unknown> }
| {
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<string, unknown> | 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<string, unknown> | undefined;
if (!item || item.type !== "function_call_output") continue;
return typeof item.output === "string" ? item.output : "";
}
return "";
}
async function* fakeOpenAIResponsesStream(
params: OpenAIResponsesParams,
): AsyncGenerator<OpenAIResponseStreamEvent> {
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<Response> {
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<Uint8Array>({
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<number> {
return await getDeterministicFreePortBlock({ offsets: [0, 1, 2, 3, 4] });
}
function extractPayloadText(result: unknown): string {
const record = result as Record<string, unknown>;
const payloads = Array.isArray(record.payloads) ? record.payloads : [];
const texts = payloads
.map((p) => (p && typeof p === "object" ? (p as Record<string, unknown>).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<InstanceType<typeof GatewayClient>>((resolve, reject) => {
let settled = false;
const stop = (err?: Error, client?: InstanceType<typeof GatewayClient>) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (err) reject(err);
else resolve(client as InstanceType<typeof GatewayClient>);
};
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<Response> => {
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<string, unknown>) : {};
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<Record<string, unknown>>("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;
}
});
});

View File

@@ -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<number> {
return await getDeterministicFreePortBlock({ offsets: [0, 1, 2, 3, 4] });
}
async function onceMessage<T = unknown>(
ws: WebSocket,
filter: (obj: unknown) => boolean,
timeoutMs = 5000,
): Promise<T> {
return await new Promise<T>((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<void>((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<InstanceType<typeof GatewayClient>>((resolve, reject) => {
let settled = false;
const stop = (err?: Error, client?: InstanceType<typeof GatewayClient>) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (err) reject(err);
else resolve(client as InstanceType<typeof GatewayClient>);
};
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<WizardNextPayload>("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<WizardNextPayload>("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<string, unknown>)?.gateway as
| Record<string, unknown>
| 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);
});

View File

@@ -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<string, unknown>;
expect((json.error as Record<string, unknown> | undefined)?.type).toBe(
const resMissingModel = await postResponses(port, { input: "hi" });
expect(resMissingModel.status).toBe(400);
const missingModelJson = (await resMissingModel.json()) as Record<string, unknown>;
expect((missingModelJson.error as Record<string, unknown> | 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('<file name="hello.txt">');
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('<file name="hello.txt">');
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(shapeJson.object).toBe("response");
expect(shapeJson.status).toBe("completed");
expect(Array.isArray(shapeJson.output)).toBe(true);
const output = json.output as Array<Record<string, unknown>>;
const output = shapeJson.output as Array<Record<string, unknown>>;
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<string, unknown>;
expect((json.error as Record<string, unknown> | undefined)?.type).toBe(
expect(resNoUser.status).toBe(400);
const noUserJson = (await resNoUser.json()) as Record<string, unknown>;
expect((noUserJson.error as Record<string, unknown> | 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" });
}

View File

@@ -25,46 +25,7 @@ function _expectChannels(call: Record<string, unknown>, 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<string, unknown>)
: {};
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<string, unknown>)
: {};
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<string, unknown>)
: {};
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<string, unknown>)
: {};
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;
});
});

View File

@@ -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 {

View File

@@ -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 <T>(
tempDirs: string[],
entries: Record<
string,
{ sessionId: string; updatedAt: number; lastChannel?: string; lastTo?: string }
>,
fn: (dir: string) => Promise<T>,
): Promise<T> => {
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<unknown> | 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<string, { lastChannel?: string; lastTo?: string } | undefined>;
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<unknown> | undefined;
try {
const callsBefore = spy.mock.calls.length;
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((resolve) => {
agentStartedResolve = resolve;
});
spy.mockImplementationOnce(async (opts) => {
agentStartedResolve?.();
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((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();
});
});

View File

@@ -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<void>((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<void>((resolve) => {
agentStartedResolve = resolve;
});
spy.mockImplementationOnce(async (opts) => {
agentStartedResolve?.();
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((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();
});
});

View File

@@ -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<void>((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();
});
});

View File

@@ -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/<jobId>.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);
});

View File

@@ -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();
}
});
});

View File

@@ -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<void>((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();
});
});

View File

@@ -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<number> {
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<InstanceType<typeof GatewayClient>>((resolve, reject) => {
let settled = false;
const stop = (err?: Error, client?: InstanceType<typeof GatewayClient>) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (err) reject(err);
else resolve(client as InstanceType<typeof GatewayClient>);
};
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<void>((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;
}

View File

@@ -0,0 +1,198 @@
type OpenAIResponsesParams = {
input?: unknown[];
};
type OpenAIResponseStreamEvent =
| { type: "response.output_item.added"; item: Record<string, unknown> }
| { type: "response.function_call_arguments.delta"; delta: string }
| { type: "response.output_item.done"; item: Record<string, unknown> }
| {
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<string, unknown> | 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<string, unknown> | undefined;
if (!item || item.type !== "function_call_output") continue;
return typeof item.output === "string" ? item.output : "";
}
return "";
}
async function* fakeOpenAIResponsesStream(
params: OpenAIResponsesParams,
): AsyncGenerator<OpenAIResponseStreamEvent> {
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<Response> {
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<Uint8Array>({
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<Response> => {
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<string, unknown>) : {};
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;
},
};
}

View File

@@ -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 }],

View File

@@ -88,7 +88,11 @@ async function uploadSlackFile(params: {
threadTs?: string;
maxBytes?: number;
}): Promise<string> {
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,

View File

@@ -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<typeof vi.fn>,
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<typeof vi.fn>,
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 };

View File

@@ -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({