From 09cb6bb136afbeb78e6619e01fcd162e0a9b4120 Mon Sep 17 00:00:00 2001 From: Logan Yang Date: Sun, 14 Jun 2026 00:25:01 -0700 Subject: [PATCH] fix(agent-mode): restore user message text when a recent chat has image attachments (#2610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A native Claude chat reopened from recent chats rebuilds its transcript from the on-disk CLI .jsonl. parseClaudeTranscript only accepted user records with string content, treating all array content as a tool_result and skipping it. A user message with an attached image is logged with array content (text + image blocks), so the entire user turn — including its text — was dropped, leaving an orphaned assistant reply. Now array user content is kept unless it carries a tool_result block: its text blocks are restored and images are omitted from the display. Co-authored-by: Claude Opus 4.8 (1M context) --- .../sdk/claudeSessionTranscript.test.ts | 42 +++++++++++++++++++ src/agentMode/sdk/claudeSessionTranscript.ts | 42 ++++++++++++++----- 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/src/agentMode/sdk/claudeSessionTranscript.test.ts b/src/agentMode/sdk/claudeSessionTranscript.test.ts index 343c3c85..f03ae60c 100644 --- a/src/agentMode/sdk/claudeSessionTranscript.test.ts +++ b/src/agentMode/sdk/claudeSessionTranscript.test.ts @@ -52,6 +52,48 @@ describe("parseClaudeTranscript", () => { ]); }); + it("keeps the text of a multimodal user prompt and drops its image blocks", () => { + const jsonl = [ + line({ + type: "user", + timestamp: TS, + message: { + role: "user", + content: [ + { type: "text", text: "what is in this picture?" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "iVBOR" } }, + ], + }, + }), + line({ + type: "assistant", + timestamp: TS, + message: { role: "assistant", content: [{ type: "text", text: "a cat" }] }, + }), + ].join("\n"); + + expect(parseClaudeTranscript(jsonl).map((m) => [m.sender, m.message])).toEqual([ + [USER_SENDER, "what is in this picture?"], + [AI_SENDER, "a cat"], + ]); + }); + + it("unwraps the envelope on a multimodal prompt", () => { + const wrapped = + "\nNotes:\n- a.md\n\n\n\ndescribe the image\n"; + const jsonl = line({ + type: "user", + message: { + role: "user", + content: [ + { type: "text", text: wrapped }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "iVBOR" } }, + ], + }, + }); + expect(parseClaudeTranscript(jsonl)[0].message).toBe("describe the image"); + }); + it("unwraps the envelope, dropping the context block", () => { const wrapped = "\nNotes:\n- a.md\n\n\n\nsummarize a.md\n"; diff --git a/src/agentMode/sdk/claudeSessionTranscript.ts b/src/agentMode/sdk/claudeSessionTranscript.ts index e04a6b9e..4273f4ff 100644 --- a/src/agentMode/sdk/claudeSessionTranscript.ts +++ b/src/agentMode/sdk/claudeSessionTranscript.ts @@ -15,8 +15,11 @@ import type { AgentChatMessage } from "@/agentMode/session/types"; * * Each line is one JSON record. We keep only genuine user prompts and assistant * prose, skipping everything else the CLI logs: - * - `type: "user"` with a **string** content → a typed prompt. (Array content - * on a user record is a `tool_result`, not user input — skipped.) + * - `type: "user"` with a **string** content → a typed prompt. + * - `type: "user"` with **array** content → either a multimodal prompt (text + + * image blocks, e.g. an attached image) or a `tool_result`. We keep the text + * blocks (images are dropped from the display) and skip the record only when + * it carries a `tool_result` block, which is agent output rather than input. * - `type: "assistant"` → concatenated `text` blocks (tool_use / thinking * blocks dropped); skipped entirely when the turn was pure tool use. * - `isMeta` / `isSidechain` records, summaries, attachments, queue ops, @@ -43,16 +46,17 @@ export function parseClaudeTranscript(jsonlText: string): AgentChatMessage[] { if (entry.type === "user" && typeof content === "string") { sender = USER_SENDER; text = stripUserMessageWrapper(content).trim(); + } else if (entry.type === "user" && Array.isArray(content)) { + // A tool_result is agent output the CLI logs as a user record — skip it. + // Anything else (text + image blocks) is a genuine multimodal prompt; + // keep its text and drop the images from the display. + if (!content.some((b) => b?.type === "tool_result")) { + sender = USER_SENDER; + text = stripUserMessageWrapper(joinTextBlocks(content)).trim(); + } } else if (entry.type === "assistant" && Array.isArray(content)) { sender = AI_SENDER; - text = content - .filter( - (b): b is { type: "text"; text: string } => - b?.type === "text" && typeof b.text === "string" - ) - .map((b) => b.text) - .join("\n\n") - .trim(); + text = joinTextBlocks(content); } if (!sender || !text) continue; @@ -74,10 +78,26 @@ interface ClaudeTranscriptEntry { timestamp?: unknown; message?: { role?: string; - content?: string | Array<{ type?: string; text?: string }>; + content?: string | ContentBlock[]; }; } +interface ContentBlock { + type?: string; + text?: string; +} + +/** Concatenate the `text` blocks of a content array, ignoring tool_use, image, etc. */ +function joinTextBlocks(content: ContentBlock[]): string { + return content + .filter( + (b): b is { type: "text"; text: string } => b?.type === "text" && typeof b.text === "string" + ) + .map((b) => b.text) + .join("\n\n") + .trim(); +} + /** * Unwrap the plugin's `` envelope so the stored * prompt (which prepends a `` block when notes are attached)