From c5e3b746634710ec2c0918e39f485bdeb3bd4fb6 Mon Sep 17 00:00:00 2001 From: Jassy Date: Tue, 19 May 2026 11:40:23 +0800 Subject: [PATCH 1/3] fix: sanitize codex image acp output --- ARCHITECTURE.zh-CN.md | 13 + .../src/protocol/events/mod.rs | 117 +++ .../src/protocol/events/translate.rs | 104 ++- .../aionui-conversation/src/stream_relay.rs | 87 ++ .../2026-05-19-codex-image-acp-output.md | 776 ++++++++++++++++++ 5 files changed, 1095 insertions(+), 2 deletions(-) create mode 100644 docs/plans/2026-05-19-codex-image-acp-output.md diff --git a/ARCHITECTURE.zh-CN.md b/ARCHITECTURE.zh-CN.md index fd37f4264..a64797eee 100644 --- a/ARCHITECTURE.zh-CN.md +++ b/ARCHITECTURE.zh-CN.md @@ -256,6 +256,19 @@ async fn handler( 新增事件必须遵循上述两级 camelCase 规范, 现有不一致的事件在相关模块迭代时逐步统一。 +### ACP 工具输出清洗 + +ACP Agent 的工具调用事件在 `aionui-ai-agent` 翻译层进入统一 +`AgentStreamEvent`。该边界必须保证 WebSocket 和 SQLite 消息内容 +是可控大小,不能把工具返回的大型二进制或 inline base64 原样透传。 + +Codex 图片生成工具可能同时返回 `saved_path` 和 `raw_output.result` +中的 PNG/JPEG/WebP base64。翻译层会在转发和持久化之前移除 +`result`,保留 `saved_path`、`image.path`、`result_omitted`、 +`result_bytes` 等小型结构化字段;如果图片已经落盘,则把工具状态 +归一化为 `completed`。前端应通过文件路径按需加载图片,不应依赖 +inline base64。 + ## 数据层 ### Repository Trait 模式 diff --git a/crates/aionui-ai-agent/src/protocol/events/mod.rs b/crates/aionui-ai-agent/src/protocol/events/mod.rs index 45a33a67d..b25ffafb8 100644 --- a/crates/aionui-ai-agent/src/protocol/events/mod.rs +++ b/crates/aionui-ai-agent/src/protocol/events/mod.rs @@ -328,6 +328,123 @@ mod tests { assert!(json["data"]["update"].get("rawInput").is_none()); } + #[test] + fn codex_image_tool_update_omits_base64_result() { + let large_png_base64 = format!("iVBORw0KGgo{}", "A".repeat(128 * 1024)); + let notif = SessionNotification::new( + "sess-1", + SessionUpdate::ToolCallUpdate(SdkToolCallUpdate::new( + "ig_test_image", + ToolCallUpdateFields::new() + .status(SdkToolCallStatus::InProgress) + .raw_output(json!({ + "call_id": "ig_test_image", + "status": "generating", + "saved_path": "/Users/test/.codex/generated_images/session/ig_test_image.png", + "revised_prompt": "一只小猫", + "result": large_png_base64 + })), + )), + ); + + let events = session_notification_to_events(¬if); + assert_eq!(events.len(), 1); + let json = serde_json::to_value(&events[0]).unwrap(); + let raw_output = &json["data"]["update"]["rawOutput"]; + + assert_eq!( + raw_output["saved_path"], + "/Users/test/.codex/generated_images/session/ig_test_image.png" + ); + assert_eq!( + raw_output["image"]["path"], + "/Users/test/.codex/generated_images/session/ig_test_image.png" + ); + assert_eq!(raw_output["result_omitted"], true); + assert!(raw_output.get("result").is_none()); + } + + #[test] + fn codex_image_tool_update_with_saved_path_is_completed() { + let notif = SessionNotification::new( + "sess-1", + SessionUpdate::ToolCallUpdate(SdkToolCallUpdate::new( + "ig_done_image", + ToolCallUpdateFields::new() + .status(SdkToolCallStatus::InProgress) + .raw_output(json!({ + "call_id": "ig_done_image", + "status": "generating", + "saved_path": "/Users/test/.codex/generated_images/session/ig_done_image.png", + "result": format!("iVBORw0KGgo{}", "A".repeat(128 * 1024)) + })), + )), + ); + + let events = session_notification_to_events(¬if); + assert_eq!(events.len(), 1); + let json = serde_json::to_value(&events[0]).unwrap(); + + assert_eq!(json["data"]["update"]["status"], "completed"); + assert_eq!(json["data"]["update"]["rawOutput"]["status"], "completed"); + } + + #[test] + fn codex_image_tool_update_keeps_small_text_result_in_progress() { + let notif = SessionNotification::new( + "sess-1", + SessionUpdate::ToolCallUpdate(SdkToolCallUpdate::new( + "small-result", + ToolCallUpdateFields::new() + .status(SdkToolCallStatus::InProgress) + .raw_output(json!({ + "status": "generating", + "saved_path": "/tmp/result.txt", + "result": "not an inline image" + })), + )), + ); + + let events = session_notification_to_events(¬if); + let json = serde_json::to_value(&events[0]).unwrap(); + let raw_output = &json["data"]["update"]["rawOutput"]; + + assert_eq!(json["data"]["update"]["status"], "in_progress"); + assert_eq!(raw_output["result"], "not an inline image"); + assert!(raw_output.get("image").is_none()); + assert!(raw_output.get("result_omitted").is_none()); + } + + #[test] + fn codex_image_tool_update_detects_image_mime_types_from_saved_path() { + let cases = [ + ("photo.jpg", "image/jpeg", "/9j/"), + ("photo.webp", "image/webp", "UklGR"), + ("photo.gif", "image/gif", "data:image/gif;base64,"), + ("photo.bin", "image/png", "iVBORw0KGgo"), + ]; + + for (file_name, expected_mime, prefix) in cases { + let saved_path = format!("/Users/test/.codex/generated_images/session/{file_name}"); + let notif = SessionNotification::new( + "sess-1", + SessionUpdate::ToolCallUpdate(SdkToolCallUpdate::new( + "ig_mime", + ToolCallUpdateFields::new().raw_output(json!({ + "saved_path": saved_path, + "result": format!("{prefix}{}", "A".repeat(128 * 1024)) + })), + )), + ); + + let events = session_notification_to_events(¬if); + let json = serde_json::to_value(&events[0]).unwrap(); + + assert_eq!(json["data"]["update"]["rawOutput"]["image"]["mime_type"], expected_mime); + assert!(json["data"]["update"]["rawOutput"].get("result").is_none()); + } + } + #[test] fn permission_request_maps_to_snake_case_event_data() { let request = RequestPermissionRequest::new( diff --git a/crates/aionui-ai-agent/src/protocol/events/translate.rs b/crates/aionui-ai-agent/src/protocol/events/translate.rs index 3aff1263c..41811b246 100644 --- a/crates/aionui-ai-agent/src/protocol/events/translate.rs +++ b/crates/aionui-ai-agent/src/protocol/events/translate.rs @@ -63,16 +63,20 @@ pub(crate) fn session_notification_to_events(notif: &SessionNotification) -> Vec } SessionUpdate::ToolCallUpdate(tcu) => { + let mut raw_output = sanitize_raw_output(tcu.fields.raw_output.clone()); + let status = normalize_tool_status(tcu.fields.status.as_ref(), raw_output.as_ref()); + normalize_raw_output_status(&mut raw_output, status.as_ref()); + events.push(AgentStreamEvent::AcpToolCall(AcpToolCallEventData { session_id, update: AcpToolCallUpdateData { session_update: AcpToolCallSessionUpdateKind::ToolCallUpdate, tool_call_id: tcu.tool_call_id.to_string(), - status: tcu.fields.status.as_ref().map(map_sdk_tool_status), + status, title: tcu.fields.title.clone(), kind: tcu.fields.kind.as_ref().map(map_sdk_tool_kind), raw_input: tcu.fields.raw_input.clone(), - raw_output: tcu.fields.raw_output.clone(), + raw_output, content: tcu .fields .content @@ -157,6 +161,102 @@ fn map_sdk_tool_status(sdk: &SdkToolCallStatus) -> AcpToolCallStatus { } } +const ACP_RAW_OUTPUT_INLINE_IMAGE_LIMIT: usize = 64 * 1024; + +fn sanitize_raw_output(raw_output: Option) -> Option { + let mut value = raw_output?; + sanitize_inline_image_result(&mut value); + Some(value) +} + +fn sanitize_inline_image_result(value: &mut serde_json::Value) { + let Some(obj) = value.as_object_mut() else { + return; + }; + + let saved_path = obj.get("saved_path").and_then(|v| v.as_str()).map(str::to_owned); + let should_omit = obj + .get("result") + .and_then(|v| v.as_str()) + .map(|result| saved_path.is_some() && is_probably_inline_image_result(result)) + .unwrap_or(false); + + if !should_omit { + return; + } + + let result_len = obj.get("result").and_then(|v| v.as_str()).map(str::len).unwrap_or(0); + obj.remove("result"); + obj.insert("result_omitted".to_owned(), serde_json::Value::Bool(true)); + obj.insert( + "result_omitted_reason".to_owned(), + serde_json::Value::String("image_base64".to_owned()), + ); + obj.insert( + "result_bytes".to_owned(), + serde_json::Value::Number(serde_json::Number::from(result_len)), + ); + + if let Some(path) = saved_path { + let mime_type = mime_type_from_image_path(&path); + obj.insert( + "image".to_owned(), + serde_json::json!({ + "path": path, + "mime_type": mime_type, + "source": "codex_image_generation" + }), + ); + } +} + +fn is_probably_inline_image_result(value: &str) -> bool { + value.len() > ACP_RAW_OUTPUT_INLINE_IMAGE_LIMIT + && (value.starts_with("iVBORw0KGgo") + || value.starts_with("/9j/") + || value.starts_with("UklGR") + || value.starts_with("data:image/")) +} + +fn mime_type_from_image_path(path: &str) -> &'static str { + let lower = path.to_ascii_lowercase(); + if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { + "image/jpeg" + } else if lower.ends_with(".webp") { + "image/webp" + } else if lower.ends_with(".gif") { + "image/gif" + } else { + "image/png" + } +} + +fn normalize_tool_status( + sdk_status: Option<&SdkToolCallStatus>, + raw_output: Option<&serde_json::Value>, +) -> Option { + if raw_output + .and_then(|v| v.get("image")) + .and_then(|v| v.get("path")) + .and_then(|v| v.as_str()) + .is_some() + { + return Some(AcpToolCallStatus::Completed); + } + + sdk_status.map(map_sdk_tool_status) +} + +fn normalize_raw_output_status(raw_output: &mut Option, status: Option<&AcpToolCallStatus>) { + let Some(AcpToolCallStatus::Completed) = status else { + return; + }; + let Some(obj) = raw_output.as_mut().and_then(|v| v.as_object_mut()) else { + return; + }; + obj.insert("status".to_owned(), serde_json::Value::String("completed".to_owned())); +} + fn map_sdk_tool_kind(kind: &SdkToolKind) -> AcpToolCallKind { match kind { SdkToolKind::Read | SdkToolKind::Search => AcpToolCallKind::Read, diff --git a/crates/aionui-conversation/src/stream_relay.rs b/crates/aionui-conversation/src/stream_relay.rs index ff353babe..adf93c001 100644 --- a/crates/aionui-conversation/src/stream_relay.rs +++ b/crates/aionui-conversation/src/stream_relay.rs @@ -1444,6 +1444,93 @@ mod tests { ); } + #[tokio::test] + async fn run_acp_image_tool_call_update_persists_finish_without_base64() { + use aionui_ai_agent::protocol::events::tool_call::{ + AcpToolCallEventData, AcpToolCallKind, AcpToolCallSessionUpdateKind, AcpToolCallStatus, + AcpToolCallUpdateData, + }; + + let repo = Arc::new(RecordingRepo::new()); + let bus = Arc::new(aionui_realtime::BroadcastEventBus::new(64)); + let (tx, _) = broadcast::channel(64); + + let relay = StreamRelay::new( + "conv-1".into(), + "asst-1".into(), + "user-1".into(), + repo.clone(), + bus.clone(), + None, + ); + + let rx = tx.subscribe(); + + tx.send(AgentStreamEvent::AcpToolCall(AcpToolCallEventData { + session_id: "sess-1".into(), + update: AcpToolCallUpdateData { + session_update: AcpToolCallSessionUpdateKind::ToolCall, + tool_call_id: "ig_test_image".into(), + status: Some(AcpToolCallStatus::InProgress), + title: Some("Image generation".into()), + kind: Some(AcpToolCallKind::Execute), + raw_input: Some(json!({"prompt": "一只小猫"})), + raw_output: None, + content: None, + locations: None, + }, + meta: None, + })) + .unwrap(); + + tx.send(AgentStreamEvent::AcpToolCall(AcpToolCallEventData { + session_id: "sess-1".into(), + update: AcpToolCallUpdateData { + session_update: AcpToolCallSessionUpdateKind::ToolCallUpdate, + tool_call_id: "ig_test_image".into(), + status: Some(AcpToolCallStatus::Completed), + title: None, + kind: Some(AcpToolCallKind::Execute), + raw_input: None, + raw_output: Some(json!({ + "saved_path": "/Users/test/.codex/generated_images/session/ig_test_image.png", + "image": { + "path": "/Users/test/.codex/generated_images/session/ig_test_image.png", + "mime_type": "image/png", + "source": "codex_image_generation" + }, + "result_omitted": true, + "result_omitted_reason": "image_base64", + "result_bytes": 131_083 + })), + content: None, + locations: None, + }, + meta: None, + })) + .unwrap(); + + tx.send(AgentStreamEvent::Finish(FinishEventData::default())).unwrap(); + + relay.consume(rx).await; + + let updates = repo.take_updates(); + let acp_update = updates.iter().find(|(id, _)| id == "ig_test_image"); + assert!(acp_update.is_some()); + let (_, upd) = acp_update.unwrap(); + assert_eq!(upd.status, Some(Some("finish".to_owned()))); + + let content = upd.content.as_deref().unwrap(); + assert!(!content.contains("iVBORw0KGgo")); + assert!(content.contains("result_omitted")); + + let merged: serde_json::Value = serde_json::from_str(content).unwrap(); + assert_eq!( + merged["update"]["raw_output"]["image"]["path"], + "/Users/test/.codex/generated_images/session/ig_test_image.png" + ); + } + #[tokio::test] async fn run_tool_group_persists_message() { use aionui_ai_agent::protocol::events::tool_call::{ToolCallStatus, ToolGroupEntry}; diff --git a/docs/plans/2026-05-19-codex-image-acp-output.md b/docs/plans/2026-05-19-codex-image-acp-output.md new file mode 100644 index 000000000..19db63e0d --- /dev/null +++ b/docs/plans/2026-05-19-codex-image-acp-output.md @@ -0,0 +1,776 @@ +# Codex 图片 ACP 输出修复实施计划 + +**目标:** 修复 Codex 自带图片生成通过 ACP 接入 AionUi 时会话停在执行中、数据库和前端承载大段图片 base64 的问题,并让生成图片可直接预览。 + +**架构:** 在 AionCLI 的 ACP 翻译边界清洗 Codex 图片工具输出,确保 WebSocket 和数据库都只接收小型结构化结果。AionUi 只负责识别清洗后的 `saved_path` / `image.path` 并复用已有本地图片读取能力展示预览。 + +**技术栈:** Rust / Tokio / serde_json / ACP SDK event mapping / React / TypeScript / Arco Design / bun / cargo nextest 或 cargo test。 + +--- + +## 背景事实 + +当前数据流: + +1. Codex ACP 产出 `SessionUpdate::ToolCallUpdate`。 +2. `AionCLI/crates/aionui-ai-agent/src/protocol/events/translate.rs` 直接复制 `tcu.fields.raw_output`。 +3. `AionCLI/crates/aionui-conversation/src/stream_relay.rs` 先把事件转发到 WebSocket,再持久化到 `messages.content`。 +4. AionUi 前端收到 `acp_tool_call` 后直接合并进消息列表并渲染工具卡。 + +实测异常样本: + +- 会话:`a747f520` +- 消息:`ig_0597c8b499d4f9bd016a0b03149e50819bb807f73f080e2822` +- DB 单条 `messages.content` 约 `2.8MB` +- `raw_output.result` 是完整 PNG base64 +- `raw_output.saved_path` 指向已生成文件 +- 工具状态仍为 `in_progress/generating`,导致前端看起来卡在执行中 + +## 设计原则 + +- 大字段必须在 AionCLI ACP 边界被移除,不能等到前端处理。 +- 普通 shell/read/edit 工具输出不能被误删。 +- 图片已落盘时,以文件路径作为唯一大图载体。 +- 前端展示只消费小型结构化字段,不解析完整 base64。 +- 测试先行,先复现大 base64 和状态卡住,再实现最小修复。 + +--- + +### 任务 1:后端单测覆盖 Codex 图片 raw_output 清洗 + +**文件:** + +- 修改:`AionCLI/crates/aionui-ai-agent/src/protocol/events/translate.rs` +- 测试:`AionCLI/crates/aionui-ai-agent/src/protocol/events/mod.rs` + +**步骤 1:写失败测试** + +在 `events/mod.rs` 的现有 ACP tool call 测试附近新增测试: + +```rust +#[test] +fn codex_image_tool_update_omits_base64_result() { + use agent_client_protocol as acp; + use serde_json::json; + + let large_png_base64 = format!("iVBORw0KGgo{}", "A".repeat(128 * 1024)); + let update = acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + acp::ToolCallId::from("ig_test_image"), + acp::ToolCallUpdateFields::new() + .status(acp::ToolCallStatus::InProgress) + .raw_output(json!({ + "call_id": "ig_test_image", + "status": "generating", + "saved_path": "/Users/test/.codex/generated_images/session/ig_test_image.png", + "revised_prompt": "一只小猫", + "result": large_png_base64 + })), + )); + + let events = translate_session_update("session-1".to_owned(), update); + let json = serde_json::to_value(&events[0]).unwrap(); + let raw_output = &json["data"]["update"]["raw_output"]; + + assert_eq!(raw_output["saved_path"], "/Users/test/.codex/generated_images/session/ig_test_image.png"); + assert_eq!(raw_output["image"]["path"], "/Users/test/.codex/generated_images/session/ig_test_image.png"); + assert_eq!(raw_output["result_omitted"], true); + assert!(raw_output.get("result").is_none()); +} +``` + +**步骤 2:运行测试确认失败** + +运行: + +```bash +cd AionCLI +cargo test -p aionui-ai-agent codex_image_tool_update_omits_base64_result +``` + +预期:FAIL,原因是当前 `raw_output.result` 仍保留完整 base64,且没有 `image` / `result_omitted` 字段。 + +**步骤 3:实现最小清洗函数** + +在 `translate.rs` 增加私有函数: + +```rust +const ACP_RAW_OUTPUT_INLINE_IMAGE_LIMIT: usize = 64 * 1024; + +fn sanitize_raw_output(raw_output: Option) -> Option { + let mut value = raw_output?; + sanitize_inline_image_result(&mut value); + Some(value) +} + +fn sanitize_inline_image_result(value: &mut serde_json::Value) { + let Some(obj) = value.as_object_mut() else { + return; + }; + + let saved_path = obj + .get("saved_path") + .and_then(|v| v.as_str()) + .map(str::to_owned); + + let should_omit = obj + .get("result") + .and_then(|v| v.as_str()) + .map(|s| saved_path.is_some() && is_probably_inline_image_result(s)) + .unwrap_or(false); + + if !should_omit { + return; + } + + let result_len = obj.get("result").and_then(|v| v.as_str()).map(str::len).unwrap_or(0); + obj.remove("result"); + obj.insert("result_omitted".to_owned(), serde_json::Value::Bool(true)); + obj.insert( + "result_omitted_reason".to_owned(), + serde_json::Value::String("image_base64".to_owned()), + ); + obj.insert( + "result_bytes".to_owned(), + serde_json::Value::Number(serde_json::Number::from(result_len)), + ); + + if let Some(path) = saved_path { + obj.insert( + "image".to_owned(), + serde_json::json!({ + "path": path, + "mime_type": mime_type_from_image_path(&path), + "source": "codex_image_generation" + }), + ); + } +} + +fn is_probably_inline_image_result(value: &str) -> bool { + value.len() > ACP_RAW_OUTPUT_INLINE_IMAGE_LIMIT + && (value.starts_with("iVBORw0KGgo") + || value.starts_with("/9j/") + || value.starts_with("UklGR") + || value.starts_with("data:image/")) +} + +fn mime_type_from_image_path(path: &str) -> &'static str { + let lower = path.to_ascii_lowercase(); + if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { + "image/jpeg" + } else if lower.ends_with(".webp") { + "image/webp" + } else if lower.ends_with(".gif") { + "image/gif" + } else { + "image/png" + } +} +``` + +然后把 `ToolCallUpdate` 分支里的: + +```rust +raw_output: tcu.fields.raw_output.clone(), +``` + +替换为: + +```rust +raw_output: sanitize_raw_output(tcu.fields.raw_output.clone()), +``` + +**步骤 4:运行测试确认通过** + +运行: + +```bash +cd AionCLI +cargo test -p aionui-ai-agent codex_image_tool_update_omits_base64_result +``` + +预期:PASS。 + +**步骤 5:提交** + +暂不单独提交,等任务 2 后一起提交后端边界修复。 + +--- + +### 任务 2:后端单测覆盖图片工具状态归一化 + +**文件:** + +- 修改:`AionCLI/crates/aionui-ai-agent/src/protocol/events/translate.rs` +- 测试:`AionCLI/crates/aionui-ai-agent/src/protocol/events/mod.rs` + +**步骤 1:写失败测试** + +新增测试: + +```rust +#[test] +fn codex_image_tool_update_with_saved_path_is_completed() { + use agent_client_protocol as acp; + use serde_json::json; + + let update = acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + acp::ToolCallId::from("ig_done_image"), + acp::ToolCallUpdateFields::new() + .status(acp::ToolCallStatus::InProgress) + .raw_output(json!({ + "call_id": "ig_done_image", + "status": "generating", + "saved_path": "/Users/test/.codex/generated_images/session/ig_done_image.png", + "result": format!("iVBORw0KGgo{}", "A".repeat(128 * 1024)) + })), + )); + + let events = translate_session_update("session-1".to_owned(), update); + let json = serde_json::to_value(&events[0]).unwrap(); + + assert_eq!(json["data"]["update"]["status"], "completed"); + assert_eq!(json["data"]["update"]["raw_output"]["status"], "completed"); +} +``` + +**步骤 2:运行测试确认失败** + +运行: + +```bash +cd AionCLI +cargo test -p aionui-ai-agent codex_image_tool_update_with_saved_path_is_completed +``` + +预期:FAIL,当前状态仍是 `in_progress` / `generating`。 + +**步骤 3:实现状态归一化** + +在 `translate.rs` 增加: + +```rust +fn normalize_tool_status( + sdk_status: Option<&SdkToolCallStatus>, + raw_output: Option<&serde_json::Value>, +) -> Option { + if raw_output.and_then(|v| v.get("image")).and_then(|v| v.get("path")).is_some() { + return Some(AcpToolCallStatus::Completed); + } + + sdk_status.map(map_sdk_tool_status) +} + +fn normalize_raw_output_status(raw_output: &mut Option, status: Option<&AcpToolCallStatus>) { + let Some(AcpToolCallStatus::Completed) = status else { + return; + }; + let Some(obj) = raw_output.as_mut().and_then(|v| v.as_object_mut()) else { + return; + }; + obj.insert("status".to_owned(), serde_json::Value::String("completed".to_owned())); +} +``` + +在 `ToolCallUpdate` 分支先构造: + +```rust +let mut raw_output = sanitize_raw_output(tcu.fields.raw_output.clone()); +let status = normalize_tool_status(tcu.fields.status.as_ref(), raw_output.as_ref()); +normalize_raw_output_status(&mut raw_output, status.as_ref()); +``` + +再填入: + +```rust +status, +raw_output, +``` + +**步骤 4:运行后端相关测试** + +运行: + +```bash +cd AionCLI +cargo test -p aionui-ai-agent codex_image_tool_update +``` + +预期:两个新增测试 PASS。 + +**步骤 5:后端边界提交** + +运行: + +```bash +cd AionCLI +git add crates/aionui-ai-agent/src/protocol/events/translate.rs crates/aionui-ai-agent/src/protocol/events/mod.rs docs/plans/2026-05-19-codex-image-acp-output.md +git commit -m "fix: sanitize codex image acp output" +``` + +--- + +### 任务 3:会话持久化层增加回归测试 + +**文件:** + +- 修改:`AionCLI/crates/aionui-conversation/src/stream_relay.rs` + +**步骤 1:写失败测试或补充现有测试** + +在现有 `run_acp_tool_call_inserts_then_updates` 附近新增测试,直接发送清洗后的 completed 图片事件: + +```rust +#[tokio::test] +async fn run_acp_image_tool_call_update_persists_finish_without_base64() { + use aionui_ai_agent::protocol::events::tool_call::{ + AcpToolCallEventData, AcpToolCallSessionUpdateKind, AcpToolCallStatus, AcpToolCallUpdateData, + }; + + let (relay, tx, repo) = setup_relay_for_test(); + + tx.send(AgentStreamEvent::AcpToolCall(AcpToolCallEventData { + session_id: "session-1".to_owned(), + update: AcpToolCallUpdateData { + session_update: AcpToolCallSessionUpdateKind::ToolCallUpdate, + tool_call_id: "ig_test_image".into(), + status: Some(AcpToolCallStatus::Completed), + title: Some("Image generation".into()), + kind: Some(AcpToolCallKind::Execute), + raw_input: None, + raw_output: Some(serde_json::json!({ + "saved_path": "/Users/test/.codex/generated_images/session/ig_test_image.png", + "image": { + "path": "/Users/test/.codex/generated_images/session/ig_test_image.png", + "mime_type": "image/png", + "source": "codex_image_generation" + }, + "result_omitted": true + })), + content: None, + locations: None, + }, + meta: None, + })) + .unwrap(); + tx.send(AgentStreamEvent::Finish(Default::default())).unwrap(); + + relay.run().await.unwrap(); + + let msg = repo + .get_message_by_msg_id("conv-1", "ig_test_image", "acp_tool_call") + .await + .unwrap() + .unwrap(); + + assert_eq!(msg.status.as_deref(), Some("finish")); + assert!(!msg.content.contains("iVBORw0KGgo")); + assert!(msg.content.contains("result_omitted")); +} +``` + +如果现有测试 helper 不能直接复用,先按已有 stream relay 测试样式补齐最小 fixture,不引入新测试框架。 + +**步骤 2:运行测试确认失败或编译失败** + +运行: + +```bash +cd AionCLI +cargo test -p aionui-conversation acp_image_tool_call +``` + +预期:初次可能因 fixture 名称不存在失败,按现有测试结构修正。 + +**步骤 3:最小调整** + +如果任务 1/2 已在翻译层处理,这里通常无需业务实现。只需要确保测试使用现有 helper 正确创建 relay 和 mock repo。 + +**步骤 4:运行测试确认通过** + +运行: + +```bash +cd AionCLI +cargo test -p aionui-conversation acp_image_tool_call +``` + +预期:PASS。 + +**步骤 5:提交** + +运行: + +```bash +cd AionCLI +git add crates/aionui-conversation/src/stream_relay.rs +git commit -m "test: cover acp image tool persistence" +``` + +--- + +### 任务 4:前端类型补齐 ACP raw output 字段 + +**文件:** + +- 修改:`AionUi/packages/desktop/src/common/types/platform/acpTypes.ts` + +**步骤 1:写类型结构** + +给 `ToolCallUpdate.update` 增加兼容字段: + +```ts +export interface AcpImageOutput { + path: string; + mime_type?: string; + source?: string; +} + +export interface AcpRawOutput { + saved_path?: string; + image?: AcpImageOutput; + result_omitted?: boolean; + result_omitted_reason?: string; + result_bytes?: number; + status?: string; + [key: string]: unknown; +} +``` + +然后在 `ToolCallUpdate.update` 内增加: + +```ts +rawOutput?: AcpRawOutput; +raw_output?: AcpRawOutput; +``` + +保留 `rawInput`,不要破坏现有字段。 + +**步骤 2:运行类型检查确认现有代码能编译** + +运行: + +```bash +cd AionUi +bun run typecheck +``` + +预期:PASS。如果仓库没有 `typecheck` 脚本,改跑 `bun run build`。 + +**步骤 3:提交** + +暂不提交,等任务 5 前端展示一起提交。 + +--- + +### 任务 5:前端 ACP 工具卡展示图片预览 + +**文件:** + +- 修改:`AionUi/packages/desktop/src/renderer/pages/conversation/Messages/acp/MessageAcpToolCall.tsx` + +**步骤 1:写提取函数** + +在组件文件内新增: + +```ts +function getAcpImagePath(update: IMessageAcpToolCall['content']['update']): string | undefined { + const rawOutput = update.rawOutput || update.raw_output; + const imagePath = rawOutput?.image?.path; + if (typeof imagePath === 'string' && imagePath) return imagePath; + + const savedPath = rawOutput?.saved_path; + if (typeof savedPath === 'string' && savedPath) return savedPath; + + return undefined; +} +``` + +**步骤 2:引入 LocalImageView** + +```ts +import LocalImageView from '@/renderer/components/media/LocalImageView'; +``` + +**步骤 3:在工具卡内容区展示图片** + +在 `MessageAcpToolCall` 中: + +```ts +const imagePath = getAcpImagePath(update); +``` + +在 `diffContent` 渲染前后加入: + +```tsx +{imagePath && ( +
+ +
+)} +``` + +**步骤 4:运行前端检查** + +运行: + +```bash +cd AionUi +bun run typecheck +``` + +预期:PASS。如果没有该脚本: + +```bash +cd AionUi +bun run build +``` + +预期:PASS。 + +**步骤 5:提交** + +运行: + +```bash +cd AionUi +git add packages/desktop/src/common/types/platform/acpTypes.ts packages/desktop/src/renderer/pages/conversation/Messages/acp/MessageAcpToolCall.tsx +git commit -m "feat: preview codex acp generated images" +``` + +--- + +### 任务 6:前端兜底清洗超大 ACP raw output + +**文件:** + +- 修改:`AionUi/packages/desktop/src/common/chat/chatLib.ts` + +**步骤 1:增加防御性 sanitizer** + +在 `mergeAcpToolCallContent` 前新增: + +```ts +const INLINE_IMAGE_RESULT_LIMIT = 64 * 1024; + +function sanitizeAcpToolUpdate(update: T): T { + const next = { ...update }; + for (const key of ['rawOutput', 'raw_output'] as const) { + const raw = next[key]; + if (!raw || typeof raw !== 'object') continue; + + const result = raw.result; + const savedPath = raw.saved_path; + if (typeof result !== 'string' || result.length <= INLINE_IMAGE_RESULT_LIMIT || typeof savedPath !== 'string') { + continue; + } + + next[key] = { + ...raw, + result: undefined, + image: raw.image || { + path: savedPath, + mime_type: 'image/png', + source: 'codex_image_generation', + }, + result_omitted: true, + result_omitted_reason: raw.result_omitted_reason || 'image_base64', + result_bytes: raw.result_bytes || result.length, + }; + delete next[key].result; + } + return next; +} +``` + +然后修改 merge: + +```ts +update: sanitizeAcpToolUpdate({ + ...existing.update, + ...incoming.update, +}), +``` + +**步骤 2:运行前端检查** + +运行: + +```bash +cd AionUi +bun run typecheck +``` + +预期:PASS。 + +**步骤 3:提交** + +运行: + +```bash +cd AionUi +git add packages/desktop/src/common/chat/chatLib.ts +git commit -m "fix: guard acp tool output size in renderer" +``` + +--- + +### 任务 7:文档同步 + +**文件:** + +- 修改:`AionCLI/ARCHITECTURE.zh-CN.md` +- 修改:`AionUi/docs/guides/webui.md` 或新增 `AionUi/docs/guides/acp-image-output.md` + +**步骤 1:更新 AionCLI 架构文档** + +在 ACP 事件或 agent runtime 相关章节补充: + +```markdown +### ACP 工具输出清洗 + +Codex ACP 的图片生成工具可能返回 `saved_path` 与 inline image base64。AionCLI 在 ACP 翻译边界会把图片 base64 从 `raw_output.result` 中移除,只保留 `saved_path`、`image.path`、`result_omitted` 与大小元数据,避免 WebSocket、SQLite 和前端渲染承载大 payload。 +``` + +**步骤 2:更新 AionUi 展示文档** + +如果 `webui.md` 适合补充,则新增一小节;否则新增指南: + +```markdown +# ACP 图片输出展示 + +AionUi 对 ACP 工具调用中的 `raw_output.image.path` / `raw_output.saved_path` 渲染本地图片预览。前端不依赖 inline base64,图片文件通过 `/api/fs/image-base64` 按需读取。 +``` + +**步骤 3:提交文档** + +运行: + +```bash +cd AionCLI +git add ARCHITECTURE.zh-CN.md docs/plans/2026-05-19-codex-image-acp-output.md +git commit -m "docs: document acp image output handling" +``` + +运行: + +```bash +cd AionUi +git add docs/guides/webui.md +git commit -m "docs: document acp image preview handling" +``` + +如果实际新增了 `docs/guides/acp-image-output.md`,提交该文件。 + +--- + +### 任务 8:本地回归验证 + +**文件:** + +- No code changes. + +**步骤 1:后端测试** + +运行: + +```bash +cd AionCLI +cargo test -p aionui-ai-agent codex_image_tool_update +cargo test -p aionui-conversation acp_image_tool_call +cargo test -p aionui-ai-agent +cargo test -p aionui-conversation +``` + +预期:PASS。 + +**步骤 2:前端测试** + +运行: + +```bash +cd AionUi +bun run typecheck +bun run build +``` + +预期:PASS。 + +**步骤 3:静态检查** + +运行: + +```bash +cd AionCLI +cargo fmt --check +cargo clippy -p aionui-ai-agent -p aionui-conversation --all-targets -- -D warnings +git diff --check +``` + +预期:PASS。 + +运行: + +```bash +cd AionUi +git diff --check +``` + +预期:PASS。 + +**步骤 4:手工复现验证** + +启动本地 AionUi/AionCLI 后,用 Codex 会话发送: + +```text +帮我生成一只小猫 +``` + +预期: + +- 不再卡在持续执行中。 +- DB 中 `acp_tool_call` 消息小于 `100KB`。 +- `raw_output.result` 不存在。 +- `raw_output.image.path` 或 `raw_output.saved_path` 存在。 +- 前端工具卡直接显示生成图片。 + +检查 DB: + +```bash +sqlite3 "$HOME/Library/Application Support/AionUi/aionui/aionui-backend.db" \ + "select id,status,length(content),instr(content,'iVBORw0KGgo'),instr(content,'result_omitted') from messages where type='acp_tool_call' order by created_at desc limit 5;" +``` + +预期: + +- `status` 为 `finish` +- `length(content)` 不再是 MB 级 +- `instr(content,'iVBORw0KGgo')` 为 `0` +- `instr(content,'result_omitted')` 大于 `0` + +**步骤 5:最终状态检查** + +运行: + +```bash +git -C AionCLI status --short --branch +git -C AionUi status --short --branch +``` + +预期: + +- 只有预期提交或干净工作区。 +- 不包含 co-author 元信息。 + +--- + +## 风险与回滚 + +- 风险:某些非图片工具也可能返回超长字符串并带 `saved_path`。缓解:清洗条件同时要求图片 base64 前缀或 `data:image/`。 +- 风险:Codex 后续字段名变化。缓解:前端同时兼容 `rawOutput` 和 `raw_output`,后端只依赖 `saved_path/result/status` 这些当前已观测字段。 +- 风险:状态归一化误把仍在生成的图片标记完成。缓解:只在存在 `saved_path` 且图片 result 被清洗后归一化为 completed。 +- 回滚:后端 sanitizer 是边界纯函数,回滚 `translate.rs` 相关提交即可恢复原始 ACP 事件透传;前端展示改动独立,可单独回滚。 From 82562ec46ed9671ca0a0993f05a355c9ca41029a Mon Sep 17 00:00:00 2001 From: Jassy Date: Mon, 15 Jun 2026 11:29:03 +0800 Subject: [PATCH 2/3] fix: address review on codex image acp sanitization Strip oversized inline-image base64 from ToolCallUpdate raw_output unconditionally, regardless of whether saved_path is present. Older codex versions and interrupted/failed generations emit multi-MB base64 without saved_path; that payload must not reach WebSocket or SQLite (the failure mode in #297). saved_path now only decides whether the structured image{path,mime_type,source} object is attached. Restrict status normalization to non-terminal statuses so a genuine Failed reported by the agent is preserved instead of being rewritten to completed. Add regression tests: - oversized base64 without saved_path is stripped, no image object, status passes through unchanged - Failed + saved_path keeps failed and does not rewrite raw_output.status Drop the 776-line working plan doc from the repo per review. --- .../src/protocol/events/mod.rs | 59 ++ .../src/protocol/events/translate.rs | 24 +- .../2026-05-19-codex-image-acp-output.md | 776 ------------------ 3 files changed, 76 insertions(+), 783 deletions(-) delete mode 100644 docs/plans/2026-05-19-codex-image-acp-output.md diff --git a/crates/aionui-ai-agent/src/protocol/events/mod.rs b/crates/aionui-ai-agent/src/protocol/events/mod.rs index b25ffafb8..9b506e334 100644 --- a/crates/aionui-ai-agent/src/protocol/events/mod.rs +++ b/crates/aionui-ai-agent/src/protocol/events/mod.rs @@ -445,6 +445,65 @@ mod tests { } } + #[test] + fn codex_image_tool_update_omits_base64_without_saved_path() { + let large_png_base64 = format!("iVBORw0KGgo{}", "A".repeat(128 * 1024)); + let notif = SessionNotification::new( + "sess-1", + SessionUpdate::ToolCallUpdate(SdkToolCallUpdate::new( + "ig_no_path", + ToolCallUpdateFields::new() + .status(SdkToolCallStatus::InProgress) + .raw_output(json!({ + "call_id": "ig_no_path", + "status": "generating", + "result": large_png_base64 + })), + )), + ); + + let events = session_notification_to_events(¬if); + assert_eq!(events.len(), 1); + let json = serde_json::to_value(&events[0]).unwrap(); + let raw_output = &json["data"]["update"]["rawOutput"]; + + // Oversized base64 must be stripped even though Codex did not save the file. + assert!(raw_output.get("result").is_none()); + assert_eq!(raw_output["result_omitted"], true); + // No saved_path means we cannot offer a path-based preview, so no image object. + assert!(raw_output.get("image").is_none()); + // Without a saved image the status must pass through unchanged. + assert_eq!(json["data"]["update"]["status"], "in_progress"); + } + + #[test] + fn codex_image_tool_update_preserves_failed_status() { + let notif = SessionNotification::new( + "sess-1", + SessionUpdate::ToolCallUpdate(SdkToolCallUpdate::new( + "ig_failed_image", + ToolCallUpdateFields::new() + .status(SdkToolCallStatus::Failed) + .raw_output(json!({ + "call_id": "ig_failed_image", + "status": "failed", + "saved_path": "/Users/test/.codex/generated_images/session/ig_failed_image.png", + "result": format!("iVBORw0KGgo{}", "A".repeat(128 * 1024)) + })), + )), + ); + + let events = session_notification_to_events(¬if); + assert_eq!(events.len(), 1); + let json = serde_json::to_value(&events[0]).unwrap(); + + // A terminal `failed` status must never be rewritten to `completed`. + assert_eq!(json["data"]["update"]["status"], "failed"); + assert_eq!(json["data"]["update"]["rawOutput"]["status"], "failed"); + // The base64 payload is still stripped regardless of the failure. + assert!(json["data"]["update"]["rawOutput"].get("result").is_none()); + } + #[test] fn permission_request_maps_to_snake_case_event_data() { let request = RequestPermissionRequest::new( diff --git a/crates/aionui-ai-agent/src/protocol/events/translate.rs b/crates/aionui-ai-agent/src/protocol/events/translate.rs index 41811b246..b8ee16dd9 100644 --- a/crates/aionui-ai-agent/src/protocol/events/translate.rs +++ b/crates/aionui-ai-agent/src/protocol/events/translate.rs @@ -175,10 +175,15 @@ fn sanitize_inline_image_result(value: &mut serde_json::Value) { }; let saved_path = obj.get("saved_path").and_then(|v| v.as_str()).map(str::to_owned); + // Strip any oversized inline-image `result` regardless of whether the image was + // saved to disk. Older codex versions and interrupted/failed generations may emit + // the multi-MB base64 without a `saved_path`; that payload must never reach the + // WebSocket broadcast or SQLite either. `saved_path` only decides whether we attach + // the structured `image { path, mime_type, source }` object below. let should_omit = obj .get("result") .and_then(|v| v.as_str()) - .map(|result| saved_path.is_some() && is_probably_inline_image_result(result)) + .map(is_probably_inline_image_result) .unwrap_or(false); if !should_omit { @@ -235,16 +240,21 @@ fn normalize_tool_status( sdk_status: Option<&SdkToolCallStatus>, raw_output: Option<&serde_json::Value>, ) -> Option { - if raw_output + let image_saved = raw_output .and_then(|v| v.get("image")) .and_then(|v| v.get("path")) .and_then(|v| v.as_str()) - .is_some() - { - return Some(AcpToolCallStatus::Completed); + .is_some(); + + // Only force `completed` when the image is on disk AND the agent did not already + // report a terminal status. Codex stalls by leaving the final event as + // `generating`/`in_progress`, but a genuine `failed` must be preserved as-is. + match (image_saved, sdk_status.map(map_sdk_tool_status)) { + (true, None | Some(AcpToolCallStatus::Pending | AcpToolCallStatus::InProgress)) => { + Some(AcpToolCallStatus::Completed) + } + (_, status) => status, } - - sdk_status.map(map_sdk_tool_status) } fn normalize_raw_output_status(raw_output: &mut Option, status: Option<&AcpToolCallStatus>) { diff --git a/docs/plans/2026-05-19-codex-image-acp-output.md b/docs/plans/2026-05-19-codex-image-acp-output.md deleted file mode 100644 index 19db63e0d..000000000 --- a/docs/plans/2026-05-19-codex-image-acp-output.md +++ /dev/null @@ -1,776 +0,0 @@ -# Codex 图片 ACP 输出修复实施计划 - -**目标:** 修复 Codex 自带图片生成通过 ACP 接入 AionUi 时会话停在执行中、数据库和前端承载大段图片 base64 的问题,并让生成图片可直接预览。 - -**架构:** 在 AionCLI 的 ACP 翻译边界清洗 Codex 图片工具输出,确保 WebSocket 和数据库都只接收小型结构化结果。AionUi 只负责识别清洗后的 `saved_path` / `image.path` 并复用已有本地图片读取能力展示预览。 - -**技术栈:** Rust / Tokio / serde_json / ACP SDK event mapping / React / TypeScript / Arco Design / bun / cargo nextest 或 cargo test。 - ---- - -## 背景事实 - -当前数据流: - -1. Codex ACP 产出 `SessionUpdate::ToolCallUpdate`。 -2. `AionCLI/crates/aionui-ai-agent/src/protocol/events/translate.rs` 直接复制 `tcu.fields.raw_output`。 -3. `AionCLI/crates/aionui-conversation/src/stream_relay.rs` 先把事件转发到 WebSocket,再持久化到 `messages.content`。 -4. AionUi 前端收到 `acp_tool_call` 后直接合并进消息列表并渲染工具卡。 - -实测异常样本: - -- 会话:`a747f520` -- 消息:`ig_0597c8b499d4f9bd016a0b03149e50819bb807f73f080e2822` -- DB 单条 `messages.content` 约 `2.8MB` -- `raw_output.result` 是完整 PNG base64 -- `raw_output.saved_path` 指向已生成文件 -- 工具状态仍为 `in_progress/generating`,导致前端看起来卡在执行中 - -## 设计原则 - -- 大字段必须在 AionCLI ACP 边界被移除,不能等到前端处理。 -- 普通 shell/read/edit 工具输出不能被误删。 -- 图片已落盘时,以文件路径作为唯一大图载体。 -- 前端展示只消费小型结构化字段,不解析完整 base64。 -- 测试先行,先复现大 base64 和状态卡住,再实现最小修复。 - ---- - -### 任务 1:后端单测覆盖 Codex 图片 raw_output 清洗 - -**文件:** - -- 修改:`AionCLI/crates/aionui-ai-agent/src/protocol/events/translate.rs` -- 测试:`AionCLI/crates/aionui-ai-agent/src/protocol/events/mod.rs` - -**步骤 1:写失败测试** - -在 `events/mod.rs` 的现有 ACP tool call 测试附近新增测试: - -```rust -#[test] -fn codex_image_tool_update_omits_base64_result() { - use agent_client_protocol as acp; - use serde_json::json; - - let large_png_base64 = format!("iVBORw0KGgo{}", "A".repeat(128 * 1024)); - let update = acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( - acp::ToolCallId::from("ig_test_image"), - acp::ToolCallUpdateFields::new() - .status(acp::ToolCallStatus::InProgress) - .raw_output(json!({ - "call_id": "ig_test_image", - "status": "generating", - "saved_path": "/Users/test/.codex/generated_images/session/ig_test_image.png", - "revised_prompt": "一只小猫", - "result": large_png_base64 - })), - )); - - let events = translate_session_update("session-1".to_owned(), update); - let json = serde_json::to_value(&events[0]).unwrap(); - let raw_output = &json["data"]["update"]["raw_output"]; - - assert_eq!(raw_output["saved_path"], "/Users/test/.codex/generated_images/session/ig_test_image.png"); - assert_eq!(raw_output["image"]["path"], "/Users/test/.codex/generated_images/session/ig_test_image.png"); - assert_eq!(raw_output["result_omitted"], true); - assert!(raw_output.get("result").is_none()); -} -``` - -**步骤 2:运行测试确认失败** - -运行: - -```bash -cd AionCLI -cargo test -p aionui-ai-agent codex_image_tool_update_omits_base64_result -``` - -预期:FAIL,原因是当前 `raw_output.result` 仍保留完整 base64,且没有 `image` / `result_omitted` 字段。 - -**步骤 3:实现最小清洗函数** - -在 `translate.rs` 增加私有函数: - -```rust -const ACP_RAW_OUTPUT_INLINE_IMAGE_LIMIT: usize = 64 * 1024; - -fn sanitize_raw_output(raw_output: Option) -> Option { - let mut value = raw_output?; - sanitize_inline_image_result(&mut value); - Some(value) -} - -fn sanitize_inline_image_result(value: &mut serde_json::Value) { - let Some(obj) = value.as_object_mut() else { - return; - }; - - let saved_path = obj - .get("saved_path") - .and_then(|v| v.as_str()) - .map(str::to_owned); - - let should_omit = obj - .get("result") - .and_then(|v| v.as_str()) - .map(|s| saved_path.is_some() && is_probably_inline_image_result(s)) - .unwrap_or(false); - - if !should_omit { - return; - } - - let result_len = obj.get("result").and_then(|v| v.as_str()).map(str::len).unwrap_or(0); - obj.remove("result"); - obj.insert("result_omitted".to_owned(), serde_json::Value::Bool(true)); - obj.insert( - "result_omitted_reason".to_owned(), - serde_json::Value::String("image_base64".to_owned()), - ); - obj.insert( - "result_bytes".to_owned(), - serde_json::Value::Number(serde_json::Number::from(result_len)), - ); - - if let Some(path) = saved_path { - obj.insert( - "image".to_owned(), - serde_json::json!({ - "path": path, - "mime_type": mime_type_from_image_path(&path), - "source": "codex_image_generation" - }), - ); - } -} - -fn is_probably_inline_image_result(value: &str) -> bool { - value.len() > ACP_RAW_OUTPUT_INLINE_IMAGE_LIMIT - && (value.starts_with("iVBORw0KGgo") - || value.starts_with("/9j/") - || value.starts_with("UklGR") - || value.starts_with("data:image/")) -} - -fn mime_type_from_image_path(path: &str) -> &'static str { - let lower = path.to_ascii_lowercase(); - if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { - "image/jpeg" - } else if lower.ends_with(".webp") { - "image/webp" - } else if lower.ends_with(".gif") { - "image/gif" - } else { - "image/png" - } -} -``` - -然后把 `ToolCallUpdate` 分支里的: - -```rust -raw_output: tcu.fields.raw_output.clone(), -``` - -替换为: - -```rust -raw_output: sanitize_raw_output(tcu.fields.raw_output.clone()), -``` - -**步骤 4:运行测试确认通过** - -运行: - -```bash -cd AionCLI -cargo test -p aionui-ai-agent codex_image_tool_update_omits_base64_result -``` - -预期:PASS。 - -**步骤 5:提交** - -暂不单独提交,等任务 2 后一起提交后端边界修复。 - ---- - -### 任务 2:后端单测覆盖图片工具状态归一化 - -**文件:** - -- 修改:`AionCLI/crates/aionui-ai-agent/src/protocol/events/translate.rs` -- 测试:`AionCLI/crates/aionui-ai-agent/src/protocol/events/mod.rs` - -**步骤 1:写失败测试** - -新增测试: - -```rust -#[test] -fn codex_image_tool_update_with_saved_path_is_completed() { - use agent_client_protocol as acp; - use serde_json::json; - - let update = acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( - acp::ToolCallId::from("ig_done_image"), - acp::ToolCallUpdateFields::new() - .status(acp::ToolCallStatus::InProgress) - .raw_output(json!({ - "call_id": "ig_done_image", - "status": "generating", - "saved_path": "/Users/test/.codex/generated_images/session/ig_done_image.png", - "result": format!("iVBORw0KGgo{}", "A".repeat(128 * 1024)) - })), - )); - - let events = translate_session_update("session-1".to_owned(), update); - let json = serde_json::to_value(&events[0]).unwrap(); - - assert_eq!(json["data"]["update"]["status"], "completed"); - assert_eq!(json["data"]["update"]["raw_output"]["status"], "completed"); -} -``` - -**步骤 2:运行测试确认失败** - -运行: - -```bash -cd AionCLI -cargo test -p aionui-ai-agent codex_image_tool_update_with_saved_path_is_completed -``` - -预期:FAIL,当前状态仍是 `in_progress` / `generating`。 - -**步骤 3:实现状态归一化** - -在 `translate.rs` 增加: - -```rust -fn normalize_tool_status( - sdk_status: Option<&SdkToolCallStatus>, - raw_output: Option<&serde_json::Value>, -) -> Option { - if raw_output.and_then(|v| v.get("image")).and_then(|v| v.get("path")).is_some() { - return Some(AcpToolCallStatus::Completed); - } - - sdk_status.map(map_sdk_tool_status) -} - -fn normalize_raw_output_status(raw_output: &mut Option, status: Option<&AcpToolCallStatus>) { - let Some(AcpToolCallStatus::Completed) = status else { - return; - }; - let Some(obj) = raw_output.as_mut().and_then(|v| v.as_object_mut()) else { - return; - }; - obj.insert("status".to_owned(), serde_json::Value::String("completed".to_owned())); -} -``` - -在 `ToolCallUpdate` 分支先构造: - -```rust -let mut raw_output = sanitize_raw_output(tcu.fields.raw_output.clone()); -let status = normalize_tool_status(tcu.fields.status.as_ref(), raw_output.as_ref()); -normalize_raw_output_status(&mut raw_output, status.as_ref()); -``` - -再填入: - -```rust -status, -raw_output, -``` - -**步骤 4:运行后端相关测试** - -运行: - -```bash -cd AionCLI -cargo test -p aionui-ai-agent codex_image_tool_update -``` - -预期:两个新增测试 PASS。 - -**步骤 5:后端边界提交** - -运行: - -```bash -cd AionCLI -git add crates/aionui-ai-agent/src/protocol/events/translate.rs crates/aionui-ai-agent/src/protocol/events/mod.rs docs/plans/2026-05-19-codex-image-acp-output.md -git commit -m "fix: sanitize codex image acp output" -``` - ---- - -### 任务 3:会话持久化层增加回归测试 - -**文件:** - -- 修改:`AionCLI/crates/aionui-conversation/src/stream_relay.rs` - -**步骤 1:写失败测试或补充现有测试** - -在现有 `run_acp_tool_call_inserts_then_updates` 附近新增测试,直接发送清洗后的 completed 图片事件: - -```rust -#[tokio::test] -async fn run_acp_image_tool_call_update_persists_finish_without_base64() { - use aionui_ai_agent::protocol::events::tool_call::{ - AcpToolCallEventData, AcpToolCallSessionUpdateKind, AcpToolCallStatus, AcpToolCallUpdateData, - }; - - let (relay, tx, repo) = setup_relay_for_test(); - - tx.send(AgentStreamEvent::AcpToolCall(AcpToolCallEventData { - session_id: "session-1".to_owned(), - update: AcpToolCallUpdateData { - session_update: AcpToolCallSessionUpdateKind::ToolCallUpdate, - tool_call_id: "ig_test_image".into(), - status: Some(AcpToolCallStatus::Completed), - title: Some("Image generation".into()), - kind: Some(AcpToolCallKind::Execute), - raw_input: None, - raw_output: Some(serde_json::json!({ - "saved_path": "/Users/test/.codex/generated_images/session/ig_test_image.png", - "image": { - "path": "/Users/test/.codex/generated_images/session/ig_test_image.png", - "mime_type": "image/png", - "source": "codex_image_generation" - }, - "result_omitted": true - })), - content: None, - locations: None, - }, - meta: None, - })) - .unwrap(); - tx.send(AgentStreamEvent::Finish(Default::default())).unwrap(); - - relay.run().await.unwrap(); - - let msg = repo - .get_message_by_msg_id("conv-1", "ig_test_image", "acp_tool_call") - .await - .unwrap() - .unwrap(); - - assert_eq!(msg.status.as_deref(), Some("finish")); - assert!(!msg.content.contains("iVBORw0KGgo")); - assert!(msg.content.contains("result_omitted")); -} -``` - -如果现有测试 helper 不能直接复用,先按已有 stream relay 测试样式补齐最小 fixture,不引入新测试框架。 - -**步骤 2:运行测试确认失败或编译失败** - -运行: - -```bash -cd AionCLI -cargo test -p aionui-conversation acp_image_tool_call -``` - -预期:初次可能因 fixture 名称不存在失败,按现有测试结构修正。 - -**步骤 3:最小调整** - -如果任务 1/2 已在翻译层处理,这里通常无需业务实现。只需要确保测试使用现有 helper 正确创建 relay 和 mock repo。 - -**步骤 4:运行测试确认通过** - -运行: - -```bash -cd AionCLI -cargo test -p aionui-conversation acp_image_tool_call -``` - -预期:PASS。 - -**步骤 5:提交** - -运行: - -```bash -cd AionCLI -git add crates/aionui-conversation/src/stream_relay.rs -git commit -m "test: cover acp image tool persistence" -``` - ---- - -### 任务 4:前端类型补齐 ACP raw output 字段 - -**文件:** - -- 修改:`AionUi/packages/desktop/src/common/types/platform/acpTypes.ts` - -**步骤 1:写类型结构** - -给 `ToolCallUpdate.update` 增加兼容字段: - -```ts -export interface AcpImageOutput { - path: string; - mime_type?: string; - source?: string; -} - -export interface AcpRawOutput { - saved_path?: string; - image?: AcpImageOutput; - result_omitted?: boolean; - result_omitted_reason?: string; - result_bytes?: number; - status?: string; - [key: string]: unknown; -} -``` - -然后在 `ToolCallUpdate.update` 内增加: - -```ts -rawOutput?: AcpRawOutput; -raw_output?: AcpRawOutput; -``` - -保留 `rawInput`,不要破坏现有字段。 - -**步骤 2:运行类型检查确认现有代码能编译** - -运行: - -```bash -cd AionUi -bun run typecheck -``` - -预期:PASS。如果仓库没有 `typecheck` 脚本,改跑 `bun run build`。 - -**步骤 3:提交** - -暂不提交,等任务 5 前端展示一起提交。 - ---- - -### 任务 5:前端 ACP 工具卡展示图片预览 - -**文件:** - -- 修改:`AionUi/packages/desktop/src/renderer/pages/conversation/Messages/acp/MessageAcpToolCall.tsx` - -**步骤 1:写提取函数** - -在组件文件内新增: - -```ts -function getAcpImagePath(update: IMessageAcpToolCall['content']['update']): string | undefined { - const rawOutput = update.rawOutput || update.raw_output; - const imagePath = rawOutput?.image?.path; - if (typeof imagePath === 'string' && imagePath) return imagePath; - - const savedPath = rawOutput?.saved_path; - if (typeof savedPath === 'string' && savedPath) return savedPath; - - return undefined; -} -``` - -**步骤 2:引入 LocalImageView** - -```ts -import LocalImageView from '@/renderer/components/media/LocalImageView'; -``` - -**步骤 3:在工具卡内容区展示图片** - -在 `MessageAcpToolCall` 中: - -```ts -const imagePath = getAcpImagePath(update); -``` - -在 `diffContent` 渲染前后加入: - -```tsx -{imagePath && ( -
- -
-)} -``` - -**步骤 4:运行前端检查** - -运行: - -```bash -cd AionUi -bun run typecheck -``` - -预期:PASS。如果没有该脚本: - -```bash -cd AionUi -bun run build -``` - -预期:PASS。 - -**步骤 5:提交** - -运行: - -```bash -cd AionUi -git add packages/desktop/src/common/types/platform/acpTypes.ts packages/desktop/src/renderer/pages/conversation/Messages/acp/MessageAcpToolCall.tsx -git commit -m "feat: preview codex acp generated images" -``` - ---- - -### 任务 6:前端兜底清洗超大 ACP raw output - -**文件:** - -- 修改:`AionUi/packages/desktop/src/common/chat/chatLib.ts` - -**步骤 1:增加防御性 sanitizer** - -在 `mergeAcpToolCallContent` 前新增: - -```ts -const INLINE_IMAGE_RESULT_LIMIT = 64 * 1024; - -function sanitizeAcpToolUpdate(update: T): T { - const next = { ...update }; - for (const key of ['rawOutput', 'raw_output'] as const) { - const raw = next[key]; - if (!raw || typeof raw !== 'object') continue; - - const result = raw.result; - const savedPath = raw.saved_path; - if (typeof result !== 'string' || result.length <= INLINE_IMAGE_RESULT_LIMIT || typeof savedPath !== 'string') { - continue; - } - - next[key] = { - ...raw, - result: undefined, - image: raw.image || { - path: savedPath, - mime_type: 'image/png', - source: 'codex_image_generation', - }, - result_omitted: true, - result_omitted_reason: raw.result_omitted_reason || 'image_base64', - result_bytes: raw.result_bytes || result.length, - }; - delete next[key].result; - } - return next; -} -``` - -然后修改 merge: - -```ts -update: sanitizeAcpToolUpdate({ - ...existing.update, - ...incoming.update, -}), -``` - -**步骤 2:运行前端检查** - -运行: - -```bash -cd AionUi -bun run typecheck -``` - -预期:PASS。 - -**步骤 3:提交** - -运行: - -```bash -cd AionUi -git add packages/desktop/src/common/chat/chatLib.ts -git commit -m "fix: guard acp tool output size in renderer" -``` - ---- - -### 任务 7:文档同步 - -**文件:** - -- 修改:`AionCLI/ARCHITECTURE.zh-CN.md` -- 修改:`AionUi/docs/guides/webui.md` 或新增 `AionUi/docs/guides/acp-image-output.md` - -**步骤 1:更新 AionCLI 架构文档** - -在 ACP 事件或 agent runtime 相关章节补充: - -```markdown -### ACP 工具输出清洗 - -Codex ACP 的图片生成工具可能返回 `saved_path` 与 inline image base64。AionCLI 在 ACP 翻译边界会把图片 base64 从 `raw_output.result` 中移除,只保留 `saved_path`、`image.path`、`result_omitted` 与大小元数据,避免 WebSocket、SQLite 和前端渲染承载大 payload。 -``` - -**步骤 2:更新 AionUi 展示文档** - -如果 `webui.md` 适合补充,则新增一小节;否则新增指南: - -```markdown -# ACP 图片输出展示 - -AionUi 对 ACP 工具调用中的 `raw_output.image.path` / `raw_output.saved_path` 渲染本地图片预览。前端不依赖 inline base64,图片文件通过 `/api/fs/image-base64` 按需读取。 -``` - -**步骤 3:提交文档** - -运行: - -```bash -cd AionCLI -git add ARCHITECTURE.zh-CN.md docs/plans/2026-05-19-codex-image-acp-output.md -git commit -m "docs: document acp image output handling" -``` - -运行: - -```bash -cd AionUi -git add docs/guides/webui.md -git commit -m "docs: document acp image preview handling" -``` - -如果实际新增了 `docs/guides/acp-image-output.md`,提交该文件。 - ---- - -### 任务 8:本地回归验证 - -**文件:** - -- No code changes. - -**步骤 1:后端测试** - -运行: - -```bash -cd AionCLI -cargo test -p aionui-ai-agent codex_image_tool_update -cargo test -p aionui-conversation acp_image_tool_call -cargo test -p aionui-ai-agent -cargo test -p aionui-conversation -``` - -预期:PASS。 - -**步骤 2:前端测试** - -运行: - -```bash -cd AionUi -bun run typecheck -bun run build -``` - -预期:PASS。 - -**步骤 3:静态检查** - -运行: - -```bash -cd AionCLI -cargo fmt --check -cargo clippy -p aionui-ai-agent -p aionui-conversation --all-targets -- -D warnings -git diff --check -``` - -预期:PASS。 - -运行: - -```bash -cd AionUi -git diff --check -``` - -预期:PASS。 - -**步骤 4:手工复现验证** - -启动本地 AionUi/AionCLI 后,用 Codex 会话发送: - -```text -帮我生成一只小猫 -``` - -预期: - -- 不再卡在持续执行中。 -- DB 中 `acp_tool_call` 消息小于 `100KB`。 -- `raw_output.result` 不存在。 -- `raw_output.image.path` 或 `raw_output.saved_path` 存在。 -- 前端工具卡直接显示生成图片。 - -检查 DB: - -```bash -sqlite3 "$HOME/Library/Application Support/AionUi/aionui/aionui-backend.db" \ - "select id,status,length(content),instr(content,'iVBORw0KGgo'),instr(content,'result_omitted') from messages where type='acp_tool_call' order by created_at desc limit 5;" -``` - -预期: - -- `status` 为 `finish` -- `length(content)` 不再是 MB 级 -- `instr(content,'iVBORw0KGgo')` 为 `0` -- `instr(content,'result_omitted')` 大于 `0` - -**步骤 5:最终状态检查** - -运行: - -```bash -git -C AionCLI status --short --branch -git -C AionUi status --short --branch -``` - -预期: - -- 只有预期提交或干净工作区。 -- 不包含 co-author 元信息。 - ---- - -## 风险与回滚 - -- 风险:某些非图片工具也可能返回超长字符串并带 `saved_path`。缓解:清洗条件同时要求图片 base64 前缀或 `data:image/`。 -- 风险:Codex 后续字段名变化。缓解:前端同时兼容 `rawOutput` 和 `raw_output`,后端只依赖 `saved_path/result/status` 这些当前已观测字段。 -- 风险:状态归一化误把仍在生成的图片标记完成。缓解:只在存在 `saved_path` 且图片 result 被清洗后归一化为 completed。 -- 回滚:后端 sanitizer 是边界纯函数,回滚 `translate.rs` 相关提交即可恢复原始 ACP 事件透传;前端展示改动独立,可单独回滚。 From 38f94cd61da109364304e6ab293a71ae7cc63a1e Mon Sep 17 00:00:00 2001 From: Jassy Date: Mon, 15 Jun 2026 20:36:06 +0800 Subject: [PATCH 3/3] fix: update acp image stream relay test --- crates/aionui-conversation/src/stream_relay.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/aionui-conversation/src/stream_relay.rs b/crates/aionui-conversation/src/stream_relay.rs index adf93c001..542933e28 100644 --- a/crates/aionui-conversation/src/stream_relay.rs +++ b/crates/aionui-conversation/src/stream_relay.rs @@ -1458,6 +1458,7 @@ mod tests { let relay = StreamRelay::new( "conv-1".into(), "asst-1".into(), + "turn-1".into(), "user-1".into(), repo.clone(), bus.clone(),