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 { extractFirstTextBlock } from "../shared/chat-message-content.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { connectOk, getReplyFromConfig, installGatewayTestHooks, onceMessage, rpcReq, testState, trackConnectChallengeNonce, writeSessionStore, } from "./test-helpers.js"; import { agentCommand } from "./test-helpers.mocks.js"; import { installConnectedControlUiServerSuite } from "./test-with-server.js"; installGatewayTestHooks({ scope: "suite" }); const CHAT_RESPONSE_TIMEOUT_MS = 4_000; let ws: WebSocket; let port: number; installConnectedControlUiServerSuite((started) => { ws = started.ws; port = started.port; }); async function waitFor(condition: () => boolean, timeoutMs = 250) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (condition()) { return; } await new Promise((r) => setTimeout(r, 2)); } throw new Error("timeout waiting for condition"); } describe("gateway server chat", () => { const buildNoReplyHistoryFixture = (includeMixedAssistant = false) => [ { role: "user", content: [{ type: "text", text: "hello" }], timestamp: 1, }, { role: "assistant", content: [{ type: "text", text: "NO_REPLY" }], timestamp: 2, }, { role: "assistant", content: [{ type: "text", text: "real reply" }], timestamp: 3, }, { role: "assistant", text: "real text field reply", content: "NO_REPLY", timestamp: 4, }, { role: "user", content: [{ type: "text", text: "NO_REPLY" }], timestamp: 5, }, ...(includeMixedAssistant ? [ { role: "assistant", content: [ { type: "text", text: "NO_REPLY" }, { type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } }, ], timestamp: 6, }, ] : []), ]; const loadChatHistoryWithMessages = async ( messages: Array>, ): Promise => { return withMainSessionStore(async (dir) => { const lines = messages.map((message) => JSON.stringify({ message })); await fs.writeFile(path.join(dir, "sess-main.jsonl"), lines.join("\n"), "utf-8"); const res = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", { sessionKey: "main", }); expect(res.ok).toBe(true); return res.payload?.messages ?? []; }); }; const withMainSessionStore = async (run: (dir: string) => Promise): Promise => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-")); try { testState.sessionStorePath = path.join(dir, "sessions.json"); await writeSessionStore({ entries: { main: { sessionId: "sess-main", updatedAt: Date.now(), }, }, }); return await run(dir); } finally { testState.sessionStorePath = undefined; await fs.rm(dir, { recursive: true, force: true }); } }; const collectHistoryTextValues = (historyMessages: unknown[]) => historyMessages .map((message) => { if (message && typeof message === "object") { const entry = message as { text?: unknown }; if (typeof entry.text === "string") { return entry.text; } } return extractFirstTextBlock(message); }) .filter((value): value is string => typeof value === "string"); const expectAgentWaitTimeout = (res: Awaited>) => { expect(res.ok).toBe(true); expect(res.payload?.status).toBe("timeout"); }; const expectAgentWaitStartedAt = (res: Awaited>, startedAt: number) => { expect(res.ok).toBe(true); expect(res.payload?.status).toBe("ok"); expect(res.payload?.startedAt).toBe(startedAt); }; const mockBlockedChatReply = () => { let releaseBlockedReply: (() => void) | undefined; const blockedReply = new Promise((resolve) => { releaseBlockedReply = resolve; }); const replySpy = vi.mocked(getReplyFromConfig); replySpy.mockImplementationOnce(async (_ctx, opts) => { await new Promise((resolve) => { let settled = false; const finish = () => { if (settled) { return; } settled = true; resolve(); }; void blockedReply.then(finish); if (opts?.abortSignal?.aborted) { finish(); return; } opts?.abortSignal?.addEventListener("abort", finish, { once: true }); }); return undefined; }); return () => { releaseBlockedReply?.(); }; }; test("sanitizes inbound chat.send message text and rejects null bytes", async () => { const nullByteRes = await rpcReq(ws, "chat.send", { sessionKey: "main", message: "hello\u0000world", idempotencyKey: "idem-null-byte-1", }); expect(nullByteRes.ok).toBe(false); expect((nullByteRes.error as { message?: string } | undefined)?.message ?? "").toMatch( /null bytes/i, ); const spy = vi.mocked(getReplyFromConfig); spy.mockClear(); const spyCalls = spy.mock.calls as unknown[][]; const callsBeforeSanitized = spyCalls.length; const sanitizedRes = await rpcReq(ws, "chat.send", { sessionKey: "main", message: "Cafe\u0301\u0007\tline", idempotencyKey: "idem-sanitized-1", }); expect(sanitizedRes.ok).toBe(true); await waitFor(() => spyCalls.length > callsBeforeSanitized); const ctx = spyCalls.at(-1)?.[0] as | { Body?: string; RawBody?: string; BodyForCommands?: string } | undefined; expect(ctx?.Body).toBe("Café\tline"); expect(ctx?.RawBody).toBe("Café\tline"); expect(ctx?.BodyForCommands).toBe("Café\tline"); }); test("handles chat send and history flows", async () => { const tempDirs: string[] = []; let webchatWs: WebSocket | undefined; try { webchatWs = new WebSocket(`ws://127.0.0.1:${port}`, { headers: { origin: `http://127.0.0.1:${port}` }, }); trackConnectChallengeNonce(webchatWs); await new Promise((resolve) => webchatWs?.once("open", resolve)); await connectOk(webchatWs, { client: { id: GATEWAY_CLIENT_NAMES.CONTROL_UI, version: "dev", platform: "web", mode: GATEWAY_CLIENT_MODES.WEBCHAT, }, }); const webchatRes = await rpcReq(webchatWs, "chat.send", { sessionKey: "main", message: "hello", idempotencyKey: "idem-webchat-1", }); expect(webchatRes.ok).toBe(true); webchatWs.close(); webchatWs = undefined; const spy = vi.mocked(getReplyFromConfig); spy.mockClear(); const spyCalls = spy.mock.calls as unknown[][]; testState.agentConfig = { timeoutSeconds: 123 }; const callsBeforeTimeout = spyCalls.length; const timeoutRes = await rpcReq(ws, "chat.send", { sessionKey: "main", message: "hello", idempotencyKey: "idem-timeout-1", }); expect(timeoutRes.ok).toBe(true); await waitFor(() => spyCalls.length > callsBeforeTimeout); const timeoutCall = spyCalls.at(-1)?.[1] as { runId?: string } | undefined; expect(timeoutCall?.runId).toBe("idem-timeout-1"); testState.agentConfig = undefined; const sessionRes = await rpcReq(ws, "chat.send", { sessionKey: "agent:main:subagent:abc", message: "hello", idempotencyKey: "idem-session-key-1", }); expect(sessionRes.ok).toBe(true); expect(sessionRes.payload?.runId).toBe("idem-session-key-1"); const sendPolicyDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-")); tempDirs.push(sendPolicyDir); testState.sessionStorePath = path.join(sendPolicyDir, "sessions.json"); testState.sessionConfig = { sendPolicy: { default: "allow", rules: [ { action: "deny", match: { channel: "discord", chatType: "group" }, }, ], }, }; await writeSessionStore({ entries: { "discord:group:dev": { sessionId: "sess-discord", updatedAt: Date.now(), chatType: "group", channel: "discord", }, }, }); const blockedRes = await rpcReq(ws, "chat.send", { sessionKey: "discord:group:dev", message: "hello", idempotencyKey: "idem-1", }); expect(blockedRes.ok).toBe(false); expect((blockedRes.error as { message?: string } | undefined)?.message ?? "").toMatch( /send blocked/i, ); testState.sessionStorePath = undefined; testState.sessionConfig = undefined; const agentBlockedDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-")); tempDirs.push(agentBlockedDir); testState.sessionStorePath = path.join(agentBlockedDir, "sessions.json"); testState.sessionConfig = { sendPolicy: { default: "allow", rules: [{ action: "deny", match: { keyPrefix: "cron:" } }], }, }; await writeSessionStore({ entries: { "cron:job-1": { sessionId: "sess-cron", updatedAt: Date.now(), }, }, }); const agentBlockedRes = await rpcReq(ws, "agent", { sessionKey: "cron:job-1", message: "hi", idempotencyKey: "idem-2", }); expect(agentBlockedRes.ok).toBe(false); expect((agentBlockedRes.error as { message?: string } | undefined)?.message ?? "").toMatch( /send blocked/i, ); testState.sessionStorePath = undefined; testState.sessionConfig = undefined; const pngB64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; const reqId = "chat-img"; ws.send( JSON.stringify({ type: "req", id: reqId, method: "chat.send", params: { sessionKey: "main", message: "see image", idempotencyKey: "idem-img", attachments: [ { type: "image", mimeType: "image/png", fileName: "dot.png", content: `data:image/png;base64,${pngB64}`, }, ], }, }), ); const imgRes = await onceMessage( ws, (o) => o.type === "res" && o.id === reqId, CHAT_RESPONSE_TIMEOUT_MS, ); expect(imgRes.ok).toBe(true); expect(imgRes.payload?.runId).toBeDefined(); const reqIdOnly = "chat-img-only"; ws.send( JSON.stringify({ type: "req", id: reqIdOnly, method: "chat.send", params: { sessionKey: "main", message: "", idempotencyKey: "idem-img-only", attachments: [ { type: "image", mimeType: "image/png", fileName: "dot.png", content: `data:image/png;base64,${pngB64}`, }, ], }, }), ); const imgOnlyRes = await onceMessage( ws, (o) => o.type === "res" && o.id === reqIdOnly, CHAT_RESPONSE_TIMEOUT_MS, ); expect(imgOnlyRes.ok).toBe(true); expect(imgOnlyRes.payload?.runId).toBeDefined(); const historyDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-")); tempDirs.push(historyDir); testState.sessionStorePath = path.join(historyDir, "sessions.json"); await writeSessionStore({ entries: { main: { sessionId: "sess-main", updatedAt: Date.now(), }, }, }); const lines: string[] = []; for (let i = 0; i < 300; i += 1) { lines.push( JSON.stringify({ message: { role: "user", content: [{ type: "text", text: `m${i}` }], timestamp: Date.now() + i, }, }), ); } await fs.writeFile(path.join(historyDir, "sess-main.jsonl"), lines.join("\n"), "utf-8"); const defaultRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", { sessionKey: "main", }); expect(defaultRes.ok).toBe(true); const defaultMsgs = defaultRes.payload?.messages ?? []; expect(defaultMsgs.length).toBe(200); expect(extractFirstTextBlock(defaultMsgs[0])).toBe("m100"); } finally { testState.agentConfig = undefined; testState.sessionStorePath = undefined; testState.sessionConfig = undefined; if (webchatWs) { webchatWs.close(); } await Promise.all(tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true }))); } }); test("chat.history hides assistant NO_REPLY-only entries", async () => { const historyMessages = await loadChatHistoryWithMessages(buildNoReplyHistoryFixture()); const textValues = collectHistoryTextValues(historyMessages); // The NO_REPLY assistant message (content block) should be dropped. // The assistant with text="real text field reply" + content="NO_REPLY" stays // because entry.text takes precedence over entry.content for the silent check. // The user message with NO_REPLY text is preserved (only assistant filtered). expect(textValues).toEqual(["hello", "real reply", "real text field reply", "NO_REPLY"]); }); test("routes chat.send slash commands without agent runs", async () => { await withMainSessionStore(async () => { const spy = vi.mocked(agentCommand); const callsBefore = spy.mock.calls.length; const eventPromise = onceMessage( ws, (o) => o.type === "event" && o.event === "chat" && o.payload?.state === "final" && o.payload?.runId === "idem-command-1", 8000, ); const res = await rpcReq(ws, "chat.send", { sessionKey: "main", message: "/context list", idempotencyKey: "idem-command-1", }); expect(res.ok).toBe(true); await eventPromise; expect(spy.mock.calls.length).toBe(callsBefore); }); }); test("chat.history hides assistant NO_REPLY-only entries and keeps mixed-content assistant entries", async () => { const historyMessages = await loadChatHistoryWithMessages(buildNoReplyHistoryFixture(true)); const roleAndText = historyMessages .map((message) => { const role = message && typeof message === "object" && typeof (message as { role?: unknown }).role === "string" ? (message as { role: string }).role : "unknown"; const text = message && typeof message === "object" && typeof (message as { text?: unknown }).text === "string" ? (message as { text: string }).text : (extractFirstTextBlock(message) ?? ""); return `${role}:${text}`; }) .filter((entry) => entry !== "unknown:"); expect(roleAndText).toEqual([ "user:hello", "assistant:real reply", "assistant:real text field reply", "user:NO_REPLY", "assistant:NO_REPLY", ]); }); test("agent.wait resolves chat.send runs that finish without lifecycle events", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-")); try { testState.sessionStorePath = path.join(dir, "sessions.json"); await writeSessionStore({ entries: { main: { sessionId: "sess-main", updatedAt: Date.now(), }, }, }); const runId = "idem-wait-chat-1"; const sendRes = await rpcReq(ws, "chat.send", { sessionKey: "main", message: "/context list", idempotencyKey: runId, }); expect(sendRes.ok).toBe(true); expect(sendRes.payload?.status).toBe("started"); const waitRes = await rpcReq(ws, "agent.wait", { runId, timeoutMs: 1_000, }); expect(waitRes.ok).toBe(true); expect(waitRes.payload?.status).toBe("ok"); } finally { testState.sessionStorePath = undefined; await fs.rm(dir, { recursive: true, force: true }); } }); test("agent.wait ignores stale chat dedupe when an agent run with the same runId is in flight", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-")); let resolveAgentRun: (() => void) | undefined; const blockedAgentRun = new Promise((resolve) => { resolveAgentRun = resolve; }); const agentSpy = vi.mocked(agentCommand); agentSpy.mockImplementationOnce(async () => { await blockedAgentRun; return undefined; }); try { testState.sessionStorePath = path.join(dir, "sessions.json"); await writeSessionStore({ entries: { main: { sessionId: "sess-main", updatedAt: Date.now(), }, }, }); const runId = "idem-wait-chat-vs-agent"; const sendRes = await rpcReq(ws, "chat.send", { sessionKey: "main", message: "/context list", idempotencyKey: runId, }); expect(sendRes.ok).toBe(true); expect(sendRes.payload?.status).toBe("started"); const chatWaitRes = await rpcReq(ws, "agent.wait", { runId, timeoutMs: 1_000, }); expect(chatWaitRes.ok).toBe(true); expect(chatWaitRes.payload?.status).toBe("ok"); const agentRes = await rpcReq(ws, "agent", { sessionKey: "main", message: "hold this run open", idempotencyKey: runId, }); expect(agentRes.ok).toBe(true); expect(agentRes.payload?.status).toBe("accepted"); const waitWhileAgentInFlight = await rpcReq(ws, "agent.wait", { runId, timeoutMs: 40, }); expectAgentWaitTimeout(waitWhileAgentInFlight); resolveAgentRun?.(); const waitAfterAgentCompletion = await rpcReq(ws, "agent.wait", { runId, timeoutMs: 1_000, }); expect(waitAfterAgentCompletion.ok).toBe(true); expect(waitAfterAgentCompletion.payload?.status).toBe("ok"); } finally { resolveAgentRun?.(); testState.sessionStorePath = undefined; await fs.rm(dir, { recursive: true, force: true }); } }); test("agent.wait ignores stale agent snapshots while same-runId chat.send is active", async () => { await withMainSessionStore(async () => { const runId = "idem-wait-chat-active-vs-stale-agent"; const seedAgentRes = await rpcReq(ws, "agent", { sessionKey: "main", message: "seed stale agent snapshot", idempotencyKey: runId, }); expect(seedAgentRes.ok).toBe(true); expect(seedAgentRes.payload?.status).toBe("accepted"); const seedWaitRes = await rpcReq(ws, "agent.wait", { runId, timeoutMs: 1_000, }); expect(seedWaitRes.ok).toBe(true); expect(seedWaitRes.payload?.status).toBe("ok"); const releaseBlockedReply = mockBlockedChatReply(); try { const chatRes = await rpcReq(ws, "chat.send", { sessionKey: "main", message: "hold chat run open", idempotencyKey: runId, }); expect(chatRes.ok).toBe(true); expect(chatRes.payload?.status).toBe("started"); const waitWhileChatActive = await rpcReq(ws, "agent.wait", { runId, timeoutMs: 40, }); expectAgentWaitTimeout(waitWhileChatActive); const abortRes = await rpcReq(ws, "chat.abort", { sessionKey: "main", runId, }); expect(abortRes.ok).toBe(true); } finally { releaseBlockedReply(); } }); }); test("agent.wait keeps lifecycle wait active while same-runId chat.send is active", async () => { await withMainSessionStore(async () => { const runId = "idem-wait-chat-active-with-agent-lifecycle"; const releaseBlockedReply = mockBlockedChatReply(); try { const chatRes = await rpcReq(ws, "chat.send", { sessionKey: "main", message: "hold chat run open", idempotencyKey: runId, }); expect(chatRes.ok).toBe(true); expect(chatRes.payload?.status).toBe("started"); const waitP = rpcReq(ws, "agent.wait", { runId, timeoutMs: 1_000, }); await new Promise((resolve) => setTimeout(resolve, 20)); emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 1 }, }); emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end", startedAt: 1, endedAt: 2 }, }); const waitRes = await waitP; expect(waitRes.ok).toBe(true); expect(waitRes.payload?.status).toBe("ok"); const abortRes = await rpcReq(ws, "chat.abort", { sessionKey: "main", runId, }); expect(abortRes.ok).toBe(true); } finally { releaseBlockedReply(); } }); }); test("agent events include sessionKey and agent.wait covers lifecycle flows", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-")); testState.sessionStorePath = path.join(dir, "sessions.json"); await writeSessionStore({ entries: { main: { sessionId: "sess-main", updatedAt: Date.now(), verboseLevel: "off", }, }, }); const webchatWs = new WebSocket(`ws://127.0.0.1:${port}`, { headers: { origin: `http://127.0.0.1:${port}` }, }); trackConnectChallengeNonce(webchatWs); await new Promise((resolve) => webchatWs.once("open", resolve)); await connectOk(webchatWs, { client: { id: GATEWAY_CLIENT_NAMES.WEBCHAT, version: "1.0.0", platform: "test", mode: GATEWAY_CLIENT_MODES.WEBCHAT, }, }); try { registerAgentRunContext("run-tool-1", { sessionKey: "main", verboseLevel: "on", }); { const agentEvtP = onceMessage( webchatWs, (o) => o.type === "event" && o.event === "agent" && o.payload?.runId === "run-tool-1", 8000, ); emitAgentEvent({ runId: "run-tool-1", stream: "assistant", data: { text: "hello" }, }); const evt = await agentEvtP; const payload = evt.payload && typeof evt.payload === "object" ? evt.payload : {}; expect(payload.sessionKey).toBe("main"); expect(payload.stream).toBe("assistant"); } { const waitP = rpcReq(webchatWs, "agent.wait", { runId: "run-wait-1", timeoutMs: 200, }); queueMicrotask(() => { emitAgentEvent({ runId: "run-wait-1", stream: "lifecycle", data: { phase: "end", startedAt: 200, endedAt: 210 }, }); }); const res = await waitP; expectAgentWaitStartedAt(res, 200); } { emitAgentEvent({ runId: "run-wait-early", stream: "lifecycle", data: { phase: "end", startedAt: 50, endedAt: 55 }, }); const res = await rpcReq(webchatWs, "agent.wait", { runId: "run-wait-early", timeoutMs: 200, }); expect(res.ok).toBe(true); expect(res.payload?.status).toBe("ok"); expect(res.payload?.startedAt).toBe(50); } { const res = await rpcReq(webchatWs, "agent.wait", { runId: "run-wait-3", timeoutMs: 30, }); expectAgentWaitTimeout(res); } { const waitP = rpcReq(webchatWs, "agent.wait", { runId: "run-wait-err", timeoutMs: 50, }); queueMicrotask(() => { emitAgentEvent({ runId: "run-wait-err", stream: "lifecycle", data: { phase: "error", error: "boom" }, }); }); const res = await waitP; expectAgentWaitTimeout(res); } { const waitP = rpcReq(webchatWs, "agent.wait", { runId: "run-wait-start", timeoutMs: 200, }); emitAgentEvent({ runId: "run-wait-start", stream: "lifecycle", data: { phase: "start", startedAt: 123 }, }); queueMicrotask(() => { emitAgentEvent({ runId: "run-wait-start", stream: "lifecycle", data: { phase: "end", endedAt: 456 }, }); }); const res = await waitP; expectAgentWaitStartedAt(res, 123); expect(res.payload?.endedAt).toBe(456); } } finally { webchatWs.close(); await fs.rm(dir, { recursive: true, force: true }); testState.sessionStorePath = undefined; } }); });