From e77531859f4956eeeedc99bfb15d7ad1f4132948 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sun, 25 Oct 2026 09:06:49 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(agent):=20=E6=81=A2=E5=A4=8D=E5=8E=8B?= =?UTF-8?q?=E7=BC=A9=E8=BF=87=E7=9A=84=20Claude=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=97=B6=E4=BF=9D=E7=95=99=E5=8E=8B=E7=BC=A9=E5=89=8D=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=B6=88=E6=81=AF=E9=87=8C=E7=9A=84=E6=88=AA=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/claude_earlier_history.rs | 47 +++++++++++++++++++ .../agent-tool-activity-timeline.json | 7 +-- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/rust/lithe-agent-host/src/claude_earlier_history.rs b/rust/lithe-agent-host/src/claude_earlier_history.rs index 0ac46fc63..2315d0d07 100644 --- a/rust/lithe-agent-host/src/claude_earlier_history.rs +++ b/rust/lithe-agent-host/src/claude_earlier_history.rs @@ -182,10 +182,37 @@ impl Converter { } (Chunk::Agent, Some("tool_use")) => self.tool_call(block), (Chunk::User, Some("tool_result")) => self.tool_result(block), + (Chunk::User, Some("image")) => self.user_image(block), _ => {} } } + /// A pasted screenshot stays attached to the user message that references + /// it. Only inline base64 sources are replayed; the client bounds the size. + fn user_image(&mut self, block: &Value) { + let source = &block["source"]; + let (Some("base64"), Some(data)) = (source["type"].as_str(), source["data"].as_str()) + else { + return; + }; + if data.is_empty() { + return; + } + self.last = Some(Chunk::User); + let mut update = json!({ + "sessionUpdate": "user_message_chunk", + "content": { + "type": "image", + "data": data, + "mimeType": source["media_type"].as_str().unwrap_or("image/png"), + }, + }); + if let Some(id) = &self.row_id { + update["messageId"] = json!(id); + } + self.updates.push(update); + } + fn text(&mut self, kind: Chunk, text: &str) { if text.trim().is_empty() { return; @@ -424,6 +451,26 @@ mod tests { assert_eq!(updates[1]["messageId"], "q1"); } + #[test] + fn pasted_screenshots_stay_on_the_user_message_that_references_them() { + let (dir, cwd) = transcript(&[ + json!({"type": "user", "uuid": "u1", "message": {"content": [ + {"type": "text", "text": "[Image #1] look"}, + {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "/9j/4A=="}}, + {"type": "image", "source": {"type": "url", "url": "https://example.test/a.png"}}, + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": ""}}, + ]}}), + boundary(), + ]); + let updates = earlier_updates(dir.path(), &cwd, "abc-1"); + assert_eq!(updates.len(), 2, "only the inline base64 image is replayed"); + assert_eq!(updates[1]["sessionUpdate"], "user_message_chunk"); + assert_eq!(updates[1]["content"]["type"], "image"); + assert_eq!(updates[1]["content"]["data"], "/9j/4A=="); + assert_eq!(updates[1]["content"]["mimeType"], "image/jpeg"); + assert_eq!(updates[1]["messageId"], updates[0]["messageId"]); + } + #[test] fn long_text_is_cut_on_a_character_boundary() { let long = "é".repeat(TEXT_LIMIT + 10); diff --git a/shared/platform-feature-matrix/features/agent-tool-activity-timeline.json b/shared/platform-feature-matrix/features/agent-tool-activity-timeline.json index 45e545cc7..200fe9c76 100644 --- a/shared/platform-feature-matrix/features/agent-tool-activity-timeline.json +++ b/shared/platform-feature-matrix/features/agent-tool-activity-timeline.json @@ -12,7 +12,8 @@ "macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift", "macos/Sources/LitheAgentConversationModule/Application/AgentImage.swift", "macos/Sources/Lithe/Views/Agent/AgentImageViews.swift", - "macos/Tests/LitheTests/AgentImageTests.swift" + "macos/Tests/LitheTests/AgentImageTests.swift", + "rust/lithe-agent-host/src/claude_earlier_history.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -33,6 +34,6 @@ "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS 与 Windows:构造思考→过程说明→读文件→思考→执行命令→最终回复,确认每轮只有一个过程入口且默认收起,流式也不自动展开;收起时过程文字与工具行不占高度,最终回复始终可读。展开按原顺序呈现,只有相邻工具合并,状态与证据可继续查看。纯空白思考、过程说明不产生空行,没有思考和工具的直接回答不产生入口;新用户消息、已记录的轮次结束分别切断分组,工具或思考结束而没有最终回复时不伪造回复。搜索过程文字及工具输入、输出、文件路径,匹配组临时展开且不能在搜索期间收起,清空后恢复手动选择;切换和关闭会话验证偏好隔离与释放。深浅主题、窄宽面板、历史恢复和实时流均验证;历史导出、文件审阅、权限卡片、轮次统计和回复复制继续可用。单元测试与组件回放不替代真实 Agent 和 Windows Tauri 运行验收,未执行部分保持 pending。 macOS 图片:让 Agent 读取多张图片,确认缩略图最多三张并显示 +N,悬停出现带标题和序号的预览,点击进入放大查看并可用方向键切换、Esc 关闭;用户粘贴图片后气泡显示缩略图且可放大;触发后台任务后不再出现原始 task-notification 标记。Windows 对应能力尚未实现。 macOS 后台任务与压缩历史:让 Agent 连续启动多个后台命令,确认成功的通知不再单独成行,而是在对应一轮的“思考过程”展开后以中文状态行出现;失败的通知仍在对话中可见。恢复一个经历过多次压缩的 Claude 会话,确认压缩分界线之前的用户消息、回复、思考和工具调用都能滚动查看,且每次压缩处有“对话已从摘要继续”标记。 点击缩略图后的大图支持双指缩放、拖动、百分比与 ⌘+/⌘−/⌘0;用户消息里的 file:// 链接与 [Image: source:] 占位符显示为缩略图;Claude Code 的斜杠命令、本地命令输出、Shell 转义、压缩续接摘要、技能展开和宿主上下文显示为状态行或折叠块,只给模型看的提醒不显示。", - "notes": "2026-10-24:改为每轮一个默认折叠的过程区,包含思考、工具和中间说明;只改变呈现,不改写历史或 ACP 协议。ACP 无最终回复角色,按最后一条思考或工具的位置保留末尾正文;后续活动到达时才将先前正文移入过程。 2026-10-24:macOS 增加工具图片缩略图、悬停预览、放大查看器和后台任务通知状态行(#1462),Windows 待对齐。 2026-10-24(续):折叠的思考过程标题行也显示图片缩略图;放大查看器增加缩放;识别更多 Claude Code 特殊消息格式。 2026-10-24(再续):成功的后台任务通知并入后续过程区;Rust 宿主在 session/load 时只读取最后一个压缩点之前的会话记录,补全 Claude 适配器不回放的历史。" + "verification": "macOS 与 Windows:构造思考→过程说明→读文件→思考→执行命令→最终回复,确认每轮只有一个过程入口且默认收起,流式也不自动展开;收起时过程文字与工具行不占高度,最终回复始终可读。展开按原顺序呈现,只有相邻工具合并,状态与证据可继续查看。纯空白思考、过程说明不产生空行,没有思考和工具的直接回答不产生入口;新用户消息、已记录的轮次结束分别切断分组,工具或思考结束而没有最终回复时不伪造回复。搜索过程文字及工具输入、输出、文件路径,匹配组临时展开且不能在搜索期间收起,清空后恢复手动选择;切换和关闭会话验证偏好隔离与释放。深浅主题、窄宽面板、历史恢复和实时流均验证;历史导出、文件审阅、权限卡片、轮次统计和回复复制继续可用。单元测试与组件回放不替代真实 Agent 和 Windows Tauri 运行验收,未执行部分保持 pending。 macOS 图片:让 Agent 读取多张图片,确认缩略图最多三张并显示 +N,悬停出现带标题和序号的预览,点击进入放大查看并可用方向键切换、Esc 关闭;用户粘贴图片后气泡显示缩略图且可放大;触发后台任务后不再出现原始 task-notification 标记。Windows 对应能力尚未实现。 macOS 后台任务与压缩历史:让 Agent 连续启动多个后台命令,确认成功的通知不再单独成行,而是在对应一轮的“思考过程”展开后以中文状态行出现;失败的通知仍在对话中可见。恢复一个经历过多次压缩的 Claude 会话,确认压缩分界线之前的用户消息、回复、思考和工具调用都能滚动查看,且每次压缩处有“对话已从摘要继续”标记。 点击缩略图后的大图支持双指缩放、拖动、百分比与 ⌘+/⌘−/⌘0;用户消息里的 file:// 链接与 [Image: source:] 占位符显示为缩略图;Claude Code 的斜杠命令、本地命令输出、Shell 转义、压缩续接摘要、技能展开和宿主上下文显示为状态行或折叠块,只给模型看的提醒不显示。 压缩前的用户消息若带粘贴截图,恢复会话后截图仍以缩略图显示在原消息气泡里(不是只剩 [Image #N] 文字),可点击放大。", + "notes": "2026-10-24:改为每轮一个默认折叠的过程区,包含思考、工具和中间说明;只改变呈现,不改写历史或 ACP 协议。ACP 无最终回复角色,按最后一条思考或工具的位置保留末尾正文;后续活动到达时才将先前正文移入过程。 2026-10-24:macOS 增加工具图片缩略图、悬停预览、放大查看器和后台任务通知状态行(#1462),Windows 待对齐。 2026-10-24(续):折叠的思考过程标题行也显示图片缩略图;放大查看器增加缩放;识别更多 Claude Code 特殊消息格式。 2026-10-24(再续):成功的后台任务通知并入后续过程区;Rust 宿主在 session/load 时只读取最后一个压缩点之前的会话记录,补全 Claude 适配器不回放的历史。 2026-10-25:压缩前历史由 Rust 宿主读取会话记录补全,之前丢弃用户消息里的内联截图,现在作为同一条用户消息的 image 块回放(仅 base64 来源);Windows 待对齐。" } From 318e4858b809efab821b6a9975e31cdff161be04 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sun, 25 Oct 2026 09:21:30 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(agent):=20=E5=8E=8B=E7=BC=A9=E5=89=8D?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E5=9B=9E=E6=94=BE=E7=9A=84=E6=88=AA=E5=9B=BE?= =?UTF-8?q?=E8=B6=85=E8=BF=87=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=A4=A7=E5=B0=8F?= =?UTF-8?q?=E4=B8=8A=E9=99=90=E6=97=B6=E4=B8=8D=E5=86=8D=E5=8F=91=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust/lithe-agent-host/src/claude_earlier_history.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/rust/lithe-agent-host/src/claude_earlier_history.rs b/rust/lithe-agent-host/src/claude_earlier_history.rs index 2315d0d07..c5ccf5077 100644 --- a/rust/lithe-agent-host/src/claude_earlier_history.rs +++ b/rust/lithe-agent-host/src/claude_earlier_history.rs @@ -22,6 +22,10 @@ const MAX_TRANSCRIPT_BYTES: u64 = 128 * 1024 * 1024; const MAX_UPDATES: usize = 30_000; /// Per-field text limit, in characters, for tool output and tool input values. const TEXT_LIMIT: usize = 4_000; +/// Longest base64 image replayed, in bytes: the client's 10 MB decoded limit +/// (`AgentImage.maximumBytes`) inflated by a third. A larger screenshot would only +/// be dropped on arrival, so it is not sent. +const IMAGE_DATA_LIMIT: usize = 10_000_000 / 3 * 4; /// Projects directory of the account the adapter runs under: the explicit /// account directory, then `CLAUDE_CONFIG_DIR`, then `~/.claude`. @@ -195,7 +199,7 @@ impl Converter { else { return; }; - if data.is_empty() { + if data.is_empty() || data.len() > IMAGE_DATA_LIMIT { return; } self.last = Some(Chunk::User); @@ -459,11 +463,12 @@ mod tests { {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "/9j/4A=="}}, {"type": "image", "source": {"type": "url", "url": "https://example.test/a.png"}}, {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": ""}}, + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "A".repeat(IMAGE_DATA_LIMIT + 4)}}, ]}}), boundary(), ]); let updates = earlier_updates(dir.path(), &cwd, "abc-1"); - assert_eq!(updates.len(), 2, "only the inline base64 image is replayed"); + assert_eq!(updates.len(), 2, "only the inline base64 image within the limit is replayed"); assert_eq!(updates[1]["sessionUpdate"], "user_message_chunk"); assert_eq!(updates[1]["content"]["type"], "image"); assert_eq!(updates[1]["content"]["data"], "/9j/4A==");