diff --git a/src/gateway/server.chat.gateway-server-chat.test.ts b/src/gateway/server.chat.gateway-server-chat.test.ts index f14293f2db1..e110ace1d73 100644 --- a/src/gateway/server.chat.gateway-server-chat.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.test.ts @@ -42,6 +42,105 @@ async function waitFor(condition: () => boolean, timeoutMs = 250) { } 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); + }; + test("sanitizes inbound chat.send message text and rejects null bytes", async () => { const nullByteRes = await rpcReq(ws, "chat.send", { sessionKey: "main", @@ -305,89 +404,17 @@ describe("gateway server chat", () => { }); test("chat.history hides assistant NO_REPLY-only entries", 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 messages = [ - { - 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, - }, - ]; - 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); - const historyMessages = res.payload?.messages ?? []; - const textValues = 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"); - // 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"]); - } finally { - testState.sessionStorePath = undefined; - await fs.rm(dir, { recursive: true, force: true }); - } + 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 () => { - 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(), - }, - }, - }); - + await withMainSessionStore(async () => { const spy = vi.mocked(agentCommand); const callsBefore = spy.mock.calls.length; const eventPromise = onceMessage( @@ -407,98 +434,36 @@ describe("gateway server chat", () => { expect(res.ok).toBe(true); await eventPromise; expect(spy.mock.calls.length).toBe(callsBefore); - } finally { - testState.sessionStorePath = undefined; - await fs.rm(dir, { recursive: true, force: true }); - } + }); }); test("chat.history hides assistant NO_REPLY-only entries and keeps mixed-content assistant entries", 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 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:"); - const messages = [ - { - 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, - }, - { - role: "assistant", - content: [ - { type: "text", text: "NO_REPLY" }, - { type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } }, - ], - timestamp: 6, - }, - ]; - 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); - const historyMessages = res.payload?.messages ?? []; - 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", - ]); - } finally { - testState.sessionStorePath = undefined; - await fs.rm(dir, { recursive: true, force: true }); - } + expect(roleAndText).toEqual([ + "user:hello", + "assistant:real reply", + "assistant:real text field reply", + "user:NO_REPLY", + "assistant:NO_REPLY", + ]); }); test("agent events include sessionKey and agent.wait covers lifecycle flows", async () => { @@ -568,9 +533,7 @@ describe("gateway server chat", () => { }); const res = await waitP; - expect(res.ok).toBe(true); - expect(res.payload?.status).toBe("ok"); - expect(res.payload?.startedAt).toBe(200); + expectAgentWaitStartedAt(res, 200); } { @@ -594,8 +557,7 @@ describe("gateway server chat", () => { runId: "run-wait-3", timeoutMs: 30, }); - expect(res.ok).toBe(true); - expect(res.payload?.status).toBe("timeout"); + expectAgentWaitTimeout(res); } { @@ -613,8 +575,7 @@ describe("gateway server chat", () => { }); const res = await waitP; - expect(res.ok).toBe(true); - expect(res.payload?.status).toBe("timeout"); + expectAgentWaitTimeout(res); } { @@ -638,9 +599,7 @@ describe("gateway server chat", () => { }); const res = await waitP; - expect(res.ok).toBe(true); - expect(res.payload?.status).toBe("ok"); - expect(res.payload?.startedAt).toBe(123); + expectAgentWaitStartedAt(res, 123); expect(res.payload?.endedAt).toBe(456); } } finally {