mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 14:48:28 +00:00
test: speed up test suite
This commit is contained in:
269
src/gateway/gateway.e2e.test.ts
Normal file
269
src/gateway/gateway.e2e.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
133
src/gateway/test-helpers.e2e.ts
Normal file
133
src/gateway/test-helpers.e2e.ts
Normal 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;
|
||||
}
|
||||
198
src/gateway/test-helpers.openai-mock.ts
Normal file
198
src/gateway/test-helpers.openai-mock.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user