fix(agent-mode): restore user message text when a recent chat has image attachments (#2610)

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) <noreply@anthropic.com>
This commit is contained in:
Logan Yang 2026-06-14 00:25:01 -07:00 committed by GitHub
parent 4c3044f4b5
commit 09cb6bb136
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 73 additions and 11 deletions

View file

@ -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 <user-message> envelope on a multimodal prompt", () => {
const wrapped =
"<copilot-context>\nNotes:\n- a.md\n</copilot-context>\n\n<user-message>\ndescribe the image\n</user-message>";
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 <user-message> envelope, dropping the context block", () => {
const wrapped =
"<copilot-context>\nNotes:\n- a.md\n</copilot-context>\n\n<user-message>\nsummarize a.md\n</user-message>";

View file

@ -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 `<user-message>…</user-message>` envelope so the stored
* prompt (which prepends a `<copilot-context>` block when notes are attached)