mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
4c3044f4b5
commit
09cb6bb136
2 changed files with 73 additions and 11 deletions
|
|
@ -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>";
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue