refactor: split gateway server helpers and tests

This commit is contained in:
Peter Steinberger
2026-01-03 17:34:52 +01:00
parent 00c3e98431
commit 6ae51ae3de
21 changed files with 5385 additions and 5427 deletions

83
src/gateway/hooks.test.ts Normal file
View 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
View 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
View 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);
}

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

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

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

View 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 });
}
});
});

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

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

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

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

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

View 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;
}
});
});

View 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

View 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");
});
});

View 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
View 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 };

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