import { describe, expect, it } from "vitest"; import { buildAgentChatItems, buildAgentChatRenderBlocks, buildFinalAgentChatItems, summarizeToolLabel, } from "@/features/agents/components/chatItems"; import { formatMetaMarkdown, formatThinkingMarkdown, formatToolCallMarkdown, formatToolResultMarkdown } from "@/lib/text/message-extract"; describe("buildAgentChatItems", () => { it("keeps thinking traces aligned with each assistant turn", () => { const items = buildAgentChatItems({ outputLines: [ "> first question", formatThinkingMarkdown("first plan"), "first answer", "> second question", formatThinkingMarkdown("second plan"), "second answer", ], streamText: null, liveThinkingTrace: "", showThinkingTraces: true, toolCallingEnabled: true, }); expect(items.map((item) => item.kind)).toEqual([ "user", "thinking", "assistant", "user", "thinking", "assistant", ]); expect(items[1]).toMatchObject({ kind: "thinking", text: "_first plan_" }); expect(items[4]).toMatchObject({ kind: "thinking", text: "_second plan_" }); }); it("does not include saved traces when thinking traces are disabled", () => { const items = buildAgentChatItems({ outputLines: [ "> first question", formatThinkingMarkdown("first plan"), "first answer", ], streamText: null, liveThinkingTrace: "live plan", showThinkingTraces: false, toolCallingEnabled: true, }); expect(items.map((item) => item.kind)).toEqual(["user", "assistant"]); }); it("adds a live trace before the live assistant stream", () => { const items = buildAgentChatItems({ outputLines: ["first answer"], streamText: "stream answer", liveThinkingTrace: "first plan", showThinkingTraces: true, toolCallingEnabled: true, }); expect(items.map((item) => item.kind)).toEqual(["assistant", "thinking", "assistant"]); expect(items[1]).toMatchObject({ kind: "thinking", text: "_first plan_", live: true }); }); it("merges adjacent thinking traces into a single item", () => { const items = buildAgentChatItems({ outputLines: [formatThinkingMarkdown("first plan"), formatThinkingMarkdown("second plan"), "answer"], streamText: null, liveThinkingTrace: "", showThinkingTraces: true, toolCallingEnabled: true, }); expect(items.map((item) => item.kind)).toEqual(["thinking", "assistant"]); expect(items[0]).toMatchObject({ kind: "thinking", text: "_first plan_\n\n_second plan_", }); }); }); describe("buildFinalAgentChatItems", () => { it("does not include live thinking or live assistant items", () => { const items = buildFinalAgentChatItems({ outputLines: ["> question", formatThinkingMarkdown("plan"), "answer"], showThinkingTraces: true, toolCallingEnabled: true, }); expect(items.map((item) => item.kind)).toEqual(["user", "thinking", "assistant"]); }); it("propagates meta timestamps and thinking duration into subsequent items", () => { const items = buildFinalAgentChatItems({ outputLines: [ formatMetaMarkdown({ role: "user", timestamp: 1700000000000 }), "> hello", formatMetaMarkdown({ role: "assistant", timestamp: 1700000001234, thinkingDurationMs: 1800 }), formatThinkingMarkdown("plan"), "answer", ], showThinkingTraces: true, toolCallingEnabled: true, }); expect(items[0]).toMatchObject({ kind: "user", text: "hello", timestampMs: 1700000000000 }); expect(items[1]).toMatchObject({ kind: "thinking", text: "_plan_", timestampMs: 1700000001234, thinkingDurationMs: 1800, }); expect(items[2]).toMatchObject({ kind: "assistant", text: "answer", timestampMs: 1700000001234, thinkingDurationMs: 1800, }); }); it("collapses adjacent duplicate user items when optimistic and persisted turns match", () => { const items = buildFinalAgentChatItems({ outputLines: [ "> hello\n\nworld", formatMetaMarkdown({ role: "user", timestamp: 1700000000000 }), "> hello world", ], showThinkingTraces: true, toolCallingEnabled: true, }); expect(items).toEqual([ { kind: "user", text: "hello world", timestampMs: 1700000000000, }, ]); }); it("does_not_collapse_repeated_user_message_when_second_turn_is_only_optimistic", () => { const items = buildFinalAgentChatItems({ outputLines: [ formatMetaMarkdown({ role: "user", timestamp: 1700000000000 }), "> repeat", "> repeat", ], showThinkingTraces: true, toolCallingEnabled: true, }); expect(items).toEqual([ { kind: "user", text: "repeat", timestampMs: 1700000000000, }, { kind: "user", text: "repeat", }, ]); }); it("keeps assistant markdown as assistant content", () => { const items = buildFinalAgentChatItems({ outputLines: ["- first item\n- second item"], showThinkingTraces: true, toolCallingEnabled: true, }); expect(items).toHaveLength(1); expect(items[0]).toMatchObject({ kind: "assistant" }); expect(items[0]?.text).toContain("- first item"); }); it("classifies tool markdown as tool items when tool calling is enabled", () => { const callLine = formatToolCallMarkdown({ id: "call_123", name: "exec", arguments: { command: "pwd" }, }); const toolLine = formatToolResultMarkdown({ toolCallId: "call_123", toolName: "exec", details: { status: "completed", exitCode: 0 }, text: "pwd", isError: false, }); const items = buildFinalAgentChatItems({ outputLines: [callLine, toolLine], showThinkingTraces: true, toolCallingEnabled: true, }); expect(items).toEqual([ { kind: "tool", text: callLine, }, { kind: "tool", text: toolLine, }, ]); }); it("hides tool results when tool calling is disabled", () => { const toolLine = formatToolResultMarkdown({ toolCallId: "call_456", toolName: "exec", details: { status: "completed", exitCode: 0 }, text: "pwd", isError: false, }); const items = buildFinalAgentChatItems({ outputLines: [toolLine], showThinkingTraces: true, toolCallingEnabled: false, }); expect(items).toEqual([]); }); }); describe("summarizeToolLabel", () => { it("hides long tool call ids and prefers showing the command/path/url value", () => { const toolCallLine = formatToolCallMarkdown({ id: "call_ABC123|fc_456", name: "functions.exec", arguments: { command: "gh auth status" }, }); const { summaryText: callSummary } = summarizeToolLabel(toolCallLine); expect(callSummary).toContain("gh auth status"); expect(callSummary).not.toContain("call_"); const toolResultLine = formatToolResultMarkdown({ toolCallId: "call_ABC123|fc_456", toolName: "functions.exec", details: { status: "completed", exitCode: 0, durationMs: 168 }, isError: false, text: "ok", }); const { summaryText: resultSummary } = summarizeToolLabel(toolResultLine); expect(resultSummary).toContain("completed"); expect(resultSummary).toContain("exit 0"); expect(resultSummary).not.toContain("call_"); }); it("renders read file calls as inline path labels without JSON body", () => { const toolCallLine = formatToolCallMarkdown({ id: "call_read_1", name: "read", arguments: { file_path: "/path/to/openclaw-agent-home/README.md" }, }); const summary = summarizeToolLabel(toolCallLine); expect(summary.summaryText).toBe( "read /path/to/openclaw-agent-home/README.md" ); expect(summary.inlineOnly).toBe(true); expect(summary.body).toBe(""); }); }); describe("buildAgentChatRenderBlocks", () => { it("groups thinking and tool events into one assistant block in original order", () => { const toolCallLine = formatToolCallMarkdown({ id: "call_1", name: "exec", arguments: { command: "pwd" }, }); const toolResultLine = formatToolResultMarkdown({ toolCallId: "call_1", toolName: "exec", details: { status: "completed", exitCode: 0 }, text: "/repo", isError: false, }); const blocks = buildAgentChatRenderBlocks([ { kind: "thinking", text: "_plan before tool_", timestampMs: 100 }, { kind: "tool", text: toolCallLine, timestampMs: 101 }, { kind: "thinking", text: "_plan after tool_", timestampMs: 102 }, { kind: "tool", text: toolResultLine, timestampMs: 103 }, { kind: "assistant", text: "done", timestampMs: 104 }, ]); expect(blocks).toEqual([ { kind: "assistant", text: "done", timestampMs: 100, traceEvents: [ { kind: "thinking", text: "_plan before tool_" }, { kind: "tool", text: toolCallLine }, { kind: "thinking", text: "_plan after tool_" }, { kind: "tool", text: toolResultLine }, ], }, ]); }); it("starts a new assistant block after a user turn", () => { const blocks = buildAgentChatRenderBlocks([ { kind: "thinking", text: "_first plan_", timestampMs: 10 }, { kind: "assistant", text: "first answer", timestampMs: 11 }, { kind: "user", text: "next question", timestampMs: 12 }, { kind: "thinking", text: "_second plan_", timestampMs: 13 }, { kind: "assistant", text: "second answer", timestampMs: 14 }, ]); expect(blocks.map((block) => block.kind)).toEqual(["assistant", "user", "assistant"]); }); it("merges adjacent incremental thinking updates", () => { const blocks = buildAgentChatRenderBlocks([ { kind: "thinking", text: "_a_", timestampMs: 10 }, { kind: "thinking", text: "_a_\n\n_b_", timestampMs: 10 }, { kind: "assistant", text: "answer", timestampMs: 10 }, ]); expect(blocks).toEqual([ { kind: "assistant", text: "answer", timestampMs: 10, traceEvents: [{ kind: "thinking", text: "_a_\n\n_b_" }], }, ]); }); });