Files
openclaw/src/gateway/server.chat.gateway-server-chat.test.ts
2026-03-07 17:58:31 +00:00

835 lines
25 KiB
TypeScript

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<Record<string, unknown>>,
): Promise<unknown[]> => {
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 <T>(run: (dir: string) => Promise<T>): Promise<T> => {
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<ReturnType<typeof rpcReq>>) => {
expect(res.ok).toBe(true);
expect(res.payload?.status).toBe("timeout");
};
const expectAgentWaitStartedAt = (res: Awaited<ReturnType<typeof rpcReq>>, 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<void>((resolve) => {
releaseBlockedReply = resolve;
});
const replySpy = vi.mocked(getReplyFromConfig);
replySpy.mockImplementationOnce(async (_ctx, opts) => {
await new Promise<void>((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<void>((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<void>((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<void>((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;
}
});
});