mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
test: speed up test suite
This commit is contained in:
@@ -1,258 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
|
||||||
import { ensureClawdbotModelsJson } from "./models-config.js";
|
|
||||||
|
|
||||||
const buildAssistantMessage = (model: { api: string; provider: string; id: string }) => ({
|
|
||||||
role: "assistant" as const,
|
|
||||||
content: [{ type: "text" as const, text: "ok" }],
|
|
||||||
stopReason: "stop" as const,
|
|
||||||
api: model.api,
|
|
||||||
provider: model.provider,
|
|
||||||
model: model.id,
|
|
||||||
usage: {
|
|
||||||
input: 1,
|
|
||||||
output: 1,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
totalTokens: 2,
|
|
||||||
cost: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
total: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const buildAssistantErrorMessage = (model: { api: string; provider: string; id: string }) => ({
|
|
||||||
role: "assistant" as const,
|
|
||||||
content: [] as const,
|
|
||||||
stopReason: "error" as const,
|
|
||||||
errorMessage: "boom",
|
|
||||||
api: model.api,
|
|
||||||
provider: model.provider,
|
|
||||||
model: model.id,
|
|
||||||
usage: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
totalTokens: 0,
|
|
||||||
cost: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
total: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockPiAi = () => {
|
|
||||||
vi.doMock("@mariozechner/pi-ai", async () => {
|
|
||||||
const actual =
|
|
||||||
await vi.importActual<typeof import("@mariozechner/pi-ai")>("@mariozechner/pi-ai");
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
complete: async (model: { api: string; provider: string; id: string }) => {
|
|
||||||
if (model.id === "mock-error") return buildAssistantErrorMessage(model);
|
|
||||||
return buildAssistantMessage(model);
|
|
||||||
},
|
|
||||||
completeSimple: async (model: { api: string; provider: string; id: string }) => {
|
|
||||||
if (model.id === "mock-error") return buildAssistantErrorMessage(model);
|
|
||||||
return buildAssistantMessage(model);
|
|
||||||
},
|
|
||||||
streamSimple: (model: { api: string; provider: string; id: string }) => {
|
|
||||||
const stream = new actual.AssistantMessageEventStream();
|
|
||||||
queueMicrotask(() => {
|
|
||||||
stream.push({
|
|
||||||
type: "done",
|
|
||||||
reason: "stop",
|
|
||||||
message:
|
|
||||||
model.id === "mock-error"
|
|
||||||
? buildAssistantErrorMessage(model)
|
|
||||||
: buildAssistantMessage(model),
|
|
||||||
});
|
|
||||||
stream.end();
|
|
||||||
});
|
|
||||||
return stream;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
mockPiAi();
|
|
||||||
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
|
||||||
}, 20_000);
|
|
||||||
|
|
||||||
const makeOpenAiConfig = (modelIds: string[]) =>
|
|
||||||
({
|
|
||||||
models: {
|
|
||||||
providers: {
|
|
||||||
openai: {
|
|
||||||
api: "openai-responses",
|
|
||||||
apiKey: "sk-test",
|
|
||||||
baseUrl: "https://example.com",
|
|
||||||
models: modelIds.map((id) => ({
|
|
||||||
id,
|
|
||||||
name: `Mock ${id}`,
|
|
||||||
reasoning: false,
|
|
||||||
input: ["text"],
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
||||||
contextWindow: 16_000,
|
|
||||||
maxTokens: 2048,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}) satisfies ClawdbotConfig;
|
|
||||||
|
|
||||||
const ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
|
|
||||||
ensureClawdbotModelsJson(cfg, agentDir);
|
|
||||||
|
|
||||||
const testSessionKey = "agent:test:embedded-models";
|
|
||||||
const immediateEnqueue = async <T>(task: () => Promise<T>) => task();
|
|
||||||
|
|
||||||
const textFromContent = (content: unknown) => {
|
|
||||||
if (typeof content === "string") return content;
|
|
||||||
if (Array.isArray(content) && content[0]?.type === "text") {
|
|
||||||
return (content[0] as { text?: string }).text;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const readSessionMessages = async (sessionFile: string) => {
|
|
||||||
const raw = await fs.readFile(sessionFile, "utf-8");
|
|
||||||
return raw
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.filter(Boolean)
|
|
||||||
.map(
|
|
||||||
(line) =>
|
|
||||||
JSON.parse(line) as {
|
|
||||||
type?: string;
|
|
||||||
message?: { role?: string; content?: unknown };
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.filter((entry) => entry.type === "message")
|
|
||||||
.map((entry) => entry.message as { role?: string; content?: unknown });
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("runEmbeddedPiAgent", () => {
|
|
||||||
it("writes models.json into the provided agentDir", async () => {
|
|
||||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
|
||||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
|
||||||
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
|
||||||
|
|
||||||
const cfg = {
|
|
||||||
models: {
|
|
||||||
providers: {
|
|
||||||
minimax: {
|
|
||||||
baseUrl: "https://api.minimax.io/anthropic",
|
|
||||||
api: "anthropic-messages",
|
|
||||||
apiKey: "sk-minimax-test",
|
|
||||||
models: [
|
|
||||||
{
|
|
||||||
id: "MiniMax-M2.1",
|
|
||||||
name: "MiniMax M2.1",
|
|
||||||
reasoning: false,
|
|
||||||
input: ["text"],
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
||||||
contextWindow: 200000,
|
|
||||||
maxTokens: 8192,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies ClawdbotConfig;
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
runEmbeddedPiAgent({
|
|
||||||
sessionId: "session:test",
|
|
||||||
sessionKey: testSessionKey,
|
|
||||||
sessionFile,
|
|
||||||
workspaceDir,
|
|
||||||
config: cfg,
|
|
||||||
prompt: "hi",
|
|
||||||
provider: "definitely-not-a-provider",
|
|
||||||
model: "definitely-not-a-model",
|
|
||||||
timeoutMs: 1,
|
|
||||||
agentDir,
|
|
||||||
enqueue: immediateEnqueue,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/Unknown model:/);
|
|
||||||
|
|
||||||
await expect(fs.stat(path.join(agentDir, "models.json"))).resolves.toBeTruthy();
|
|
||||||
});
|
|
||||||
it("persists the first user message before assistant output", { timeout: 60_000 }, async () => {
|
|
||||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
|
||||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
|
||||||
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
|
||||||
|
|
||||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
|
||||||
await ensureModels(cfg, agentDir);
|
|
||||||
|
|
||||||
await runEmbeddedPiAgent({
|
|
||||||
sessionId: "session:test",
|
|
||||||
sessionKey: testSessionKey,
|
|
||||||
sessionFile,
|
|
||||||
workspaceDir,
|
|
||||||
config: cfg,
|
|
||||||
prompt: "hello",
|
|
||||||
provider: "openai",
|
|
||||||
model: "mock-1",
|
|
||||||
timeoutMs: 5_000,
|
|
||||||
agentDir,
|
|
||||||
enqueue: immediateEnqueue,
|
|
||||||
});
|
|
||||||
|
|
||||||
const messages = await readSessionMessages(sessionFile);
|
|
||||||
const firstUserIndex = messages.findIndex(
|
|
||||||
(message) => message?.role === "user" && textFromContent(message.content) === "hello",
|
|
||||||
);
|
|
||||||
const firstAssistantIndex = messages.findIndex((message) => message?.role === "assistant");
|
|
||||||
expect(firstUserIndex).toBeGreaterThanOrEqual(0);
|
|
||||||
if (firstAssistantIndex !== -1) {
|
|
||||||
expect(firstUserIndex).toBeLessThan(firstAssistantIndex);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
it("persists the user message when prompt fails before assistant output", async () => {
|
|
||||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
|
||||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
|
||||||
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
|
||||||
|
|
||||||
const cfg = makeOpenAiConfig(["mock-error"]);
|
|
||||||
await ensureModels(cfg, agentDir);
|
|
||||||
|
|
||||||
const result = await runEmbeddedPiAgent({
|
|
||||||
sessionId: "session:test",
|
|
||||||
sessionKey: testSessionKey,
|
|
||||||
sessionFile,
|
|
||||||
workspaceDir,
|
|
||||||
config: cfg,
|
|
||||||
prompt: "boom",
|
|
||||||
provider: "openai",
|
|
||||||
model: "mock-error",
|
|
||||||
timeoutMs: 5_000,
|
|
||||||
agentDir,
|
|
||||||
enqueue: immediateEnqueue,
|
|
||||||
});
|
|
||||||
expect(result.payloads[0]?.isError).toBe(true);
|
|
||||||
|
|
||||||
const messages = await readSessionMessages(sessionFile);
|
|
||||||
const userIndex = messages.findIndex(
|
|
||||||
(message) => message?.role === "user" && textFromContent(message.content) === "boom",
|
|
||||||
);
|
|
||||||
expect(userIndex).toBeGreaterThanOrEqual(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { ensureClawdbotModelsJson } from "./models-config.js";
|
import { ensureClawdbotModelsJson } from "./models-config.js";
|
||||||
|
|
||||||
@@ -86,10 +87,25 @@ vi.mock("@mariozechner/pi-ai", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent;
|
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent;
|
||||||
|
let tempRoot: string | undefined;
|
||||||
|
let agentDir: string;
|
||||||
|
let workspaceDir: string;
|
||||||
|
let sessionCounter = 0;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeAll(async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
||||||
|
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-embedded-agent-"));
|
||||||
|
agentDir = path.join(tempRoot, "agent");
|
||||||
|
workspaceDir = path.join(tempRoot, "workspace");
|
||||||
|
await fs.mkdir(agentDir, { recursive: true });
|
||||||
|
await fs.mkdir(workspaceDir, { recursive: true });
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (!tempRoot) return;
|
||||||
|
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||||
|
tempRoot = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeOpenAiConfig = (modelIds: string[]) =>
|
const makeOpenAiConfig = (modelIds: string[]) =>
|
||||||
@@ -114,10 +130,14 @@ const makeOpenAiConfig = (modelIds: string[]) =>
|
|||||||
},
|
},
|
||||||
}) satisfies ClawdbotConfig;
|
}) satisfies ClawdbotConfig;
|
||||||
|
|
||||||
const ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
|
const ensureModels = (cfg: ClawdbotConfig) => ensureClawdbotModelsJson(cfg, agentDir);
|
||||||
ensureClawdbotModelsJson(cfg, agentDir);
|
|
||||||
|
|
||||||
const testSessionKey = "agent:test:embedded-ordering";
|
const nextSessionFile = () => {
|
||||||
|
sessionCounter += 1;
|
||||||
|
return path.join(workspaceDir, `session-${sessionCounter}.jsonl`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const testSessionKey = "agent:test:embedded";
|
||||||
const immediateEnqueue = async <T>(task: () => Promise<T>) => task();
|
const immediateEnqueue = async <T>(task: () => Promise<T>) => task();
|
||||||
|
|
||||||
const textFromContent = (content: unknown) => {
|
const textFromContent = (content: unknown) => {
|
||||||
@@ -145,15 +165,114 @@ const readSessionMessages = async (sessionFile: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("runEmbeddedPiAgent", () => {
|
describe("runEmbeddedPiAgent", () => {
|
||||||
|
it("writes models.json into the provided agentDir", async () => {
|
||||||
|
const sessionFile = nextSessionFile();
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
minimax: {
|
||||||
|
baseUrl: "https://api.minimax.io/anthropic",
|
||||||
|
api: "anthropic-messages",
|
||||||
|
apiKey: "sk-minimax-test",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "MiniMax-M2.1",
|
||||||
|
name: "MiniMax M2.1",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxTokens: 8192,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies ClawdbotConfig;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runEmbeddedPiAgent({
|
||||||
|
sessionId: "session:test",
|
||||||
|
sessionKey: testSessionKey,
|
||||||
|
sessionFile,
|
||||||
|
workspaceDir,
|
||||||
|
config: cfg,
|
||||||
|
prompt: "hi",
|
||||||
|
provider: "definitely-not-a-provider",
|
||||||
|
model: "definitely-not-a-model",
|
||||||
|
timeoutMs: 1,
|
||||||
|
agentDir,
|
||||||
|
enqueue: immediateEnqueue,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Unknown model:/);
|
||||||
|
|
||||||
|
await expect(fs.stat(path.join(agentDir, "models.json"))).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists the first user message before assistant output", { timeout: 60_000 }, async () => {
|
||||||
|
const sessionFile = nextSessionFile();
|
||||||
|
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||||
|
await ensureModels(cfg);
|
||||||
|
|
||||||
|
await runEmbeddedPiAgent({
|
||||||
|
sessionId: "session:test",
|
||||||
|
sessionKey: testSessionKey,
|
||||||
|
sessionFile,
|
||||||
|
workspaceDir,
|
||||||
|
config: cfg,
|
||||||
|
prompt: "hello",
|
||||||
|
provider: "openai",
|
||||||
|
model: "mock-1",
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
agentDir,
|
||||||
|
enqueue: immediateEnqueue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = await readSessionMessages(sessionFile);
|
||||||
|
const firstUserIndex = messages.findIndex(
|
||||||
|
(message) => message?.role === "user" && textFromContent(message.content) === "hello",
|
||||||
|
);
|
||||||
|
const firstAssistantIndex = messages.findIndex((message) => message?.role === "assistant");
|
||||||
|
expect(firstUserIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
if (firstAssistantIndex !== -1) {
|
||||||
|
expect(firstUserIndex).toBeLessThan(firstAssistantIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists the user message when prompt fails before assistant output", async () => {
|
||||||
|
const sessionFile = nextSessionFile();
|
||||||
|
const cfg = makeOpenAiConfig(["mock-error"]);
|
||||||
|
await ensureModels(cfg);
|
||||||
|
|
||||||
|
const result = await runEmbeddedPiAgent({
|
||||||
|
sessionId: "session:test",
|
||||||
|
sessionKey: testSessionKey,
|
||||||
|
sessionFile,
|
||||||
|
workspaceDir,
|
||||||
|
config: cfg,
|
||||||
|
prompt: "boom",
|
||||||
|
provider: "openai",
|
||||||
|
model: "mock-error",
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
agentDir,
|
||||||
|
enqueue: immediateEnqueue,
|
||||||
|
});
|
||||||
|
expect(result.payloads[0]?.isError).toBe(true);
|
||||||
|
|
||||||
|
const messages = await readSessionMessages(sessionFile);
|
||||||
|
const userIndex = messages.findIndex(
|
||||||
|
(message) => message?.role === "user" && textFromContent(message.content) === "boom",
|
||||||
|
);
|
||||||
|
expect(userIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
it(
|
it(
|
||||||
"appends new user + assistant after existing transcript entries",
|
"appends new user + assistant after existing transcript entries",
|
||||||
{ timeout: 90_000 },
|
{ timeout: 90_000 },
|
||||||
async () => {
|
async () => {
|
||||||
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
|
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
|
||||||
|
const sessionFile = nextSessionFile();
|
||||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
|
||||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
|
||||||
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
|
||||||
|
|
||||||
const sessionManager = SessionManager.open(sessionFile);
|
const sessionManager = SessionManager.open(sessionFile);
|
||||||
sessionManager.appendMessage({
|
sessionManager.appendMessage({
|
||||||
@@ -185,7 +304,7 @@ describe("runEmbeddedPiAgent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||||
await ensureModels(cfg, agentDir);
|
await ensureModels(cfg);
|
||||||
|
|
||||||
await runEmbeddedPiAgent({
|
await runEmbeddedPiAgent({
|
||||||
sessionId: "session:test",
|
sessionId: "session:test",
|
||||||
@@ -221,13 +340,11 @@ describe("runEmbeddedPiAgent", () => {
|
|||||||
expect(newAssistantIndex).toBeGreaterThan(newUserIndex);
|
expect(newAssistantIndex).toBeGreaterThan(newUserIndex);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
it("persists multi-turn user/assistant ordering across runs", async () => {
|
|
||||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
|
||||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
|
||||||
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
|
||||||
|
|
||||||
|
it("persists multi-turn user/assistant ordering across runs", async () => {
|
||||||
|
const sessionFile = nextSessionFile();
|
||||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||||
await ensureModels(cfg, agentDir);
|
await ensureModels(cfg);
|
||||||
|
|
||||||
await runEmbeddedPiAgent({
|
await runEmbeddedPiAgent({
|
||||||
sessionId: "session:test",
|
sessionId: "session:test",
|
||||||
@@ -265,58 +382,33 @@ describe("runEmbeddedPiAgent", () => {
|
|||||||
(message, index) => index > firstUserIndex && message?.role === "assistant",
|
(message, index) => index > firstUserIndex && message?.role === "assistant",
|
||||||
);
|
);
|
||||||
const secondUserIndex = messages.findIndex(
|
const secondUserIndex = messages.findIndex(
|
||||||
(message) => message?.role === "user" && textFromContent(message.content) === "second",
|
(message, index) =>
|
||||||
|
index > firstAssistantIndex &&
|
||||||
|
message?.role === "user" &&
|
||||||
|
textFromContent(message.content) === "second",
|
||||||
);
|
);
|
||||||
const secondAssistantIndex = messages.findIndex(
|
const secondAssistantIndex = messages.findIndex(
|
||||||
(message, index) => index > secondUserIndex && message?.role === "assistant",
|
(message, index) => index > secondUserIndex && message?.role === "assistant",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(firstUserIndex).toBeGreaterThanOrEqual(0);
|
expect(firstUserIndex).toBeGreaterThanOrEqual(0);
|
||||||
expect(firstAssistantIndex).toBeGreaterThan(firstUserIndex);
|
expect(firstAssistantIndex).toBeGreaterThan(firstUserIndex);
|
||||||
expect(secondUserIndex).toBeGreaterThan(firstAssistantIndex);
|
expect(secondUserIndex).toBeGreaterThan(firstAssistantIndex);
|
||||||
expect(secondAssistantIndex).toBeGreaterThan(secondUserIndex);
|
expect(secondAssistantIndex).toBeGreaterThan(secondUserIndex);
|
||||||
}, 90_000);
|
});
|
||||||
|
|
||||||
it("repairs orphaned user messages and continues", async () => {
|
it("repairs orphaned user messages and continues", async () => {
|
||||||
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
|
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
|
||||||
|
const sessionFile = nextSessionFile();
|
||||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
|
||||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
|
||||||
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
|
||||||
|
|
||||||
const sessionManager = SessionManager.open(sessionFile);
|
const sessionManager = SessionManager.open(sessionFile);
|
||||||
sessionManager.appendMessage({
|
sessionManager.appendMessage({
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [{ type: "text", text: "seed user 1" }],
|
content: [{ type: "text", text: "orphaned user" }],
|
||||||
});
|
|
||||||
sessionManager.appendMessage({
|
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "text", text: "seed assistant" }],
|
|
||||||
stopReason: "stop",
|
|
||||||
api: "openai-responses",
|
|
||||||
provider: "openai",
|
|
||||||
model: "mock-1",
|
|
||||||
usage: {
|
|
||||||
input: 1,
|
|
||||||
output: 1,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
totalTokens: 2,
|
|
||||||
cost: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
total: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
sessionManager.appendMessage({
|
|
||||||
role: "user",
|
|
||||||
content: [{ type: "text", text: "seed user 2" }],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||||
await ensureModels(cfg, agentDir);
|
await ensureModels(cfg);
|
||||||
|
|
||||||
const result = await runEmbeddedPiAgent({
|
const result = await runEmbeddedPiAgent({
|
||||||
sessionId: "session:test",
|
sessionId: "session:test",
|
||||||
@@ -338,19 +430,16 @@ describe("runEmbeddedPiAgent", () => {
|
|||||||
|
|
||||||
it("repairs orphaned single-user sessions and continues", async () => {
|
it("repairs orphaned single-user sessions and continues", async () => {
|
||||||
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
|
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
|
||||||
|
const sessionFile = nextSessionFile();
|
||||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
|
||||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
|
||||||
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
|
||||||
|
|
||||||
const sessionManager = SessionManager.open(sessionFile);
|
const sessionManager = SessionManager.open(sessionFile);
|
||||||
sessionManager.appendMessage({
|
sessionManager.appendMessage({
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [{ type: "text", text: "seed user only" }],
|
content: [{ type: "text", text: "solo user" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||||
await ensureModels(cfg, agentDir);
|
await ensureModels(cfg);
|
||||||
|
|
||||||
const result = await runEmbeddedPiAgent({
|
const result = await runEmbeddedPiAgent({
|
||||||
sessionId: "session:test",
|
sessionId: "session:test",
|
||||||
@@ -1,66 +1,66 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import net from "node:net";
|
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const waitForPortOpen = async (
|
const waitForReady = async (
|
||||||
proc: ReturnType<typeof spawn>,
|
proc: ReturnType<typeof spawn>,
|
||||||
chunksOut: string[],
|
chunksOut: string[],
|
||||||
chunksErr: string[],
|
chunksErr: string[],
|
||||||
port: number,
|
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
) => {
|
) => {
|
||||||
const startedAt = Date.now();
|
await new Promise<void>((resolve, reject) => {
|
||||||
while (Date.now() - startedAt < timeoutMs) {
|
const timer = setTimeout(() => {
|
||||||
if (proc.exitCode !== null) {
|
|
||||||
const stdout = chunksOut.join("");
|
const stdout = chunksOut.join("");
|
||||||
const stderr = chunksErr.join("");
|
const stderr = chunksErr.join("");
|
||||||
throw new Error(
|
cleanup();
|
||||||
`gateway exited before listening (code=${String(proc.exitCode)} signal=${String(proc.signalCode)})\n` +
|
reject(
|
||||||
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
|
new Error(
|
||||||
|
`timeout waiting for gateway to start\n` +
|
||||||
|
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}, timeoutMs);
|
||||||
|
|
||||||
try {
|
const cleanup = () => {
|
||||||
await new Promise<void>((resolve, reject) => {
|
clearTimeout(timer);
|
||||||
const socket = net.connect({ host: "127.0.0.1", port });
|
proc.off("exit", onExit);
|
||||||
socket.once("connect", () => {
|
proc.off("message", onMessage);
|
||||||
socket.destroy();
|
proc.stdout?.off("data", onStdout);
|
||||||
resolve();
|
};
|
||||||
});
|
|
||||||
socket.once("error", (err) => {
|
|
||||||
socket.destroy();
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
// keep polling
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
const onExit = () => {
|
||||||
}
|
const stdout = chunksOut.join("");
|
||||||
const stdout = chunksOut.join("");
|
const stderr = chunksErr.join("");
|
||||||
const stderr = chunksErr.join("");
|
cleanup();
|
||||||
throw new Error(
|
reject(
|
||||||
`timeout waiting for gateway to listen on port ${port}\n` +
|
new Error(
|
||||||
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
|
`gateway exited before ready (code=${String(proc.exitCode)} signal=${String(proc.signalCode)})\n` +
|
||||||
);
|
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
|
||||||
};
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const getFreePort = async () => {
|
const onMessage = (msg: unknown) => {
|
||||||
const srv = net.createServer();
|
if (msg && typeof msg === "object" && "ready" in msg) {
|
||||||
await new Promise<void>((resolve) => srv.listen(0, "127.0.0.1", resolve));
|
cleanup();
|
||||||
const addr = srv.address();
|
resolve();
|
||||||
if (!addr || typeof addr === "string") {
|
}
|
||||||
srv.close();
|
};
|
||||||
throw new Error("failed to bind ephemeral port");
|
|
||||||
}
|
const onStdout = (chunk: unknown) => {
|
||||||
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
if (String(chunk).includes("READY")) {
|
||||||
return addr.port;
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
proc.once("exit", onExit);
|
||||||
|
proc.on("message", onMessage);
|
||||||
|
proc.stdout?.on("data", onStdout);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("gateway SIGTERM", () => {
|
describe("gateway SIGTERM", () => {
|
||||||
@@ -77,67 +77,50 @@ describe("gateway SIGTERM", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("exits 0 on SIGTERM", { timeout: 180_000 }, async () => {
|
it("exits 0 on SIGTERM", { timeout: 180_000 }, async () => {
|
||||||
const port = await getFreePort();
|
|
||||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-gateway-test-"));
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-gateway-test-"));
|
||||||
const configPath = path.join(stateDir, "clawdbot.json");
|
|
||||||
fs.writeFileSync(
|
|
||||||
configPath,
|
|
||||||
JSON.stringify({ gateway: { mode: "local", port } }, null, 2),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
const err: string[] = [];
|
const err: string[] = [];
|
||||||
|
|
||||||
const nodeBin = process.execPath;
|
const nodeBin = process.execPath;
|
||||||
const entryArgs = [
|
|
||||||
"gateway",
|
|
||||||
"--port",
|
|
||||||
String(port),
|
|
||||||
"--bind",
|
|
||||||
"loopback",
|
|
||||||
"--allow-unconfigured",
|
|
||||||
];
|
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
CLAWDBOT_NO_RESPAWN: "1",
|
CLAWDBOT_NO_RESPAWN: "1",
|
||||||
CLAWDBOT_STATE_DIR: stateDir,
|
CLAWDBOT_STATE_DIR: stateDir,
|
||||||
CLAWDBOT_CONFIG_PATH: configPath,
|
|
||||||
CLAWDBOT_SKIP_CHANNELS: "1",
|
CLAWDBOT_SKIP_CHANNELS: "1",
|
||||||
|
CLAWDBOT_SKIP_GMAIL_WATCHER: "1",
|
||||||
|
CLAWDBOT_SKIP_CRON: "1",
|
||||||
CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1",
|
CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1",
|
||||||
CLAWDBOT_SKIP_CANVAS_HOST: "1",
|
CLAWDBOT_SKIP_CANVAS_HOST: "1",
|
||||||
// Avoid port collisions with other test processes that may also start a gateway server.
|
|
||||||
CLAWDBOT_BRIDGE_HOST: "127.0.0.1",
|
|
||||||
CLAWDBOT_BRIDGE_PORT: "0",
|
|
||||||
};
|
};
|
||||||
const bootstrapPath = path.join(stateDir, "clawdbot-entry-bootstrap.mjs");
|
const bootstrapPath = path.join(stateDir, "clawdbot-entry-bootstrap.mjs");
|
||||||
const runMainPath = path.resolve("src/cli/run-main.ts");
|
const runLoopPath = path.resolve("src/cli/gateway-cli/run-loop.ts");
|
||||||
|
const runtimePath = path.resolve("src/runtime.ts");
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
bootstrapPath,
|
bootstrapPath,
|
||||||
[
|
[
|
||||||
'import { pathToFileURL } from "node:url";',
|
'import { pathToFileURL } from "node:url";',
|
||||||
'const rawArgs = process.env.CLAWDBOT_ENTRY_ARGS ?? "[]";',
|
`const runLoopUrl = ${JSON.stringify(pathToFileURL(runLoopPath).href)};`,
|
||||||
"let entryArgs = [];",
|
`const runtimeUrl = ${JSON.stringify(pathToFileURL(runtimePath).href)};`,
|
||||||
"try {",
|
"const { runGatewayLoop } = await import(runLoopUrl);",
|
||||||
" entryArgs = JSON.parse(rawArgs);",
|
"const { defaultRuntime } = await import(runtimeUrl);",
|
||||||
"} catch (err) {",
|
"await runGatewayLoop({",
|
||||||
' console.error("Failed to parse CLAWDBOT_ENTRY_ARGS", err);',
|
" start: async () => {",
|
||||||
" process.exit(1);",
|
' process.stdout.write("READY\\\\n");',
|
||||||
"}",
|
" if (process.send) process.send({ ready: true });",
|
||||||
"if (!Array.isArray(entryArgs)) entryArgs = [];",
|
" const keepAlive = setInterval(() => {}, 1000);",
|
||||||
'entryArgs = entryArgs.filter((arg) => typeof arg === "string" && !arg.toLowerCase().includes("node.exe"));',
|
" return { close: async () => clearInterval(keepAlive) };",
|
||||||
`const runMainUrl = ${JSON.stringify(pathToFileURL(runMainPath).href)};`,
|
" },",
|
||||||
"const { runCli } = await import(runMainUrl);",
|
" runtime: defaultRuntime,",
|
||||||
'await runCli(["node", "clawdbot", ...entryArgs]);',
|
"});",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
const childArgs = ["--import", "tsx", bootstrapPath];
|
const childArgs = ["--import", "tsx", bootstrapPath];
|
||||||
env.CLAWDBOT_ENTRY_ARGS = JSON.stringify(entryArgs);
|
|
||||||
|
|
||||||
child = spawn(nodeBin, childArgs, {
|
child = spawn(nodeBin, childArgs, {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
env,
|
env,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const proc = child;
|
const proc = child;
|
||||||
@@ -148,7 +131,7 @@ describe("gateway SIGTERM", () => {
|
|||||||
child.stdout?.on("data", (d) => out.push(String(d)));
|
child.stdout?.on("data", (d) => out.push(String(d)));
|
||||||
child.stderr?.on("data", (d) => err.push(String(d)));
|
child.stderr?.on("data", (d) => err.push(String(d)));
|
||||||
|
|
||||||
await waitForPortOpen(proc, out, err, port, 150_000);
|
await waitForReady(proc, out, err, 150_000);
|
||||||
|
|
||||||
proc.kill("SIGTERM");
|
proc.kill("SIGTERM");
|
||||||
|
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import { createServer } from "node:net";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { WebSocket } from "ws";
|
|
||||||
|
|
||||||
import {
|
|
||||||
loadOrCreateDeviceIdentity,
|
|
||||||
publicKeyRawBase64UrlFromPem,
|
|
||||||
signDevicePayload,
|
|
||||||
} from "../infra/device-identity.js";
|
|
||||||
import { buildDeviceAuthPayload } from "../gateway/device-auth.js";
|
|
||||||
import { PROTOCOL_VERSION } from "../gateway/protocol/index.js";
|
|
||||||
import { rawDataToString } from "../infra/ws.js";
|
|
||||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
|
||||||
|
|
||||||
async function getFreePort(): Promise<number> {
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
const srv = createServer();
|
|
||||||
srv.on("error", reject);
|
|
||||||
srv.listen(0, "127.0.0.1", () => {
|
|
||||||
const addr = srv.address();
|
|
||||||
if (!addr || typeof addr === "string") {
|
|
||||||
srv.close();
|
|
||||||
reject(new Error("failed to acquire free port"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const port = addr.port;
|
|
||||||
srv.close((err) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(port);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onceMessage<T = unknown>(
|
|
||||||
ws: WebSocket,
|
|
||||||
filter: (obj: unknown) => boolean,
|
|
||||||
timeoutMs = 5000,
|
|
||||||
): Promise<T> {
|
|
||||||
return await new Promise<T>((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
|
|
||||||
const closeHandler = (code: number, reason: Buffer) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
ws.off("message", handler);
|
|
||||||
reject(new Error(`closed ${code}: ${rawDataToString(reason)}`));
|
|
||||||
};
|
|
||||||
const handler = (data: WebSocket.RawData) => {
|
|
||||||
const obj = JSON.parse(rawDataToString(data));
|
|
||||||
if (!filter(obj)) return;
|
|
||||||
clearTimeout(timer);
|
|
||||||
ws.off("message", handler);
|
|
||||||
ws.off("close", closeHandler);
|
|
||||||
resolve(obj as T);
|
|
||||||
};
|
|
||||||
ws.on("message", handler);
|
|
||||||
ws.once("close", closeHandler);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connectReq(params: { url: string; token?: string }) {
|
|
||||||
const ws = new WebSocket(params.url);
|
|
||||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
|
||||||
const identity = loadOrCreateDeviceIdentity();
|
|
||||||
const signedAtMs = Date.now();
|
|
||||||
const payload = buildDeviceAuthPayload({
|
|
||||||
deviceId: identity.deviceId,
|
|
||||||
clientId: GATEWAY_CLIENT_NAMES.TEST,
|
|
||||||
clientMode: GATEWAY_CLIENT_MODES.TEST,
|
|
||||||
role: "operator",
|
|
||||||
scopes: [],
|
|
||||||
signedAtMs,
|
|
||||||
token: params.token ?? null,
|
|
||||||
});
|
|
||||||
const device = {
|
|
||||||
id: identity.deviceId,
|
|
||||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
|
||||||
signature: signDevicePayload(identity.privateKeyPem, payload),
|
|
||||||
signedAt: signedAtMs,
|
|
||||||
};
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "req",
|
|
||||||
id: "c1",
|
|
||||||
method: "connect",
|
|
||||||
params: {
|
|
||||||
minProtocol: PROTOCOL_VERSION,
|
|
||||||
maxProtocol: PROTOCOL_VERSION,
|
|
||||||
client: {
|
|
||||||
id: GATEWAY_CLIENT_NAMES.TEST,
|
|
||||||
displayName: "vitest",
|
|
||||||
version: "dev",
|
|
||||||
platform: process.platform,
|
|
||||||
mode: GATEWAY_CLIENT_MODES.TEST,
|
|
||||||
},
|
|
||||||
caps: [],
|
|
||||||
auth: params.token ? { token: params.token } : undefined,
|
|
||||||
device,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const res = await onceMessage<{
|
|
||||||
type: "res";
|
|
||||||
id: string;
|
|
||||||
ok: boolean;
|
|
||||||
error?: { message?: string };
|
|
||||||
}>(ws, (o) => {
|
|
||||||
const obj = o as { type?: unknown; id?: unknown } | undefined;
|
|
||||||
return obj?.type === "res" && obj?.id === "c1";
|
|
||||||
});
|
|
||||||
ws.close();
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("onboard (non-interactive): gateway auth", () => {
|
|
||||||
it("writes gateway token auth into config and gateway enforces it", async () => {
|
|
||||||
const prev = {
|
|
||||||
home: process.env.HOME,
|
|
||||||
stateDir: process.env.CLAWDBOT_STATE_DIR,
|
|
||||||
configPath: process.env.CLAWDBOT_CONFIG_PATH,
|
|
||||||
skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
|
|
||||||
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
|
|
||||||
skipCron: process.env.CLAWDBOT_SKIP_CRON,
|
|
||||||
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
|
|
||||||
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
|
|
||||||
};
|
|
||||||
|
|
||||||
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
|
|
||||||
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
|
|
||||||
process.env.CLAWDBOT_SKIP_CRON = "1";
|
|
||||||
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
|
|
||||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
|
||||||
|
|
||||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-noninteractive-"));
|
|
||||||
process.env.HOME = tempHome;
|
|
||||||
delete process.env.CLAWDBOT_STATE_DIR;
|
|
||||||
delete process.env.CLAWDBOT_CONFIG_PATH;
|
|
||||||
vi.resetModules();
|
|
||||||
|
|
||||||
const token = "tok_test_123";
|
|
||||||
const workspace = path.join(tempHome, "clawd");
|
|
||||||
|
|
||||||
const runtime = {
|
|
||||||
log: () => {},
|
|
||||||
error: (msg: string) => {
|
|
||||||
throw new Error(msg);
|
|
||||||
},
|
|
||||||
exit: (code: number) => {
|
|
||||||
throw new Error(`exit:${code}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js");
|
|
||||||
await runNonInteractiveOnboarding(
|
|
||||||
{
|
|
||||||
nonInteractive: true,
|
|
||||||
mode: "local",
|
|
||||||
workspace,
|
|
||||||
authChoice: "skip",
|
|
||||||
skipSkills: true,
|
|
||||||
skipHealth: true,
|
|
||||||
installDaemon: false,
|
|
||||||
gatewayBind: "loopback",
|
|
||||||
gatewayAuth: "token",
|
|
||||||
gatewayToken: token,
|
|
||||||
},
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js");
|
|
||||||
const cfg = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8")) as {
|
|
||||||
gateway?: { auth?: { mode?: string; token?: string } };
|
|
||||||
agents?: { defaults?: { workspace?: string } };
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(cfg?.agents?.defaults?.workspace).toBe(workspace);
|
|
||||||
expect(cfg?.gateway?.auth?.mode).toBe("token");
|
|
||||||
expect(cfg?.gateway?.auth?.token).toBe(token);
|
|
||||||
|
|
||||||
const { startGatewayServer } = await import("../gateway/server.js");
|
|
||||||
const port = await getFreePort();
|
|
||||||
const server = await startGatewayServer(port, {
|
|
||||||
bind: "loopback",
|
|
||||||
controlUiEnabled: false,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const resNoToken = await connectReq({ url: `ws://127.0.0.1:${port}` });
|
|
||||||
expect(resNoToken.ok).toBe(false);
|
|
||||||
expect(resNoToken.error?.message ?? "").toContain("unauthorized");
|
|
||||||
|
|
||||||
const resToken = await connectReq({
|
|
||||||
url: `ws://127.0.0.1:${port}`,
|
|
||||||
token,
|
|
||||||
});
|
|
||||||
expect(resToken.ok).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await server.close({ reason: "non-interactive onboard auth test" });
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.rm(tempHome, { recursive: true, force: true });
|
|
||||||
process.env.HOME = prev.home;
|
|
||||||
process.env.CLAWDBOT_STATE_DIR = prev.stateDir;
|
|
||||||
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
|
|
||||||
process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels;
|
|
||||||
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
|
|
||||||
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
|
|
||||||
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
|
|
||||||
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
|
|
||||||
}, 60_000);
|
|
||||||
});
|
|
||||||
@@ -3,7 +3,7 @@ import { createServer } from "node:net";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -13,33 +13,32 @@ import {
|
|||||||
} from "../infra/device-identity.js";
|
} from "../infra/device-identity.js";
|
||||||
import { buildDeviceAuthPayload } from "../gateway/device-auth.js";
|
import { buildDeviceAuthPayload } from "../gateway/device-auth.js";
|
||||||
import { PROTOCOL_VERSION } from "../gateway/protocol/index.js";
|
import { PROTOCOL_VERSION } from "../gateway/protocol/index.js";
|
||||||
import { getFreePort as getFreeTestPort } from "../gateway/test-helpers.js";
|
|
||||||
import { rawDataToString } from "../infra/ws.js";
|
import { 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 { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||||
|
|
||||||
async function isPortFree(port: number): Promise<boolean> {
|
async function getFreePort(): Promise<number> {
|
||||||
if (!Number.isFinite(port) || port <= 0 || port > 65535) return false;
|
return await new Promise((resolve, reject) => {
|
||||||
return await new Promise((resolve) => {
|
|
||||||
const srv = createServer();
|
const srv = createServer();
|
||||||
srv.once("error", () => resolve(false));
|
srv.on("error", reject);
|
||||||
srv.listen(port, "127.0.0.1", () => {
|
srv.listen(0, "127.0.0.1", () => {
|
||||||
srv.close(() => resolve(true));
|
const addr = srv.address();
|
||||||
|
if (!addr || typeof addr === "string") {
|
||||||
|
srv.close();
|
||||||
|
reject(new Error("failed to acquire free port"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const port = addr.port;
|
||||||
|
srv.close((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(port);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getFreeGatewayPort(): Promise<number> {
|
async function getFreeGatewayPort(): Promise<number> {
|
||||||
// Gateway uses derived ports (bridge/browser/canvas). Avoid flaky collisions by
|
return await getDeterministicFreePortBlock({ offsets: [0, 1, 2, 4] });
|
||||||
// ensuring the common derived offsets are free too.
|
|
||||||
for (let attempt = 0; attempt < 25; attempt += 1) {
|
|
||||||
const port = await getFreeTestPort();
|
|
||||||
const candidates = [port, port + 1, port + 2, port + 4];
|
|
||||||
const ok = (await Promise.all(candidates.map((candidate) => isPortFree(candidate)))).every(
|
|
||||||
Boolean,
|
|
||||||
);
|
|
||||||
if (ok) return port;
|
|
||||||
}
|
|
||||||
throw new Error("failed to acquire a free gateway port block");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onceMessage<T = unknown>(
|
async function onceMessage<T = unknown>(
|
||||||
@@ -121,47 +120,177 @@ async function connectReq(params: { url: string; token?: string }) {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("onboard (non-interactive): lan bind auto-token", () => {
|
const runtime = {
|
||||||
it("auto-enables token auth when binding LAN and persists the token", async () => {
|
log: () => {},
|
||||||
if (process.platform === "win32") {
|
error: (msg: string) => {
|
||||||
// Windows runner occasionally drops the temp config write in this flow; skip to keep CI green.
|
throw new Error(msg);
|
||||||
return;
|
},
|
||||||
}
|
exit: (code: number) => {
|
||||||
const prev = {
|
throw new Error(`exit:${code}`);
|
||||||
home: process.env.HOME,
|
},
|
||||||
stateDir: process.env.CLAWDBOT_STATE_DIR,
|
};
|
||||||
configPath: process.env.CLAWDBOT_CONFIG_PATH,
|
|
||||||
skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
|
|
||||||
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
|
|
||||||
skipCron: process.env.CLAWDBOT_SKIP_CRON,
|
|
||||||
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
|
|
||||||
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
describe("onboard (non-interactive): gateway and remote auth", () => {
|
||||||
|
const prev = {
|
||||||
|
home: process.env.HOME,
|
||||||
|
stateDir: process.env.CLAWDBOT_STATE_DIR,
|
||||||
|
configPath: process.env.CLAWDBOT_CONFIG_PATH,
|
||||||
|
skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
|
||||||
|
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
|
||||||
|
skipCron: process.env.CLAWDBOT_SKIP_CRON,
|
||||||
|
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
|
||||||
|
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||||
|
password: process.env.CLAWDBOT_GATEWAY_PASSWORD,
|
||||||
|
};
|
||||||
|
let tempHome: string | undefined;
|
||||||
|
|
||||||
|
const initStateDir = async (prefix: string) => {
|
||||||
|
if (!tempHome) {
|
||||||
|
throw new Error("temp home not initialized");
|
||||||
|
}
|
||||||
|
const stateDir = await fs.mkdtemp(path.join(tempHome, prefix));
|
||||||
|
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
||||||
|
delete process.env.CLAWDBOT_CONFIG_PATH;
|
||||||
|
vi.resetModules();
|
||||||
|
return stateDir;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
|
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
|
||||||
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
|
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
|
||||||
process.env.CLAWDBOT_SKIP_CRON = "1";
|
process.env.CLAWDBOT_SKIP_CRON = "1";
|
||||||
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
|
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
|
||||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
|
delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
|
||||||
|
|
||||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-lan-"));
|
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-"));
|
||||||
process.env.HOME = tempHome;
|
process.env.HOME = tempHome;
|
||||||
const stateDir = path.join(tempHome, ".clawdbot");
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (tempHome) {
|
||||||
|
await fs.rm(tempHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
process.env.HOME = prev.home;
|
||||||
|
process.env.CLAWDBOT_STATE_DIR = prev.stateDir;
|
||||||
|
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
|
||||||
|
process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels;
|
||||||
|
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
|
||||||
|
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
|
||||||
|
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
|
||||||
|
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
|
||||||
|
process.env.CLAWDBOT_GATEWAY_PASSWORD = prev.password;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes gateway token auth into config and gateway enforces it", async () => {
|
||||||
|
const stateDir = await initStateDir("state-noninteractive-");
|
||||||
|
const token = "tok_test_123";
|
||||||
|
const workspace = path.join(stateDir, "clawd");
|
||||||
|
|
||||||
|
const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js");
|
||||||
|
await runNonInteractiveOnboarding(
|
||||||
|
{
|
||||||
|
nonInteractive: true,
|
||||||
|
mode: "local",
|
||||||
|
workspace,
|
||||||
|
authChoice: "skip",
|
||||||
|
skipSkills: true,
|
||||||
|
skipHealth: true,
|
||||||
|
installDaemon: false,
|
||||||
|
gatewayBind: "loopback",
|
||||||
|
gatewayAuth: "token",
|
||||||
|
gatewayToken: token,
|
||||||
|
},
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js");
|
||||||
|
const cfg = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8")) as {
|
||||||
|
gateway?: { auth?: { mode?: string; token?: string } };
|
||||||
|
agents?: { defaults?: { workspace?: string } };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(cfg?.agents?.defaults?.workspace).toBe(workspace);
|
||||||
|
expect(cfg?.gateway?.auth?.mode).toBe("token");
|
||||||
|
expect(cfg?.gateway?.auth?.token).toBe(token);
|
||||||
|
|
||||||
|
const { startGatewayServer } = await import("../gateway/server.js");
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startGatewayServer(port, {
|
||||||
|
bind: "loopback",
|
||||||
|
controlUiEnabled: false,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const resNoToken = await connectReq({ url: `ws://127.0.0.1:${port}` });
|
||||||
|
expect(resNoToken.ok).toBe(false);
|
||||||
|
expect(resNoToken.error?.message ?? "").toContain("unauthorized");
|
||||||
|
|
||||||
|
const resToken = await connectReq({
|
||||||
|
url: `ws://127.0.0.1:${port}`,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
expect(resToken.ok).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "non-interactive onboard auth test" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.rm(stateDir, { recursive: true, force: true });
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it("writes gateway.remote url/token and callGateway uses them", async () => {
|
||||||
|
const stateDir = await initStateDir("state-remote-");
|
||||||
|
const port = await getFreePort();
|
||||||
|
const token = "tok_remote_123";
|
||||||
|
const { startGatewayServer } = await import("../gateway/server.js");
|
||||||
|
const server = await startGatewayServer(port, {
|
||||||
|
bind: "loopback",
|
||||||
|
auth: { mode: "token", token },
|
||||||
|
controlUiEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js");
|
||||||
|
await runNonInteractiveOnboarding(
|
||||||
|
{
|
||||||
|
nonInteractive: true,
|
||||||
|
mode: "remote",
|
||||||
|
remoteUrl: `ws://127.0.0.1:${port}`,
|
||||||
|
remoteToken: token,
|
||||||
|
authChoice: "skip",
|
||||||
|
json: true,
|
||||||
|
},
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { resolveConfigPath } = await import("../config/config.js");
|
||||||
|
const cfg = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8")) as {
|
||||||
|
gateway?: { mode?: string; remote?: { url?: string; token?: string } };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(cfg.gateway?.mode).toBe("remote");
|
||||||
|
expect(cfg.gateway?.remote?.url).toBe(`ws://127.0.0.1:${port}`);
|
||||||
|
expect(cfg.gateway?.remote?.token).toBe(token);
|
||||||
|
|
||||||
|
const { callGateway } = await import("../gateway/call.js");
|
||||||
|
const health = await callGateway<{ ok?: boolean }>({ method: "health" });
|
||||||
|
expect(health?.ok).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "non-interactive remote test complete" });
|
||||||
|
await fs.rm(stateDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it("auto-enables token auth when binding LAN and persists the token", async () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
// Windows runner occasionally drops the temp config write in this flow; skip to keep CI green.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stateDir = await initStateDir("state-lan-");
|
||||||
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
||||||
process.env.CLAWDBOT_CONFIG_PATH = path.join(stateDir, "clawdbot.json");
|
process.env.CLAWDBOT_CONFIG_PATH = path.join(stateDir, "clawdbot.json");
|
||||||
|
|
||||||
const port = await getFreeGatewayPort();
|
const port = await getFreeGatewayPort();
|
||||||
const workspace = path.join(tempHome, "clawd");
|
const workspace = path.join(stateDir, "clawd");
|
||||||
|
|
||||||
const runtime = {
|
|
||||||
log: () => {},
|
|
||||||
error: (msg: string) => {
|
|
||||||
throw new Error(msg);
|
|
||||||
},
|
|
||||||
exit: (code: number) => {
|
|
||||||
throw new Error(`exit:${code}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Other test files mock ../config/config.js. This onboarding flow needs the real
|
// Other test files mock ../config/config.js. This onboarding flow needs the real
|
||||||
// implementation so it can persist the config and then read it back (Windows CI
|
// implementation so it can persist the config and then read it back (Windows CI
|
||||||
@@ -226,14 +355,6 @@ describe("onboard (non-interactive): lan bind auto-token", () => {
|
|||||||
await server.close({ reason: "lan auto-token test complete" });
|
await server.close({ reason: "lan auto-token test complete" });
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.rm(tempHome, { recursive: true, force: true });
|
await fs.rm(stateDir, { recursive: true, force: true });
|
||||||
process.env.HOME = prev.home;
|
|
||||||
process.env.CLAWDBOT_STATE_DIR = prev.stateDir;
|
|
||||||
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
|
|
||||||
process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels;
|
|
||||||
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
|
|
||||||
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
|
|
||||||
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
|
|
||||||
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
|
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
});
|
});
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import { createServer } from "node:net";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
async function getFreePort(): Promise<number> {
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
const srv = createServer();
|
|
||||||
srv.on("error", reject);
|
|
||||||
srv.listen(0, "127.0.0.1", () => {
|
|
||||||
const addr = srv.address();
|
|
||||||
if (!addr || typeof addr === "string") {
|
|
||||||
srv.close();
|
|
||||||
reject(new Error("failed to acquire free port"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const port = addr.port;
|
|
||||||
srv.close((err) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(port);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("onboard (non-interactive): remote gateway config", () => {
|
|
||||||
it("writes gateway.remote url/token and callGateway uses them", async () => {
|
|
||||||
const prev = {
|
|
||||||
home: process.env.HOME,
|
|
||||||
stateDir: process.env.CLAWDBOT_STATE_DIR,
|
|
||||||
configPath: process.env.CLAWDBOT_CONFIG_PATH,
|
|
||||||
skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
|
|
||||||
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
|
|
||||||
skipCron: process.env.CLAWDBOT_SKIP_CRON,
|
|
||||||
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
|
|
||||||
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
|
|
||||||
password: process.env.CLAWDBOT_GATEWAY_PASSWORD,
|
|
||||||
};
|
|
||||||
|
|
||||||
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
|
|
||||||
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
|
|
||||||
process.env.CLAWDBOT_SKIP_CRON = "1";
|
|
||||||
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
|
|
||||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
|
||||||
delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
|
|
||||||
|
|
||||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-remote-"));
|
|
||||||
process.env.HOME = tempHome;
|
|
||||||
delete process.env.CLAWDBOT_STATE_DIR;
|
|
||||||
delete process.env.CLAWDBOT_CONFIG_PATH;
|
|
||||||
|
|
||||||
const port = await getFreePort();
|
|
||||||
const token = "tok_remote_123";
|
|
||||||
const { startGatewayServer } = await import("../gateway/server.js");
|
|
||||||
const server = await startGatewayServer(port, {
|
|
||||||
bind: "loopback",
|
|
||||||
auth: { mode: "token", token },
|
|
||||||
controlUiEnabled: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const runtime = {
|
|
||||||
log: () => {},
|
|
||||||
error: (msg: string) => {
|
|
||||||
throw new Error(msg);
|
|
||||||
},
|
|
||||||
exit: (code: number) => {
|
|
||||||
throw new Error(`exit:${code}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js");
|
|
||||||
await runNonInteractiveOnboarding(
|
|
||||||
{
|
|
||||||
nonInteractive: true,
|
|
||||||
mode: "remote",
|
|
||||||
remoteUrl: `ws://127.0.0.1:${port}`,
|
|
||||||
remoteToken: token,
|
|
||||||
authChoice: "skip",
|
|
||||||
json: true,
|
|
||||||
},
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { resolveConfigPath } = await import("../config/config.js");
|
|
||||||
const cfg = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8")) as {
|
|
||||||
gateway?: { mode?: string; remote?: { url?: string; token?: string } };
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(cfg.gateway?.mode).toBe("remote");
|
|
||||||
expect(cfg.gateway?.remote?.url).toBe(`ws://127.0.0.1:${port}`);
|
|
||||||
expect(cfg.gateway?.remote?.token).toBe(token);
|
|
||||||
|
|
||||||
const { callGateway } = await import("../gateway/call.js");
|
|
||||||
const health = await callGateway<{ ok?: boolean }>({ method: "health" });
|
|
||||||
expect(health?.ok).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await server.close({ reason: "non-interactive remote test complete" });
|
|
||||||
await fs.rm(tempHome, { recursive: true, force: true });
|
|
||||||
process.env.HOME = prev.home;
|
|
||||||
process.env.CLAWDBOT_STATE_DIR = prev.stateDir;
|
|
||||||
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
|
|
||||||
process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels;
|
|
||||||
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
|
|
||||||
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
|
|
||||||
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
|
|
||||||
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
|
|
||||||
process.env.CLAWDBOT_GATEWAY_PASSWORD = prev.password;
|
|
||||||
}
|
|
||||||
}, 60_000);
|
|
||||||
});
|
|
||||||
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)", () => {
|
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 port = await getFreePort();
|
||||||
const server = await startServerWithDefaultConfig(port);
|
const server = await startServerWithDefaultConfig(port);
|
||||||
try {
|
try {
|
||||||
@@ -83,201 +83,112 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
|||||||
} finally {
|
} finally {
|
||||||
await server.close({ reason: "test done" });
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
it("can be disabled via config (404)", async () => {
|
const disabledPort = await getFreePort();
|
||||||
const port = await getFreePort();
|
const disabledServer = await startServer(disabledPort, {
|
||||||
const server = await startServer(port, {
|
|
||||||
openResponsesEnabled: false,
|
openResponsesEnabled: false,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const res = await postResponses(port, {
|
const res = await postResponses(disabledPort, {
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
await ensureResponseConsumed(res);
|
await ensureResponseConsumed(res);
|
||||||
} finally {
|
} 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 port = await getFreePort();
|
||||||
const server = await startServer(port);
|
const server = await startServer(port);
|
||||||
|
const mockAgentOnce = (payloads: Array<{ text: string }>, meta?: unknown) => {
|
||||||
|
agentCommand.mockReset();
|
||||||
|
agentCommand.mockResolvedValueOnce({ payloads, meta } as never);
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
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",
|
method: "GET",
|
||||||
headers: { authorization: "Bearer secret" },
|
headers: { authorization: "Bearer secret" },
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(405);
|
expect(resNonPost.status).toBe(405);
|
||||||
await ensureResponseConsumed(res);
|
await ensureResponseConsumed(resNonPost);
|
||||||
} finally {
|
|
||||||
await server.close({ reason: "test done" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects missing auth", async () => {
|
const resMissingAuth = await fetch(`http://127.0.0.1:${port}/v1/responses`, {
|
||||||
const port = await getFreePort();
|
|
||||||
const server = await startServer(port);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/v1/responses`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ model: "clawdbot", input: "hi" }),
|
body: JSON.stringify({ model: "clawdbot", input: "hi" }),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(401);
|
expect(resMissingAuth.status).toBe(401);
|
||||||
await ensureResponseConsumed(res);
|
await ensureResponseConsumed(resMissingAuth);
|
||||||
} finally {
|
|
||||||
await server.close({ reason: "test done" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects invalid request body (missing model)", async () => {
|
const resMissingModel = await postResponses(port, { input: "hi" });
|
||||||
const port = await getFreePort();
|
expect(resMissingModel.status).toBe(400);
|
||||||
const server = await startServer(port);
|
const missingModelJson = (await resMissingModel.json()) as Record<string, unknown>;
|
||||||
try {
|
expect((missingModelJson.error as Record<string, unknown> | undefined)?.type).toBe(
|
||||||
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(
|
|
||||||
"invalid_request_error",
|
"invalid_request_error",
|
||||||
);
|
);
|
||||||
await ensureResponseConsumed(res);
|
await ensureResponseConsumed(resMissingModel);
|
||||||
} finally {
|
|
||||||
await server.close({ reason: "test done" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes to a specific agent via header", async () => {
|
mockAgentOnce([{ text: "hello" }]);
|
||||||
agentCommand.mockResolvedValueOnce({
|
const resHeader = await postResponses(
|
||||||
payloads: [{ text: "hello" }],
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const port = await getFreePort();
|
|
||||||
const server = await startServer(port);
|
|
||||||
try {
|
|
||||||
const res = await postResponses(
|
|
||||||
port,
|
port,
|
||||||
{ model: "clawdbot", input: "hi" },
|
{ model: "clawdbot", input: "hi" },
|
||||||
{ "x-clawdbot-agent-id": "beta" },
|
{ "x-clawdbot-agent-id": "beta" },
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(200);
|
expect(resHeader.status).toBe(200);
|
||||||
|
const [optsHeader] = agentCommand.mock.calls[0] ?? [];
|
||||||
expect(agentCommand).toHaveBeenCalledTimes(1);
|
expect((optsHeader as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
||||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
|
||||||
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
|
||||||
/^agent:beta:/,
|
/^agent:beta:/,
|
||||||
);
|
);
|
||||||
await ensureResponseConsumed(res);
|
await ensureResponseConsumed(resHeader);
|
||||||
} finally {
|
|
||||||
await server.close({ reason: "test done" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes to a specific agent via model (no custom headers)", async () => {
|
mockAgentOnce([{ text: "hello" }]);
|
||||||
agentCommand.mockResolvedValueOnce({
|
const resModel = await postResponses(port, { model: "clawdbot:beta", input: "hi" });
|
||||||
payloads: [{ text: "hello" }],
|
expect(resModel.status).toBe(200);
|
||||||
} as never);
|
const [optsModel] = agentCommand.mock.calls[0] ?? [];
|
||||||
|
expect((optsModel as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
||||||
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(
|
|
||||||
/^agent:beta:/,
|
/^agent:beta:/,
|
||||||
);
|
);
|
||||||
await ensureResponseConsumed(res);
|
await ensureResponseConsumed(resModel);
|
||||||
} finally {
|
|
||||||
await server.close({ reason: "test done" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses OpenResponses user for a stable session key", async () => {
|
mockAgentOnce([{ text: "hello" }]);
|
||||||
agentCommand.mockResolvedValueOnce({
|
const resUser = await postResponses(port, {
|
||||||
payloads: [{ text: "hello" }],
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const port = await getFreePort();
|
|
||||||
const server = await startServer(port);
|
|
||||||
try {
|
|
||||||
const res = await postResponses(port, {
|
|
||||||
user: "alice",
|
user: "alice",
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(resUser.status).toBe(200);
|
||||||
|
const [optsUser] = agentCommand.mock.calls[0] ?? [];
|
||||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
expect((optsUser as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain(
|
||||||
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain(
|
|
||||||
"openresponses-user:alice",
|
"openresponses-user:alice",
|
||||||
);
|
);
|
||||||
await ensureResponseConsumed(res);
|
await ensureResponseConsumed(resUser);
|
||||||
} finally {
|
|
||||||
await server.close({ reason: "test done" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts string input", async () => {
|
mockAgentOnce([{ text: "hello" }]);
|
||||||
agentCommand.mockResolvedValueOnce({
|
const resString = await postResponses(port, {
|
||||||
payloads: [{ text: "hello" }],
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const port = await getFreePort();
|
|
||||||
const server = await startServer(port);
|
|
||||||
try {
|
|
||||||
const res = await postResponses(port, {
|
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hello world",
|
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] ?? [];
|
mockAgentOnce([{ text: "hello" }]);
|
||||||
expect((opts as { message?: string } | undefined)?.message).toBe("hello world");
|
const resArray = await postResponses(port, {
|
||||||
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, {
|
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: [{ type: "message", role: "user", content: "hello there" }],
|
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] ?? [];
|
mockAgentOnce([{ text: "hello" }]);
|
||||||
expect((opts as { message?: string } | undefined)?.message).toBe("hello there");
|
const resSystemDeveloper = await postResponses(port, {
|
||||||
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, {
|
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: [
|
input: [
|
||||||
{ type: "message", role: "system", content: "You are a helpful assistant." },
|
{ 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" },
|
{ type: "message", role: "user", content: "Hello" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(resSystemDeveloper.status).toBe(200);
|
||||||
|
const [optsSystemDeveloper] = agentCommand.mock.calls[0] ?? [];
|
||||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
|
||||||
const extraSystemPrompt =
|
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("You are a helpful assistant.");
|
||||||
expect(extraSystemPrompt).toContain("Be concise.");
|
expect(extraSystemPrompt).toContain("Be concise.");
|
||||||
await ensureResponseConsumed(res);
|
await ensureResponseConsumed(resSystemDeveloper);
|
||||||
} finally {
|
|
||||||
await server.close({ reason: "test done" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes instructions in extraSystemPrompt", async () => {
|
mockAgentOnce([{ text: "hello" }]);
|
||||||
agentCommand.mockResolvedValueOnce({
|
const resInstructions = await postResponses(port, {
|
||||||
payloads: [{ text: "hello" }],
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const port = await getFreePort();
|
|
||||||
const server = await startServer(port);
|
|
||||||
try {
|
|
||||||
const res = await postResponses(port, {
|
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
instructions: "Always respond in French.",
|
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] ?? [];
|
mockAgentOnce([{ text: "I am Claude" }]);
|
||||||
const extraSystemPrompt =
|
const resHistory = await postResponses(port, {
|
||||||
(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, {
|
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: [
|
input: [
|
||||||
{ type: "message", role: "system", content: "You are a helpful assistant." },
|
{ 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?" },
|
{ 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] ?? [];
|
mockAgentOnce([{ text: "ok" }]);
|
||||||
const message = (opts as { message?: string } | undefined)?.message ?? "";
|
const resFunctionOutput = await postResponses(port, {
|
||||||
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, {
|
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: [
|
input: [
|
||||||
{ type: "message", role: "user", content: "What's the weather?" },
|
{ type: "message", role: "user", content: "What's the weather?" },
|
||||||
{ type: "function_call_output", call_id: "call_1", output: "Sunny, 70F." },
|
{ 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] ?? [];
|
mockAgentOnce([{ text: "ok" }]);
|
||||||
const message = (opts as { message?: string } | undefined)?.message ?? "";
|
const resInputFile = await postResponses(port, {
|
||||||
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, {
|
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: [
|
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] ?? [];
|
mockAgentOnce([{ text: "ok" }]);
|
||||||
const message = (opts as { message?: string } | undefined)?.message ?? "";
|
const resToolNone = await postResponses(port, {
|
||||||
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, {
|
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
tools: [
|
tools: [
|
||||||
@@ -443,25 +296,15 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
|||||||
],
|
],
|
||||||
tool_choice: "none",
|
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] ?? [];
|
mockAgentOnce([{ text: "ok" }]);
|
||||||
expect((opts as { clientTools?: unknown[] } | undefined)?.clientTools).toBeUndefined();
|
const resToolChoice = await postResponses(port, {
|
||||||
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, {
|
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
tools: [
|
tools: [
|
||||||
@@ -476,24 +319,16 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
|||||||
],
|
],
|
||||||
tool_choice: { type: "function", function: { name: "get_time" } },
|
tool_choice: { type: "function", function: { name: "get_time" } },
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(resToolChoice.status).toBe(200);
|
||||||
|
const [optsToolChoice] = agentCommand.mock.calls[0] ?? [];
|
||||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
|
||||||
const clientTools =
|
const clientTools =
|
||||||
(opts as { clientTools?: Array<{ function?: { name?: string } }> })?.clientTools ?? [];
|
(optsToolChoice as { clientTools?: Array<{ function?: { name?: string } }> })
|
||||||
|
?.clientTools ?? [];
|
||||||
expect(clientTools).toHaveLength(1);
|
expect(clientTools).toHaveLength(1);
|
||||||
expect(clientTools[0]?.function?.name).toBe("get_time");
|
expect(clientTools[0]?.function?.name).toBe("get_time");
|
||||||
await ensureResponseConsumed(res);
|
await ensureResponseConsumed(resToolChoice);
|
||||||
} finally {
|
|
||||||
await server.close({ reason: "test done" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects tool_choice that references an unknown tool", async () => {
|
const resUnknownTool = await postResponses(port, {
|
||||||
const port = await getFreePort();
|
|
||||||
const server = await startServer(port);
|
|
||||||
try {
|
|
||||||
const res = await postResponses(port, {
|
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
tools: [
|
tools: [
|
||||||
@@ -504,85 +339,51 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
|||||||
],
|
],
|
||||||
tool_choice: { type: "function", function: { name: "unknown_tool" } },
|
tool_choice: { type: "function", function: { name: "unknown_tool" } },
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(400);
|
expect(resUnknownTool.status).toBe(400);
|
||||||
await ensureResponseConsumed(res);
|
await ensureResponseConsumed(resUnknownTool);
|
||||||
} finally {
|
|
||||||
await server.close({ reason: "test done" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes max_output_tokens through to the agent stream params", async () => {
|
mockAgentOnce([{ text: "ok" }]);
|
||||||
agentCommand.mockResolvedValueOnce({
|
const resMaxTokens = await postResponses(port, {
|
||||||
payloads: [{ text: "ok" }],
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const port = await getFreePort();
|
|
||||||
const server = await startServer(port);
|
|
||||||
try {
|
|
||||||
const res = await postResponses(port, {
|
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
max_output_tokens: 123,
|
max_output_tokens: 123,
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(resMaxTokens.status).toBe(200);
|
||||||
|
const [optsMaxTokens] = agentCommand.mock.calls[0] ?? [];
|
||||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
|
||||||
expect(
|
expect(
|
||||||
(opts as { streamParams?: { maxTokens?: number } } | undefined)?.streamParams?.maxTokens,
|
(optsMaxTokens as { streamParams?: { maxTokens?: number } } | undefined)?.streamParams
|
||||||
|
?.maxTokens,
|
||||||
).toBe(123);
|
).toBe(123);
|
||||||
await ensureResponseConsumed(res);
|
await ensureResponseConsumed(resMaxTokens);
|
||||||
} finally {
|
|
||||||
await server.close({ reason: "test done" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns usage when available", async () => {
|
mockAgentOnce([{ text: "ok" }], {
|
||||||
agentCommand.mockResolvedValueOnce({
|
|
||||||
payloads: [{ text: "ok" }],
|
|
||||||
meta: {
|
|
||||||
agentMeta: {
|
agentMeta: {
|
||||||
usage: { input: 3, output: 5, cacheRead: 1, cacheWrite: 1 },
|
usage: { input: 3, output: 5, cacheRead: 1, cacheWrite: 1 },
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
} as never);
|
const resUsage = await postResponses(port, {
|
||||||
|
|
||||||
const port = await getFreePort();
|
|
||||||
const server = await startServer(port);
|
|
||||||
try {
|
|
||||||
const res = await postResponses(port, {
|
|
||||||
stream: false,
|
stream: false,
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(resUsage.status).toBe(200);
|
||||||
const json = (await res.json()) as Record<string, unknown>;
|
const usageJson = (await resUsage.json()) as Record<string, unknown>;
|
||||||
expect(json.usage).toEqual({ input_tokens: 3, output_tokens: 5, total_tokens: 10 });
|
expect(usageJson.usage).toEqual({ input_tokens: 3, output_tokens: 5, total_tokens: 10 });
|
||||||
await ensureResponseConsumed(res);
|
await ensureResponseConsumed(resUsage);
|
||||||
} finally {
|
|
||||||
await server.close({ reason: "test done" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a non-streaming response with correct shape", async () => {
|
mockAgentOnce([{ text: "hello" }]);
|
||||||
agentCommand.mockResolvedValueOnce({
|
const resShape = await postResponses(port, {
|
||||||
payloads: [{ text: "hello" }],
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const port = await getFreePort();
|
|
||||||
const server = await startServer(port);
|
|
||||||
try {
|
|
||||||
const res = await postResponses(port, {
|
|
||||||
stream: false,
|
stream: false,
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(resShape.status).toBe(200);
|
||||||
const json = (await res.json()) as Record<string, unknown>;
|
const shapeJson = (await resShape.json()) as Record<string, unknown>;
|
||||||
expect(json.object).toBe("response");
|
expect(shapeJson.object).toBe("response");
|
||||||
expect(json.status).toBe("completed");
|
expect(shapeJson.status).toBe("completed");
|
||||||
expect(Array.isArray(json.output)).toBe(true);
|
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);
|
expect(output.length).toBe(1);
|
||||||
const item = output[0] ?? {};
|
const item = output[0] ?? {};
|
||||||
expect(item.type).toBe("message");
|
expect(item.type).toBe("message");
|
||||||
@@ -592,55 +393,48 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
|||||||
expect(content.length).toBe(1);
|
expect(content.length).toBe(1);
|
||||||
expect(content[0]?.type).toBe("output_text");
|
expect(content[0]?.type).toBe("output_text");
|
||||||
expect(content[0]?.text).toBe("hello");
|
expect(content[0]?.text).toBe("hello");
|
||||||
await ensureResponseConsumed(res);
|
await ensureResponseConsumed(resShape);
|
||||||
} finally {
|
|
||||||
await server.close({ reason: "test done" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requires a user message in input", async () => {
|
const resNoUser = await postResponses(port, {
|
||||||
const port = await getFreePort();
|
|
||||||
const server = await startServer(port);
|
|
||||||
try {
|
|
||||||
const res = await postResponses(port, {
|
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: [{ type: "message", role: "system", content: "yo" }],
|
input: [{ type: "message", role: "system", content: "yo" }],
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(400);
|
expect(resNoUser.status).toBe(400);
|
||||||
const json = (await res.json()) as Record<string, unknown>;
|
const noUserJson = (await resNoUser.json()) as Record<string, unknown>;
|
||||||
expect((json.error as Record<string, unknown> | undefined)?.type).toBe(
|
expect((noUserJson.error as Record<string, unknown> | undefined)?.type).toBe(
|
||||||
"invalid_request_error",
|
"invalid_request_error",
|
||||||
);
|
);
|
||||||
await ensureResponseConsumed(res);
|
await ensureResponseConsumed(resNoUser);
|
||||||
} finally {
|
} finally {
|
||||||
await server.close({ reason: "test done" });
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("streams SSE events when stream=true (delta events)", async () => {
|
it("streams OpenResponses SSE 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;
|
|
||||||
});
|
|
||||||
|
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
const server = await startServer(port);
|
const server = await startServer(port);
|
||||||
|
|
||||||
try {
|
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,
|
stream: true,
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(resDelta.status).toBe(200);
|
||||||
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
|
expect(resDelta.headers.get("content-type") ?? "").toContain("text/event-stream");
|
||||||
|
|
||||||
const text = await res.text();
|
const deltaText = await resDelta.text();
|
||||||
const events = parseSseEvents(text);
|
const deltaEvents = parseSseEvents(deltaText);
|
||||||
|
|
||||||
// Check for required event types
|
const eventTypes = deltaEvents.map((e) => e.event).filter(Boolean);
|
||||||
const eventTypes = events.map((e) => e.event).filter(Boolean);
|
|
||||||
expect(eventTypes).toContain("response.created");
|
expect(eventTypes).toContain("response.created");
|
||||||
expect(eventTypes).toContain("response.output_item.added");
|
expect(eventTypes).toContain("response.output_item.added");
|
||||||
expect(eventTypes).toContain("response.in_progress");
|
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.output_text.done");
|
||||||
expect(eventTypes).toContain("response.content_part.done");
|
expect(eventTypes).toContain("response.content_part.done");
|
||||||
expect(eventTypes).toContain("response.completed");
|
expect(eventTypes).toContain("response.completed");
|
||||||
|
expect(deltaEvents.some((e) => e.data === "[DONE]")).toBe(true);
|
||||||
|
|
||||||
// Check for [DONE] terminal event
|
const deltas = deltaEvents
|
||||||
expect(events.some((e) => e.data === "[DONE]")).toBe(true);
|
.filter((e) => e.event === "response.output_text.delta")
|
||||||
|
|
||||||
// Verify delta content
|
|
||||||
const deltaEvents = events.filter((e) => e.event === "response.output_text.delta");
|
|
||||||
const allDeltas = deltaEvents
|
|
||||||
.map((e) => {
|
.map((e) => {
|
||||||
const parsed = JSON.parse(e.data) as { delta?: string };
|
const parsed = JSON.parse(e.data) as { delta?: string };
|
||||||
return parsed.delta ?? "";
|
return parsed.delta ?? "";
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
expect(allDeltas).toBe("hello");
|
expect(deltas).toBe("hello");
|
||||||
await ensureResponseConsumed(res);
|
|
||||||
} finally {
|
|
||||||
await server.close({ reason: "test done" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("streams SSE events when stream=true (fallback when no deltas)", async () => {
|
agentCommand.mockReset();
|
||||||
agentCommand.mockResolvedValueOnce({
|
agentCommand.mockResolvedValueOnce({
|
||||||
payloads: [{ text: "hello" }],
|
payloads: [{ text: "hello" }],
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
const port = await getFreePort();
|
const resFallback = await postResponses(port, {
|
||||||
const server = await startServer(port);
|
|
||||||
try {
|
|
||||||
const res = await postResponses(port, {
|
|
||||||
stream: true,
|
stream: true,
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(resFallback.status).toBe(200);
|
||||||
const text = await res.text();
|
const fallbackText = await resFallback.text();
|
||||||
expect(text).toContain("[DONE]");
|
expect(fallbackText).toContain("[DONE]");
|
||||||
expect(text).toContain("hello");
|
expect(fallbackText).toContain("hello");
|
||||||
await ensureResponseConsumed(res);
|
|
||||||
} finally {
|
|
||||||
await server.close({ reason: "test done" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("event type matches JSON type field", async () => {
|
agentCommand.mockReset();
|
||||||
agentCommand.mockResolvedValueOnce({
|
agentCommand.mockResolvedValueOnce({
|
||||||
payloads: [{ text: "hello" }],
|
payloads: [{ text: "hello" }],
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
const port = await getFreePort();
|
const resTypeMatch = await postResponses(port, {
|
||||||
const server = await startServer(port);
|
|
||||||
try {
|
|
||||||
const res = await postResponses(port, {
|
|
||||||
stream: true,
|
stream: true,
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(resTypeMatch.status).toBe(200);
|
||||||
|
|
||||||
const text = await res.text();
|
const typeText = await resTypeMatch.text();
|
||||||
const events = parseSseEvents(text);
|
const typeEvents = parseSseEvents(typeText);
|
||||||
|
for (const event of typeEvents) {
|
||||||
for (const event of events) {
|
|
||||||
if (event.data === "[DONE]") continue;
|
if (event.data === "[DONE]") continue;
|
||||||
const parsed = JSON.parse(event.data) as { type?: string };
|
const parsed = JSON.parse(event.data) as { type?: string };
|
||||||
expect(event.event).toBe(parsed.type);
|
expect(event.event).toBe(parsed.type);
|
||||||
}
|
}
|
||||||
await ensureResponseConsumed(res);
|
|
||||||
} finally {
|
} finally {
|
||||||
await server.close({ reason: "test done" });
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,46 +25,7 @@ function _expectChannels(call: Record<string, unknown>, channel: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("gateway server agent", () => {
|
describe("gateway server agent", () => {
|
||||||
test("agent events include sessionKey in agent payloads", async () => {
|
test("agent events include sessionKey and agent.wait covers lifecycle flows", 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 () => {
|
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
await writeSessionStore({
|
await writeSessionStore({
|
||||||
@@ -87,153 +48,153 @@ describe("gateway server agent", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
registerAgentRunContext("run-tool-off", { sessionKey: "agent:main:main" });
|
registerAgentRunContext("run-tool-1", {
|
||||||
|
sessionKey: "main",
|
||||||
emitAgentEvent({
|
verboseLevel: "on",
|
||||||
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,
|
const agentEvtP = onceMessage(
|
||||||
(o) => o.type === "event" && o.event === "agent" && o.payload?.runId === "run-tool-off",
|
ws,
|
||||||
8000,
|
(o) => o.type === "event" && o.event === "agent" && o.payload?.runId === "run-tool-1",
|
||||||
);
|
8000,
|
||||||
const payload =
|
);
|
||||||
evt.payload && typeof evt.payload === "object"
|
|
||||||
? (evt.payload as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
expect(payload.stream).toBe("assistant");
|
|
||||||
|
|
||||||
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({
|
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",
|
runId: "run-wait-1",
|
||||||
stream: "lifecycle",
|
timeoutMs: 1000,
|
||||||
data: { phase: "end", startedAt: 200, endedAt: 210 },
|
|
||||||
});
|
});
|
||||||
}, 10);
|
|
||||||
|
|
||||||
const res = await waitP;
|
setTimeout(() => {
|
||||||
expect(res.ok).toBe(true);
|
emitAgentEvent({
|
||||||
expect(res.payload.status).toBe("ok");
|
runId: "run-wait-1",
|
||||||
expect(res.payload.startedAt).toBe(200);
|
stream: "lifecycle",
|
||||||
|
data: { phase: "end", startedAt: 200, endedAt: 210 },
|
||||||
|
});
|
||||||
|
}, 5);
|
||||||
|
|
||||||
ws.close();
|
const res = await waitP;
|
||||||
await server.close();
|
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({
|
emitAgentEvent({
|
||||||
runId: "run-wait-err",
|
runId: "run-wait-early",
|
||||||
stream: "lifecycle",
|
stream: "lifecycle",
|
||||||
data: { phase: "error", error: "boom" },
|
data: { phase: "end", startedAt: 50, endedAt: 55 },
|
||||||
});
|
});
|
||||||
}, 10);
|
|
||||||
|
|
||||||
const res = await waitP;
|
const res = await rpcReq(ws, "agent.wait", {
|
||||||
expect(res.ok).toBe(true);
|
runId: "run-wait-early",
|
||||||
expect(res.payload.status).toBe("error");
|
timeoutMs: 1000,
|
||||||
expect(res.payload.error).toBe("boom");
|
});
|
||||||
|
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();
|
const waitP = rpcReq(ws, "agent.wait", {
|
||||||
await connectOk(ws);
|
runId: "run-wait-err",
|
||||||
|
timeoutMs: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
const waitP = rpcReq(ws, "agent.wait", {
|
setTimeout(() => {
|
||||||
runId: "run-wait-start",
|
emitAgentEvent({
|
||||||
timeoutMs: 1000,
|
runId: "run-wait-err",
|
||||||
});
|
stream: "lifecycle",
|
||||||
|
data: { phase: "error", error: "boom" },
|
||||||
|
});
|
||||||
|
}, 5);
|
||||||
|
|
||||||
emitAgentEvent({
|
const res = await waitP;
|
||||||
runId: "run-wait-start",
|
expect(res.ok).toBe(true);
|
||||||
stream: "lifecycle",
|
expect(res.payload.status).toBe("error");
|
||||||
data: { phase: "start", startedAt: 123 },
|
expect(res.payload.error).toBe("boom");
|
||||||
});
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const waitP = rpcReq(ws, "agent.wait", {
|
||||||
|
runId: "run-wait-start",
|
||||||
|
timeoutMs: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId: "run-wait-start",
|
runId: "run-wait-start",
|
||||||
stream: "lifecycle",
|
stream: "lifecycle",
|
||||||
data: { phase: "end", endedAt: 456 },
|
data: { phase: "start", startedAt: 123 },
|
||||||
});
|
});
|
||||||
}, 10);
|
|
||||||
|
|
||||||
const res = await waitP;
|
setTimeout(() => {
|
||||||
expect(res.ok).toBe(true);
|
emitAgentEvent({
|
||||||
expect(res.payload.status).toBe("ok");
|
runId: "run-wait-start",
|
||||||
expect(res.payload.startedAt).toBe(123);
|
stream: "lifecycle",
|
||||||
expect(res.payload.endedAt).toBe(456);
|
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();
|
ws.close();
|
||||||
await server.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 () => {
|
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
const prevHandshakeTimeout = process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
|
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 {
|
try {
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
const handshakeTimeoutMs = getHandshakeTimeoutMs();
|
const handshakeTimeoutMs = getHandshakeTimeoutMs();
|
||||||
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 2_000);
|
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 250);
|
||||||
expect(closed).toBe(true);
|
expect(closed).toBe(true);
|
||||||
await server.close();
|
await server.close();
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||||
import {
|
import {
|
||||||
agentCommand,
|
agentCommand,
|
||||||
connectOk,
|
connectOk,
|
||||||
@@ -13,9 +14,7 @@ import {
|
|||||||
testState,
|
testState,
|
||||||
writeSessionStore,
|
writeSessionStore,
|
||||||
} from "./test-helpers.js";
|
} from "./test-helpers.js";
|
||||||
|
|
||||||
installGatewayTestHooks();
|
installGatewayTestHooks();
|
||||||
|
|
||||||
async function waitFor(condition: () => boolean, timeoutMs = 1500) {
|
async function waitFor(condition: () => boolean, timeoutMs = 1500) {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
@@ -24,106 +23,300 @@ async function waitFor(condition: () => boolean, timeoutMs = 1500) {
|
|||||||
}
|
}
|
||||||
throw new Error("timeout waiting for condition");
|
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", () => {
|
describe("gateway server chat", () => {
|
||||||
test("chat.history caps payload bytes", { timeout: 60_000 }, async () => {
|
test("handles history, abort, idempotency, and ordering flows", { timeout: 60_000 }, async () => {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const tempDirs: string[] = [];
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
|
||||||
await writeSessionStore({
|
|
||||||
entries: {
|
|
||||||
main: {
|
|
||||||
sessionId: "sess-main",
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
await connectOk(ws);
|
const spy = vi.mocked(agentCommand);
|
||||||
|
const resetSpy = () => {
|
||||||
const bigText = "x".repeat(200_000);
|
spy.mockReset();
|
||||||
const largeLines: string[] = [];
|
spy.mockResolvedValue(undefined);
|
||||||
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;
|
|
||||||
try {
|
try {
|
||||||
await connectOk(ws);
|
await connectOk(ws);
|
||||||
|
await withSessionStore(
|
||||||
const spy = vi.mocked(agentCommand);
|
tempDirs,
|
||||||
const callsBefore = spy.mock.calls.length;
|
{ 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) => {
|
spy.mockImplementationOnce(async (opts) => {
|
||||||
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
|
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
@@ -132,260 +325,198 @@ describe("gateway server chat", () => {
|
|||||||
signal.addEventListener("abort", () => resolve(), { once: true });
|
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(
|
const abortedEventP = onceMessage(
|
||||||
ws,
|
ws,
|
||||||
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted",
|
(o) =>
|
||||||
8000,
|
o.type === "event" &&
|
||||||
|
o.event === "chat" &&
|
||||||
|
o.payload?.state === "aborted" &&
|
||||||
|
o.payload?.runId === "idem-abort-all-1",
|
||||||
);
|
);
|
||||||
inFlight = Promise.allSettled([sendResP, abortResP, abortedEventP]);
|
const startedAbortAll = await rpcReq(ws, "chat.send", {
|
||||||
|
|
||||||
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", {
|
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
message: "hello",
|
message: "hello",
|
||||||
idempotencyKey: "idem-status-1",
|
idempotencyKey: "idem-abort-all-1",
|
||||||
});
|
});
|
||||||
if (again.ok && again.payload?.status === "ok") {
|
expect(startedAbortAll.ok).toBe(true);
|
||||||
completed = true;
|
const abortRes = await rpcReq<{
|
||||||
break;
|
ok?: boolean;
|
||||||
}
|
aborted?: boolean;
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
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 os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
import { WebSocket } from "ws";
|
||||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||||
import {
|
import {
|
||||||
agentCommand,
|
agentCommand,
|
||||||
connectOk,
|
connectOk,
|
||||||
installGatewayTestHooks,
|
installGatewayTestHooks,
|
||||||
onceMessage,
|
onceMessage,
|
||||||
piSdkMock,
|
|
||||||
rpcReq,
|
rpcReq,
|
||||||
startServerWithClient,
|
startServerWithClient,
|
||||||
testState,
|
testState,
|
||||||
@@ -27,468 +27,222 @@ async function waitFor(condition: () => boolean, timeoutMs = 1500) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("gateway server chat", () => {
|
describe("gateway server chat", () => {
|
||||||
test("webchat can chat.send without a mobile node", async () => {
|
test("handles chat send and history flows", async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
const tempDirs: string[] = [];
|
||||||
await connectOk(ws, {
|
const { server, ws, port } = await startServerWithClient();
|
||||||
client: {
|
let webchatWs: WebSocket | undefined;
|
||||||
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
|
||||||
version: "dev",
|
|
||||||
platform: "web",
|
|
||||||
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await rpcReq(ws, "chat.send", {
|
try {
|
||||||
sessionKey: "main",
|
await connectOk(ws);
|
||||||
message: "hello",
|
|
||||||
idempotencyKey: "idem-webchat-1",
|
|
||||||
});
|
|
||||||
expect(res.ok).toBe(true);
|
|
||||||
|
|
||||||
ws.close();
|
webchatWs = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||||
await server.close();
|
await new Promise<void>((resolve) => webchatWs?.once("open", resolve));
|
||||||
});
|
await connectOk(webchatWs, {
|
||||||
|
client: {
|
||||||
test("chat.send defaults to agent timeout config", async () => {
|
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||||
testState.agentConfig = { timeoutSeconds: 123 };
|
version: "dev",
|
||||||
const { server, ws } = await startServerWithClient();
|
platform: "web",
|
||||||
await connectOk(ws);
|
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||||
|
|
||||||
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",
|
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const { server, ws } = await startServerWithClient();
|
const webchatRes = await rpcReq(webchatWs, "chat.send", {
|
||||||
await connectOk(ws);
|
sessionKey: "main",
|
||||||
|
message: "hello",
|
||||||
|
idempotencyKey: "idem-webchat-1",
|
||||||
|
});
|
||||||
|
expect(webchatRes.ok).toBe(true);
|
||||||
|
|
||||||
const res = await rpcReq(ws, "chat.send", {
|
webchatWs.close();
|
||||||
sessionKey: "discord:group:dev",
|
webchatWs = undefined;
|
||||||
message: "hello",
|
|
||||||
idempotencyKey: "idem-1",
|
|
||||||
});
|
|
||||||
expect(res.ok).toBe(false);
|
|
||||||
expect((res.error as { message?: string } | undefined)?.message ?? "").toMatch(/send blocked/i);
|
|
||||||
|
|
||||||
ws.close();
|
const spy = vi.mocked(agentCommand);
|
||||||
await server.close();
|
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 () => {
|
await waitFor(() => spy.mock.calls.length > callsBeforeTimeout);
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const timeoutCall = spy.mock.calls.at(-1)?.[0] as { timeout?: string } | undefined;
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
expect(timeoutCall?.timeout).toBe("123");
|
||||||
testState.sessionConfig = {
|
testState.agentConfig = undefined;
|
||||||
sendPolicy: {
|
|
||||||
default: "allow",
|
|
||||||
rules: [{ action: "deny", match: { keyPrefix: "cron:" } }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await writeSessionStore({
|
spy.mockClear();
|
||||||
entries: {
|
const callsBeforeSession = spy.mock.calls.length;
|
||||||
"cron:job-1": {
|
const sessionRes = await rpcReq(ws, "chat.send", {
|
||||||
sessionId: "sess-cron",
|
sessionKey: "agent:main:subagent:abc",
|
||||||
updatedAt: Date.now(),
|
message: "hello",
|
||||||
},
|
idempotencyKey: "idem-session-key-1",
|
||||||
},
|
});
|
||||||
});
|
expect(sessionRes.ok).toBe(true);
|
||||||
|
|
||||||
const { server, ws } = await startServerWithClient();
|
await waitFor(() => spy.mock.calls.length > callsBeforeSession);
|
||||||
await connectOk(ws);
|
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", {
|
const sendPolicyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
sessionKey: "cron:job-1",
|
tempDirs.push(sendPolicyDir);
|
||||||
message: "hi",
|
testState.sessionStorePath = path.join(sendPolicyDir, "sessions.json");
|
||||||
idempotencyKey: "idem-2",
|
testState.sessionConfig = {
|
||||||
});
|
sendPolicy: {
|
||||||
expect(res.ok).toBe(false);
|
default: "allow",
|
||||||
expect((res.error as { message?: string } | undefined)?.message ?? "").toMatch(/send blocked/i);
|
rules: [
|
||||||
|
|
||||||
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: [
|
|
||||||
{
|
{
|
||||||
type: "image",
|
action: "deny",
|
||||||
mimeType: "image/png",
|
match: { channel: "discord", chatType: "group" },
|
||||||
fileName: "dot.png",
|
|
||||||
content: `data:image/png;base64,${pngB64}`,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
};
|
||||||
);
|
|
||||||
|
|
||||||
const res = await onceMessage(ws, (o) => o.type === "res" && o.id === reqId, 8000);
|
await writeSessionStore({
|
||||||
expect(res.ok).toBe(true);
|
entries: {
|
||||||
expect(res.payload?.runId).toBeDefined();
|
"discord:group:dev": {
|
||||||
|
sessionId: "sess-discord",
|
||||||
await waitFor(() => spy.mock.calls.length > callsBefore, 8000);
|
updatedAt: Date.now(),
|
||||||
const call = spy.mock.calls.at(-1)?.[0] as
|
chatType: "group",
|
||||||
| { images?: Array<{ type: string; data: string; mimeType: string }> }
|
channel: "discord",
|
||||||
| 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(),
|
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
const blockedRes = await rpcReq(ws, "chat.send", {
|
||||||
for (let i = 0; i < 300; i += 1) {
|
sessionKey: "discord:group:dev",
|
||||||
lines.push(
|
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({
|
JSON.stringify({
|
||||||
message: {
|
type: "req",
|
||||||
role: "user",
|
id: reqId,
|
||||||
content: [{ type: "text", text: `m${i}` }],
|
method: "chat.send",
|
||||||
timestamp: Date.now() + i,
|
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();
|
const imgRes = await onceMessage(ws, (o) => o.type === "res" && o.id === reqId, 8000);
|
||||||
await connectOk(ws);
|
expect(imgRes.ok).toBe(true);
|
||||||
|
expect(imgRes.payload?.runId).toBeDefined();
|
||||||
|
|
||||||
const defaultRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
|
await waitFor(() => spy.mock.calls.length > callsBeforeImage, 8000);
|
||||||
sessionKey: "main",
|
const imgCall = spy.mock.calls.at(-1)?.[0] as
|
||||||
});
|
| { images?: Array<{ type: string; data: string; mimeType: string }> }
|
||||||
expect(defaultRes.ok).toBe(true);
|
| undefined;
|
||||||
const defaultMsgs = defaultRes.payload?.messages ?? [];
|
expect(imgCall?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
|
||||||
expect(defaultMsgs.length).toBe(200);
|
|
||||||
expect(firstContentText(defaultMsgs[0])).toBe("m100");
|
|
||||||
|
|
||||||
const limitedRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
|
const historyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
sessionKey: "main",
|
tempDirs.push(historyDir);
|
||||||
limit: 5,
|
testState.sessionStorePath = path.join(historyDir, "sessions.json");
|
||||||
});
|
await writeSessionStore({
|
||||||
expect(limitedRes.ok).toBe(true);
|
entries: {
|
||||||
const limitedMsgs = limitedRes.payload?.messages ?? [];
|
main: {
|
||||||
expect(limitedMsgs.length).toBe(5);
|
sessionId: "sess-main",
|
||||||
expect(firstContentText(limitedMsgs[0])).toBe("m295");
|
updatedAt: Date.now(),
|
||||||
|
|
||||||
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 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 fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
connectOk,
|
connectOk,
|
||||||
installGatewayTestHooks,
|
installGatewayTestHooks,
|
||||||
|
onceMessage,
|
||||||
rpcReq,
|
rpcReq,
|
||||||
startServerWithClient,
|
startServerWithClient,
|
||||||
testState,
|
testState,
|
||||||
@@ -35,246 +36,35 @@ async function rmTempDir(dir: string) {
|
|||||||
await fs.rm(dir, { recursive: true, force: true });
|
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", () => {
|
describe("gateway server cron", () => {
|
||||||
test("supports cron.add and cron.list", { timeout: 120_000 }, 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");
|
|
||||||
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 () => {
|
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
|
||||||
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
|
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
|
||||||
testState.sessionConfig = { mainKey: "primary" };
|
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;
|
testState.cronEnabled = false;
|
||||||
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
|
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
|
||||||
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
|
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
|
||||||
@@ -282,218 +72,256 @@ describe("gateway server cron", () => {
|
|||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
await connectOk(ws);
|
await connectOk(ws);
|
||||||
|
|
||||||
const addRes = await rpcReq(ws, "cron.add", {
|
try {
|
||||||
name: "jobId test",
|
const addRes = await rpcReq(ws, "cron.add", {
|
||||||
enabled: true,
|
name: "daily",
|
||||||
schedule: { kind: "every", everyMs: 60_000 },
|
enabled: true,
|
||||||
sessionTarget: "main",
|
schedule: { kind: "every", everyMs: 60_000 },
|
||||||
wakeMode: "next-heartbeat",
|
sessionTarget: "main",
|
||||||
payload: { kind: "systemEvent", text: "hello" },
|
wakeMode: "next-heartbeat",
|
||||||
});
|
payload: { kind: "systemEvent", text: "hello" },
|
||||||
expect(addRes.ok).toBe(true);
|
});
|
||||||
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
|
expect(addRes.ok).toBe(true);
|
||||||
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
|
expect(typeof (addRes.payload as { id?: unknown } | null)?.id).toBe("string");
|
||||||
expect(jobId.length > 0).toBe(true);
|
|
||||||
|
|
||||||
const atMs = Date.now() + 2_000;
|
const listRes = await rpcReq(ws, "cron.list", {
|
||||||
const updateRes = await rpcReq(ws, "cron.update", {
|
includeDisabled: true,
|
||||||
jobId,
|
});
|
||||||
patch: {
|
expect(listRes.ok).toBe(true);
|
||||||
schedule: { atMs },
|
const jobs = (listRes.payload as { jobs?: unknown } | null)?.jobs;
|
||||||
payload: { kind: "systemEvent", text: "updated" },
|
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");
|
||||||
expect(updateRes.ok).toBe(true);
|
|
||||||
|
|
||||||
ws.close();
|
const routeAtMs = Date.now() - 1;
|
||||||
await server.close();
|
const routeRes = await rpcReq(ws, "cron.add", {
|
||||||
await rmTempDir(dir);
|
name: "route test",
|
||||||
testState.cronStorePath = undefined;
|
enabled: true,
|
||||||
testState.cronEnabled = undefined;
|
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 () => {
|
test("writes cron run history and auto-runs due jobs", 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 () => {
|
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-log-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-log-"));
|
||||||
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
|
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
|
||||||
|
testState.cronEnabled = undefined;
|
||||||
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
|
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
|
||||||
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
|
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
|
||||||
|
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
await connectOk(ws);
|
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 {
|
try {
|
||||||
await fs.mkdir(path.dirname(testState.cronStorePath), {
|
const atMs = Date.now() - 1;
|
||||||
recursive: true,
|
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();
|
const finishedP = waitForCronFinished(ws, jobId);
|
||||||
await connectOk(ws);
|
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", {});
|
const statusRes = await rpcReq(ws, "cron.status", {});
|
||||||
expect(statusRes.ok).toBe(true);
|
expect(statusRes.ok).toBe(true);
|
||||||
@@ -504,45 +332,41 @@ describe("gateway server cron", () => {
|
|||||||
const storePath = typeof statusPayload?.storePath === "string" ? statusPayload.storePath : "";
|
const storePath = typeof statusPayload?.storePath === "string" ? statusPayload.storePath : "";
|
||||||
expect(storePath).toContain("jobs.json");
|
expect(storePath).toContain("jobs.json");
|
||||||
|
|
||||||
// Keep the job due immediately; we poll run logs instead of relying on
|
const autoRes = await rpcReq(ws, "cron.add", {
|
||||||
// the cron finished event to avoid timing races under heavy load.
|
|
||||||
const atMs = Date.now() - 10;
|
|
||||||
const addRes = await rpcReq(ws, "cron.add", {
|
|
||||||
name: "auto run test",
|
name: "auto run test",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: { kind: "at", atMs },
|
schedule: { kind: "at", atMs: Date.now() - 10 },
|
||||||
sessionTarget: "main",
|
sessionTarget: "main",
|
||||||
wakeMode: "next-heartbeat",
|
wakeMode: "next-heartbeat",
|
||||||
payload: { kind: "systemEvent", text: "auto" },
|
payload: { kind: "systemEvent", text: "auto" },
|
||||||
});
|
});
|
||||||
expect(addRes.ok).toBe(true);
|
expect(autoRes.ok).toBe(true);
|
||||||
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
|
const autoJobIdValue = (autoRes.payload as { id?: unknown } | null)?.id;
|
||||||
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
|
const autoJobId = typeof autoJobIdValue === "string" ? autoJobIdValue : "";
|
||||||
expect(jobId.length > 0).toBe(true);
|
expect(autoJobId.length > 0).toBe(true);
|
||||||
|
|
||||||
const waitForRuns = async () => {
|
vi.useFakeTimers();
|
||||||
for (let i = 0; i < 500; i += 1) {
|
try {
|
||||||
const runsRes = await rpcReq(ws, "cron.runs", {
|
const autoFinishedP = waitForCronFinished(ws, autoJobId);
|
||||||
id: jobId,
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
limit: 10,
|
await autoFinishedP;
|
||||||
});
|
} finally {
|
||||||
expect(runsRes.ok).toBe(true);
|
vi.useRealTimers();
|
||||||
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);
|
|
||||||
|
|
||||||
|
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();
|
ws.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
} finally {
|
|
||||||
testState.cronEnabled = false;
|
|
||||||
testState.cronStorePath = undefined;
|
|
||||||
await rmTempDir(dir);
|
await rmTempDir(dir);
|
||||||
|
testState.cronStorePath = undefined;
|
||||||
|
testState.cronEnabled = undefined;
|
||||||
}
|
}
|
||||||
}, 45_000);
|
}, 45_000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,161 +66,132 @@ const connectNodeClient = async (params: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("gateway node command allowlist", () => {
|
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();
|
const { server, ws, port } = await startServerWithClient();
|
||||||
await connectOk(ws);
|
await connectOk(ws);
|
||||||
|
|
||||||
const nodeClient = await connectNodeClient({
|
const waitForConnectedCount = async (count: number) => {
|
||||||
port,
|
await expect
|
||||||
commands: ["system.run"],
|
.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 getConnectedNodeId = async () => {
|
||||||
const nodeId = listRes.payload?.nodes?.[0]?.nodeId ?? "";
|
const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string; connected?: boolean }> }>(
|
||||||
expect(nodeId).toBeTruthy();
|
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", {
|
try {
|
||||||
nodeId,
|
const systemClient = await connectNodeClient({
|
||||||
command: "system.run",
|
port,
|
||||||
params: { command: "echo hi" },
|
commands: ["system.run"],
|
||||||
idempotencyKey: "allowlist-1",
|
instanceId: "node-system-run",
|
||||||
});
|
displayName: "node-system-run",
|
||||||
expect(res.ok).toBe(false);
|
});
|
||||||
expect(res.error?.message).toContain("node command not allowed");
|
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();
|
const emptyClient = await connectNodeClient({
|
||||||
ws.close();
|
port,
|
||||||
await server.close();
|
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 () => {
|
let resolveInvoke: ((payload: { id?: string; nodeId?: string }) => void) | null = null;
|
||||||
const { server, ws, port } = await startServerWithClient();
|
const waitForInvoke = () =>
|
||||||
await connectOk(ws);
|
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({
|
const invokeResP = rpcReq(ws, "node.invoke", {
|
||||||
port,
|
nodeId: allowedNodeId,
|
||||||
commands: [],
|
command: "canvas.snapshot",
|
||||||
instanceId: "node-empty",
|
params: { format: "png" },
|
||||||
displayName: "node-empty",
|
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 invokeNullResP = rpcReq(ws, "node.invoke", {
|
||||||
const nodeId = listRes.payload?.nodes?.find((entry) => entry.nodeId)?.nodeId ?? "";
|
nodeId: allowedNodeId,
|
||||||
expect(nodeId).toBeTruthy();
|
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", {
|
allowedClient.stop();
|
||||||
nodeId,
|
} finally {
|
||||||
command: "canvas.snapshot",
|
ws.close();
|
||||||
params: {},
|
await server.close();
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
installGatewayTestHooks();
|
installGatewayTestHooks();
|
||||||
|
|
||||||
describe("gateway role enforcement", () => {
|
describe("gateway role enforcement", () => {
|
||||||
test("operator cannot send node events or invoke results", async () => {
|
test("enforces operator and node permissions", async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws, port } = await startServerWithClient();
|
||||||
await connectOk(ws);
|
await connectOk(ws);
|
||||||
|
|
||||||
const eventRes = await rpcReq(ws, "node.event", { event: "test", payload: { ok: true } });
|
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.ok).toBe(false);
|
||||||
expect(invokeRes.error?.message ?? "").toContain("unauthorized role");
|
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}`);
|
const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||||
await new Promise<void>((resolve) => nodeWs.once("open", resolve));
|
await new Promise<void>((resolve) => nodeWs.once("open", resolve));
|
||||||
await connectOk(nodeWs, {
|
await connectOk(nodeWs, {
|
||||||
@@ -56,6 +50,7 @@ describe("gateway role enforcement", () => {
|
|||||||
expect(statusRes.error?.message ?? "").toContain("unauthorized role");
|
expect(statusRes.error?.message ?? "").toContain("unauthorized role");
|
||||||
|
|
||||||
nodeWs.close();
|
nodeWs.close();
|
||||||
|
ws.close();
|
||||||
await server.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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -126,7 +126,7 @@ describe("memory indexing with OpenAI batches", () => {
|
|||||||
store: { path: indexPath },
|
store: { path: indexPath },
|
||||||
sync: { watch: false, onSessionStart: false, onSearch: false },
|
sync: { watch: false, onSessionStart: false, onSearch: false },
|
||||||
query: { minScore: 0 },
|
query: { minScore: 0 },
|
||||||
remote: { batch: { enabled: true, wait: true } },
|
remote: { batch: { enabled: true, wait: true, pollIntervalMs: 1 } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
list: [{ id: "main", default: true }],
|
list: [{ id: "main", default: true }],
|
||||||
@@ -232,7 +232,7 @@ describe("memory indexing with OpenAI batches", () => {
|
|||||||
store: { path: indexPath },
|
store: { path: indexPath },
|
||||||
sync: { watch: false, onSessionStart: false, onSearch: false },
|
sync: { watch: false, onSessionStart: false, onSearch: false },
|
||||||
query: { minScore: 0 },
|
query: { minScore: 0 },
|
||||||
remote: { batch: { enabled: true, wait: true } },
|
remote: { batch: { enabled: true, wait: true, pollIntervalMs: 1 } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
list: [{ id: "main", default: true }],
|
list: [{ id: "main", default: true }],
|
||||||
@@ -329,7 +329,7 @@ describe("memory indexing with OpenAI batches", () => {
|
|||||||
store: { path: indexPath },
|
store: { path: indexPath },
|
||||||
sync: { watch: false, onSessionStart: false, onSearch: false },
|
sync: { watch: false, onSessionStart: false, onSearch: false },
|
||||||
query: { minScore: 0 },
|
query: { minScore: 0 },
|
||||||
remote: { batch: { enabled: true, wait: true } },
|
remote: { batch: { enabled: true, wait: true, pollIntervalMs: 1 } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
list: [{ id: "main", default: true }],
|
list: [{ id: "main", default: true }],
|
||||||
@@ -426,7 +426,7 @@ describe("memory indexing with OpenAI batches", () => {
|
|||||||
store: { path: indexPath },
|
store: { path: indexPath },
|
||||||
sync: { watch: false, onSessionStart: false, onSearch: false },
|
sync: { watch: false, onSessionStart: false, onSearch: false },
|
||||||
query: { minScore: 0 },
|
query: { minScore: 0 },
|
||||||
remote: { batch: { enabled: true, wait: true } },
|
remote: { batch: { enabled: true, wait: true, pollIntervalMs: 1 } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
list: [{ id: "main", default: true }],
|
list: [{ id: "main", default: true }],
|
||||||
|
|||||||
@@ -88,7 +88,11 @@ async function uploadSlackFile(params: {
|
|||||||
threadTs?: string;
|
threadTs?: string;
|
||||||
maxBytes?: number;
|
maxBytes?: number;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
const { buffer, fileName } = await loadWebMedia(params.mediaUrl, params.maxBytes);
|
const {
|
||||||
|
buffer,
|
||||||
|
contentType: _contentType,
|
||||||
|
fileName,
|
||||||
|
} = await loadWebMedia(params.mediaUrl, params.maxBytes);
|
||||||
const basePayload = {
|
const basePayload = {
|
||||||
channel_id: params.channelId,
|
channel_id: params.channelId,
|
||||||
file: buffer,
|
file: buffer,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||||
|
import { MEDIA_GROUP_TIMEOUT_MS } from "./bot-updates.js";
|
||||||
|
|
||||||
const useSpy = vi.fn();
|
const useSpy = vi.fn();
|
||||||
const middlewareUseSpy = vi.fn();
|
const middlewareUseSpy = vi.fn();
|
||||||
@@ -253,23 +254,15 @@ describe("telegram inbound media", () => {
|
|||||||
|
|
||||||
describe("telegram media groups", () => {
|
describe("telegram media groups", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// These tests rely on real setTimeout aggregation; guard against leaked fake timers.
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
const MEDIA_GROUP_POLL_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 15_000;
|
|
||||||
const MEDIA_GROUP_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 20_000;
|
const MEDIA_GROUP_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 20_000;
|
||||||
|
const MEDIA_GROUP_FLUSH_MS = MEDIA_GROUP_TIMEOUT_MS + 25;
|
||||||
const waitForMediaGroupProcessing = async (
|
|
||||||
replySpy: ReturnType<typeof vi.fn>,
|
|
||||||
expectedCalls: number,
|
|
||||||
) => {
|
|
||||||
await expect
|
|
||||||
.poll(() => replySpy.mock.calls.length, {
|
|
||||||
timeout: MEDIA_GROUP_POLL_TIMEOUT_MS,
|
|
||||||
})
|
|
||||||
.toBe(expectedCalls);
|
|
||||||
};
|
|
||||||
|
|
||||||
it(
|
it(
|
||||||
"buffers messages with same media_group_id and processes them together",
|
"buffers messages with same media_group_id and processes them together",
|
||||||
@@ -334,7 +327,7 @@ describe("telegram media groups", () => {
|
|||||||
await second;
|
await second;
|
||||||
|
|
||||||
expect(replySpy).not.toHaveBeenCalled();
|
expect(replySpy).not.toHaveBeenCalled();
|
||||||
await waitForMediaGroupProcessing(replySpy, 1);
|
await vi.advanceTimersByTimeAsync(MEDIA_GROUP_FLUSH_MS);
|
||||||
|
|
||||||
expect(runtimeError).not.toHaveBeenCalled();
|
expect(runtimeError).not.toHaveBeenCalled();
|
||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
@@ -400,7 +393,7 @@ describe("telegram media groups", () => {
|
|||||||
await Promise.all([first, second]);
|
await Promise.all([first, second]);
|
||||||
|
|
||||||
expect(replySpy).not.toHaveBeenCalled();
|
expect(replySpy).not.toHaveBeenCalled();
|
||||||
await waitForMediaGroupProcessing(replySpy, 2);
|
await vi.advanceTimersByTimeAsync(MEDIA_GROUP_FLUSH_MS);
|
||||||
|
|
||||||
expect(replySpy).toHaveBeenCalledTimes(2);
|
expect(replySpy).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
@@ -412,21 +405,15 @@ describe("telegram media groups", () => {
|
|||||||
|
|
||||||
describe("telegram text fragments", () => {
|
describe("telegram text fragments", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// These tests rely on real setTimeout aggregation; guard against leaked fake timers.
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
const TEXT_FRAGMENT_POLL_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 15_000;
|
|
||||||
const TEXT_FRAGMENT_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 20_000;
|
const TEXT_FRAGMENT_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 20_000;
|
||||||
|
const TEXT_FRAGMENT_FLUSH_MS = 1600;
|
||||||
const waitForFragmentProcessing = async (
|
|
||||||
replySpy: ReturnType<typeof vi.fn>,
|
|
||||||
expectedCalls: number,
|
|
||||||
) => {
|
|
||||||
await expect
|
|
||||||
.poll(() => replySpy.mock.calls.length, { timeout: TEXT_FRAGMENT_POLL_TIMEOUT_MS })
|
|
||||||
.toBe(expectedCalls);
|
|
||||||
};
|
|
||||||
|
|
||||||
it(
|
it(
|
||||||
"buffers near-limit text and processes sequential parts as one message",
|
"buffers near-limit text and processes sequential parts as one message",
|
||||||
@@ -470,7 +457,7 @@ describe("telegram text fragments", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(replySpy).not.toHaveBeenCalled();
|
expect(replySpy).not.toHaveBeenCalled();
|
||||||
await waitForFragmentProcessing(replySpy, 1);
|
await vi.advanceTimersByTimeAsync(TEXT_FRAGMENT_FLUSH_MS);
|
||||||
|
|
||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
const payload = replySpy.mock.calls[0][0] as { RawBody?: string; Body?: string };
|
const payload = replySpy.mock.calls[0][0] as { RawBody?: string; Body?: string };
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
@@ -5,7 +6,7 @@ import { defineConfig } from "vitest/config";
|
|||||||
const repoRoot = path.dirname(fileURLToPath(import.meta.url));
|
const repoRoot = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
|
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
|
||||||
const isWindows = process.platform === "win32";
|
const isWindows = process.platform === "win32";
|
||||||
const localWorkers = 4;
|
const localWorkers = Math.max(4, Math.min(8, os.cpus().length));
|
||||||
const ciWorkers = isWindows ? 2 : 3;
|
const ciWorkers = isWindows ? 2 : 3;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
Reference in New Issue
Block a user