From 63c495dab02132c86096cef6662b2a79af50fcfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 14:40:36 +0800 Subject: [PATCH] =?UTF-8?q?DirectProject=20=E4=B8=8D=E5=86=8D=E6=8A=8A=20C?= =?UTF-8?q?odex=20=E5=9B=9E=E6=98=BE=E7=9A=84=E7=94=A8=E6=88=B7=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E4=B8=8B=E5=8F=91=E7=BB=99=E8=81=8A=E5=A4=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 运行态条目投影新增回显过滤 direct_thread_visible_item,item/started 与 rawResponseItem/completed 两条路径共用 - is_direct_project_codex_user_item 提升为 pub(crate),落盘侧与运行态侧共用同一判据 - 新增 E2E direct_project_turn_does_not_forward_codex_user_echo_as_chat_items:修复前事件流会多出两条同文本用户条目(已做变异验证) - 同步 ADR 与 shared-memory decision-log:回显过滤必须同时覆盖落盘与运行态两侧 --- .../src/agent/codex_app_server/mod.rs | 172 +++++++++++++++++- .../src/agent/direct_project_history.rs | 7 +- ...irectProject对话历史单一事实源-2026-09-16.md | 1 + .../shared-memory/decision-log.md | 1 + 4 files changed, 178 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 9be967bcd..ca96f6a03 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -765,6 +765,25 @@ fn direct_thread_event_item( direct_thread_item_from_value(root, item, direct_tool_call_now_ms()) } +/// 运行态条目投影:Codex 回显的用户消息整条跳过。 +/// +/// AGC 是 DirectProject 用户消息的唯一来源——回合入口在 `turn/start` 之前就把 +/// `direct-codex:{clientTurnId}:user` 落盘,并下发同一条运行态条目。app-server 之后 +/// 回显的 `userMessage`(`item/started`)和原始 `role=user` item +/// (`rawResponseItem/completed`)只用于观察和关联:落盘侧已由 +/// `append_direct_project_history_item_at` 过滤,运行态必须用同一口径过滤,否则前端会多 +/// 渲染出两条没有历史对应的孤儿用户气泡,各自开出一个耗时 0 秒的假回合,直到重进页面才 +/// 恢复(那时读的是同一份已过滤的 `project.jsonl`)。 +fn direct_thread_visible_item( + root: &std::path::Path, + item: &serde_json::Value, +) -> Option { + if is_direct_project_codex_user_item(item) { + return None; + } + direct_thread_event_item(root, item) +} + fn direct_codex_command_is_game_verification(command: &str) -> bool { let command = command.to_ascii_lowercase(); command.contains("game.static_smoke") @@ -3125,7 +3144,7 @@ impl CodexAppServerConnection { "rawResponseItem/completed 缺少 item".to_string(), )); } - let entry_item = direct_thread_event_item(history_root, &item); + let entry_item = direct_thread_visible_item(history_root, &item); let history_root = history_root.to_path_buf(); let history_item = item.clone(); tokio::task::spawn_blocking(move || { @@ -3269,7 +3288,7 @@ impl CodexAppServerConnection { == CodexAppServerWorkspaceMode::DirectProject { if let Some(entry_item) = - direct_thread_event_item(history_root, item) + direct_thread_visible_item(history_root, item) { append_direct_thread_event( &direct_thread_id, @@ -6503,6 +6522,155 @@ while IFS= read -r line; do :; done ); } + #[cfg(unix)] + #[tokio::test] + async fn direct_project_turn_does_not_forward_codex_user_echo_as_chat_items() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let project = temp.path().join("direct-user-echo-project"); + crate::init_local_game_project_at(&project, "direct-user-echo", "回显过滤") + .expect("init project"); + let executable = temp.path().join("fake-codex-app-server-direct-user-echo"); + std::fs::write( + &executable, + r#"#!/bin/sh +while IFS= read -r line; do + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') + case "$line" in + *'"method":"initialize"'*) printf '{"id":%s,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}\n' "$id" ;; + *'"method":"skills/extraRoots/set"'*) printf '{"id":%s,"result":{}}\n' "$id" ;; + *'"method":"skills/list"'*) printf '{"id":%s,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}\n' "$id" ;; + *'"method":"thread/start"'*) printf '{"id":%s,"result":{"thread":{"id":"thread-echo"}}}\n' "$id" ;; + *'"method":"thread/inject_items"'*) printf '{"id":%s,"result":{}}\n' "$id" ;; + *'"method":"turn/start"'*) + printf '{"id":%s,"result":{"turn":{"id":"turn-echo","items":[],"status":"inProgress"}}}\n' "$id" + printf '%s\n' '{"method":"turn/started","params":{"threadId":"thread-echo","turn":{"id":"turn-echo","items":[],"status":"inProgress"}}}' + printf '%s\n' '{"method":"item/started","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"codex-user-echo-1","type":"userMessage","clientId":"turn-0001","content":[{"type":"text","text":"请创建菜单"}]}}}' + printf '%s\n' '{"method":"rawResponseItem/completed","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"codex-raw-user-echo-1","type":"message","role":"user","content":[{"type":"input_text","text":"请创建菜单"}]}}}' + printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thread-echo","turnId":"turn-echo","itemId":"item-echo-1","delta":"好的"}}' + printf '%s\n' '{"method":"item/completed","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"item-echo-1","type":"agentMessage","text":"好的"}}}' + printf '%s\n' '{"method":"rawResponseItem/completed","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"item-echo-1","type":"message","role":"assistant","content":[{"type":"output_text","text":"好的"}]}}}' + printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thread-echo","turn":{"id":"turn-echo","items":[],"status":"completed"}}}' + ;; + esac +done +"#, + ) + .expect("write fake app-server"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod fake app-server"); + + let llm = test_llm(); + let credential = CodexAppServerCredential::AppDataKey { + fingerprint: "fixture-credential".to_string(), + }; + let connection = + CodexAppServerConnection::spawn_with_executable_and_credential_at_workspace( + &llm, + &credential, + executable.as_os_str(), + Some(&project), + CodexAppServerWorkspaceMode::DirectProject, + ) + .await + .expect("spawn direct-project app-server"); + + let user_item = serde_json::json!({ + "type": "message", + "role": "user", + "id": "direct-codex:turn-0001:user", + "content": [{"type": "input_text", "text": "请创建菜单"}] + }); + let thread_id = direct_thread_id_for_project(&project); + let bootstrap = crate::agent::subscribe_direct_thread(&thread_id); + assert!( + bootstrap.events.is_empty(), + "订阅发生在回合之前,bootstrap 必须为空" + ); + // 生产入口(`chat_with_game_creator_direct_codex`)在发起回合前登记本客户端的 + // 付费生成身份;工具桥在这一轮里按它绑定付费调用,这里补上同一步。 + let _active_invocation = + crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001") + .expect("enter direct invocation"); + let mut observer = |_observation| {}; + connection + .run_turn_with_direct_observer_and_history( + &test_snapshot(), + &llm, + LlmRunRequest::single_turn("系统", "请创建菜单"), + Some(&project), + Some("turn-0001"), + Some(&user_item), + None, + Some(&mut observer), + None, + ) + .await + .expect("run direct-project turn"); + drop(observer); + + let consumed = crate::agent::consume_direct_thread(&bootstrap.subscription_id) + .expect("consume events"); + let mut user_items = Vec::new(); + let mut assistant_items = Vec::new(); + for event in &consumed.events { + let item = match event { + DirectThreadEvent::ItemStarted { item } + | DirectThreadEvent::ItemCompleted { item } => item, + _ => continue, + }; + match item { + DirectThreadItem::Message { + item_id, + role, + text, + .. + } if role.as_str() == "user" => { + user_items.push((item_id.clone(), text.clone())); + } + DirectThreadItem::Message { text, .. } if !text.trim().is_empty() => { + assistant_items.push(text.clone()); + } + _ => {} + } + } + assert_eq!( + user_items, + vec![( + "direct-codex:turn-0001:user".to_string(), + "请创建菜单".to_string() + )], + "Codex 回显的用户消息不得再作为聊天条目下发,否则前端会渲染出多余的孤儿用户气泡" + ); + assert_eq!( + assistant_items, + vec!["好的".to_string()], + "assistant 正文仍必须按同一 itemId 下发一次" + ); + let history = read_direct_project_history_items_at(&project).expect("read history"); + let history_user_items = history + .iter() + .filter(|item| item.get("role").and_then(serde_json::Value::as_str) == Some("user")) + .cloned() + .collect::>(); + assert!( + history + .iter() + .any(|item| item.get("id").and_then(serde_json::Value::as_str) + == Some("item-echo-1")), + "assistant 与工具条目仍必须落盘:{history:?}" + ); + assert_eq!( + history_user_items, + vec![user_item], + "回显的用户消息也不得落盘,历史里只能有 AGC 自己那条" + ); + } + #[cfg(unix)] #[tokio::test] async fn direct_home_app_server_uses_read_only_protocol_and_rejects_file_change_items() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 5a59918d8..f7bf03745 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -492,7 +492,12 @@ fn append_direct_project_history_item_once(root: &Path, item: &Value) -> Result< append_jsonl_line_unlocked(&path, &line, "DirectProject 历史") } -fn is_direct_project_codex_user_item(item: &Value) -> bool { +/// Codex app-server 回显的用户条目(`userMessage` / 非 AGC 的 `role=user` message)。 +/// +/// AGC 自己预写的用户条目 id 固定是 `direct-codex:{clientTurnId}:user`,因此这里必须 +/// 把两者区分开:落盘侧(本模块)与运行态事件侧(`codex_app_server`)共用同一口径, +/// 任何一侧漏判都会让同一条用户消息出现第二个身份。 +pub(crate) fn is_direct_project_codex_user_item(item: &Value) -> bool { if item.get("type").and_then(Value::as_str) == Some("userMessage") { return true; } diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index afde4e259..1f2e51478 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -17,6 +17,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 可见性判断留在前端聊天投影:后端历史分页只按原始条目切片,前端自己跳过不可显示条目并推进锚点。 - 线上模型是 **ts-rs 导出的 tagged enum**(`agent/direct_thread_wire.rs`),不是"一个大结构体加一堆可空字段":`DirectThreadItem` 用 `itemType` 区分条目,`DirectThreadEvent` 用 `type` 区分事件,前端直接消费生成的 TS 类型(改完 Rust 模型跑 `cargo test export_bindings`)。条目上的毫秒时间戳标 `#[ts(as = "f64")]`,因为 ts-rs 默认把 `u64` 映射成 `bigint`,而 Tauri 的 JSON 通道传的是 `number`。 - 运行态事件与历史切片使用同形条目,Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「原始条目 → 视图」投影函数。 +- 两侧的过滤口径必须完全一致,包含「哪些条目根本不是本项目的聊天条目」:Codex app-server 回显的用户消息(`userMessage` / 非 AGC 的 `role=user`)在落盘侧被过滤,在运行态事件侧也必须被过滤(`direct_thread_visible_item`)。少一侧就会出现「实时比历史多出两条同文本用户条目、各自开出一个耗时 0 秒的假回合,重进页面又正常」这类只有其中一侧的事实源缺陷。 - 搬运层不生成展示形状:Thread Manager 只下发脱敏原始条目(`itemType` 原样透传),工具卡片的 `kind`、标题、折叠摘要都由前端生成。 - 条目身份只有一套:进队列前归一成一个 `itemId`。工具条目在 `project.jsonl` 里带两个 id(调用 id 与 response item id,调用与输出共用前者),归一只在 Rust 边界做一次,Thread Manager 与前端都不暴露第二个 id 概念。 - 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;前端 state 里只有一个 `turnRunning` 布尔,没有 `turnId`。`subscribe` 返回的条目、增量、请求与队列锚点都不带 turn id。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d778bcade..2b0554396 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8251,6 +8251,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - `.agent/conversations/project.jsonl` 中的 DirectProject 用户消息由 AGC 在 `turn/start` 前以 `direct-codex:{clientTurnId}:user` 幂等追加;写入失败时禁止发起 Codex turn,失败或中断也保留该 user item。 - Codex app-server 回显的 `userMessage` / `role=user` item 不是第二个历史来源。AGC 只处理其观察和关联,不再把该 echo 追加到项目历史;Codex 的 assistant、tool 和其它有效 response item 仍按现有 append-only 规则落盘。 +- 2026-09-17 追加:回显过滤必须同时覆盖**运行态事件侧**(`direct_thread_visible_item`,`item/started` 与 `rawResponseItem/completed` 两条路径),不能只过滤落盘。只过滤落盘时实时聊天会多出两条没有历史对应的孤儿用户气泡,各自开出一个「耗时 0 秒」的假回合,重进页面读同一份已过滤的 `project.jsonl` 又恢复正常;判据是 `direct_project_turn_does_not_forward_codex_user_echo_as_chat_items`(回合事件里只能有一条 `direct-codex:{clientTurnId}:user` 用户条目)。 - 本地 AGC user-item 写入必须使用允许 user item 的内部入口,Codex raw item 写入使用过滤入口,避免“过滤回显”反过来阻断预写。相同 `clientTurnId` 只能复用相同规范化 prompt,内容冲突必须失败关闭。 ## 2026-08-31 AGC 错误报告与诊断上传