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 { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { agentCommand, bridgeStartCalls, connectOk, getFreePort, installGatewayTestHooks, sessionStoreSaveDelayMs, startGatewayServer, startServerWithClient, testState, } from "./test-helpers.js"; const decodeWsData = (data: unknown): string => { if (typeof data === "string") return data; if (Buffer.isBuffer(data)) return data.toString("utf-8"); if (Array.isArray(data)) return Buffer.concat(data).toString("utf-8"); if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf-8"); if (ArrayBuffer.isView(data)) { return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf-8"); } return ""; }; async function _waitFor(condition: () => boolean, timeoutMs = 1500) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (condition()) return; await new Promise((r) => setTimeout(r, 5)); } throw new Error("timeout waiting for condition"); } installGatewayTestHooks(); describe("gateway server node/bridge", () => { test("bridge voice transcript defaults to main session", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-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; expect(call.sessionId).toBe("sess-main"); expect(call.sessionKey).toBe("main"); expect(call.deliver).toBe(false); expect(call.messageChannel).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(), "clawdbot-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: { id: GATEWAY_CLIENT_NAMES.WEBCHAT, version: "1.0.0", platform: "test", mode: GATEWAY_CLIENT_MODES.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; if (rec.type !== "event" || rec.event !== "chat") return false; if (!rec.payload || typeof rec.payload !== "object") return false; const payload = rec.payload as Record; 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(decodeWsData(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: "lifecycle", data: { phase: "end" }, }); const evt = await finalChatP; const payload = evt.payload && typeof evt.payload === "object" ? (evt.payload as Record) : {}; expect(payload.sessionKey).toBe("main"); const message = payload.message && typeof payload.message === "object" ? (payload.message as Record) : {}; 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(), "clawdbot-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((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(); }); });