refactor: split gateway server helpers and tests
This commit is contained in:
83
src/gateway/hooks.test.ts
Normal file
83
src/gateway/hooks.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { ClawdisConfig } from "../config/config.js";
|
||||
import {
|
||||
extractHookToken,
|
||||
normalizeAgentPayload,
|
||||
normalizeWakePayload,
|
||||
resolveHooksConfig,
|
||||
} from "./hooks.js";
|
||||
|
||||
describe("gateway hooks helpers", () => {
|
||||
test("resolveHooksConfig normalizes paths + requires token", () => {
|
||||
const base = {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
token: "secret",
|
||||
path: "hooks///",
|
||||
},
|
||||
} as ClawdisConfig;
|
||||
const resolved = resolveHooksConfig(base);
|
||||
expect(resolved?.basePath).toBe("/hooks");
|
||||
expect(resolved?.token).toBe("secret");
|
||||
});
|
||||
|
||||
test("resolveHooksConfig rejects root path", () => {
|
||||
const cfg = {
|
||||
hooks: { enabled: true, token: "x", path: "/" },
|
||||
} as ClawdisConfig;
|
||||
expect(() => resolveHooksConfig(cfg)).toThrow("hooks.path may not be '/'");
|
||||
});
|
||||
|
||||
test("extractHookToken prefers bearer > header > query", () => {
|
||||
const req = {
|
||||
headers: {
|
||||
authorization: "Bearer top",
|
||||
"x-clawdis-token": "header",
|
||||
},
|
||||
} as unknown as IncomingMessage;
|
||||
const url = new URL("http://localhost/hooks/wake?token=query");
|
||||
expect(extractHookToken(req, url)).toBe("top");
|
||||
|
||||
const req2 = {
|
||||
headers: { "x-clawdis-token": "header" },
|
||||
} as unknown as IncomingMessage;
|
||||
expect(extractHookToken(req2, url)).toBe("header");
|
||||
|
||||
const req3 = { headers: {} } as unknown as IncomingMessage;
|
||||
expect(extractHookToken(req3, url)).toBe("query");
|
||||
});
|
||||
|
||||
test("normalizeWakePayload trims + validates", () => {
|
||||
expect(normalizeWakePayload({ text: " hi " })).toEqual({
|
||||
ok: true,
|
||||
value: { text: "hi", mode: "now" },
|
||||
});
|
||||
expect(normalizeWakePayload({ text: " ", mode: "now" }).ok).toBe(false);
|
||||
});
|
||||
|
||||
test("normalizeAgentPayload defaults + validates channel", () => {
|
||||
const ok = normalizeAgentPayload(
|
||||
{ message: "hello" },
|
||||
{ idFactory: () => "fixed" },
|
||||
);
|
||||
expect(ok.ok).toBe(true);
|
||||
if (ok.ok) {
|
||||
expect(ok.value.sessionKey).toBe("hook:fixed");
|
||||
expect(ok.value.channel).toBe("last");
|
||||
expect(ok.value.name).toBe("Hook");
|
||||
}
|
||||
|
||||
const imsg = normalizeAgentPayload(
|
||||
{ message: "yo", channel: "imsg" },
|
||||
{ idFactory: () => "x" },
|
||||
);
|
||||
expect(imsg.ok).toBe(true);
|
||||
if (imsg.ok) {
|
||||
expect(imsg.value.channel).toBe("imessage");
|
||||
}
|
||||
|
||||
const bad = normalizeAgentPayload({ message: "yo", channel: "sms" });
|
||||
expect(bad.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
219
src/gateway/hooks.ts
Normal file
219
src/gateway/hooks.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { ClawdisConfig } from "../config/config.js";
|
||||
import {
|
||||
type HookMappingResolved,
|
||||
resolveHookMappings,
|
||||
} from "./hooks-mapping.js";
|
||||
|
||||
const DEFAULT_HOOKS_PATH = "/hooks";
|
||||
const DEFAULT_HOOKS_MAX_BODY_BYTES = 256 * 1024;
|
||||
|
||||
export type HooksConfigResolved = {
|
||||
basePath: string;
|
||||
token: string;
|
||||
maxBodyBytes: number;
|
||||
mappings: HookMappingResolved[];
|
||||
};
|
||||
|
||||
export function resolveHooksConfig(
|
||||
cfg: ClawdisConfig,
|
||||
): HooksConfigResolved | null {
|
||||
if (cfg.hooks?.enabled !== true) return null;
|
||||
const token = cfg.hooks?.token?.trim();
|
||||
if (!token) {
|
||||
throw new Error("hooks.enabled requires hooks.token");
|
||||
}
|
||||
const rawPath = cfg.hooks?.path?.trim() || DEFAULT_HOOKS_PATH;
|
||||
const withSlash = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
|
||||
const trimmed =
|
||||
withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
|
||||
if (trimmed === "/") {
|
||||
throw new Error("hooks.path may not be '/'");
|
||||
}
|
||||
const maxBodyBytes =
|
||||
cfg.hooks?.maxBodyBytes && cfg.hooks.maxBodyBytes > 0
|
||||
? cfg.hooks.maxBodyBytes
|
||||
: DEFAULT_HOOKS_MAX_BODY_BYTES;
|
||||
const mappings = resolveHookMappings(cfg.hooks);
|
||||
return {
|
||||
basePath: trimmed,
|
||||
token,
|
||||
maxBodyBytes,
|
||||
mappings,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractHookToken(
|
||||
req: IncomingMessage,
|
||||
url: URL,
|
||||
): string | undefined {
|
||||
const auth =
|
||||
typeof req.headers.authorization === "string"
|
||||
? req.headers.authorization.trim()
|
||||
: "";
|
||||
if (auth.toLowerCase().startsWith("bearer ")) {
|
||||
const token = auth.slice(7).trim();
|
||||
if (token) return token;
|
||||
}
|
||||
const headerToken =
|
||||
typeof req.headers["x-clawdis-token"] === "string"
|
||||
? req.headers["x-clawdis-token"].trim()
|
||||
: "";
|
||||
if (headerToken) return headerToken;
|
||||
const queryToken = url.searchParams.get("token");
|
||||
if (queryToken) return queryToken.trim();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function readJsonBody(
|
||||
req: IncomingMessage,
|
||||
maxBytes: number,
|
||||
): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> {
|
||||
return await new Promise((resolve) => {
|
||||
let done = false;
|
||||
let total = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
if (done) return;
|
||||
total += chunk.length;
|
||||
if (total > maxBytes) {
|
||||
done = true;
|
||||
resolve({ ok: false, error: "payload too large" });
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
const raw = Buffer.concat(chunks).toString("utf-8").trim();
|
||||
if (!raw) {
|
||||
resolve({ ok: true, value: {} });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
resolve({ ok: true, value: parsed });
|
||||
} catch (err) {
|
||||
resolve({ ok: false, error: String(err) });
|
||||
}
|
||||
});
|
||||
req.on("error", (err) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
resolve({ ok: false, error: String(err) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeHookHeaders(req: IncomingMessage) {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (typeof value === "string") {
|
||||
headers[key.toLowerCase()] = value;
|
||||
} else if (Array.isArray(value) && value.length > 0) {
|
||||
headers[key.toLowerCase()] = value.join(", ");
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function normalizeWakePayload(
|
||||
payload: Record<string, unknown>,
|
||||
):
|
||||
| { ok: true; value: { text: string; mode: "now" | "next-heartbeat" } }
|
||||
| { ok: false; error: string } {
|
||||
const text = typeof payload.text === "string" ? payload.text.trim() : "";
|
||||
if (!text) return { ok: false, error: "text required" };
|
||||
const mode = payload.mode === "next-heartbeat" ? "next-heartbeat" : "now";
|
||||
return { ok: true, value: { text, mode } };
|
||||
}
|
||||
|
||||
export type HookAgentPayload = {
|
||||
message: string;
|
||||
name: string;
|
||||
wakeMode: "now" | "next-heartbeat";
|
||||
sessionKey: string;
|
||||
deliver: boolean;
|
||||
channel: "last" | "whatsapp" | "telegram" | "discord" | "signal" | "imessage";
|
||||
to?: string;
|
||||
thinking?: string;
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
|
||||
export function normalizeAgentPayload(
|
||||
payload: Record<string, unknown>,
|
||||
opts?: { idFactory?: () => string },
|
||||
):
|
||||
| {
|
||||
ok: true;
|
||||
value: HookAgentPayload;
|
||||
}
|
||||
| { ok: false; error: string } {
|
||||
const message =
|
||||
typeof payload.message === "string" ? payload.message.trim() : "";
|
||||
if (!message) return { ok: false, error: "message required" };
|
||||
const nameRaw = payload.name;
|
||||
const name =
|
||||
typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : "Hook";
|
||||
const wakeMode =
|
||||
payload.wakeMode === "next-heartbeat" ? "next-heartbeat" : "now";
|
||||
const sessionKeyRaw = payload.sessionKey;
|
||||
const idFactory = opts?.idFactory ?? randomUUID;
|
||||
const sessionKey =
|
||||
typeof sessionKeyRaw === "string" && sessionKeyRaw.trim()
|
||||
? sessionKeyRaw.trim()
|
||||
: `hook:${idFactory()}`;
|
||||
const channelRaw = payload.channel;
|
||||
const channel =
|
||||
channelRaw === "whatsapp" ||
|
||||
channelRaw === "telegram" ||
|
||||
channelRaw === "discord" ||
|
||||
channelRaw === "signal" ||
|
||||
channelRaw === "imessage" ||
|
||||
channelRaw === "last"
|
||||
? channelRaw
|
||||
: channelRaw === "imsg"
|
||||
? "imessage"
|
||||
: channelRaw === undefined
|
||||
? "last"
|
||||
: null;
|
||||
if (channel === null) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "channel must be last|whatsapp|telegram|discord|signal|imessage",
|
||||
};
|
||||
}
|
||||
const toRaw = payload.to;
|
||||
const to =
|
||||
typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined;
|
||||
const deliver = payload.deliver === true;
|
||||
const thinkingRaw = payload.thinking;
|
||||
const thinking =
|
||||
typeof thinkingRaw === "string" && thinkingRaw.trim()
|
||||
? thinkingRaw.trim()
|
||||
: undefined;
|
||||
const timeoutRaw = payload.timeoutSeconds;
|
||||
const timeoutSeconds =
|
||||
typeof timeoutRaw === "number" &&
|
||||
Number.isFinite(timeoutRaw) &&
|
||||
timeoutRaw > 0
|
||||
? Math.floor(timeoutRaw)
|
||||
: undefined;
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
message,
|
||||
name,
|
||||
wakeMode,
|
||||
sessionKey,
|
||||
deliver,
|
||||
channel,
|
||||
to,
|
||||
thinking,
|
||||
timeoutSeconds,
|
||||
},
|
||||
};
|
||||
}
|
||||
25
src/gateway/net.ts
Normal file
25
src/gateway/net.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
||||
|
||||
export function isLoopbackAddress(ip: string | undefined): boolean {
|
||||
if (!ip) return false;
|
||||
if (ip === "127.0.0.1") return true;
|
||||
if (ip.startsWith("127.")) return true;
|
||||
if (ip === "::1") return true;
|
||||
if (ip.startsWith("::ffff:127.")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function resolveGatewayBindHost(
|
||||
bind: import("../config/config.js").BridgeBindMode | undefined,
|
||||
): string | null {
|
||||
const mode = bind ?? "loopback";
|
||||
if (mode === "loopback") return "127.0.0.1";
|
||||
if (mode === "lan") return "0.0.0.0";
|
||||
if (mode === "tailnet") return pickPrimaryTailnetIPv4() ?? null;
|
||||
if (mode === "auto") return pickPrimaryTailnetIPv4() ?? "0.0.0.0";
|
||||
return "127.0.0.1";
|
||||
}
|
||||
|
||||
export function isLoopbackHost(host: string): boolean {
|
||||
return isLoopbackAddress(host);
|
||||
}
|
||||
481
src/gateway/server.agent.test.ts
Normal file
481
src/gateway/server.agent.test.ts
Normal file
@@ -0,0 +1,481 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import {
|
||||
emitAgentEvent,
|
||||
registerAgentRunContext,
|
||||
} from "../infra/agent-events.js";
|
||||
import {
|
||||
agentCommand,
|
||||
connectOk,
|
||||
getFreePort,
|
||||
installGatewayTestHooks,
|
||||
onceMessage,
|
||||
rpcReq,
|
||||
startGatewayServer,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
describe("gateway server agent", () => {
|
||||
test("agent falls back to allowFrom when lastTo is stale", async () => {
|
||||
testState.allowFrom = ["+436769770569"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main-stale",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
channel: "last",
|
||||
deliver: true,
|
||||
idempotencyKey: "idem-agent-last-stale",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const spy = vi.mocked(agentCommand);
|
||||
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||
expect(call.provider).toBe("whatsapp");
|
||||
expect(call.to).toBe("+436769770569");
|
||||
expect(call.sessionId).toBe("sess-main-stale");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
testState.allowFrom = undefined;
|
||||
});
|
||||
|
||||
test("agent routes main last-channel whatsapp", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main-whatsapp",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
channel: "last",
|
||||
deliver: true,
|
||||
idempotencyKey: "idem-agent-last-whatsapp",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const spy = vi.mocked(agentCommand);
|
||||
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||
expect(call.provider).toBe("whatsapp");
|
||||
expect(call.to).toBe("+1555");
|
||||
expect(call.deliver).toBe(true);
|
||||
expect(call.bestEffortDeliver).toBe(true);
|
||||
expect(call.sessionId).toBe("sess-main-whatsapp");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent routes main last-channel telegram", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "telegram",
|
||||
lastTo: "123",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
channel: "last",
|
||||
deliver: true,
|
||||
idempotencyKey: "idem-agent-last",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const spy = vi.mocked(agentCommand);
|
||||
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||
expect(call.provider).toBe("telegram");
|
||||
expect(call.to).toBe("123");
|
||||
expect(call.deliver).toBe(true);
|
||||
expect(call.bestEffortDeliver).toBe(true);
|
||||
expect(call.sessionId).toBe("sess-main");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent routes main last-channel discord", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-discord",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "discord",
|
||||
lastTo: "channel:discord-123",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
channel: "last",
|
||||
deliver: true,
|
||||
idempotencyKey: "idem-agent-last-discord",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const spy = vi.mocked(agentCommand);
|
||||
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||
expect(call.provider).toBe("discord");
|
||||
expect(call.to).toBe("channel:discord-123");
|
||||
expect(call.deliver).toBe(true);
|
||||
expect(call.bestEffortDeliver).toBe(true);
|
||||
expect(call.sessionId).toBe("sess-discord");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent routes main last-channel signal", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-signal",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "signal",
|
||||
lastTo: "+15551234567",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
channel: "last",
|
||||
deliver: true,
|
||||
idempotencyKey: "idem-agent-last-signal",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const spy = vi.mocked(agentCommand);
|
||||
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||
expect(call.provider).toBe("signal");
|
||||
expect(call.to).toBe("+15551234567");
|
||||
expect(call.deliver).toBe(true);
|
||||
expect(call.bestEffortDeliver).toBe(true);
|
||||
expect(call.sessionId).toBe("sess-signal");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent ignores webchat last-channel for routing", async () => {
|
||||
testState.allowFrom = ["+1555"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main-webchat",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "webchat",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
channel: "last",
|
||||
deliver: true,
|
||||
idempotencyKey: "idem-agent-webchat",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const spy = vi.mocked(agentCommand);
|
||||
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||
expect(call.provider).toBe("whatsapp");
|
||||
expect(call.to).toBe("+1555");
|
||||
expect(call.deliver).toBe(true);
|
||||
expect(call.bestEffortDeliver).toBe(true);
|
||||
expect(call.sessionId).toBe("sess-main-webchat");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test(
|
||||
"agent ack response then final response",
|
||||
{ timeout: 8000 },
|
||||
async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const ackP = onceMessage(
|
||||
ws,
|
||||
(o) =>
|
||||
o.type === "res" &&
|
||||
o.id === "ag1" &&
|
||||
o.payload?.status === "accepted",
|
||||
);
|
||||
const finalP = onceMessage(
|
||||
ws,
|
||||
(o) =>
|
||||
o.type === "res" &&
|
||||
o.id === "ag1" &&
|
||||
o.payload?.status !== "accepted",
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "ag1",
|
||||
method: "agent",
|
||||
params: { message: "hi", idempotencyKey: "idem-ag" },
|
||||
}),
|
||||
);
|
||||
|
||||
const ack = await ackP;
|
||||
const final = await finalP;
|
||||
expect(ack.payload.runId).toBeDefined();
|
||||
expect(final.payload.runId).toBe(ack.payload.runId);
|
||||
expect(final.payload.status).toBe("ok");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
},
|
||||
);
|
||||
|
||||
test("agent dedupes by idempotencyKey after completion", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const firstFinalP = onceMessage(
|
||||
ws,
|
||||
(o) =>
|
||||
o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted",
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "ag1",
|
||||
method: "agent",
|
||||
params: { message: "hi", idempotencyKey: "same-agent" },
|
||||
}),
|
||||
);
|
||||
const firstFinal = await firstFinalP;
|
||||
|
||||
const secondP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag2");
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "ag2",
|
||||
method: "agent",
|
||||
params: { message: "hi again", idempotencyKey: "same-agent" },
|
||||
}),
|
||||
);
|
||||
const second = await secondP;
|
||||
expect(second.payload).toEqual(firstFinal.payload);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent dedupe survives reconnect", { timeout: 15000 }, async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
|
||||
const dial = async () => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
await connectOk(ws);
|
||||
return ws;
|
||||
};
|
||||
|
||||
const idem = "reconnect-agent";
|
||||
const ws1 = await dial();
|
||||
const final1P = onceMessage(
|
||||
ws1,
|
||||
(o) =>
|
||||
o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted",
|
||||
6000,
|
||||
);
|
||||
ws1.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "ag1",
|
||||
method: "agent",
|
||||
params: { message: "hi", idempotencyKey: idem },
|
||||
}),
|
||||
);
|
||||
const final1 = await final1P;
|
||||
ws1.close();
|
||||
|
||||
const ws2 = await dial();
|
||||
const final2P = onceMessage(
|
||||
ws2,
|
||||
(o) =>
|
||||
o.type === "res" && o.id === "ag2" && o.payload?.status !== "accepted",
|
||||
6000,
|
||||
);
|
||||
ws2.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "ag2",
|
||||
method: "agent",
|
||||
params: { message: "hi again", idempotencyKey: idem },
|
||||
}),
|
||||
);
|
||||
const res = await final2P;
|
||||
expect(res.payload).toEqual(final1.payload);
|
||||
ws2.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent events stream to webchat clients when run context is registered", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws, {
|
||||
client: {
|
||||
name: "webchat",
|
||||
version: "1.0.0",
|
||||
platform: "test",
|
||||
mode: "webchat",
|
||||
},
|
||||
});
|
||||
|
||||
registerAgentRunContext("run-auto-1", { sessionKey: "main" });
|
||||
|
||||
const finalChatP = onceMessage(
|
||||
ws,
|
||||
(o) => {
|
||||
if (o.type !== "event" || o.event !== "chat") return false;
|
||||
const payload = o.payload as
|
||||
| { state?: unknown; runId?: unknown }
|
||||
| undefined;
|
||||
return payload?.state === "final" && payload.runId === "run-auto-1";
|
||||
},
|
||||
8000,
|
||||
);
|
||||
|
||||
emitAgentEvent({
|
||||
runId: "run-auto-1",
|
||||
stream: "assistant",
|
||||
data: { text: "hi from agent" },
|
||||
});
|
||||
emitAgentEvent({
|
||||
runId: "run-auto-1",
|
||||
stream: "job",
|
||||
data: { state: "done" },
|
||||
});
|
||||
|
||||
const evt = await finalChatP;
|
||||
const payload =
|
||||
evt.payload && typeof evt.payload === "object"
|
||||
? (evt.payload as Record<string, unknown>)
|
||||
: {};
|
||||
expect(payload.sessionKey).toBe("main");
|
||||
expect(payload.runId).toBe("run-auto-1");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
126
src/gateway/server.auth.test.ts
Normal file
126
src/gateway/server.auth.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { CONFIG_PATH_CLAWDIS, STATE_DIR_CLAWDIS } from "../config/config.js";
|
||||
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||
import {
|
||||
connectReq,
|
||||
getFreePort,
|
||||
installGatewayTestHooks,
|
||||
onceMessage,
|
||||
startGatewayServer,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
describe("gateway server auth/connect", () => {
|
||||
test(
|
||||
"closes silent handshakes after timeout",
|
||||
{ timeout: 15_000 },
|
||||
async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const closed = await new Promise<boolean>((resolve) => {
|
||||
const timer = setTimeout(() => resolve(false), 12_000);
|
||||
ws.once("close", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
expect(closed).toBe(true);
|
||||
await server.close();
|
||||
},
|
||||
);
|
||||
|
||||
test("connect (req) handshake returns hello-ok payload", async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
|
||||
const res = await connectReq(ws);
|
||||
expect(res.ok).toBe(true);
|
||||
const payload = res.payload as
|
||||
| {
|
||||
type?: unknown;
|
||||
snapshot?: { configPath?: string; stateDir?: string };
|
||||
}
|
||||
| undefined;
|
||||
expect(payload?.type).toBe("hello-ok");
|
||||
expect(payload?.snapshot?.configPath).toBe(CONFIG_PATH_CLAWDIS);
|
||||
expect(payload?.snapshot?.stateDir).toBe(STATE_DIR_CLAWDIS);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("rejects protocol mismatch", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
try {
|
||||
const res = await connectReq(ws, {
|
||||
minProtocol: PROTOCOL_VERSION + 1,
|
||||
maxProtocol: PROTOCOL_VERSION + 2,
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
} catch {
|
||||
// If the server closed before we saw the frame, that's acceptable.
|
||||
}
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("rejects invalid token", async () => {
|
||||
const { server, ws, prevToken } = await startServerWithClient("secret");
|
||||
const res = await connectReq(ws, { token: "wrong" });
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("unauthorized");
|
||||
ws.close();
|
||||
await server.close();
|
||||
if (prevToken === undefined) {
|
||||
delete process.env.CLAWDIS_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.CLAWDIS_GATEWAY_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts password auth when configured", async () => {
|
||||
testState.gatewayAuth = { mode: "password", password: "secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
|
||||
const res = await connectReq(ws, { password: "secret" });
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("rejects invalid password", async () => {
|
||||
testState.gatewayAuth = { mode: "password", password: "secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
|
||||
const res = await connectReq(ws, { password: "wrong" });
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("unauthorized");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("rejects non-connect first request", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
ws.send(JSON.stringify({ type: "req", id: "h1", method: "health" }));
|
||||
const res = await onceMessage<{ ok: boolean; error?: unknown }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === "h1",
|
||||
);
|
||||
expect(res.ok).toBe(false);
|
||||
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
758
src/gateway/server.chat.test.ts
Normal file
758
src/gateway/server.chat.test.ts
Normal file
@@ -0,0 +1,758 @@
|
||||
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,
|
||||
piSdkMock,
|
||||
rpcReq,
|
||||
sessionStoreSaveDelayMs,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
describe("gateway server chat", () => {
|
||||
test("chat.send accepts image attachment", { timeout: 12000 }, async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
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",
|
||||
mimeType: "image/png",
|
||||
fileName: "dot.png",
|
||||
content:
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await onceMessage(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === reqId,
|
||||
8000,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.payload?.runId).toBeDefined();
|
||||
|
||||
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(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
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(dir, "sess-main.jsonl"),
|
||||
lines.join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const defaultRes = await rpcReq<{ messages?: unknown[] }>(
|
||||
ws,
|
||||
"chat.history",
|
||||
{
|
||||
sessionKey: "main",
|
||||
},
|
||||
);
|
||||
expect(defaultRes.ok).toBe(true);
|
||||
const defaultMsgs = defaultRes.payload?.messages ?? [];
|
||||
expect(defaultMsgs.length).toBe(200);
|
||||
expect(firstContentText(defaultMsgs[0])).toBe("m100");
|
||||
|
||||
const limitedRes = await rpcReq<{ messages?: unknown[] }>(
|
||||
ws,
|
||||
"chat.history",
|
||||
{
|
||||
sessionKey: "main",
|
||||
limit: 5,
|
||||
},
|
||||
);
|
||||
expect(limitedRes.ok).toBe(true);
|
||||
const limitedMsgs = limitedRes.payload?.messages ?? [];
|
||||
expect(limitedMsgs.length).toBe(5);
|
||||
expect(firstContentText(limitedMsgs[0])).toBe("m295");
|
||||
|
||||
const largeLines: string[] = [];
|
||||
for (let i = 0; i < 1500; i += 1) {
|
||||
largeLines.push(
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: `b${i}` }],
|
||||
timestamp: Date.now() + i,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
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 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(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
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();
|
||||
});
|
||||
|
||||
test("chat.history caps payload bytes", { timeout: 15_000 }, async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const bigText = "x".repeat(200_000);
|
||||
const largeLines: string[] = [];
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
largeLines.push(
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: `${i}:${bigText}` }],
|
||||
timestamp: Date.now() + i,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(dir, "sess-main.jsonl"),
|
||||
largeLines.join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const cappedRes = await rpcReq<{ messages?: unknown[] }>(
|
||||
ws,
|
||||
"chat.history",
|
||||
{ sessionKey: "main", limit: 1000 },
|
||||
);
|
||||
expect(cappedRes.ok).toBe(true);
|
||||
const cappedMsgs = cappedRes.payload?.messages ?? [];
|
||||
const bytes = Buffer.byteLength(JSON.stringify(cappedMsgs), "utf8");
|
||||
expect(bytes).toBeLessThanOrEqual(6 * 1024 * 1024);
|
||||
expect(cappedMsgs.length).toBeLessThan(60);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("chat.send does not overwrite last delivery route", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
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 {
|
||||
main?: { lastChannel?: string; lastTo?: string };
|
||||
};
|
||||
expect(stored.main?.lastChannel).toBe("whatsapp");
|
||||
expect(stored.main?.lastTo).toBe("+1555");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test(
|
||||
"chat.abort cancels an in-flight chat.send",
|
||||
{ timeout: 15000 },
|
||||
async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
let inFlight: Promise<unknown> | undefined;
|
||||
try {
|
||||
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-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,
|
||||
);
|
||||
inFlight = Promise.allSettled([sendResP, abortResP, abortedEventP]);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "send-abort-1",
|
||||
method: "chat.send",
|
||||
params: {
|
||||
sessionKey: "main",
|
||||
message: "hello",
|
||||
idempotencyKey: "idem-abort-1",
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
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 sendRes = await sendResP;
|
||||
expect(sendRes.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(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
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.abort returns aborted=false for unknown runId", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify({}, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
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(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
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(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
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(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
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: "sess-main",
|
||||
stream: "job",
|
||||
data: { state: "done" },
|
||||
});
|
||||
|
||||
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: "sess-main",
|
||||
stream: "job",
|
||||
data: { state: "done" },
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
296
src/gateway/server.cron.test.ts
Normal file
296
src/gateway/server.cron.test.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
describe("gateway server cron", () => {
|
||||
test("supports cron.add and cron.list", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-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 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(), "clawdis-gw-cron-log-"),
|
||||
);
|
||||
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() - 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);
|
||||
|
||||
const runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" });
|
||||
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 new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
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(), "clawdis-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 new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
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 });
|
||||
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(), "clawdis-gw-cron-default-on-"),
|
||||
);
|
||||
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
|
||||
testState.cronEnabled = undefined;
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(testState.cronStorePath), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(
|
||||
testState.cronStorePath,
|
||||
JSON.stringify({ version: 1, jobs: [] }),
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const statusRes = await rpcReq(ws, "cron.status", {});
|
||||
expect(statusRes.ok).toBe(true);
|
||||
const statusPayload = statusRes.payload as
|
||||
| { enabled?: unknown; storePath?: unknown }
|
||||
| undefined;
|
||||
expect(statusPayload?.enabled).toBe(true);
|
||||
const storePath =
|
||||
typeof statusPayload?.storePath === "string"
|
||||
? statusPayload.storePath
|
||||
: "";
|
||||
expect(storePath).toContain("jobs.json");
|
||||
|
||||
const atMs = Date.now() + 80;
|
||||
const addRes = await rpcReq(ws, "cron.add", {
|
||||
name: "auto run test",
|
||||
enabled: true,
|
||||
schedule: { kind: "at", atMs },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "systemEvent", text: "auto" },
|
||||
});
|
||||
expect(addRes.ok).toBe(true);
|
||||
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
|
||||
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
|
||||
expect(jobId.length > 0).toBe(true);
|
||||
|
||||
const finishedEvt = await new Promise<{
|
||||
type: "event";
|
||||
event: string;
|
||||
payload?: { jobId?: string; action?: string; status?: string } | null;
|
||||
}>((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(null as never), 8000);
|
||||
ws.on("message", (data) => {
|
||||
const obj = JSON.parse(String(data));
|
||||
if (
|
||||
obj.type === "event" &&
|
||||
obj.event === "cron" &&
|
||||
obj.payload?.jobId === jobId &&
|
||||
obj.payload?.action === "finished"
|
||||
) {
|
||||
clearTimeout(timeout);
|
||||
resolve(obj);
|
||||
}
|
||||
});
|
||||
});
|
||||
expect(finishedEvt.payload?.status).toBe("ok");
|
||||
|
||||
const waitForRuns = async () => {
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
const runsRes = await rpcReq(ws, "cron.runs", {
|
||||
id: jobId,
|
||||
limit: 10,
|
||||
});
|
||||
expect(runsRes.ok).toBe(true);
|
||||
const entries = (runsRes.payload as { entries?: unknown } | null)
|
||||
?.entries;
|
||||
if (Array.isArray(entries) && entries.length > 0) return entries;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
throw new Error("timeout waiting for cron.runs entries");
|
||||
};
|
||||
|
||||
const entries = (await waitForRuns()) as Array<{ jobId?: unknown }>;
|
||||
expect(entries.at(-1)?.jobId).toBe(jobId);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
} finally {
|
||||
testState.cronEnabled = false;
|
||||
testState.cronStorePath = undefined;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
310
src/gateway/server.health.test.ts
Normal file
310
src/gateway/server.health.test.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
|
||||
import {
|
||||
connectOk,
|
||||
getFreePort,
|
||||
installGatewayTestHooks,
|
||||
onceMessage,
|
||||
startGatewayServer,
|
||||
startServerWithClient,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
describe("gateway server health/presence", () => {
|
||||
test(
|
||||
"connect + health + presence + status succeed",
|
||||
{ timeout: 8000 },
|
||||
async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const healthP = onceMessage(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === "health1",
|
||||
);
|
||||
const statusP = onceMessage(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === "status1",
|
||||
);
|
||||
const presenceP = onceMessage(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === "presence1",
|
||||
);
|
||||
const providersP = onceMessage(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === "providers1",
|
||||
);
|
||||
|
||||
const sendReq = (id: string, method: string) =>
|
||||
ws.send(JSON.stringify({ type: "req", id, method }));
|
||||
sendReq("health1", "health");
|
||||
sendReq("status1", "status");
|
||||
sendReq("presence1", "system-presence");
|
||||
sendReq("providers1", "providers.status");
|
||||
|
||||
const health = await healthP;
|
||||
const status = await statusP;
|
||||
const presence = await presenceP;
|
||||
const providers = await providersP;
|
||||
expect(health.ok).toBe(true);
|
||||
expect(status.ok).toBe(true);
|
||||
expect(presence.ok).toBe(true);
|
||||
expect(providers.ok).toBe(true);
|
||||
expect(Array.isArray(presence.payload)).toBe(true);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
},
|
||||
);
|
||||
|
||||
test("broadcasts heartbeat events and serves last-heartbeat", async () => {
|
||||
type HeartbeatPayload = {
|
||||
ts: number;
|
||||
status: string;
|
||||
to?: string;
|
||||
preview?: string;
|
||||
durationMs?: number;
|
||||
hasMedia?: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
type EventFrame = {
|
||||
type: "event";
|
||||
event: string;
|
||||
payload?: HeartbeatPayload | null;
|
||||
};
|
||||
type ResFrame = {
|
||||
type: "res";
|
||||
id: string;
|
||||
ok: boolean;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const waitHeartbeat = onceMessage<EventFrame>(
|
||||
ws,
|
||||
(o) => o.type === "event" && o.event === "heartbeat",
|
||||
);
|
||||
emitHeartbeatEvent({ status: "sent", to: "+123", preview: "ping" });
|
||||
const evt = await waitHeartbeat;
|
||||
expect(evt.payload?.status).toBe("sent");
|
||||
expect(typeof evt.payload?.ts).toBe("number");
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "hb-last",
|
||||
method: "last-heartbeat",
|
||||
}),
|
||||
);
|
||||
const last = await onceMessage<ResFrame>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === "hb-last",
|
||||
);
|
||||
expect(last.ok).toBe(true);
|
||||
const lastPayload = last.payload as HeartbeatPayload | null | undefined;
|
||||
expect(lastPayload?.status).toBe("sent");
|
||||
expect(lastPayload?.ts).toBe(evt.payload?.ts);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "hb-toggle-off",
|
||||
method: "set-heartbeats",
|
||||
params: { enabled: false },
|
||||
}),
|
||||
);
|
||||
const toggle = await onceMessage<ResFrame>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === "hb-toggle-off",
|
||||
);
|
||||
expect(toggle.ok).toBe(true);
|
||||
expect((toggle.payload as { enabled?: boolean } | undefined)?.enabled).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test(
|
||||
"presence events carry seq + stateVersion",
|
||||
{ timeout: 8000 },
|
||||
async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const presenceEventP = onceMessage(
|
||||
ws,
|
||||
(o) => o.type === "event" && o.event === "presence",
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "evt-1",
|
||||
method: "system-event",
|
||||
params: { text: "note from test" },
|
||||
}),
|
||||
);
|
||||
|
||||
const evt = await presenceEventP;
|
||||
expect(typeof evt.seq).toBe("number");
|
||||
expect(evt.stateVersion?.presence).toBeGreaterThan(0);
|
||||
expect(Array.isArray(evt.payload?.presence)).toBe(true);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
},
|
||||
);
|
||||
|
||||
test("agent events stream with seq", { timeout: 8000 }, async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const runId = randomUUID();
|
||||
const evtPromise = onceMessage(
|
||||
ws,
|
||||
(o) =>
|
||||
o.type === "event" &&
|
||||
o.event === "agent" &&
|
||||
o.payload?.runId === runId &&
|
||||
o.payload?.stream === "job",
|
||||
);
|
||||
emitAgentEvent({ runId, stream: "job", data: { msg: "hi" } });
|
||||
const evt = await evtPromise;
|
||||
expect(evt.payload.runId).toBe(runId);
|
||||
expect(typeof evt.seq).toBe("number");
|
||||
expect(evt.payload.data.msg).toBe("hi");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("shutdown event is broadcast on close", { timeout: 8000 }, async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const shutdownP = onceMessage(
|
||||
ws,
|
||||
(o) => o.type === "event" && o.event === "shutdown",
|
||||
5000,
|
||||
);
|
||||
await server.close();
|
||||
const evt = await shutdownP;
|
||||
expect(evt.payload?.reason).toBeDefined();
|
||||
});
|
||||
|
||||
test(
|
||||
"presence broadcast reaches multiple clients",
|
||||
{ timeout: 8000 },
|
||||
async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const mkClient = async () => {
|
||||
const c = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => c.once("open", resolve));
|
||||
await connectOk(c);
|
||||
return c;
|
||||
};
|
||||
|
||||
const clients = await Promise.all([mkClient(), mkClient(), mkClient()]);
|
||||
const waits = clients.map((c) =>
|
||||
onceMessage(c, (o) => o.type === "event" && o.event === "presence"),
|
||||
);
|
||||
clients[0].send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "broadcast",
|
||||
method: "system-event",
|
||||
params: { text: "fanout" },
|
||||
}),
|
||||
);
|
||||
const events = await Promise.all(waits);
|
||||
for (const evt of events) {
|
||||
expect(evt.payload?.presence?.length).toBeGreaterThan(0);
|
||||
expect(typeof evt.seq).toBe("number");
|
||||
}
|
||||
for (const c of clients) c.close();
|
||||
await server.close();
|
||||
},
|
||||
);
|
||||
|
||||
test("presence includes client fingerprint", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws, {
|
||||
client: {
|
||||
name: "fingerprint",
|
||||
version: "9.9.9",
|
||||
platform: "test",
|
||||
deviceFamily: "iPad",
|
||||
modelIdentifier: "iPad16,6",
|
||||
mode: "ui",
|
||||
instanceId: "abc",
|
||||
},
|
||||
});
|
||||
|
||||
const presenceP = onceMessage(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === "fingerprint",
|
||||
4000,
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "fingerprint",
|
||||
method: "system-presence",
|
||||
}),
|
||||
);
|
||||
|
||||
const presenceRes = await presenceP;
|
||||
const entries = presenceRes.payload as Array<Record<string, unknown>>;
|
||||
const clientEntry = entries.find((e) => e.instanceId === "abc");
|
||||
expect(clientEntry?.host).toBe("fingerprint");
|
||||
expect(clientEntry?.version).toBe("9.9.9");
|
||||
expect(clientEntry?.mode).toBe("ui");
|
||||
expect(clientEntry?.deviceFamily).toBe("iPad");
|
||||
expect(clientEntry?.modelIdentifier).toBe("iPad16,6");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("cli connections are not tracked as instances", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const cliId = `cli-${randomUUID()}`;
|
||||
await connectOk(ws, {
|
||||
client: {
|
||||
name: "cli",
|
||||
version: "dev",
|
||||
platform: "test",
|
||||
mode: "cli",
|
||||
instanceId: cliId,
|
||||
},
|
||||
});
|
||||
|
||||
const presenceP = onceMessage(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === "cli-presence",
|
||||
4000,
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "cli-presence",
|
||||
method: "system-presence",
|
||||
}),
|
||||
);
|
||||
|
||||
const presenceRes = await presenceP;
|
||||
const entries = presenceRes.payload as Array<Record<string, unknown>>;
|
||||
expect(entries.some((e) => e.instanceId === cliId)).toBe(false);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
184
src/gateway/server.hooks.test.ts
Normal file
184
src/gateway/server.hooks.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js";
|
||||
import {
|
||||
cronIsolatedRun,
|
||||
getFreePort,
|
||||
installGatewayTestHooks,
|
||||
startGatewayServer,
|
||||
testState,
|
||||
waitForSystemEvent,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
describe("gateway server hooks", () => {
|
||||
test("hooks wake requires auth", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: "Ping" }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("hooks wake enqueues system event", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer hook-secret",
|
||||
},
|
||||
body: JSON.stringify({ text: "Ping", mode: "next-heartbeat" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const events = await waitForSystemEvent();
|
||||
expect(events.some((e) => e.includes("Ping"))).toBe(true);
|
||||
drainSystemEvents();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("hooks agent posts summary to main", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
cronIsolatedRun.mockResolvedValueOnce({
|
||||
status: "ok",
|
||||
summary: "done",
|
||||
});
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer hook-secret",
|
||||
},
|
||||
body: JSON.stringify({ message: "Do it", name: "Email" }),
|
||||
});
|
||||
expect(res.status).toBe(202);
|
||||
const events = await waitForSystemEvent();
|
||||
expect(events.some((e) => e.includes("Hook Email: done"))).toBe(true);
|
||||
drainSystemEvents();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("hooks wake accepts query token", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(
|
||||
`http://127.0.0.1:${port}/hooks/wake?token=hook-secret`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: "Query auth" }),
|
||||
},
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const events = await waitForSystemEvent();
|
||||
expect(events.some((e) => e.includes("Query auth"))).toBe(true);
|
||||
drainSystemEvents();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("hooks agent rejects invalid channel", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer hook-secret",
|
||||
},
|
||||
body: JSON.stringify({ message: "Nope", channel: "sms" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(peekSystemEvents().length).toBe(0);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("hooks wake accepts x-clawdis-token header", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-clawdis-token": "hook-secret",
|
||||
},
|
||||
body: JSON.stringify({ text: "Header auth" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const events = await waitForSystemEvent();
|
||||
expect(events.some((e) => e.includes("Header auth"))).toBe(true);
|
||||
drainSystemEvents();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("hooks rejects non-post", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer hook-secret" },
|
||||
});
|
||||
expect(res.status).toBe(405);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("hooks wake requires text", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer hook-secret",
|
||||
},
|
||||
body: JSON.stringify({ text: " " }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("hooks agent requires message", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer hook-secret",
|
||||
},
|
||||
body: JSON.stringify({ message: " " }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("hooks rejects invalid json", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer hook-secret",
|
||||
},
|
||||
body: "{",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
103
src/gateway/server.misc.test.ts
Normal file
103
src/gateway/server.misc.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { createServer } from "node:net";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { GatewayLockError } from "../infra/gateway-lock.js";
|
||||
import {
|
||||
connectOk,
|
||||
getFreePort,
|
||||
installGatewayTestHooks,
|
||||
occupyPort,
|
||||
onceMessage,
|
||||
startGatewayServer,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
testTailnetIPv4,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
describe("gateway server misc", () => {
|
||||
test("hello-ok advertises the gateway port for canvas host", async () => {
|
||||
const prevToken = process.env.CLAWDIS_GATEWAY_TOKEN;
|
||||
process.env.CLAWDIS_GATEWAY_TOKEN = "secret";
|
||||
testTailnetIPv4.value = "100.64.0.1";
|
||||
testState.gatewayBind = "lan";
|
||||
const canvasPort = await getFreePort();
|
||||
testState.canvasHostPort = canvasPort;
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port, {
|
||||
bind: "lan",
|
||||
allowCanvasHostInTests: true,
|
||||
});
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`, {
|
||||
headers: { Host: `100.64.0.1:${port}` },
|
||||
});
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
|
||||
const hello = await connectOk(ws, { token: "secret" });
|
||||
expect(hello.canvasHostUrl).toBe(`http://100.64.0.1:${canvasPort}`);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
if (prevToken === undefined) {
|
||||
delete process.env.CLAWDIS_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.CLAWDIS_GATEWAY_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
|
||||
test("send dedupes by idempotencyKey", { timeout: 8000 }, async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const idem = "same-key";
|
||||
const res1P = onceMessage(ws, (o) => o.type === "res" && o.id === "a1");
|
||||
const res2P = onceMessage(ws, (o) => o.type === "res" && o.id === "a2");
|
||||
const sendReq = (id: string) =>
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id,
|
||||
method: "send",
|
||||
params: { to: "+15550000000", message: "hi", idempotencyKey: idem },
|
||||
}),
|
||||
);
|
||||
sendReq("a1");
|
||||
sendReq("a2");
|
||||
|
||||
const res1 = await res1P;
|
||||
const res2 = await res2P;
|
||||
expect(res1.ok).toBe(true);
|
||||
expect(res2.ok).toBe(true);
|
||||
expect(res1.payload).toEqual(res2.payload);
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("refuses to start when port already bound", async () => {
|
||||
const { server: blocker, port } = await occupyPort();
|
||||
await expect(startGatewayServer(port)).rejects.toBeInstanceOf(
|
||||
GatewayLockError,
|
||||
);
|
||||
await expect(startGatewayServer(port)).rejects.toThrow(
|
||||
/already listening/i,
|
||||
);
|
||||
blocker.close();
|
||||
});
|
||||
|
||||
test("releases port after close", async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
await server.close();
|
||||
|
||||
const probe = createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
probe.once("error", reject);
|
||||
probe.listen(port, "127.0.0.1", () => resolve());
|
||||
});
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
probe.close((err) => (err ? reject(err) : resolve())),
|
||||
);
|
||||
});
|
||||
});
|
||||
262
src/gateway/server.models-voicewake.test.ts
Normal file
262
src/gateway/server.models-voicewake.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
bridgeListConnected,
|
||||
bridgeSendEvent,
|
||||
bridgeStartCalls,
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
onceMessage,
|
||||
piSdkMock,
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
describe("gateway server models + voicewake", () => {
|
||||
test(
|
||||
"voicewake.get returns defaults and voicewake.set broadcasts",
|
||||
{ timeout: 15_000 },
|
||||
async () => {
|
||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = homeDir;
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const initial = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get");
|
||||
expect(initial.ok).toBe(true);
|
||||
expect(initial.payload?.triggers).toEqual([
|
||||
"clawd",
|
||||
"claude",
|
||||
"computer",
|
||||
]);
|
||||
|
||||
const changedP = onceMessage<{
|
||||
type: "event";
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
}>(ws, (o) => o.type === "event" && o.event === "voicewake.changed");
|
||||
|
||||
const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", {
|
||||
triggers: [" hi ", "", "there"],
|
||||
});
|
||||
expect(setRes.ok).toBe(true);
|
||||
expect(setRes.payload?.triggers).toEqual(["hi", "there"]);
|
||||
|
||||
const changed = await changedP;
|
||||
expect(changed.event).toBe("voicewake.changed");
|
||||
expect(
|
||||
(changed.payload as { triggers?: unknown } | undefined)?.triggers,
|
||||
).toEqual(["hi", "there"]);
|
||||
|
||||
const after = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get");
|
||||
expect(after.ok).toBe(true);
|
||||
expect(after.payload?.triggers).toEqual(["hi", "there"]);
|
||||
|
||||
const onDisk = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(homeDir, ".clawdis", "settings", "voicewake.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as { triggers?: unknown; updatedAtMs?: unknown };
|
||||
expect(onDisk.triggers).toEqual(["hi", "there"]);
|
||||
expect(typeof onDisk.updatedAtMs).toBe("number");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
|
||||
if (prevHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = prevHome;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("pushes voicewake.changed to nodes on connect and on updates", async () => {
|
||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = homeDir;
|
||||
|
||||
bridgeSendEvent.mockClear();
|
||||
bridgeListConnected.mockReturnValue([{ nodeId: "n1" }]);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const startCall = bridgeStartCalls.at(-1);
|
||||
expect(startCall).toBeTruthy();
|
||||
|
||||
await startCall?.onAuthenticated?.({ nodeId: "n1" });
|
||||
|
||||
const first = bridgeSendEvent.mock.calls.find(
|
||||
(c) => c[0]?.event === "voicewake.changed" && c[0]?.nodeId === "n1",
|
||||
)?.[0] as { payloadJSON?: string | null } | undefined;
|
||||
expect(first?.payloadJSON).toBeTruthy();
|
||||
const firstPayload = JSON.parse(String(first?.payloadJSON)) as {
|
||||
triggers?: unknown;
|
||||
};
|
||||
expect(firstPayload.triggers).toEqual(["clawd", "claude", "computer"]);
|
||||
|
||||
bridgeSendEvent.mockClear();
|
||||
|
||||
const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", {
|
||||
triggers: ["clawd", "computer"],
|
||||
});
|
||||
expect(setRes.ok).toBe(true);
|
||||
|
||||
const broadcast = bridgeSendEvent.mock.calls.find(
|
||||
(c) => c[0]?.event === "voicewake.changed" && c[0]?.nodeId === "n1",
|
||||
)?.[0] as { payloadJSON?: string | null } | undefined;
|
||||
expect(broadcast?.payloadJSON).toBeTruthy();
|
||||
const broadcastPayload = JSON.parse(String(broadcast?.payloadJSON)) as {
|
||||
triggers?: unknown;
|
||||
};
|
||||
expect(broadcastPayload.triggers).toEqual(["clawd", "computer"]);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
|
||||
if (prevHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = prevHome;
|
||||
}
|
||||
});
|
||||
|
||||
test("models.list returns model catalog", async () => {
|
||||
piSdkMock.enabled = true;
|
||||
piSdkMock.models = [
|
||||
{ id: "gpt-test-z", provider: "openai", contextWindow: 0 },
|
||||
{
|
||||
id: "gpt-test-a",
|
||||
name: "A-Model",
|
||||
provider: "openai",
|
||||
contextWindow: 8000,
|
||||
},
|
||||
{
|
||||
id: "claude-test-b",
|
||||
name: "B-Model",
|
||||
provider: "anthropic",
|
||||
contextWindow: 1000,
|
||||
},
|
||||
{
|
||||
id: "claude-test-a",
|
||||
name: "A-Model",
|
||||
provider: "anthropic",
|
||||
contextWindow: 200_000,
|
||||
},
|
||||
];
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res1 = await rpcReq<{
|
||||
models: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
contextWindow?: number;
|
||||
}>;
|
||||
}>(ws, "models.list");
|
||||
|
||||
const res2 = await rpcReq<{
|
||||
models: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
contextWindow?: number;
|
||||
}>;
|
||||
}>(ws, "models.list");
|
||||
|
||||
expect(res1.ok).toBe(true);
|
||||
expect(res2.ok).toBe(true);
|
||||
|
||||
const models = res1.payload?.models ?? [];
|
||||
expect(models).toEqual([
|
||||
{
|
||||
id: "claude-test-a",
|
||||
name: "A-Model",
|
||||
provider: "anthropic",
|
||||
contextWindow: 200_000,
|
||||
},
|
||||
{
|
||||
id: "claude-test-b",
|
||||
name: "B-Model",
|
||||
provider: "anthropic",
|
||||
contextWindow: 1000,
|
||||
},
|
||||
{
|
||||
id: "gpt-test-a",
|
||||
name: "A-Model",
|
||||
provider: "openai",
|
||||
contextWindow: 8000,
|
||||
},
|
||||
{
|
||||
id: "gpt-test-z",
|
||||
name: "gpt-test-z",
|
||||
provider: "openai",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(piSdkMock.discoverCalls).toBe(1);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("models.list rejects unknown params", async () => {
|
||||
piSdkMock.enabled = true;
|
||||
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "models.list", { extra: true });
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toMatch(/invalid models\.list params/i);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("bridge RPC supports models.list and validates params", async () => {
|
||||
piSdkMock.enabled = true;
|
||||
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const startCall = bridgeStartCalls.at(-1);
|
||||
expect(startCall).toBeTruthy();
|
||||
|
||||
const okRes = await startCall?.onRequest?.("n1", {
|
||||
id: "1",
|
||||
method: "models.list",
|
||||
paramsJSON: "{}",
|
||||
});
|
||||
expect(okRes?.ok).toBe(true);
|
||||
const okPayload = JSON.parse(String(okRes?.payloadJSON ?? "{}")) as {
|
||||
models?: unknown;
|
||||
};
|
||||
expect(Array.isArray(okPayload.models)).toBe(true);
|
||||
|
||||
const badRes = await startCall?.onRequest?.("n1", {
|
||||
id: "2",
|
||||
method: "models.list",
|
||||
paramsJSON: JSON.stringify({ extra: true }),
|
||||
});
|
||||
expect(badRes?.ok).toBe(false);
|
||||
expect(badRes && "error" in badRes ? badRes.error.code : "").toBe(
|
||||
"INVALID_REQUEST",
|
||||
);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
913
src/gateway/server.node-bridge.test.ts
Normal file
913
src/gateway/server.node-bridge.test.ts
Normal file
@@ -0,0 +1,913 @@
|
||||
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,
|
||||
bridgeInvoke,
|
||||
bridgeListConnected,
|
||||
bridgeSendEvent,
|
||||
bridgeStartCalls,
|
||||
connectOk,
|
||||
getFreePort,
|
||||
installGatewayTestHooks,
|
||||
onceMessage,
|
||||
rpcReq,
|
||||
sessionStoreSaveDelayMs,
|
||||
startGatewayServer,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
describe("gateway server node/bridge", () => {
|
||||
test("supports gateway-owned node pairing methods and events", async () => {
|
||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = homeDir;
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const requestedP = new Promise<{
|
||||
type: "event";
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
}>((resolve) => {
|
||||
ws.on("message", (data) => {
|
||||
const obj = JSON.parse(String(data)) as {
|
||||
type?: string;
|
||||
event?: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
if (obj.type === "event" && obj.event === "node.pair.requested") {
|
||||
resolve(obj as never);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const res1 = await rpcReq(ws, "node.pair.request", {
|
||||
nodeId: "n1",
|
||||
displayName: "Node",
|
||||
});
|
||||
expect(res1.ok).toBe(true);
|
||||
const req1 = (res1.payload as { request?: { requestId?: unknown } } | null)
|
||||
?.request;
|
||||
const requestId = typeof req1?.requestId === "string" ? req1.requestId : "";
|
||||
expect(requestId.length).toBeGreaterThan(0);
|
||||
|
||||
const evt1 = await requestedP;
|
||||
expect(evt1.event).toBe("node.pair.requested");
|
||||
expect((evt1.payload as { requestId?: unknown } | null)?.requestId).toBe(
|
||||
requestId,
|
||||
);
|
||||
|
||||
const res2 = await rpcReq(ws, "node.pair.request", {
|
||||
nodeId: "n1",
|
||||
displayName: "Node",
|
||||
});
|
||||
expect(res2.ok).toBe(true);
|
||||
await expect(
|
||||
onceMessage(
|
||||
ws,
|
||||
(o) => o.type === "event" && o.event === "node.pair.requested",
|
||||
200,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
const resolvedP = new Promise<{
|
||||
type: "event";
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
}>((resolve) => {
|
||||
ws.on("message", (data) => {
|
||||
const obj = JSON.parse(String(data)) as {
|
||||
type?: string;
|
||||
event?: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
if (obj.type === "event" && obj.event === "node.pair.resolved") {
|
||||
resolve(obj as never);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const approveRes = await rpcReq(ws, "node.pair.approve", { requestId });
|
||||
expect(approveRes.ok).toBe(true);
|
||||
const tokenValue = (
|
||||
approveRes.payload as { node?: { token?: unknown } } | null
|
||||
)?.node?.token;
|
||||
const token = typeof tokenValue === "string" ? tokenValue : "";
|
||||
expect(token.length).toBeGreaterThan(0);
|
||||
|
||||
const evt2 = await resolvedP;
|
||||
expect((evt2.payload as { requestId?: unknown } | null)?.requestId).toBe(
|
||||
requestId,
|
||||
);
|
||||
expect((evt2.payload as { decision?: unknown } | null)?.decision).toBe(
|
||||
"approved",
|
||||
);
|
||||
|
||||
const verifyRes = await rpcReq(ws, "node.pair.verify", {
|
||||
nodeId: "n1",
|
||||
token,
|
||||
});
|
||||
expect(verifyRes.ok).toBe(true);
|
||||
expect((verifyRes.payload as { ok?: unknown } | null)?.ok).toBe(true);
|
||||
|
||||
const listRes = await rpcReq(ws, "node.pair.list", {});
|
||||
expect(listRes.ok).toBe(true);
|
||||
const paired = (listRes.payload as { paired?: unknown } | null)?.paired;
|
||||
expect(Array.isArray(paired)).toBe(true);
|
||||
expect(
|
||||
(paired as Array<{ nodeId?: unknown }>).some((n) => n.nodeId === "n1"),
|
||||
).toBe(true);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
await fs.rm(homeDir, { recursive: true, force: true });
|
||||
if (prevHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = prevHome;
|
||||
}
|
||||
});
|
||||
|
||||
test("routes node.invoke to the node bridge", async () => {
|
||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = homeDir;
|
||||
|
||||
try {
|
||||
bridgeInvoke.mockResolvedValueOnce({
|
||||
type: "invoke-res",
|
||||
id: "inv-1",
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({ result: "4" }),
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
try {
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "node.invoke", {
|
||||
nodeId: "ios-node",
|
||||
command: "canvas.eval",
|
||||
params: { javaScript: "2+2" },
|
||||
timeoutMs: 123,
|
||||
idempotencyKey: "idem-1",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
expect(bridgeInvoke).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nodeId: "ios-node",
|
||||
command: "canvas.eval",
|
||||
paramsJSON: JSON.stringify({ javaScript: "2+2" }),
|
||||
timeoutMs: 123,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
ws.close();
|
||||
await server.close();
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(homeDir, { recursive: true, force: true });
|
||||
if (prevHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = prevHome;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("routes camera.list invoke to the node bridge", async () => {
|
||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = homeDir;
|
||||
|
||||
try {
|
||||
bridgeInvoke.mockResolvedValueOnce({
|
||||
type: "invoke-res",
|
||||
id: "inv-2",
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({ devices: [] }),
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
try {
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "node.invoke", {
|
||||
nodeId: "ios-node",
|
||||
command: "camera.list",
|
||||
params: {},
|
||||
idempotencyKey: "idem-2",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
expect(bridgeInvoke).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nodeId: "ios-node",
|
||||
command: "camera.list",
|
||||
paramsJSON: JSON.stringify({}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
ws.close();
|
||||
await server.close();
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(homeDir, { recursive: true, force: true });
|
||||
if (prevHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = prevHome;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("node.describe returns supported invoke commands for paired nodes", async () => {
|
||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = homeDir;
|
||||
|
||||
try {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
try {
|
||||
await connectOk(ws);
|
||||
|
||||
const reqRes = await rpcReq<{
|
||||
status?: string;
|
||||
request?: { requestId?: string };
|
||||
}>(ws, "node.pair.request", {
|
||||
nodeId: "n1",
|
||||
displayName: "iPad",
|
||||
platform: "iPadOS",
|
||||
version: "dev",
|
||||
deviceFamily: "iPad",
|
||||
modelIdentifier: "iPad16,6",
|
||||
caps: ["canvas", "camera"],
|
||||
commands: ["canvas.eval", "canvas.snapshot", "camera.snap"],
|
||||
remoteIp: "10.0.0.10",
|
||||
});
|
||||
expect(reqRes.ok).toBe(true);
|
||||
const requestId = reqRes.payload?.request?.requestId;
|
||||
expect(typeof requestId).toBe("string");
|
||||
|
||||
const approveRes = await rpcReq(ws, "node.pair.approve", {
|
||||
requestId,
|
||||
});
|
||||
expect(approveRes.ok).toBe(true);
|
||||
|
||||
const describeRes = await rpcReq<{ commands?: string[] }>(
|
||||
ws,
|
||||
"node.describe",
|
||||
{ nodeId: "n1" },
|
||||
);
|
||||
expect(describeRes.ok).toBe(true);
|
||||
expect(describeRes.payload?.commands).toEqual([
|
||||
"camera.snap",
|
||||
"canvas.eval",
|
||||
"canvas.snapshot",
|
||||
]);
|
||||
} finally {
|
||||
ws.close();
|
||||
await server.close();
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(homeDir, { recursive: true, force: true });
|
||||
if (prevHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = prevHome;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("node.describe works for connected unpaired nodes (caps + commands)", async () => {
|
||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = homeDir;
|
||||
|
||||
try {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
try {
|
||||
await connectOk(ws);
|
||||
|
||||
bridgeListConnected.mockReturnValueOnce([
|
||||
{
|
||||
nodeId: "u1",
|
||||
displayName: "Unpaired Live",
|
||||
platform: "Android",
|
||||
version: "dev-live",
|
||||
remoteIp: "10.0.0.12",
|
||||
deviceFamily: "Android",
|
||||
modelIdentifier: "samsung SM-X926B",
|
||||
caps: ["canvas", "camera", "canvas"],
|
||||
commands: ["canvas.eval", "camera.snap", "canvas.eval"],
|
||||
},
|
||||
]);
|
||||
|
||||
const describeRes = await rpcReq<{
|
||||
paired?: boolean;
|
||||
connected?: boolean;
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
remoteIp?: string;
|
||||
}>(ws, "node.describe", { nodeId: "u1" });
|
||||
expect(describeRes.ok).toBe(true);
|
||||
expect(describeRes.payload).toMatchObject({
|
||||
paired: false,
|
||||
connected: true,
|
||||
deviceFamily: "Android",
|
||||
modelIdentifier: "samsung SM-X926B",
|
||||
remoteIp: "10.0.0.12",
|
||||
});
|
||||
expect(describeRes.payload?.caps).toEqual(["camera", "canvas"]);
|
||||
expect(describeRes.payload?.commands).toEqual([
|
||||
"camera.snap",
|
||||
"canvas.eval",
|
||||
]);
|
||||
} finally {
|
||||
ws.close();
|
||||
await server.close();
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(homeDir, { recursive: true, force: true });
|
||||
if (prevHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = prevHome;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("node.list includes connected unpaired nodes with capabilities + commands", async () => {
|
||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = homeDir;
|
||||
|
||||
try {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
try {
|
||||
await connectOk(ws);
|
||||
|
||||
const reqRes = await rpcReq<{
|
||||
status?: string;
|
||||
request?: { requestId?: string };
|
||||
}>(ws, "node.pair.request", {
|
||||
nodeId: "p1",
|
||||
displayName: "Paired",
|
||||
platform: "iPadOS",
|
||||
version: "dev",
|
||||
deviceFamily: "iPad",
|
||||
modelIdentifier: "iPad16,6",
|
||||
caps: ["canvas"],
|
||||
commands: ["canvas.eval"],
|
||||
remoteIp: "10.0.0.10",
|
||||
});
|
||||
expect(reqRes.ok).toBe(true);
|
||||
const requestId = reqRes.payload?.request?.requestId;
|
||||
expect(typeof requestId).toBe("string");
|
||||
|
||||
const approveRes = await rpcReq(ws, "node.pair.approve", { requestId });
|
||||
expect(approveRes.ok).toBe(true);
|
||||
|
||||
bridgeListConnected.mockReturnValueOnce([
|
||||
{
|
||||
nodeId: "p1",
|
||||
displayName: "Paired Live",
|
||||
platform: "iPadOS",
|
||||
version: "dev-live",
|
||||
remoteIp: "10.0.0.11",
|
||||
deviceFamily: "iPad",
|
||||
modelIdentifier: "iPad16,6",
|
||||
caps: ["canvas", "camera"],
|
||||
commands: ["canvas.snapshot", "canvas.eval"],
|
||||
},
|
||||
{
|
||||
nodeId: "u1",
|
||||
displayName: "Unpaired Live",
|
||||
platform: "Android",
|
||||
version: "dev",
|
||||
remoteIp: "10.0.0.12",
|
||||
deviceFamily: "Android",
|
||||
modelIdentifier: "samsung SM-X926B",
|
||||
caps: ["canvas"],
|
||||
commands: ["canvas.eval"],
|
||||
},
|
||||
]);
|
||||
|
||||
const listRes = await rpcReq<{
|
||||
nodes?: Array<{
|
||||
nodeId: string;
|
||||
paired?: boolean;
|
||||
connected?: boolean;
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
displayName?: string;
|
||||
remoteIp?: string;
|
||||
}>;
|
||||
}>(ws, "node.list", {});
|
||||
expect(listRes.ok).toBe(true);
|
||||
const nodes = listRes.payload?.nodes ?? [];
|
||||
|
||||
const pairedNode = nodes.find((n) => n.nodeId === "p1");
|
||||
expect(pairedNode).toMatchObject({
|
||||
nodeId: "p1",
|
||||
paired: true,
|
||||
connected: true,
|
||||
displayName: "Paired Live",
|
||||
remoteIp: "10.0.0.11",
|
||||
});
|
||||
expect(pairedNode?.caps?.slice().sort()).toEqual(["camera", "canvas"]);
|
||||
expect(pairedNode?.commands?.slice().sort()).toEqual([
|
||||
"canvas.eval",
|
||||
"canvas.snapshot",
|
||||
]);
|
||||
|
||||
const unpairedNode = nodes.find((n) => n.nodeId === "u1");
|
||||
expect(unpairedNode).toMatchObject({
|
||||
nodeId: "u1",
|
||||
paired: false,
|
||||
connected: true,
|
||||
displayName: "Unpaired Live",
|
||||
});
|
||||
expect(unpairedNode?.caps).toEqual(["canvas"]);
|
||||
expect(unpairedNode?.commands).toEqual(["canvas.eval"]);
|
||||
} finally {
|
||||
ws.close();
|
||||
await server.close();
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(homeDir, { recursive: true, force: true });
|
||||
if (prevHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = prevHome;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("emits presence updates for bridge connect/disconnect", async () => {
|
||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = homeDir;
|
||||
try {
|
||||
const before = bridgeStartCalls.length;
|
||||
const { server, ws } = await startServerWithClient();
|
||||
try {
|
||||
await connectOk(ws);
|
||||
const bridgeCall = bridgeStartCalls[before];
|
||||
expect(bridgeCall).toBeTruthy();
|
||||
|
||||
const waitPresenceReason = async (reason: string) => {
|
||||
await onceMessage(
|
||||
ws,
|
||||
(o) => {
|
||||
if (o.type !== "event" || o.event !== "presence") return false;
|
||||
const payload = o.payload as { presence?: unknown } | null;
|
||||
const list = payload?.presence;
|
||||
if (!Array.isArray(list)) return false;
|
||||
return list.some(
|
||||
(p) =>
|
||||
typeof p === "object" &&
|
||||
p !== null &&
|
||||
(p as { instanceId?: unknown }).instanceId === "node-1" &&
|
||||
(p as { reason?: unknown }).reason === reason,
|
||||
);
|
||||
},
|
||||
3000,
|
||||
);
|
||||
};
|
||||
|
||||
const presenceConnectedP = waitPresenceReason("node-connected");
|
||||
await bridgeCall?.onAuthenticated?.({
|
||||
nodeId: "node-1",
|
||||
displayName: "Node",
|
||||
platform: "ios",
|
||||
version: "1.0",
|
||||
remoteIp: "10.0.0.10",
|
||||
});
|
||||
await presenceConnectedP;
|
||||
|
||||
const presenceDisconnectedP = waitPresenceReason("node-disconnected");
|
||||
await bridgeCall?.onDisconnected?.({
|
||||
nodeId: "node-1",
|
||||
displayName: "Node",
|
||||
platform: "ios",
|
||||
version: "1.0",
|
||||
remoteIp: "10.0.0.10",
|
||||
});
|
||||
await presenceDisconnectedP;
|
||||
} finally {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await server.close();
|
||||
await fs.rm(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
if (prevHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = prevHome;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("bridge RPC chat.history returns session messages", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(dir, "sess-main.jsonl"),
|
||||
[
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}),
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const bridgeCall = bridgeStartCalls.at(-1);
|
||||
expect(bridgeCall?.onRequest).toBeDefined();
|
||||
|
||||
const res = await bridgeCall?.onRequest?.("ios-node", {
|
||||
id: "r1",
|
||||
method: "chat.history",
|
||||
paramsJSON: JSON.stringify({ sessionKey: "main" }),
|
||||
});
|
||||
|
||||
expect(res?.ok).toBe(true);
|
||||
const payload = JSON.parse(
|
||||
String((res as { payloadJSON?: string }).payloadJSON ?? "{}"),
|
||||
) as {
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
messages?: unknown[];
|
||||
};
|
||||
expect(payload.sessionKey).toBe("main");
|
||||
expect(payload.sessionId).toBe("sess-main");
|
||||
expect(Array.isArray(payload.messages)).toBe(true);
|
||||
expect(payload.messages?.length).toBeGreaterThan(0);
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("bridge RPC sessions.list returns session rows", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const bridgeCall = bridgeStartCalls.at(-1);
|
||||
expect(bridgeCall?.onRequest).toBeDefined();
|
||||
|
||||
const res = await bridgeCall?.onRequest?.("ios-node", {
|
||||
id: "r1",
|
||||
method: "sessions.list",
|
||||
paramsJSON: JSON.stringify({
|
||||
includeGlobal: true,
|
||||
includeUnknown: false,
|
||||
limit: 50,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res?.ok).toBe(true);
|
||||
const payload = JSON.parse(
|
||||
String((res as { payloadJSON?: string }).payloadJSON ?? "{}"),
|
||||
) as {
|
||||
sessions?: unknown[];
|
||||
count?: number;
|
||||
path?: string;
|
||||
};
|
||||
expect(Array.isArray(payload.sessions)).toBe(true);
|
||||
expect(typeof payload.count).toBe("number");
|
||||
expect(typeof payload.path).toBe("string");
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("bridge chat events are pushed to subscribed nodes", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const bridgeCall = bridgeStartCalls.at(-1);
|
||||
expect(bridgeCall?.onEvent).toBeDefined();
|
||||
expect(bridgeCall?.onRequest).toBeDefined();
|
||||
|
||||
await bridgeCall?.onEvent?.("ios-node", {
|
||||
event: "chat.subscribe",
|
||||
payloadJSON: JSON.stringify({ sessionKey: "main" }),
|
||||
});
|
||||
|
||||
bridgeSendEvent.mockClear();
|
||||
|
||||
const reqRes = await bridgeCall?.onRequest?.("ios-node", {
|
||||
id: "s1",
|
||||
method: "chat.send",
|
||||
paramsJSON: JSON.stringify({
|
||||
sessionKey: "main",
|
||||
message: "hello",
|
||||
idempotencyKey: "idem-bridge-chat",
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
});
|
||||
expect(reqRes?.ok).toBe(true);
|
||||
|
||||
emitAgentEvent({
|
||||
runId: "sess-main",
|
||||
seq: 1,
|
||||
ts: Date.now(),
|
||||
stream: "assistant",
|
||||
data: { text: "hi from agent" },
|
||||
});
|
||||
emitAgentEvent({
|
||||
runId: "sess-main",
|
||||
seq: 2,
|
||||
ts: Date.now(),
|
||||
stream: "job",
|
||||
data: { state: "done" },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
|
||||
expect(bridgeSendEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nodeId: "ios-node",
|
||||
event: "agent",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(bridgeSendEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nodeId: "ios-node",
|
||||
event: "chat",
|
||||
}),
|
||||
);
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("bridge voice transcript defaults to main session", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const bridgeCall = bridgeStartCalls.at(-1);
|
||||
expect(bridgeCall?.onEvent).toBeDefined();
|
||||
|
||||
const spy = vi.mocked(agentCommand);
|
||||
const beforeCalls = spy.mock.calls.length;
|
||||
|
||||
await bridgeCall?.onEvent?.("ios-node", {
|
||||
event: "voice.transcript",
|
||||
payloadJSON: JSON.stringify({ text: "hello" }),
|
||||
});
|
||||
|
||||
expect(spy.mock.calls.length).toBe(beforeCalls + 1);
|
||||
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||
expect(call.sessionId).toBe("sess-main");
|
||||
expect(call.deliver).toBe(false);
|
||||
expect(call.surface).toBe("Node");
|
||||
|
||||
const stored = JSON.parse(
|
||||
await fs.readFile(testState.sessionStorePath, "utf-8"),
|
||||
) as Record<string, { sessionId?: string } | undefined>;
|
||||
expect(stored.main?.sessionId).toBe("sess-main");
|
||||
expect(stored["node-ios-node"]).toBeUndefined();
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("bridge voice transcript triggers chat events for webchat clients", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws, {
|
||||
client: {
|
||||
name: "webchat",
|
||||
version: "1.0.0",
|
||||
platform: "test",
|
||||
mode: "webchat",
|
||||
},
|
||||
});
|
||||
|
||||
const bridgeCall = bridgeStartCalls.at(-1);
|
||||
expect(bridgeCall?.onEvent).toBeDefined();
|
||||
|
||||
const isVoiceFinalChatEvent = (o: unknown) => {
|
||||
if (!o || typeof o !== "object") return false;
|
||||
const rec = o as Record<string, unknown>;
|
||||
if (rec.type !== "event" || rec.event !== "chat") return false;
|
||||
if (!rec.payload || typeof rec.payload !== "object") return false;
|
||||
const payload = rec.payload as Record<string, unknown>;
|
||||
const runId = typeof payload.runId === "string" ? payload.runId : "";
|
||||
const state = typeof payload.state === "string" ? payload.state : "";
|
||||
return runId.startsWith("voice-") && state === "final";
|
||||
};
|
||||
|
||||
const finalChatP = new Promise<{
|
||||
type: "event";
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
}>((resolve) => {
|
||||
ws.on("message", (data) => {
|
||||
const obj = JSON.parse(String(data));
|
||||
if (isVoiceFinalChatEvent(obj)) {
|
||||
resolve(obj as never);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await bridgeCall?.onEvent?.("ios-node", {
|
||||
event: "voice.transcript",
|
||||
payloadJSON: JSON.stringify({ text: "hello", sessionKey: "main" }),
|
||||
});
|
||||
|
||||
emitAgentEvent({
|
||||
runId: "sess-main",
|
||||
seq: 1,
|
||||
ts: Date.now(),
|
||||
stream: "assistant",
|
||||
data: { text: "hi from agent" },
|
||||
});
|
||||
emitAgentEvent({
|
||||
runId: "sess-main",
|
||||
seq: 2,
|
||||
ts: Date.now(),
|
||||
stream: "job",
|
||||
data: { state: "done" },
|
||||
});
|
||||
|
||||
const evt = await finalChatP;
|
||||
const payload =
|
||||
evt.payload && typeof evt.payload === "object"
|
||||
? (evt.payload as Record<string, unknown>)
|
||||
: {};
|
||||
expect(payload.sessionKey).toBe("main");
|
||||
const message =
|
||||
payload.message && typeof payload.message === "object"
|
||||
? (payload.message as Record<string, unknown>)
|
||||
: {};
|
||||
expect(message.role).toBe("assistant");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("bridge chat.abort cancels while saving the session store", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
sessionStoreSaveDelayMs.value = 120;
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const bridgeCall = bridgeStartCalls.at(-1);
|
||||
expect(bridgeCall?.onRequest).toBeDefined();
|
||||
|
||||
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 sendP = bridgeCall?.onRequest?.("ios-node", {
|
||||
id: "send-abort-save-bridge-1",
|
||||
method: "chat.send",
|
||||
paramsJSON: JSON.stringify({
|
||||
sessionKey: "main",
|
||||
message: "hello",
|
||||
idempotencyKey: "idem-abort-save-bridge-1",
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
});
|
||||
|
||||
const abortRes = await bridgeCall?.onRequest?.("ios-node", {
|
||||
id: "abort-save-bridge-1",
|
||||
method: "chat.abort",
|
||||
paramsJSON: JSON.stringify({
|
||||
sessionKey: "main",
|
||||
runId: "idem-abort-save-bridge-1",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(abortRes?.ok).toBe(true);
|
||||
|
||||
const sendRes = await sendP;
|
||||
expect(sendRes?.ok).toBe(true);
|
||||
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
98
src/gateway/server.providers.test.ts
Normal file
98
src/gateway/server.providers.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
|
||||
import {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
describe("gateway server providers", () => {
|
||||
test("providers.status returns snapshot without probe", async () => {
|
||||
const prevToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq<{
|
||||
whatsapp?: { linked?: boolean };
|
||||
telegram?: {
|
||||
configured?: boolean;
|
||||
tokenSource?: string;
|
||||
probe?: unknown;
|
||||
lastProbeAt?: unknown;
|
||||
};
|
||||
signal?: {
|
||||
configured?: boolean;
|
||||
probe?: unknown;
|
||||
lastProbeAt?: unknown;
|
||||
};
|
||||
}>(ws, "providers.status", { probe: false, timeoutMs: 2000 });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.payload?.whatsapp).toBeTruthy();
|
||||
expect(res.payload?.telegram?.configured).toBe(false);
|
||||
expect(res.payload?.telegram?.tokenSource).toBe("none");
|
||||
expect(res.payload?.telegram?.probe).toBeUndefined();
|
||||
expect(res.payload?.telegram?.lastProbeAt).toBeNull();
|
||||
expect(res.payload?.signal?.configured).toBe(false);
|
||||
expect(res.payload?.signal?.probe).toBeUndefined();
|
||||
expect(res.payload?.signal?.lastProbeAt).toBeNull();
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
if (prevToken === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
|
||||
test("web.logout reports no session when missing", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq<{ cleared?: boolean }>(ws, "web.logout");
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.payload?.cleared).toBe(false);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("telegram.logout clears bot token from config", async () => {
|
||||
const prevToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
await writeConfigFile({
|
||||
telegram: {
|
||||
botToken: "123:abc",
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq<{ cleared?: boolean; envToken?: boolean }>(
|
||||
ws,
|
||||
"telegram.logout",
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.payload?.cleared).toBe(true);
|
||||
expect(res.payload?.envToken).toBe(false);
|
||||
|
||||
const snap = await readConfigFileSnapshot();
|
||||
expect(snap.valid).toBe(true);
|
||||
expect(snap.config?.telegram?.botToken).toBeUndefined();
|
||||
expect(snap.config?.telegram?.groups?.["*"]?.requireMention).toBe(false);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
if (prevToken === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
});
|
||||
211
src/gateway/server.sessions.test.ts
Normal file
211
src/gateway/server.sessions.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
piSdkMock,
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
describe("gateway server sessions", () => {
|
||||
test("lists and patches session store via sessions.* RPC", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-sessions-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
const now = Date.now();
|
||||
testState.sessionStorePath = storePath;
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(dir, "sess-main.jsonl"),
|
||||
`${Array.from({ length: 10 })
|
||||
.map((_, idx) =>
|
||||
JSON.stringify({ role: "user", content: `line ${idx}` }),
|
||||
)
|
||||
.join("\n")}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(dir, "sess-group.jsonl"),
|
||||
`${JSON.stringify({ role: "user", content: "group line 0" })}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: now - 30_000,
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
thinkingLevel: "low",
|
||||
verboseLevel: "on",
|
||||
},
|
||||
"discord:group:dev": {
|
||||
sessionId: "sess-group",
|
||||
updatedAt: now - 120_000,
|
||||
totalTokens: 50,
|
||||
},
|
||||
global: {
|
||||
sessionId: "sess-global",
|
||||
updatedAt: now - 10_000,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const hello = await connectOk(ws);
|
||||
expect(
|
||||
(hello as unknown as { features?: { methods?: string[] } }).features
|
||||
?.methods,
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
"sessions.list",
|
||||
"sessions.patch",
|
||||
"sessions.reset",
|
||||
"sessions.delete",
|
||||
"sessions.compact",
|
||||
]),
|
||||
);
|
||||
|
||||
const list1 = await rpcReq<{
|
||||
path: string;
|
||||
sessions: Array<{
|
||||
key: string;
|
||||
totalTokens?: number;
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
}>;
|
||||
}>(ws, "sessions.list", { includeGlobal: false, includeUnknown: false });
|
||||
|
||||
expect(list1.ok).toBe(true);
|
||||
expect(list1.payload?.path).toBe(storePath);
|
||||
expect(list1.payload?.sessions.some((s) => s.key === "global")).toBe(false);
|
||||
const main = list1.payload?.sessions.find((s) => s.key === "main");
|
||||
expect(main?.totalTokens).toBe(30);
|
||||
expect(main?.thinkingLevel).toBe("low");
|
||||
expect(main?.verboseLevel).toBe("on");
|
||||
|
||||
const active = await rpcReq<{
|
||||
sessions: Array<{ key: string }>;
|
||||
}>(ws, "sessions.list", {
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
activeMinutes: 1,
|
||||
});
|
||||
expect(active.ok).toBe(true);
|
||||
expect(active.payload?.sessions.map((s) => s.key)).toEqual(["main"]);
|
||||
|
||||
const limited = await rpcReq<{
|
||||
sessions: Array<{ key: string }>;
|
||||
}>(ws, "sessions.list", {
|
||||
includeGlobal: true,
|
||||
includeUnknown: false,
|
||||
limit: 1,
|
||||
});
|
||||
expect(limited.ok).toBe(true);
|
||||
expect(limited.payload?.sessions).toHaveLength(1);
|
||||
expect(limited.payload?.sessions[0]?.key).toBe("global");
|
||||
|
||||
const patched = await rpcReq<{ ok: true; key: string }>(
|
||||
ws,
|
||||
"sessions.patch",
|
||||
{ key: "main", thinkingLevel: "medium", verboseLevel: null },
|
||||
);
|
||||
expect(patched.ok).toBe(true);
|
||||
expect(patched.payload?.ok).toBe(true);
|
||||
expect(patched.payload?.key).toBe("main");
|
||||
|
||||
const list2 = await rpcReq<{
|
||||
sessions: Array<{
|
||||
key: string;
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
}>;
|
||||
}>(ws, "sessions.list", {});
|
||||
expect(list2.ok).toBe(true);
|
||||
const main2 = list2.payload?.sessions.find((s) => s.key === "main");
|
||||
expect(main2?.thinkingLevel).toBe("medium");
|
||||
expect(main2?.verboseLevel).toBeUndefined();
|
||||
|
||||
piSdkMock.enabled = true;
|
||||
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
|
||||
const modelPatched = await rpcReq<{
|
||||
ok: true;
|
||||
entry: { modelOverride?: string; providerOverride?: string };
|
||||
}>(ws, "sessions.patch", { key: "main", model: "openai/gpt-test-a" });
|
||||
expect(modelPatched.ok).toBe(true);
|
||||
expect(modelPatched.payload?.entry.modelOverride).toBe("gpt-test-a");
|
||||
expect(modelPatched.payload?.entry.providerOverride).toBe("openai");
|
||||
|
||||
const compacted = await rpcReq<{ ok: true; compacted: boolean }>(
|
||||
ws,
|
||||
"sessions.compact",
|
||||
{ key: "main", maxLines: 3 },
|
||||
);
|
||||
expect(compacted.ok).toBe(true);
|
||||
expect(compacted.payload?.compacted).toBe(true);
|
||||
const compactedLines = (
|
||||
await fs.readFile(path.join(dir, "sess-main.jsonl"), "utf-8")
|
||||
)
|
||||
.split(/\r?\n/)
|
||||
.filter((l) => l.trim().length > 0);
|
||||
expect(compactedLines).toHaveLength(3);
|
||||
const filesAfterCompact = await fs.readdir(dir);
|
||||
expect(
|
||||
filesAfterCompact.some((f) => f.startsWith("sess-main.jsonl.bak.")),
|
||||
).toBe(true);
|
||||
|
||||
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(
|
||||
ws,
|
||||
"sessions.delete",
|
||||
{ key: "discord:group:dev" },
|
||||
);
|
||||
expect(deleted.ok).toBe(true);
|
||||
expect(deleted.payload?.deleted).toBe(true);
|
||||
const listAfterDelete = await rpcReq<{
|
||||
sessions: Array<{ key: string }>;
|
||||
}>(ws, "sessions.list", {});
|
||||
expect(listAfterDelete.ok).toBe(true);
|
||||
expect(
|
||||
listAfterDelete.payload?.sessions.some(
|
||||
(s) => s.key === "discord:group:dev",
|
||||
),
|
||||
).toBe(false);
|
||||
const filesAfterDelete = await fs.readdir(dir);
|
||||
expect(
|
||||
filesAfterDelete.some((f) => f.startsWith("sess-group.jsonl.deleted.")),
|
||||
).toBe(true);
|
||||
|
||||
const reset = await rpcReq<{
|
||||
ok: true;
|
||||
key: string;
|
||||
entry: { sessionId: string };
|
||||
}>(ws, "sessions.reset", { key: "main" });
|
||||
expect(reset.ok).toBe(true);
|
||||
expect(reset.payload?.key).toBe("main");
|
||||
expect(reset.payload?.entry.sessionId).not.toBe("sess-main");
|
||||
|
||||
const badThinking = await rpcReq(ws, "sessions.patch", {
|
||||
key: "main",
|
||||
thinkingLevel: "banana",
|
||||
});
|
||||
expect(badThinking.ok).toBe(false);
|
||||
expect(
|
||||
(badThinking.error as { message?: unknown } | undefined)?.message ?? "",
|
||||
).toMatch(/invalid thinkinglevel/i);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
34
src/gateway/session-utils.test.ts
Normal file
34
src/gateway/session-utils.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import {
|
||||
capArrayByJsonBytes,
|
||||
classifySessionKey,
|
||||
parseGroupKey,
|
||||
} from "./session-utils.js";
|
||||
|
||||
describe("gateway session utils", () => {
|
||||
test("capArrayByJsonBytes trims from the front", () => {
|
||||
const res = capArrayByJsonBytes(["a", "b", "c"], 10);
|
||||
expect(res.items).toEqual(["b", "c"]);
|
||||
});
|
||||
|
||||
test("parseGroupKey handles group prefixes", () => {
|
||||
expect(parseGroupKey("group:abc")).toEqual({ id: "abc" });
|
||||
expect(parseGroupKey("discord:group:dev")).toEqual({
|
||||
surface: "discord",
|
||||
kind: "group",
|
||||
id: "dev",
|
||||
});
|
||||
expect(parseGroupKey("foo:bar")).toBeNull();
|
||||
});
|
||||
|
||||
test("classifySessionKey respects chat type + prefixes", () => {
|
||||
expect(classifySessionKey("global")).toBe("global");
|
||||
expect(classifySessionKey("unknown")).toBe("unknown");
|
||||
expect(classifySessionKey("group:abc")).toBe("group");
|
||||
expect(classifySessionKey("discord:group:dev")).toBe("group");
|
||||
expect(classifySessionKey("main")).toBe("direct");
|
||||
const entry = { chatType: "group" } as SessionEntry;
|
||||
expect(classifySessionKey("main", entry)).toBe("group");
|
||||
});
|
||||
});
|
||||
300
src/gateway/session-utils.ts
Normal file
300
src/gateway/session-utils.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { lookupContextTokens } from "../agents/context.js";
|
||||
import {
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_PROVIDER,
|
||||
} from "../agents/defaults.js";
|
||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||
import { type ClawdisConfig, loadConfig } from "../config/config.js";
|
||||
import {
|
||||
buildGroupDisplayName,
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
} from "../config/sessions.js";
|
||||
|
||||
export type GatewaySessionsDefaults = {
|
||||
model: string | null;
|
||||
contextTokens: number | null;
|
||||
};
|
||||
|
||||
export type GatewaySessionRow = {
|
||||
key: string;
|
||||
kind: "direct" | "group" | "global" | "unknown";
|
||||
displayName?: string;
|
||||
surface?: string;
|
||||
subject?: string;
|
||||
room?: string;
|
||||
space?: string;
|
||||
updatedAt: number | null;
|
||||
sessionId?: string;
|
||||
systemSent?: boolean;
|
||||
abortedLastRun?: boolean;
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
model?: string;
|
||||
contextTokens?: number;
|
||||
};
|
||||
|
||||
export type SessionsListResult = {
|
||||
ts: number;
|
||||
path: string;
|
||||
count: number;
|
||||
defaults: GatewaySessionsDefaults;
|
||||
sessions: GatewaySessionRow[];
|
||||
};
|
||||
|
||||
export type SessionsPatchResult = {
|
||||
ok: true;
|
||||
path: string;
|
||||
key: string;
|
||||
entry: SessionEntry;
|
||||
};
|
||||
|
||||
export function readSessionMessages(
|
||||
sessionId: string,
|
||||
storePath: string | undefined,
|
||||
): unknown[] {
|
||||
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath);
|
||||
|
||||
const filePath = candidates.find((p) => fs.existsSync(p));
|
||||
if (!filePath) return [];
|
||||
|
||||
const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/);
|
||||
const messages: unknown[] = [];
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (parsed?.message) {
|
||||
messages.push(parsed.message);
|
||||
}
|
||||
} catch {
|
||||
// ignore bad lines
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function resolveSessionTranscriptCandidates(
|
||||
sessionId: string,
|
||||
storePath: string | undefined,
|
||||
): string[] {
|
||||
const candidates: string[] = [];
|
||||
if (storePath) {
|
||||
const dir = path.dirname(storePath);
|
||||
candidates.push(path.join(dir, `${sessionId}.jsonl`));
|
||||
}
|
||||
candidates.push(
|
||||
path.join(os.homedir(), ".clawdis", "sessions", `${sessionId}.jsonl`),
|
||||
);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function archiveFileOnDisk(filePath: string, reason: string): string {
|
||||
const ts = new Date().toISOString().replaceAll(":", "-");
|
||||
const archived = `${filePath}.${reason}.${ts}`;
|
||||
fs.renameSync(filePath, archived);
|
||||
return archived;
|
||||
}
|
||||
|
||||
function jsonUtf8Bytes(value: unknown): number {
|
||||
try {
|
||||
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
||||
} catch {
|
||||
return Buffer.byteLength(String(value), "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
export function capArrayByJsonBytes<T>(
|
||||
items: T[],
|
||||
maxBytes: number,
|
||||
): { items: T[]; bytes: number } {
|
||||
if (items.length === 0) return { items, bytes: 2 };
|
||||
const parts = items.map((item) => jsonUtf8Bytes(item));
|
||||
let bytes = 2 + parts.reduce((a, b) => a + b, 0) + (items.length - 1);
|
||||
let start = 0;
|
||||
while (bytes > maxBytes && start < items.length - 1) {
|
||||
bytes -= parts[start] + 1;
|
||||
start += 1;
|
||||
}
|
||||
const next = start > 0 ? items.slice(start) : items;
|
||||
return { items: next, bytes };
|
||||
}
|
||||
|
||||
export function loadSessionEntry(sessionKey: string) {
|
||||
const cfg = loadConfig();
|
||||
const sessionCfg = cfg.session;
|
||||
const storePath = sessionCfg?.store
|
||||
? resolveStorePath(sessionCfg.store)
|
||||
: resolveStorePath(undefined);
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store[sessionKey];
|
||||
return { cfg, storePath, store, entry };
|
||||
}
|
||||
|
||||
export function classifySessionKey(
|
||||
key: string,
|
||||
entry?: SessionEntry,
|
||||
): GatewaySessionRow["kind"] {
|
||||
if (key === "global") return "global";
|
||||
if (key === "unknown") return "unknown";
|
||||
if (entry?.chatType === "group" || entry?.chatType === "room") return "group";
|
||||
if (
|
||||
key.startsWith("group:") ||
|
||||
key.includes(":group:") ||
|
||||
key.includes(":channel:")
|
||||
) {
|
||||
return "group";
|
||||
}
|
||||
return "direct";
|
||||
}
|
||||
|
||||
export function parseGroupKey(
|
||||
key: string,
|
||||
): { surface?: string; kind?: "group" | "channel"; id?: string } | null {
|
||||
if (key.startsWith("group:")) {
|
||||
const raw = key.slice("group:".length);
|
||||
return raw ? { id: raw } : null;
|
||||
}
|
||||
const parts = key.split(":").filter(Boolean);
|
||||
if (parts.length >= 3) {
|
||||
const [surface, kind, ...rest] = parts;
|
||||
if (kind === "group" || kind === "channel") {
|
||||
const id = rest.join(":");
|
||||
return { surface, kind, id };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getSessionDefaults(
|
||||
cfg: ClawdisConfig,
|
||||
): GatewaySessionsDefaults {
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const contextTokens =
|
||||
cfg.agent?.contextTokens ??
|
||||
lookupContextTokens(resolved.model) ??
|
||||
DEFAULT_CONTEXT_TOKENS;
|
||||
return {
|
||||
model: resolved.model ?? null,
|
||||
contextTokens: contextTokens ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSessionModelRef(
|
||||
cfg: ClawdisConfig,
|
||||
entry?: SessionEntry,
|
||||
): { provider: string; model: string } {
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
let provider = resolved.provider;
|
||||
let model = resolved.model;
|
||||
const storedModelOverride = entry?.modelOverride?.trim();
|
||||
if (storedModelOverride) {
|
||||
provider = entry?.providerOverride?.trim() || provider;
|
||||
model = storedModelOverride;
|
||||
}
|
||||
return { provider, model };
|
||||
}
|
||||
|
||||
export function listSessionsFromStore(params: {
|
||||
cfg: ClawdisConfig;
|
||||
storePath: string;
|
||||
store: Record<string, SessionEntry>;
|
||||
opts: import("./protocol/index.js").SessionsListParams;
|
||||
}): SessionsListResult {
|
||||
const { cfg, storePath, store, opts } = params;
|
||||
const now = Date.now();
|
||||
|
||||
const includeGlobal = opts.includeGlobal === true;
|
||||
const includeUnknown = opts.includeUnknown === true;
|
||||
const activeMinutes =
|
||||
typeof opts.activeMinutes === "number" &&
|
||||
Number.isFinite(opts.activeMinutes)
|
||||
? Math.max(1, Math.floor(opts.activeMinutes))
|
||||
: undefined;
|
||||
|
||||
let sessions = Object.entries(store)
|
||||
.filter(([key]) => {
|
||||
if (!includeGlobal && key === "global") return false;
|
||||
if (!includeUnknown && key === "unknown") return false;
|
||||
return true;
|
||||
})
|
||||
.map(([key, entry]) => {
|
||||
const updatedAt = entry?.updatedAt ?? null;
|
||||
const input = entry?.inputTokens ?? 0;
|
||||
const output = entry?.outputTokens ?? 0;
|
||||
const total = entry?.totalTokens ?? input + output;
|
||||
const parsed = parseGroupKey(key);
|
||||
const surface = entry?.surface ?? parsed?.surface;
|
||||
const subject = entry?.subject;
|
||||
const room = entry?.room;
|
||||
const space = entry?.space;
|
||||
const id = parsed?.id;
|
||||
const displayName =
|
||||
entry?.displayName ??
|
||||
(surface
|
||||
? buildGroupDisplayName({
|
||||
surface,
|
||||
subject,
|
||||
room,
|
||||
space,
|
||||
id,
|
||||
key,
|
||||
})
|
||||
: undefined);
|
||||
return {
|
||||
key,
|
||||
kind: classifySessionKey(key, entry),
|
||||
displayName,
|
||||
surface,
|
||||
subject,
|
||||
room,
|
||||
space,
|
||||
updatedAt,
|
||||
sessionId: entry?.sessionId,
|
||||
systemSent: entry?.systemSent,
|
||||
abortedLastRun: entry?.abortedLastRun,
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
inputTokens: entry?.inputTokens,
|
||||
outputTokens: entry?.outputTokens,
|
||||
totalTokens: total,
|
||||
model: entry?.model,
|
||||
contextTokens: entry?.contextTokens,
|
||||
} satisfies GatewaySessionRow;
|
||||
})
|
||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
|
||||
if (activeMinutes !== undefined) {
|
||||
const cutoff = now - activeMinutes * 60_000;
|
||||
sessions = sessions.filter((s) => (s.updatedAt ?? 0) >= cutoff);
|
||||
}
|
||||
|
||||
if (typeof opts.limit === "number" && Number.isFinite(opts.limit)) {
|
||||
const limit = Math.max(1, Math.floor(opts.limit));
|
||||
sessions = sessions.slice(0, limit);
|
||||
}
|
||||
|
||||
return {
|
||||
ts: now,
|
||||
path: storePath,
|
||||
count: sessions.length,
|
||||
defaults: getSessionDefaults(cfg),
|
||||
sessions,
|
||||
};
|
||||
}
|
||||
511
src/gateway/test-helpers.ts
Normal file
511
src/gateway/test-helpers.ts
Normal file
@@ -0,0 +1,511 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { type AddressInfo, createServer } from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, expect, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { agentCommand } from "../commands/agent.js";
|
||||
import { resetAgentRunContextForTest } from "../infra/agent-events.js";
|
||||
import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js";
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||
import type { GatewayServerOptions } from "./server.js";
|
||||
|
||||
export type BridgeClientInfo = {
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
remoteIp?: string;
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
};
|
||||
|
||||
export type BridgeStartOpts = {
|
||||
onAuthenticated?: (node: BridgeClientInfo) => Promise<void> | void;
|
||||
onDisconnected?: (node: BridgeClientInfo) => Promise<void> | void;
|
||||
onPairRequested?: (request: unknown) => Promise<void> | void;
|
||||
onEvent?: (
|
||||
nodeId: string,
|
||||
evt: { event: string; payloadJSON?: string | null },
|
||||
) => Promise<void> | void;
|
||||
onRequest?: (
|
||||
nodeId: string,
|
||||
req: { id: string; method: string; paramsJSON?: string | null },
|
||||
) => Promise<
|
||||
| { ok: true; payloadJSON?: string | null }
|
||||
| { ok: false; error: { code: string; message: string; details?: unknown } }
|
||||
>;
|
||||
};
|
||||
|
||||
export const bridgeStartCalls = vi.hoisted(() => [] as BridgeStartOpts[]);
|
||||
export const bridgeInvoke = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
type: "invoke-res",
|
||||
id: "1",
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({ ok: true }),
|
||||
error: null,
|
||||
})),
|
||||
);
|
||||
export const bridgeListConnected = vi.hoisted(() =>
|
||||
vi.fn(() => [] as BridgeClientInfo[]),
|
||||
);
|
||||
export const bridgeSendEvent = vi.hoisted(() => vi.fn());
|
||||
export const testTailnetIPv4 = vi.hoisted(() => ({
|
||||
value: undefined as string | undefined,
|
||||
}));
|
||||
|
||||
export const piSdkMock = vi.hoisted(() => ({
|
||||
enabled: false,
|
||||
discoverCalls: 0,
|
||||
models: [] as Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
provider: string;
|
||||
contextWindow?: number;
|
||||
reasoning?: boolean;
|
||||
}>,
|
||||
}));
|
||||
|
||||
export const cronIsolatedRun = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ status: "ok", summary: "ok" })),
|
||||
);
|
||||
|
||||
export const testState = {
|
||||
sessionStorePath: undefined as string | undefined,
|
||||
allowFrom: undefined as string[] | undefined,
|
||||
cronStorePath: undefined as string | undefined,
|
||||
cronEnabled: false as boolean | undefined,
|
||||
gatewayBind: undefined as "auto" | "lan" | "tailnet" | "loopback" | undefined,
|
||||
gatewayAuth: undefined as Record<string, unknown> | undefined,
|
||||
hooksConfig: undefined as Record<string, unknown> | undefined,
|
||||
canvasHostPort: undefined as number | undefined,
|
||||
legacyIssues: [] as Array<{ path: string; message: string }>,
|
||||
legacyParsed: {} as Record<string, unknown>,
|
||||
migrationConfig: null as Record<string, unknown> | null,
|
||||
migrationChanges: [] as string[],
|
||||
};
|
||||
|
||||
export const testIsNixMode = vi.hoisted(() => ({ value: false }));
|
||||
export const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 }));
|
||||
|
||||
vi.mock("@mariozechner/pi-coding-agent", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@mariozechner/pi-coding-agent")
|
||||
>("@mariozechner/pi-coding-agent");
|
||||
|
||||
return {
|
||||
...actual,
|
||||
discoverModels: () => {
|
||||
if (!piSdkMock.enabled) return actual.discoverModels();
|
||||
piSdkMock.discoverCalls += 1;
|
||||
return piSdkMock.models;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../infra/bridge/server.js", () => ({
|
||||
startNodeBridgeServer: vi.fn(async (opts: BridgeStartOpts) => {
|
||||
bridgeStartCalls.push(opts);
|
||||
return {
|
||||
port: 18790,
|
||||
close: async () => {},
|
||||
listConnected: bridgeListConnected,
|
||||
invoke: bridgeInvoke,
|
||||
sendEvent: bridgeSendEvent,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../cron/isolated-agent.js", () => ({
|
||||
runCronIsolatedAgentTurn: (...args: unknown[]) => cronIsolatedRun(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/tailnet.js", () => ({
|
||||
pickPrimaryTailnetIPv4: () => testTailnetIPv4.value,
|
||||
pickPrimaryTailnetIPv6: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../config/sessions.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/sessions.js")>(
|
||||
"../config/sessions.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
saveSessionStore: vi.fn(async (storePath: string, store: unknown) => {
|
||||
const delay = sessionStoreSaveDelayMs.value;
|
||||
if (delay > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
return actual.saveSessionStore(storePath, store as never);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>(
|
||||
"../config/config.js",
|
||||
);
|
||||
const resolveConfigPath = () =>
|
||||
path.join(os.homedir(), ".clawdis", "clawdis.json");
|
||||
|
||||
const readConfigFileSnapshot = async () => {
|
||||
if (testState.legacyIssues.length > 0) {
|
||||
return {
|
||||
path: resolveConfigPath(),
|
||||
exists: true,
|
||||
raw: JSON.stringify(testState.legacyParsed ?? {}),
|
||||
parsed: testState.legacyParsed ?? {},
|
||||
valid: false,
|
||||
config: {},
|
||||
issues: testState.legacyIssues.map((issue) => ({
|
||||
path: issue.path,
|
||||
message: issue.message,
|
||||
})),
|
||||
legacyIssues: testState.legacyIssues,
|
||||
};
|
||||
}
|
||||
const configPath = resolveConfigPath();
|
||||
try {
|
||||
await fs.access(configPath);
|
||||
} catch {
|
||||
return {
|
||||
path: configPath,
|
||||
exists: false,
|
||||
raw: null,
|
||||
parsed: {},
|
||||
valid: true,
|
||||
config: {},
|
||||
issues: [],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
try {
|
||||
const raw = await fs.readFile(configPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
path: configPath,
|
||||
exists: true,
|
||||
raw,
|
||||
parsed,
|
||||
valid: true,
|
||||
config: parsed,
|
||||
issues: [],
|
||||
legacyIssues: [],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
path: configPath,
|
||||
exists: true,
|
||||
raw: null,
|
||||
parsed: {},
|
||||
valid: false,
|
||||
config: {},
|
||||
issues: [{ path: "", message: `read failed: ${String(err)}` }],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const writeConfigFile = vi.fn(async (cfg: Record<string, unknown>) => {
|
||||
const configPath = resolveConfigPath();
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
const raw = JSON.stringify(cfg, null, 2).trimEnd().concat("\n");
|
||||
await fs.writeFile(configPath, raw, "utf-8");
|
||||
});
|
||||
|
||||
return {
|
||||
...actual,
|
||||
CONFIG_PATH_CLAWDIS: resolveConfigPath(),
|
||||
STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()),
|
||||
get isNixMode() {
|
||||
return testIsNixMode.value;
|
||||
},
|
||||
migrateLegacyConfig: (raw: unknown) => ({
|
||||
config: testState.migrationConfig ?? (raw as Record<string, unknown>),
|
||||
changes: testState.migrationChanges,
|
||||
}),
|
||||
loadConfig: () => ({
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: testState.allowFrom,
|
||||
},
|
||||
session: { mainKey: "main", store: testState.sessionStorePath },
|
||||
gateway: (() => {
|
||||
const gateway: Record<string, unknown> = {};
|
||||
if (testState.gatewayBind) gateway.bind = testState.gatewayBind;
|
||||
if (testState.gatewayAuth) gateway.auth = testState.gatewayAuth;
|
||||
return Object.keys(gateway).length > 0 ? gateway : undefined;
|
||||
})(),
|
||||
canvasHost: (() => {
|
||||
const canvasHost: Record<string, unknown> = {};
|
||||
if (typeof testState.canvasHostPort === "number")
|
||||
canvasHost.port = testState.canvasHostPort;
|
||||
return Object.keys(canvasHost).length > 0 ? canvasHost : undefined;
|
||||
})(),
|
||||
hooks: testState.hooksConfig,
|
||||
cron: (() => {
|
||||
const cron: Record<string, unknown> = {};
|
||||
if (typeof testState.cronEnabled === "boolean")
|
||||
cron.enabled = testState.cronEnabled;
|
||||
if (typeof testState.cronStorePath === "string")
|
||||
cron.store = testState.cronStorePath;
|
||||
return Object.keys(cron).length > 0 ? cron : undefined;
|
||||
})(),
|
||||
}),
|
||||
parseConfigJson5: (raw: string) => {
|
||||
try {
|
||||
return { ok: true, parsed: JSON.parse(raw) as unknown };
|
||||
} catch (err) {
|
||||
return { ok: false, error: String(err) };
|
||||
}
|
||||
},
|
||||
validateConfigObject: (parsed: unknown) => ({
|
||||
ok: true,
|
||||
config: parsed as Record<string, unknown>,
|
||||
issues: [],
|
||||
}),
|
||||
readConfigFileSnapshot,
|
||||
writeConfigFile,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../commands/health.js", () => ({
|
||||
getHealthSnapshot: vi.fn().mockResolvedValue({ ok: true, stub: true }),
|
||||
}));
|
||||
vi.mock("../commands/status.js", () => ({
|
||||
getStatusSummary: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}));
|
||||
vi.mock("../web/outbound.js", () => ({
|
||||
sendMessageWhatsApp: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
|
||||
}));
|
||||
vi.mock("../commands/agent.js", () => ({
|
||||
agentCommand: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
process.env.CLAWDIS_SKIP_PROVIDERS = "1";
|
||||
|
||||
let previousHome: string | undefined;
|
||||
let tempHome: string | undefined;
|
||||
|
||||
export function installGatewayTestHooks() {
|
||||
beforeEach(async () => {
|
||||
previousHome = process.env.HOME;
|
||||
tempHome = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdis-gateway-home-"),
|
||||
);
|
||||
process.env.HOME = tempHome;
|
||||
sessionStoreSaveDelayMs.value = 0;
|
||||
testTailnetIPv4.value = undefined;
|
||||
testState.gatewayBind = undefined;
|
||||
testState.gatewayAuth = undefined;
|
||||
testState.hooksConfig = undefined;
|
||||
testState.canvasHostPort = undefined;
|
||||
testState.legacyIssues = [];
|
||||
testState.legacyParsed = {};
|
||||
testState.migrationConfig = null;
|
||||
testState.migrationChanges = [];
|
||||
testState.cronEnabled = false;
|
||||
testState.cronStorePath = undefined;
|
||||
testState.sessionStorePath = undefined;
|
||||
testState.allowFrom = undefined;
|
||||
testIsNixMode.value = false;
|
||||
cronIsolatedRun.mockClear();
|
||||
drainSystemEvents();
|
||||
resetAgentRunContextForTest();
|
||||
const mod = await import("./server.js");
|
||||
mod.__resetModelCatalogCacheForTest();
|
||||
piSdkMock.enabled = false;
|
||||
piSdkMock.discoverCalls = 0;
|
||||
piSdkMock.models = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.HOME = previousHome;
|
||||
if (tempHome) {
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
tempHome = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getFreePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
server.close((err) => (err ? reject(err) : resolve(port)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function occupyPort(): Promise<{
|
||||
server: ReturnType<typeof createServer>;
|
||||
port: number;
|
||||
}> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
resolve({ server, port });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function onceMessage<T = unknown>(
|
||||
ws: WebSocket,
|
||||
filter: (obj: unknown) => boolean,
|
||||
timeoutMs = 3000,
|
||||
): Promise<T> {
|
||||
return 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}: ${reason.toString()}`));
|
||||
};
|
||||
const handler = (data: WebSocket.RawData) => {
|
||||
const obj = JSON.parse(rawDataToString(data));
|
||||
if (filter(obj)) {
|
||||
clearTimeout(timer);
|
||||
ws.off("message", handler);
|
||||
ws.off("close", closeHandler);
|
||||
resolve(obj as T);
|
||||
}
|
||||
};
|
||||
ws.on("message", handler);
|
||||
ws.once("close", closeHandler);
|
||||
});
|
||||
}
|
||||
|
||||
export async function startGatewayServer(
|
||||
port: number,
|
||||
opts?: GatewayServerOptions,
|
||||
) {
|
||||
const mod = await import("./server.js");
|
||||
return await mod.startGatewayServer(port, opts);
|
||||
}
|
||||
|
||||
export async function startServerWithClient(
|
||||
token?: string,
|
||||
opts?: GatewayServerOptions,
|
||||
) {
|
||||
const port = await getFreePort();
|
||||
const prev = process.env.CLAWDIS_GATEWAY_TOKEN;
|
||||
if (token === undefined) {
|
||||
delete process.env.CLAWDIS_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.CLAWDIS_GATEWAY_TOKEN = token;
|
||||
}
|
||||
const server = await startGatewayServer(port, opts);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
return { server, ws, port, prevToken: prev };
|
||||
}
|
||||
|
||||
type ConnectResponse = {
|
||||
type: "res";
|
||||
id: string;
|
||||
ok: boolean;
|
||||
payload?: unknown;
|
||||
error?: { message?: string };
|
||||
};
|
||||
|
||||
export async function connectReq(
|
||||
ws: WebSocket,
|
||||
opts?: {
|
||||
token?: string;
|
||||
password?: string;
|
||||
minProtocol?: number;
|
||||
maxProtocol?: number;
|
||||
client?: {
|
||||
name: string;
|
||||
version: string;
|
||||
platform: string;
|
||||
mode: string;
|
||||
instanceId?: string;
|
||||
};
|
||||
},
|
||||
): Promise<ConnectResponse> {
|
||||
const { randomUUID } = await import("node:crypto");
|
||||
const id = randomUUID();
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id,
|
||||
method: "connect",
|
||||
params: {
|
||||
minProtocol: opts?.minProtocol ?? PROTOCOL_VERSION,
|
||||
maxProtocol: opts?.maxProtocol ?? PROTOCOL_VERSION,
|
||||
client: opts?.client ?? {
|
||||
name: "test",
|
||||
version: "1.0.0",
|
||||
platform: "test",
|
||||
mode: "test",
|
||||
},
|
||||
caps: [],
|
||||
auth:
|
||||
opts?.token || opts?.password
|
||||
? {
|
||||
token: opts?.token,
|
||||
password: opts?.password,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return await onceMessage<ConnectResponse>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === id,
|
||||
);
|
||||
}
|
||||
|
||||
export async function connectOk(
|
||||
ws: WebSocket,
|
||||
opts?: Parameters<typeof connectReq>[1],
|
||||
) {
|
||||
const res = await connectReq(ws, opts);
|
||||
expect(res.ok).toBe(true);
|
||||
expect((res.payload as { type?: unknown } | undefined)?.type).toBe(
|
||||
"hello-ok",
|
||||
);
|
||||
return res.payload as { type: "hello-ok" };
|
||||
}
|
||||
|
||||
export async function rpcReq<T = unknown>(
|
||||
ws: WebSocket,
|
||||
method: string,
|
||||
params?: unknown,
|
||||
) {
|
||||
const { randomUUID } = await import("node:crypto");
|
||||
const id = randomUUID();
|
||||
ws.send(JSON.stringify({ type: "req", id, method, params }));
|
||||
return await onceMessage<{
|
||||
type: "res";
|
||||
id: string;
|
||||
ok: boolean;
|
||||
payload?: T;
|
||||
error?: { message?: string; code?: string };
|
||||
}>(ws, (o) => o.type === "res" && o.id === id);
|
||||
}
|
||||
|
||||
export async function waitForSystemEvent(timeoutMs = 2000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const events = peekSystemEvents();
|
||||
if (events.length > 0) return events;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error("timeout waiting for system event");
|
||||
}
|
||||
|
||||
export { agentCommand };
|
||||
53
src/gateway/ws-log.test.ts
Normal file
53
src/gateway/ws-log.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
formatForLog,
|
||||
shortId,
|
||||
summarizeAgentEventForWsLog,
|
||||
} from "./ws-log.js";
|
||||
|
||||
describe("gateway ws log helpers", () => {
|
||||
test("shortId compacts uuids and long strings", () => {
|
||||
expect(shortId("12345678-1234-1234-1234-123456789abc")).toBe(
|
||||
"12345678…9abc",
|
||||
);
|
||||
expect(shortId("a".repeat(30))).toBe("aaaaaaaaaaaa…aaaa");
|
||||
expect(shortId("short")).toBe("short");
|
||||
});
|
||||
|
||||
test("formatForLog formats errors and messages", () => {
|
||||
const err = new Error("boom");
|
||||
err.name = "TestError";
|
||||
expect(formatForLog(err)).toContain("TestError");
|
||||
expect(formatForLog(err)).toContain("boom");
|
||||
|
||||
const obj = { name: "Oops", message: "failed", code: "E1" };
|
||||
expect(formatForLog(obj)).toBe("Oops: failed: code=E1");
|
||||
});
|
||||
|
||||
test("summarizeAgentEventForWsLog extracts useful fields", () => {
|
||||
const summary = summarizeAgentEventForWsLog({
|
||||
runId: "12345678-1234-1234-1234-123456789abc",
|
||||
stream: "assistant",
|
||||
seq: 2,
|
||||
data: { text: "hello world", mediaUrls: ["a", "b"] },
|
||||
});
|
||||
expect(summary).toMatchObject({
|
||||
run: "12345678…9abc",
|
||||
stream: "assistant",
|
||||
aseq: 2,
|
||||
text: "hello world",
|
||||
media: 2,
|
||||
});
|
||||
|
||||
const tool = summarizeAgentEventForWsLog({
|
||||
runId: "run-1",
|
||||
stream: "tool",
|
||||
data: { phase: "start", name: "fetch", toolCallId: "call-1" },
|
||||
});
|
||||
expect(tool).toMatchObject({
|
||||
stream: "tool",
|
||||
tool: "start:fetch",
|
||||
call: "call-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
392
src/gateway/ws-log.ts
Normal file
392
src/gateway/ws-log.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import chalk from "chalk";
|
||||
import { isVerbose } from "../globals.js";
|
||||
import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js";
|
||||
|
||||
const LOG_VALUE_LIMIT = 240;
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
type WsInflightEntry = {
|
||||
ts: number;
|
||||
method?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const wsInflightCompact = new Map<string, WsInflightEntry>();
|
||||
let wsLastCompactConnId: string | undefined;
|
||||
const wsInflightOptimized = new Map<string, number>();
|
||||
const wsInflightSince = new Map<string, number>();
|
||||
|
||||
export function shortId(value: string): string {
|
||||
const s = value.trim();
|
||||
if (UUID_RE.test(s)) return `${s.slice(0, 8)}…${s.slice(-4)}`;
|
||||
if (s.length <= 24) return s;
|
||||
return `${s.slice(0, 12)}…${s.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function formatForLog(value: unknown): string {
|
||||
try {
|
||||
if (value instanceof Error) {
|
||||
const parts: string[] = [];
|
||||
if (value.name) parts.push(value.name);
|
||||
if (value.message) parts.push(value.message);
|
||||
const code =
|
||||
"code" in value &&
|
||||
(typeof value.code === "string" || typeof value.code === "number")
|
||||
? String(value.code)
|
||||
: "";
|
||||
if (code) parts.push(`code=${code}`);
|
||||
const combined = parts.filter(Boolean).join(": ").trim();
|
||||
if (combined) {
|
||||
return combined.length > LOG_VALUE_LIMIT
|
||||
? `${combined.slice(0, LOG_VALUE_LIMIT)}...`
|
||||
: combined;
|
||||
}
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const rec = value as Record<string, unknown>;
|
||||
if (typeof rec.message === "string" && rec.message.trim()) {
|
||||
const name = typeof rec.name === "string" ? rec.name.trim() : "";
|
||||
const code =
|
||||
typeof rec.code === "string" || typeof rec.code === "number"
|
||||
? String(rec.code)
|
||||
: "";
|
||||
const parts = [name, rec.message.trim()].filter(Boolean);
|
||||
if (code) parts.push(`code=${code}`);
|
||||
const combined = parts.join(": ").trim();
|
||||
return combined.length > LOG_VALUE_LIMIT
|
||||
? `${combined.slice(0, LOG_VALUE_LIMIT)}...`
|
||||
: combined;
|
||||
}
|
||||
}
|
||||
const str =
|
||||
typeof value === "string" || typeof value === "number"
|
||||
? String(value)
|
||||
: JSON.stringify(value);
|
||||
if (!str) return "";
|
||||
return str.length > LOG_VALUE_LIMIT
|
||||
? `${str.slice(0, LOG_VALUE_LIMIT)}...`
|
||||
: str;
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function compactPreview(input: string, maxLen = 160): string {
|
||||
const oneLine = input.replace(/\s+/g, " ").trim();
|
||||
if (oneLine.length <= maxLen) return oneLine;
|
||||
return `${oneLine.slice(0, Math.max(0, maxLen - 1))}…`;
|
||||
}
|
||||
|
||||
export function summarizeAgentEventForWsLog(
|
||||
payload: unknown,
|
||||
): Record<string, unknown> {
|
||||
if (!payload || typeof payload !== "object") return {};
|
||||
const rec = payload as Record<string, unknown>;
|
||||
const runId = typeof rec.runId === "string" ? rec.runId : undefined;
|
||||
const stream = typeof rec.stream === "string" ? rec.stream : undefined;
|
||||
const seq = typeof rec.seq === "number" ? rec.seq : undefined;
|
||||
const data =
|
||||
rec.data && typeof rec.data === "object"
|
||||
? (rec.data as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
const extra: Record<string, unknown> = {};
|
||||
if (runId) extra.run = shortId(runId);
|
||||
if (stream) extra.stream = stream;
|
||||
if (seq !== undefined) extra.aseq = seq;
|
||||
|
||||
if (!data) return extra;
|
||||
|
||||
if (stream === "assistant") {
|
||||
const text = typeof data.text === "string" ? data.text : undefined;
|
||||
if (text?.trim()) extra.text = compactPreview(text);
|
||||
const mediaUrls = Array.isArray(data.mediaUrls)
|
||||
? data.mediaUrls
|
||||
: undefined;
|
||||
if (mediaUrls && mediaUrls.length > 0) extra.media = mediaUrls.length;
|
||||
return extra;
|
||||
}
|
||||
|
||||
if (stream === "tool") {
|
||||
const phase = typeof data.phase === "string" ? data.phase : undefined;
|
||||
const name = typeof data.name === "string" ? data.name : undefined;
|
||||
if (phase || name) extra.tool = `${phase ?? "?"}:${name ?? "?"}`;
|
||||
const toolCallId =
|
||||
typeof data.toolCallId === "string" ? data.toolCallId : undefined;
|
||||
if (toolCallId) extra.call = shortId(toolCallId);
|
||||
const meta = typeof data.meta === "string" ? data.meta : undefined;
|
||||
if (meta?.trim()) extra.meta = meta;
|
||||
if (typeof data.isError === "boolean") extra.err = data.isError;
|
||||
return extra;
|
||||
}
|
||||
|
||||
if (stream === "job") {
|
||||
const state = typeof data.state === "string" ? data.state : undefined;
|
||||
if (state) extra.state = state;
|
||||
if (data.to === null) extra.to = null;
|
||||
else if (typeof data.to === "string") extra.to = data.to;
|
||||
if (typeof data.durationMs === "number")
|
||||
extra.ms = Math.round(data.durationMs);
|
||||
if (typeof data.aborted === "boolean") extra.aborted = data.aborted;
|
||||
const error = typeof data.error === "string" ? data.error : undefined;
|
||||
if (error?.trim()) extra.error = compactPreview(error, 120);
|
||||
return extra;
|
||||
}
|
||||
|
||||
const reason = typeof data.reason === "string" ? data.reason : undefined;
|
||||
if (reason?.trim()) extra.reason = reason;
|
||||
return extra;
|
||||
}
|
||||
|
||||
export function logWs(
|
||||
direction: "in" | "out",
|
||||
kind: string,
|
||||
meta?: Record<string, unknown>,
|
||||
) {
|
||||
const style = getGatewayWsLogStyle();
|
||||
if (!isVerbose()) {
|
||||
logWsOptimized(direction, kind, meta);
|
||||
return;
|
||||
}
|
||||
|
||||
if (style === "compact" || style === "auto") {
|
||||
logWsCompact(direction, kind, meta);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const connId = typeof meta?.connId === "string" ? meta.connId : undefined;
|
||||
const id = typeof meta?.id === "string" ? meta.id : undefined;
|
||||
const method = typeof meta?.method === "string" ? meta.method : undefined;
|
||||
const ok = typeof meta?.ok === "boolean" ? meta.ok : undefined;
|
||||
const event = typeof meta?.event === "string" ? meta.event : undefined;
|
||||
|
||||
const inflightKey = connId && id ? `${connId}:${id}` : undefined;
|
||||
if (direction === "in" && kind === "req" && inflightKey) {
|
||||
wsInflightSince.set(inflightKey, now);
|
||||
}
|
||||
const durationMs =
|
||||
direction === "out" && kind === "res" && inflightKey
|
||||
? (() => {
|
||||
const startedAt = wsInflightSince.get(inflightKey);
|
||||
if (startedAt === undefined) return undefined;
|
||||
wsInflightSince.delete(inflightKey);
|
||||
return now - startedAt;
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
const dirArrow = direction === "in" ? "←" : "→";
|
||||
const dirColor = direction === "in" ? chalk.greenBright : chalk.cyanBright;
|
||||
const prefix = `${chalk.gray("[gws]")} ${dirColor(dirArrow)} ${chalk.bold(kind)}`;
|
||||
|
||||
const headline =
|
||||
(kind === "req" || kind === "res") && method
|
||||
? chalk.bold(method)
|
||||
: kind === "event" && event
|
||||
? chalk.bold(event)
|
||||
: undefined;
|
||||
|
||||
const statusToken =
|
||||
kind === "res" && ok !== undefined
|
||||
? ok
|
||||
? chalk.greenBright("✓")
|
||||
: chalk.redBright("✗")
|
||||
: undefined;
|
||||
|
||||
const durationToken =
|
||||
typeof durationMs === "number" ? chalk.dim(`${durationMs}ms`) : undefined;
|
||||
|
||||
const restMeta: string[] = [];
|
||||
if (meta) {
|
||||
for (const [key, value] of Object.entries(meta)) {
|
||||
if (value === undefined) continue;
|
||||
if (key === "connId" || key === "id") continue;
|
||||
if (key === "method" || key === "ok") continue;
|
||||
if (key === "event") continue;
|
||||
restMeta.push(`${chalk.dim(key)}=${formatForLog(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const trailing: string[] = [];
|
||||
if (connId) {
|
||||
trailing.push(`${chalk.dim("conn")}=${chalk.gray(shortId(connId))}`);
|
||||
}
|
||||
if (id) trailing.push(`${chalk.dim("id")}=${chalk.gray(shortId(id))}`);
|
||||
|
||||
const tokens = [
|
||||
prefix,
|
||||
statusToken,
|
||||
headline,
|
||||
durationToken,
|
||||
...restMeta,
|
||||
...trailing,
|
||||
].filter((t): t is string => Boolean(t));
|
||||
|
||||
console.log(tokens.join(" "));
|
||||
}
|
||||
|
||||
function logWsOptimized(
|
||||
direction: "in" | "out",
|
||||
kind: string,
|
||||
meta?: Record<string, unknown>,
|
||||
) {
|
||||
const connId = typeof meta?.connId === "string" ? meta.connId : undefined;
|
||||
const id = typeof meta?.id === "string" ? meta.id : undefined;
|
||||
const ok = typeof meta?.ok === "boolean" ? meta.ok : undefined;
|
||||
const method = typeof meta?.method === "string" ? meta.method : undefined;
|
||||
|
||||
const inflightKey = connId && id ? `${connId}:${id}` : undefined;
|
||||
|
||||
if (direction === "in" && kind === "req" && inflightKey) {
|
||||
wsInflightOptimized.set(inflightKey, Date.now());
|
||||
if (wsInflightOptimized.size > 2000) wsInflightOptimized.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === "parse-error") {
|
||||
const errorMsg =
|
||||
typeof meta?.error === "string" ? formatForLog(meta.error) : undefined;
|
||||
console.log(
|
||||
[
|
||||
`${chalk.gray("[gws]")} ${chalk.redBright("✗")} ${chalk.bold("parse-error")}`,
|
||||
errorMsg ? `${chalk.dim("error")}=${errorMsg}` : undefined,
|
||||
`${chalk.dim("conn")}=${chalk.gray(shortId(connId ?? "?"))}`,
|
||||
]
|
||||
.filter((t): t is string => Boolean(t))
|
||||
.join(" "),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (direction !== "out" || kind !== "res") return;
|
||||
|
||||
const startedAt = inflightKey
|
||||
? wsInflightOptimized.get(inflightKey)
|
||||
: undefined;
|
||||
if (inflightKey) wsInflightOptimized.delete(inflightKey);
|
||||
const durationMs =
|
||||
typeof startedAt === "number" ? Date.now() - startedAt : undefined;
|
||||
|
||||
const shouldLog =
|
||||
ok === false ||
|
||||
(typeof durationMs === "number" && durationMs >= DEFAULT_WS_SLOW_MS);
|
||||
if (!shouldLog) return;
|
||||
|
||||
const statusToken =
|
||||
ok === undefined
|
||||
? undefined
|
||||
: ok
|
||||
? chalk.greenBright("✓")
|
||||
: chalk.redBright("✗");
|
||||
const durationToken =
|
||||
typeof durationMs === "number" ? chalk.dim(`${durationMs}ms`) : undefined;
|
||||
|
||||
const restMeta: string[] = [];
|
||||
if (meta) {
|
||||
for (const [key, value] of Object.entries(meta)) {
|
||||
if (value === undefined) continue;
|
||||
if (key === "connId" || key === "id") continue;
|
||||
if (key === "method" || key === "ok") continue;
|
||||
restMeta.push(`${chalk.dim(key)}=${formatForLog(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const tokens = [
|
||||
`${chalk.gray("[gws]")} ${chalk.yellowBright("⇄")} ${chalk.bold("res")}`,
|
||||
statusToken,
|
||||
method ? chalk.bold(method) : undefined,
|
||||
durationToken,
|
||||
...restMeta,
|
||||
connId ? `${chalk.dim("conn")}=${chalk.gray(shortId(connId))}` : undefined,
|
||||
id ? `${chalk.dim("id")}=${chalk.gray(shortId(id))}` : undefined,
|
||||
].filter((t): t is string => Boolean(t));
|
||||
|
||||
console.log(tokens.join(" "));
|
||||
}
|
||||
|
||||
function logWsCompact(
|
||||
direction: "in" | "out",
|
||||
kind: string,
|
||||
meta?: Record<string, unknown>,
|
||||
) {
|
||||
const now = Date.now();
|
||||
const connId = typeof meta?.connId === "string" ? meta.connId : undefined;
|
||||
const id = typeof meta?.id === "string" ? meta.id : undefined;
|
||||
const method = typeof meta?.method === "string" ? meta.method : undefined;
|
||||
const ok = typeof meta?.ok === "boolean" ? meta.ok : undefined;
|
||||
const inflightKey = connId && id ? `${connId}:${id}` : undefined;
|
||||
|
||||
if (kind === "req" && direction === "in" && inflightKey) {
|
||||
wsInflightCompact.set(inflightKey, { ts: now, method, meta });
|
||||
return;
|
||||
}
|
||||
|
||||
const compactArrow = (() => {
|
||||
if (kind === "req" || kind === "res") return "⇄";
|
||||
return direction === "in" ? "←" : "→";
|
||||
})();
|
||||
const arrowColor =
|
||||
kind === "req" || kind === "res"
|
||||
? chalk.yellowBright
|
||||
: direction === "in"
|
||||
? chalk.greenBright
|
||||
: chalk.cyanBright;
|
||||
|
||||
const prefix = `${chalk.gray("[gws]")} ${arrowColor(compactArrow)} ${chalk.bold(kind)}`;
|
||||
|
||||
const statusToken =
|
||||
kind === "res" && ok !== undefined
|
||||
? ok
|
||||
? chalk.greenBright("✓")
|
||||
: chalk.redBright("✗")
|
||||
: undefined;
|
||||
|
||||
const startedAt =
|
||||
kind === "res" && direction === "out" && inflightKey
|
||||
? wsInflightCompact.get(inflightKey)?.ts
|
||||
: undefined;
|
||||
if (kind === "res" && direction === "out" && inflightKey) {
|
||||
wsInflightCompact.delete(inflightKey);
|
||||
}
|
||||
const durationToken =
|
||||
typeof startedAt === "number"
|
||||
? chalk.dim(`${now - startedAt}ms`)
|
||||
: undefined;
|
||||
|
||||
const headline =
|
||||
(kind === "req" || kind === "res") && method
|
||||
? chalk.bold(method)
|
||||
: kind === "event" && typeof meta?.event === "string"
|
||||
? chalk.bold(meta.event)
|
||||
: undefined;
|
||||
|
||||
const restMeta: string[] = [];
|
||||
if (meta) {
|
||||
for (const [key, value] of Object.entries(meta)) {
|
||||
if (value === undefined) continue;
|
||||
if (key === "connId" || key === "id") continue;
|
||||
if (key === "method" || key === "ok") continue;
|
||||
if (key === "event") continue;
|
||||
restMeta.push(`${chalk.dim(key)}=${formatForLog(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const trailing: string[] = [];
|
||||
if (connId && connId !== wsLastCompactConnId) {
|
||||
trailing.push(`${chalk.dim("conn")}=${chalk.gray(shortId(connId))}`);
|
||||
wsLastCompactConnId = connId;
|
||||
}
|
||||
if (id) trailing.push(`${chalk.dim("id")}=${chalk.gray(shortId(id))}`);
|
||||
|
||||
const tokens = [
|
||||
prefix,
|
||||
statusToken,
|
||||
headline,
|
||||
durationToken,
|
||||
...restMeta,
|
||||
...trailing,
|
||||
].filter((t): t is string => Boolean(t));
|
||||
|
||||
console.log(tokens.join(" "));
|
||||
}
|
||||
Reference in New Issue
Block a user