import { beforeEach, describe, expect, test } from "bun:test"; import type { AnsweredQuestion } from "../api/events/question-answered.js"; import { renderHistoryContent } from "../daemon/handlers/shared.js"; import type { ToolActivityMetadata } from "../daemon/message-types/web-activity.js"; import { getAttachmentsForMessage, linkAttachmentToMessage, uploadAttachment, } from "../persistence/attachments-store.js"; import { addMessage, createConversation, } from "../persistence/conversation-crud.js"; import { getDb } from "../persistence/db-connection.js"; import { initializeDb } from "../persistence/db-init.js"; await initializeDb(); describe("renderHistoryContent sentinel forgery guard", () => { const FORGED = "\u3014redacted:GitHub Token:github-app:pem\u3015"; test("neutralizes sentinel-shaped text in blocks without the redaction rider (pre-feature rows)", () => { const output = renderHistoryContent([ { type: "text", text: `quoted: ${FORGED}` }, ]); // The word joiner breaks the chip regex; text is otherwise unchanged. expect(output.textSegments[0]).toBe( `quoted: \u3014\u2060redacted:GitHub Token:github-app:pem\u3015`, ); }); test("passes sentinels through verbatim when the block carries the redaction rider", () => { const output = renderHistoryContent([ { type: "text", text: `key: ${FORGED}`, _redactionVersion: 2 }, ]); expect(output.textSegments[0]).toBe(`key: ${FORGED}`); }); test("rider-less text without sentinels is untouched", () => { const output = renderHistoryContent([ { type: "text", text: "ordinary history text" }, ]); expect(output.textSegments[0]).toBe("ordinary history text"); }); }); describe("renderHistoryContent", () => { test("renders text-only content unchanged", () => { const output = renderHistoryContent([ { type: "text", text: "hello " }, { type: "text", text: "world" }, ]); expect(output.text).toBe("hello world"); expect(output.toolCalls).toEqual([]); }); test("renders file attachments for attachment-only turns", () => { const output = renderHistoryContent([ { type: "file", source: { type: "base64", media_type: "application/pdf", filename: "spec.pdf", data: Buffer.from("hello").toString("base64"), }, extracted_text: "Important requirement from the attachment.", }, ]); expect(output.text).toContain("[File attachment] spec.pdf"); expect(output.text).toContain("type=application/pdf"); expect(output.text).toContain("size=5 B"); expect(output.text).toContain( "Attachment text: Important requirement from the attachment.", ); }); test("skips image attachment placeholder text (images sent as separate attachments)", () => { const output = renderHistoryContent([ { type: "image", source: { type: "base64", media_type: "image/png", data: Buffer.from("hello").toString("base64"), }, }, ]); expect(output.text).toBe(""); }); test("appends attachment lines after text content", () => { const output = renderHistoryContent([ { type: "text", text: "please review the file" }, { type: "file", source: { type: "base64", media_type: "text/plain", filename: "notes.txt", data: Buffer.from("hello").toString("base64"), }, }, ]); expect(output.text).toContain( "please review the file\n[File attachment] notes.txt", ); }); test("emits attachment:N entries in contentOrder for interleaved file blocks", () => { const output = renderHistoryContent([ { type: "text", text: "first paragraph" }, { type: "file", source: { type: "base64", media_type: "text/markdown", filename: "dream-015.md", data: Buffer.from("a").toString("base64"), }, _attachmentId: "att-015", }, { type: "file", source: { type: "base64", media_type: "text/markdown", filename: "dream-016.md", data: Buffer.from("b").toString("base64"), }, _attachmentId: "att-016", }, { type: "text", text: "trailing paragraph" }, ]); expect(output.contentOrder).toEqual([ "text:0", "attachment:0", "attachment:1", "text:1", ]); expect(output.attachments).toEqual([ { attachmentId: "att-015", filename: "dream-015.md", mimeType: "text/markdown", sizeBytes: 1, }, { attachmentId: "att-016", filename: "dream-016.md", mimeType: "text/markdown", sizeBytes: 1, }, ]); // Trailing-segment summary is preserved for legacy channel-reply // delivery (Slack/Telegram outbound text) and iOS rehydrate. expect(output.textSegments[1]).toContain("[File attachment] dream-015.md"); }); test("omits attachmentId when file block has no _attachmentId", () => { const output = renderHistoryContent([ { type: "file", source: { type: "base64", media_type: "application/pdf", filename: "legacy.pdf", data: Buffer.from("x").toString("base64"), }, }, ]); // attachment:0 marks the file's inline position; text:0 follows because // the legacy summary append (for Slack/iOS) opens a trailing segment. expect(output.contentOrder).toEqual(["attachment:0", "text:0"]); expect(output.attachments).toEqual([ { filename: "legacy.pdf", mimeType: "application/pdf", sizeBytes: 1, }, ]); }); test("falls back to string conversion for non-array content", () => { expect(renderHistoryContent("raw string").text).toBe("raw string"); expect(renderHistoryContent(null).text).toBe(""); expect(renderHistoryContent(undefined).text).toBe(""); expect(renderHistoryContent(42).text).toBe("42"); }); test("unwraps complete legacy external_content envelopes for plain string content", () => { const output = renderHistoryContent( '\nVisible Slack text\n', ); expect(output.text).toBe("Visible Slack text"); expect(output.textSegments).toEqual(["Visible Slack text"]); expect(output.contentOrder).toEqual(["text:0"]); }); test("unwraps complete legacy external_content envelopes in text blocks", () => { const output = renderHistoryContent([ { type: "text", text: '\nVisible block text\n', }, { type: "text", text: "Plain follow-up." }, ]); expect(output.text).toBe("Visible block text Plain follow-up."); expect(output.textSegments).toEqual([ "Visible block text Plain follow-up.", ]); expect(output.contentOrder).toEqual(["text:0"]); }); test("leaves malformed or mixed external_content text unchanged", () => { const malformed = 'Visible text'; const mixed = 'prefix \nVisible text\n'; const malformedOutput = renderHistoryContent([ { type: "text", text: malformed }, ]); expect(malformedOutput.text).toBe(malformed); expect(malformedOutput.textSegments).toEqual([malformed]); const mixedOutput = renderHistoryContent(mixed); expect(mixedOutput.text).toBe(mixed); expect(mixedOutput.textSegments).toEqual([mixed]); }); test("preserves JSON object content as JSON string", () => { expect(renderHistoryContent({ foo: "bar" }).text).toBe('{"foo":"bar"}'); }); test("extracts tool_use blocks into toolCalls", () => { const output = renderHistoryContent([ { type: "tool_use", id: "tu_1", name: "web_fetch", input: { url: "https://example.com" }, }, ]); expect(output.text).toBe(""); expect(output.toolCalls).toEqual([ { id: "tu_1", name: "web_fetch", input: { url: "https://example.com" } }, ]); expect(output.toolCallsBeforeText).toBe(true); }); test("pairs tool_result with matching tool_use by id", () => { const output = renderHistoryContent([ { type: "tool_use", id: "tu_1", name: "bash", input: { command: "ls" } }, { type: "tool_result", tool_use_id: "tu_1", content: "file1.txt\nfile2.txt", is_error: false, }, ]); expect(output.toolCalls).toEqual([ { id: "tu_1", name: "bash", input: { command: "ls" }, result: "file1.txt\nfile2.txt", isError: false, }, ]); }); test("projects a persisted errorCode onto the paired tool call", () => { // The daemon persists `errorCode` on the stored tool_result block so a // reopened history row can re-derive an error-specific surface (e.g. the // inline Connect Claude card from `acp_claude_oauth_missing`) instead of it // living only on the transient live event. const output = renderHistoryContent([ { type: "tool_use", id: "tu_1", name: "acp_spawn", input: {} }, { type: "tool_result", tool_use_id: "tu_1", content: "claude-agent-acp needs a Claude OAuth token", is_error: true, errorCode: "acp_claude_oauth_missing", }, ]); expect(output.toolCalls[0].isError).toBe(true); expect(output.toolCalls[0].errorCode).toBe("acp_claude_oauth_missing"); }); test("emits the attachment id for a workspace_ref tool-result image", async () => { // "aGVsbG8=" = "hello" — a stand-in for screenshot bytes. const stored = await uploadAttachment("shot.png", "image/png", "aGVsbG8="); const output = renderHistoryContent([ { type: "tool_use", id: "tu_1", name: "browser_screenshot", input: {} }, { type: "tool_result", tool_use_id: "tu_1", content: "captured", is_error: false, contentBlocks: [ { type: "image", source: { type: "workspace_ref", media_type: "image/png", attachmentId: stored.id, sizeBytes: 5, }, }, ], }, ]); // Referenced media emits its attachment id so clients fetch the bytes by // id on render instead of inlining base64 into the history wire; the // base64 fields stay empty for referenced images. expect(output.toolCalls[0].imageAttachmentIds).toEqual([stored.id]); expect(output.toolCalls[0].imageDataList).toBeUndefined(); expect(output.toolCalls[0].imageData).toBeUndefined(); }); test("resolves an inline base64 tool-result image without an id", () => { const output = renderHistoryContent([ { type: "tool_use", id: "tu_1", name: "browser_screenshot", input: {} }, { type: "tool_result", tool_use_id: "tu_1", content: "captured", is_error: false, contentBlocks: [ { type: "image", source: { type: "base64", media_type: "image/png", data: "aGVsbG8=", }, }, ], }, ]); // Legacy inline base64 (no workspace reference) still resolves to the // base64 wire fields and carries no attachment id. expect(output.toolCalls[0].imageDataList).toEqual(["aGVsbG8="]); expect(output.toolCalls[0].imageData).toBe("aGVsbG8="); expect(output.toolCalls[0].imageAttachmentIds).toBeUndefined(); }); test("marks error tool results", () => { const output = renderHistoryContent([ { type: "tool_use", id: "tu_1", name: "bash", input: { command: "bad" } }, { type: "tool_result", tool_use_id: "tu_1", content: "command not found", is_error: true, }, ]); expect(output.toolCalls).toEqual([ { id: "tu_1", name: "bash", input: { command: "bad" }, result: "command not found", isError: true, }, ]); }); test("omits id when the tool_use block carries none", () => { const output = renderHistoryContent([ { type: "tool_use", name: "bash", input: { command: "ls" } }, ]); // No provider id on the block — emit the entry without an `id` rather than // materializing an empty string, so clients fall back to a synthesized id. expect(output.toolCalls).toEqual([ { name: "bash", input: { command: "ls" } }, ]); expect(output.toolCalls[0]).not.toHaveProperty("id"); }); test("carries the provider id for server_tool_use blocks", () => { const output = renderHistoryContent([ { type: "server_tool_use", id: "srvtu_1", name: "web_search", input: { query: "vellum" }, }, ]); expect(output.toolCalls).toEqual([ { id: "srvtu_1", name: "web_search", input: { query: "vellum" } }, ]); }); test("synthesizes a positional id when a tool_use lacks a provider id", () => { const output = renderHistoryContent( [ { type: "tool_use", name: "bash", input: { command: "ls" } }, { type: "tool_use", name: "bash", input: { command: "pwd" } }, ], undefined, "msg-1", ); // Same positional scheme the web client used to synthesize, so snapshot and // stream tool calls stay keyed consistently and the client can drop its own // fallback once it no longer skews ahead of the daemon. expect(output.toolCalls.map((tc) => tc.id)).toEqual([ "tool-history-msg-1-0", "tool-history-msg-1-1", ]); }); test("keeps the provider id and only synthesizes for blocks missing one", () => { const output = renderHistoryContent( [ { type: "tool_use", id: "tu_1", name: "bash", input: { command: "ls" }, }, { type: "tool_use", name: "bash", input: { command: "pwd" } }, ], undefined, "msg-1", ); expect(output.toolCalls.map((tc) => tc.id)).toEqual([ "tu_1", "tool-history-msg-1-1", ]); }); // ── Persisted risk-option ladders (Phase B of conflation track) ───────────── test("hydrates persisted _risk*Options annotations onto tool calls", () => { // Mirrors what `annotatePersistedAssistantMessage` writes to the DB so the // rule editor's chip ladder survives chat-history reload. Without these, // hydrated chips fall back to the synthesized `*` allowlist (see web's // `synthesizeFallbackOption` in RuleEditorModal.tsx). const scopeOptions = [ { pattern: "exact", label: "exact: rm -rf /tmp" }, { pattern: "by-program", label: "All rm" }, ]; const allowlistOptions = [ { label: "exact", description: "exact match", pattern: "rm -rf /tmp" }, { label: "All rm", description: "All rm commands", pattern: "rm *" }, ]; const directoryScopeOptions = [ { scope: "/Users/me/code", label: "in code/" }, { scope: "everywhere", label: "Everywhere" }, ]; const output = renderHistoryContent([ { type: "tool_use", id: "tu_1", name: "bash", input: { command: "rm -rf /tmp" }, _riskLevel: "high", _matchedTrustRuleId: "rule_42", _riskScopeOptions: scopeOptions, _riskAllowlistOptions: allowlistOptions, _riskDirectoryScopeOptions: directoryScopeOptions, }, ]); const [entry] = output.toolCalls; expect(entry.riskLevel).toBe("high"); expect(entry.matchedTrustRuleId).toBe("rule_42"); expect(entry.riskScopeOptions).toEqual(scopeOptions); expect(entry.riskAllowlistOptions).toEqual(allowlistOptions); expect(entry.riskDirectoryScopeOptions).toEqual(directoryScopeOptions); }); // ── Persisted tool activity (web_search / web_fetch) ──────────────────────── test("hydrates persisted _activityMetadata onto a tool_use block", () => { // Mirrors what `annotatePersistedAssistantMessage` writes so the activity // card survives a history reopen instead of degrading to plain text. const activityMetadata: ToolActivityMetadata = { webSearch: { query: "vellum docs", provider: "brave", resultCount: 1, durationMs: 120, results: [ { rank: 1, title: "Vellum", url: "https://vellum.ai", domain: "vellum.ai", }, ], }, }; const output = renderHistoryContent([ { type: "tool_use", id: "tu_1", name: "web_search", input: { query: "vellum docs" }, _activityMetadata: activityMetadata, }, ]); expect(output.toolCalls[0].activityMetadata).toEqual(activityMetadata); }); test("hydrates persisted _activityMetadata onto a server_tool_use block", () => { const activityMetadata: ToolActivityMetadata = { webSearch: { query: "native search", provider: "anthropic-native", resultCount: 0, durationMs: 80, results: [], }, }; const output = renderHistoryContent([ { type: "server_tool_use", id: "srvtu_1", name: "web_search", input: { query: "native search" }, _activityMetadata: activityMetadata, }, ]); expect(output.toolCalls[0].activityMetadata).toEqual(activityMetadata); }); // ── Persisted answered ask_question ──────────────────────────────────────── test("hydrates a persisted _answeredQuestion onto its tool_use block", () => { // Mirrors what the daemon stamps when an `ask_question` prompt settles, so // the answered card rehydrates on a reopened conversation instead of the // user's decision disappearing with the interactive prompt. const answeredQuestion: AnsweredQuestion = { requestId: "req-1", questions: [ { id: "q1", question: "Which Alice?", options: [ { id: "alice_work", label: "Alice (work)" }, { id: "alice_personal", label: "Alice (personal)" }, ], }, ], responses: [ { questionId: "q1", decision: "option", optionId: "alice_work" }, ], overall: "completed", }; const output = renderHistoryContent([ { type: "tool_use", id: "tu_q", name: "ask_question", input: { questions: [{ question: "Which Alice?" }] }, _answeredQuestion: answeredQuestion, }, ]); expect(output.toolCalls[0].answeredQuestion).toEqual(answeredQuestion); }); test("hydrates a free-text answer verbatim", () => { const output = renderHistoryContent([ { type: "tool_use", id: "tu_q", name: "ask_question", input: {}, _answeredQuestion: { requestId: "req-2", questions: [{ id: "q1", question: "Which fruit?", options: [] }], responses: [ { questionId: "q1", decision: "free_text", text: "Cherry" }, ], overall: "completed", }, }, ]); expect(output.toolCalls[0].answeredQuestion?.responses).toEqual([ { questionId: "q1", decision: "free_text", text: "Cherry" }, ]); }); test("leaves answeredQuestion absent on rows that predate the annotation", () => { const output = renderHistoryContent([ { type: "tool_use", id: "tu_q", name: "ask_question", input: { questions: [{ question: "Which Alice?" }] }, }, ]); expect(output.toolCalls[0].answeredQuestion).toBeUndefined(); }); test("ignores a malformed _answeredQuestion annotation", () => { // The card renders these contents directly, so a rider that does not match // the schema must degrade to no card rather than a crashing render. const output = renderHistoryContent([ { type: "tool_use", id: "tu_q", name: "ask_question", input: {}, _answeredQuestion: { requestId: "req-3", overall: "sideways" }, }, ]); expect(output.toolCalls[0].answeredQuestion).toBeUndefined(); }); test("ignores non-object _activityMetadata annotations", () => { const output = renderHistoryContent([ { type: "tool_use", id: "tu_1", name: "web_search", input: { query: "x" }, _activityMetadata: "not an object", }, ]); expect(output.toolCalls[0].activityMetadata).toBeUndefined(); }); test("ignores non-array _risk*Options annotations", () => { // Defensive: a malformed persisted block should not throw or coerce. const output = renderHistoryContent([ { type: "tool_use", id: "tu_1", name: "bash", input: { command: "ls" }, _riskLevel: "low", _riskScopeOptions: "not an array", _riskAllowlistOptions: { not: "an array" }, _riskDirectoryScopeOptions: 42, }, ]); const [entry] = output.toolCalls; expect(entry.riskLevel).toBe("low"); expect(entry.riskScopeOptions).toBeUndefined(); expect(entry.riskAllowlistOptions).toBeUndefined(); expect(entry.riskDirectoryScopeOptions).toBeUndefined(); }); test("omits absent _risk*Options annotations", () => { const output = renderHistoryContent([ { type: "tool_use", id: "tu_1", name: "bash", input: { command: "ls" }, _riskLevel: "low", }, ]); const [entry] = output.toolCalls; expect(entry.riskLevel).toBe("low"); expect(entry.riskScopeOptions).toBeUndefined(); expect(entry.riskAllowlistOptions).toBeUndefined(); expect(entry.riskDirectoryScopeOptions).toBeUndefined(); }); test("reads back a persisted confirmation decision from the closed enum", () => { // GIVEN a persisted tool_use block stamped with a recorded decision const output = renderHistoryContent([ { type: "tool_use", id: "tu_1", name: "bash", input: { command: "rm file" }, _confirmationDecision: "denied", _confirmationLabel: "Run Command", }, ]); // WHEN it is rendered into a history tool call const [entry] = output.toolCalls; // THEN the decision survives verbatim alongside its label expect(entry.confirmationDecision).toBe("denied"); expect(entry.confirmationLabel).toBe("Run Command"); }); test("drops a _confirmationDecision outside the closed enum", () => { // GIVEN a malformed persisted decision the daemon never writes const output = renderHistoryContent([ { type: "tool_use", id: "tu_1", name: "bash", input: { command: "ls" }, _confirmationDecision: "bogus", }, ]); // WHEN it is rendered into a history tool call const [entry] = output.toolCalls; // THEN the unknown value is not surfaced on the wire expect(entry.confirmationDecision).toBeUndefined(); }); test("handles mixed text and tool blocks", () => { const output = renderHistoryContent([ { type: "text", text: "Let me look that up." }, { type: "tool_use", id: "tu_1", name: "web_fetch", input: { url: "https://example.com" }, }, { type: "tool_result", tool_use_id: "tu_1", content: "page content here", }, ]); expect(output.text).toBe("Let me look that up."); expect(output.toolCalls).toHaveLength(1); expect(output.toolCalls[0].name).toBe("web_fetch"); expect(output.toolCalls[0].result).toBe("page content here"); expect(output.toolCallsBeforeText).toBe(false); }); test("sets toolCallsBeforeText true when tool_use precedes text", () => { const output = renderHistoryContent([ { type: "tool_use", id: "tu_1", name: "bash", input: { command: "ls" } }, { type: "tool_result", tool_use_id: "tu_1", content: "file.txt" }, { type: "text", text: "Here are the files." }, ]); expect(output.toolCallsBeforeText).toBe(true); expect(output.text).toBe("Here are the files."); expect(output.toolCalls).toHaveLength(1); }); test("sets toolCallsBeforeText false when no tool calls exist", () => { const output = renderHistoryContent([{ type: "text", text: "Just text." }]); expect(output.toolCallsBeforeText).toBe(false); }); test("drops orphan tool_result without matching tool_use", () => { const output = renderHistoryContent([ { type: "tool_result", tool_use_id: "missing", content: "some result" }, ]); // Orphans are dropped — without the parent tool_use we can't tell the user // what tool ran, so the result is meaningless. See shared.ts comment. expect(output.toolCalls).toEqual([]); expect(output.text).toBe(""); expect(output.textSegments).toEqual([]); expect(output.contentOrder).toEqual([]); }); test("produces textSegments for text-tool-text interleaving", () => { const output = renderHistoryContent([ { type: "text", text: "What are you working on?" }, { type: "tool_use", id: "tu_1", name: "memory_manage", input: { key: "task" }, }, { type: "tool_result", tool_use_id: "tu_1", content: "saved" }, { type: "text", text: "Saved that to memory." }, ]); expect(output.textSegments).toEqual([ "What are you working on?", "Saved that to memory.", ]); expect(output.contentOrder).toEqual(["text:0", "tool:0", "text:1"]); }); test("produces single segment for text-only content", () => { const output = renderHistoryContent([ { type: "text", text: "Hello " }, { type: "text", text: "world" }, ]); expect(output.textSegments).toEqual(["Hello world"]); expect(output.contentOrder).toEqual(["text:0"]); }); test("produces tool-only contentOrder for tool-only messages", () => { const output = renderHistoryContent([ { type: "tool_use", id: "tu_1", name: "bash", input: { command: "ls" } }, ]); expect(output.textSegments).toEqual([]); expect(output.contentOrder).toEqual(["tool:0"]); }); test("produces segments for tool-text pattern", () => { const output = renderHistoryContent([ { type: "tool_use", id: "tu_1", name: "bash", input: { command: "ls" } }, { type: "tool_result", tool_use_id: "tu_1", content: "file.txt" }, { type: "text", text: "Here are the files." }, ]); expect(output.textSegments).toEqual(["Here are the files."]); expect(output.contentOrder).toEqual(["tool:0", "text:0"]); }); test("produces segments for text-tool-tool-text pattern", () => { const output = renderHistoryContent([ { type: "text", text: "Let me check." }, { type: "tool_use", id: "tu_1", name: "bash", input: { command: "ls" } }, { type: "tool_result", tool_use_id: "tu_1", content: "file.txt" }, { type: "tool_use", id: "tu_2", name: "bash", input: { command: "pwd" } }, { type: "tool_result", tool_use_id: "tu_2", content: "/home" }, { type: "text", text: "Done." }, ]); expect(output.textSegments).toEqual(["Let me check.", "Done."]); expect(output.contentOrder).toEqual([ "text:0", "tool:0", "tool:1", "text:1", ]); }); test("produces empty segments for non-array content", () => { const output = renderHistoryContent(null); expect(output.textSegments).toEqual([]); expect(output.contentOrder).toEqual([]); const output2 = renderHistoryContent("raw string"); expect(output2.textSegments).toEqual(["raw string"]); expect(output2.contentOrder).toEqual(["text:0"]); }); test("skips empty text blocks between consecutive tool_use blocks (consolidated message scenario)", () => { // Simulates message consolidation where empty text blocks from intermediate // API turns end up between tool_use blocks. Without the fix, these create // phantom text segments that break tool-call grouping in the UI. const output = renderHistoryContent([ { type: "text", text: "Let me check." }, { type: "tool_use", id: "tu_1", name: "bash", input: { command: "ls" } }, { type: "tool_result", tool_use_id: "tu_1", content: "file.txt" }, { type: "text", text: "" }, { type: "tool_use", id: "tu_2", name: "bash", input: { command: "pwd" } }, { type: "tool_result", tool_use_id: "tu_2", content: "/home" }, { type: "text", text: "Done." }, ]); expect(output.textSegments).toEqual(["Let me check.", "Done."]); expect(output.contentOrder).toEqual([ "text:0", "tool:0", "tool:1", "text:1", ]); }); test("skips whitespace-only text blocks between consecutive tool_use blocks", () => { const output = renderHistoryContent([ { type: "tool_use", id: "tu_1", name: "bash", input: { command: "ls" } }, { type: "tool_result", tool_use_id: "tu_1", content: "file.txt" }, { type: "text", text: " \n " }, { type: "tool_use", id: "tu_2", name: "bash", input: { command: "pwd" } }, { type: "tool_result", tool_use_id: "tu_2", content: "/home" }, ]); expect(output.textSegments).toEqual([]); expect(output.contentOrder).toEqual(["tool:0", "tool:1"]); }); test("preserves non-empty text blocks between tool_use blocks", () => { const output = renderHistoryContent([ { type: "tool_use", id: "tu_1", name: "bash", input: { command: "ls" } }, { type: "tool_result", tool_use_id: "tu_1", content: "file.txt" }, { type: "text", text: "Now let me try something else." }, { type: "tool_use", id: "tu_2", name: "bash", input: { command: "pwd" } }, { type: "tool_result", tool_use_id: "tu_2", content: "/home" }, ]); expect(output.textSegments).toEqual(["Now let me try something else."]); expect(output.contentOrder).toEqual(["tool:0", "text:0", "tool:1"]); }); test("drops Anthropic placeholder sentinel text blocks from history", () => { // Sentinels are injected into outbound API requests for role alternation // and must never render to the UI. Guards against any leak path that // bypasses cleanAssistantContent (bug-prone stored rows, historical // data predating migration 222, future regressions). const output = renderHistoryContent([ { type: "text", text: "Real response before." }, { type: "text", text: "\x00__PLACEHOLDER__[empty assistant turn]" }, { type: "text", text: "__PLACEHOLDER__[empty assistant turn]" }, { type: "text", text: "\x00__PLACEHOLDER__[internal blocks omitted]" }, { type: "text", text: "__PLACEHOLDER__[internal blocks omitted]" }, { type: "text", text: "Real response after." }, ]); expect(output.text).toBe("Real response before. Real response after."); expect(output.textSegments).toEqual([ "Real response before. Real response after.", ]); expect(output.contentOrder).toEqual(["text:0"]); }); test("yields empty output when content is only a placeholder sentinel", () => { const output = renderHistoryContent([ { type: "text", text: "\x00__PLACEHOLDER__[empty assistant turn]" }, ]); expect(output.text).toBe(""); expect(output.textSegments).toEqual([]); expect(output.contentOrder).toEqual([]); }); test("keeps a flagged surface-fallback text block in .text but out of blocks/segments", () => { // The approval-card builder emits [ui_surface, text(_surfaceFallback)]. The // surface renders the card for rich clients; the flagged fallback must stay // in the flat `.text` body (CLI/search) but NOT appear as a text segment or // content block, or the card would render twice. const output = renderHistoryContent([ { type: "ui_surface", surfaceId: "tool-approval-1", surfaceType: "card", title: "Tool Approval", data: { title: "Bob" }, }, { type: "text", text: "Bob wants to use bash", _surfaceFallback: true }, ]); expect(output.text).toBe("Bob wants to use bash"); expect(output.textSegments).toEqual([]); expect(output.surfaces).toHaveLength(1); expect(output.contentOrder).toEqual(["surface:0"]); expect(output.contentBlocks.map((b) => b.type)).toEqual(["surface"]); }); test("renders an unflagged text block after a surface (legacy [ui_surface, text])", () => { // Pre-flag rows carry no `_surfaceFallback`, so the sibling text still // renders as its own block — legacy cards keep their duplicate text until // re-seeded. New rows carry the flag and de-dupe. const output = renderHistoryContent([ { type: "ui_surface", surfaceId: "s1", surfaceType: "card", data: {} }, { type: "text", text: "legacy fallback" }, ]); expect(output.contentBlocks.map((b) => b.type)).toEqual([ "surface", "text", ]); expect(output.text).toBe("legacy fallback"); }); }); describe("renderHistoryContent contentBlocks", () => { test("builds an ordered block array while walking interleaved content", () => { // GIVEN a turn that interleaves text, reasoning, a tool call, a surface, // and trailing text in the raw model content const output = renderHistoryContent([ { type: "text", text: "before tool" }, { type: "thinking", thinking: "reasoning", signature: "sig" }, { type: "tool_use", id: "t1", name: "run_command", input: { cmd: "ls" } }, { type: "tool_result", tool_use_id: "t1", content: "file.txt" }, { type: "ui_surface", surfaceId: "s1", surfaceType: "ui_card", data: {}, }, { type: "text", text: "after tool" }, ]); // THEN contentBlocks mirrors the walk order expect(output.contentBlocks.map((b) => b.type)).toEqual([ "text", "thinking", "tool_use", "surface", "text", ]); expect(output.contentBlocks[0]).toEqual({ type: "text", text: "before tool", }); expect(output.contentBlocks[1]).toEqual({ type: "thinking", thinking: "reasoning", }); expect(output.contentBlocks[4]).toEqual({ type: "text", text: "after tool", }); // AND the tool_use / surface blocks reuse the same objects the positional // arrays hold, so the tool result paired in later is reflected here too const toolBlock = output.contentBlocks[2]; expect(toolBlock.type === "tool_use" && toolBlock.toolCall).toBe( output.toolCalls[0], ); expect(output.toolCalls[0].result).toBe("file.txt"); const surfaceBlock = output.contentBlocks[3]; expect(surfaceBlock.type === "surface" && surfaceBlock.surface).toBe( output.surfaces[0], ); // AND no attachment block appears (this turn has no file blocks) expect(output.contentBlocks.some((b) => b.type === "attachment")).toBe( false, ); }); test("surfaces persisted thinking timing onto the thinking block", () => { // GIVEN a thinking block the daemon stamped with internal `_startedAt` / // `_completedAt` timing at message_complete const output = renderHistoryContent([ { type: "thinking", thinking: "reasoning", signature: "sig", _startedAt: 1000, _completedAt: 1500, }, ]); // THEN the rendered block exposes them as the wire schema's // startedAt/completedAt so the client can show the duration + hover state expect(output.contentBlocks).toEqual([ { type: "thinking", thinking: "reasoning", startedAt: 1000, completedAt: 1500, }, ]); }); test("omits thinking timing when the block carries none", () => { // GIVEN a thinking block with no persisted timing (older daemon / thinking // streaming disabled) const output = renderHistoryContent([ { type: "thinking", thinking: "reasoning", signature: "sig" }, ]); // THEN the rendered block has no startedAt/completedAt and the client hides // the duration, exactly as a tool call with no timing expect(output.contentBlocks).toEqual([ { type: "thinking", thinking: "reasoning" }, ]); }); const pdfBlock = { type: "file", source: { type: "base64", media_type: "application/pdf", filename: "spec.pdf", data: Buffer.from("hi").toString("base64"), }, } as const; const pdfAttachment = { id: "att-1", filename: "spec.pdf", mimeType: "application/pdf", sizeBytes: 2, kind: "file", } as const; test("inlines an attachment block when hydrated metadata is supplied", () => { // GIVEN a turn with text, a file attachment, then more text, and the // caller supplies the DB-hydrated metadata for that file block const output = renderHistoryContent( [ { type: "text", text: "see file" }, pdfBlock, { type: "text", text: "thanks" }, ], [pdfAttachment], ); // THEN the attachment block is placed inline between the two text blocks expect(output.contentBlocks).toEqual([ { type: "text", text: "see file" }, { type: "attachment", attachment: pdfAttachment }, { type: "text", text: "thanks" }, ]); }); test("omits the file block from contentBlocks when no metadata is supplied", () => { // GIVEN the same turn but no hydrated metadata (the file still ships via // the positional attachments array, just not as an inline block) const output = renderHistoryContent([ { type: "text", text: "see file" }, pdfBlock, { type: "text", text: "thanks" }, ]); expect(output.contentBlocks).toEqual([ { type: "text", text: "see file" }, { type: "text", text: "thanks" }, ]); expect(output.attachments.length).toBe(1); }); test("excludes the trailing attachment-description segment from blocks", () => { // GIVEN an attachment-only turn (the legacy text body carries a synthetic // attachment description segment for clients without attachment UI) const output = renderHistoryContent([pdfBlock], [pdfAttachment]); // THEN the only block is the inlined attachment — the synthetic text // segment stays in textSegments but never pollutes contentBlocks expect(output.contentBlocks).toEqual([ { type: "attachment", attachment: pdfAttachment }, ]); expect(output.textSegments.length).toBe(1); }); test("emits a single text block for the non-array fallback", () => { expect(renderHistoryContent("raw string").contentBlocks).toEqual([ { type: "text", text: "raw string" }, ]); expect(renderHistoryContent(null).contentBlocks).toEqual([]); }); }); describe("getAttachmentsForMessage", () => { beforeEach(() => { const db = getDb(); db.run("DELETE FROM message_attachments"); db.run("DELETE FROM attachments"); db.run("DELETE FROM messages"); db.run("DELETE FROM conversations"); }); async function createMessage( role: "user" | "assistant" | "system", content: string, ): Promise { const conv = createConversation("test"); const msg = await addMessage(conv.id, role, content); return msg.id; } test("returns attachments linked to a message", async () => { const msgId = await createMessage("assistant", "Here is a chart"); const stored = await uploadAttachment("chart.png", "image/png", "iVBORw=="); linkAttachmentToMessage(msgId, stored.id, 0); const result = getAttachmentsForMessage(msgId); expect(result).toHaveLength(1); expect(result[0].id).toBe(stored.id); expect(result[0].originalFilename).toBe("chart.png"); expect(result[0].mimeType).toBe("image/png"); expect(result[0].dataBase64).toBe("iVBORw=="); }); test("returns empty array when no attachments are linked", () => { expect(getAttachmentsForMessage("msg-nonexistent")).toEqual([]); }); test("returns multiple attachments in position order", async () => { const msgId = await createMessage("assistant", "Two files"); const a1 = await uploadAttachment("first.txt", "text/plain", "AAAA"); const a2 = await uploadAttachment("second.txt", "text/plain", "BBBB"); linkAttachmentToMessage(msgId, a2.id, 1); linkAttachmentToMessage(msgId, a1.id, 0); const result = getAttachmentsForMessage(msgId); expect(result).toHaveLength(2); expect(result[0].originalFilename).toBe("first.txt"); expect(result[1].originalFilename).toBe("second.txt"); }); test("returns all attachments linked to a message", async () => { const msgId = await createMessage("assistant", "Mixed"); const a1 = await uploadAttachment("a.png", "image/png", "AAAA"); const a2 = await uploadAttachment("b.png", "image/png", "BBBB"); linkAttachmentToMessage(msgId, a1.id, 0); linkAttachmentToMessage(msgId, a2.id, 1); const result = getAttachmentsForMessage(msgId); expect(result).toHaveLength(2); }); });