diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index 483f42b8e..d5c33e971 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -984,21 +984,15 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( // 项目对话历史是这条对话的单一事实源:回复不落盘,UI 就看不到本轮结果。codex 路径由 // app-server 的 collect-history 负责写 assistant 条目,cc 没有那一步——只补终态会让 // 用户看到"回合结束但没有回复"。落盘失败按回合失败收口,不吞。 - let item_id = match client_turn_id { - Some(client_turn_id) => format!("direct-codex:{client_turn_id}:assistant"), - None => format!("direct-codex:{}:assistant", uuid::Uuid::new_v4()), + let item_id = match persist_direct_claude_assistant_reply_at(root, client_turn_id, text) + { + Ok(item_id) => item_id, + Err(error) => { + eprintln!("[agc-cc-direct] persist assistant failed: {error}"); + direct_turn_trace("claude-parse-error"); + return Err(format!("写入本项目对话历史失败:{error}")); + } }; - let item = serde_json::json!({ - "type": "message", - "role": "assistant", - "id": item_id.clone(), - "content": [{ "type": "output_text", "text": text }], - }); - if let Err(error) = append_direct_project_history_item_at(root, &item) { - eprintln!("[agc-cc-direct] persist assistant failed: {error}"); - direct_turn_trace("claude-parse-error"); - return Err(format!("写入本项目对话历史失败:{error}")); - } // 聊天区是按 `item.completed` 事件流投影的(codex 路径在 rawResponseItem/completed // 时下发同款事件),只落盘历史不会让本轮回复出现在界面上——重进项目才看得到。 // 条目身份与落盘的历史条目保持同一个,重进项目按 id 去重。 @@ -1026,6 +1020,38 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( parsed } +fn persist_direct_claude_assistant_reply_at( + root: &Path, + client_turn_id: Option<&str>, + text: &str, +) -> Result { + let stable_item_id = match client_turn_id { + Some(client_turn_id) => format!("direct-codex:{client_turn_id}:assistant"), + None => format!("direct-codex:{}:assistant", uuid::Uuid::new_v4()), + }; + let mut item = serde_json::json!({ + "type": "message", + "role": "assistant", + "id": stable_item_id.clone(), + "content": [{ "type": "output_text", "text": text }], + }); + match append_direct_project_history_item_at(root, &item) { + Ok(()) => Ok(stable_item_id), + Err(error) if error.starts_with("DirectProject 历史 item id 冲突:") => { + // 同一 client turn 的验收反馈会再次进入 cc 执行器。首个回复保留稳定身份, + // 后续不同回复必须另用 item id;否则历史层会把反馈收尾误判成失败。 + let feedback_item_id = format!("{stable_item_id}:{}", uuid::Uuid::new_v4()); + item["id"] = serde_json::Value::String(feedback_item_id.clone()); + append_direct_project_history_item_at(root, &item) + .map(|()| feedback_item_id) + .map_err(|retry_error| { + format!("反馈回复历史追加失败:{retry_error}(首个条目冲突:{error})") + }) + } + Err(error) => Err(error), + } +} + fn parse_direct_stream_result( stdout: &[u8], mut observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, @@ -1160,6 +1186,28 @@ mod tests { assert!(error.contains("Authentication failed")); } + #[test] + fn direct_claude_feedback_reply_does_not_fail_on_a_reused_client_turn_id() { + let root = tempfile::tempdir().expect("temp dir"); + crate::init_local_game_project_at(root.path(), "cc-feedback", "cc feedback") + .expect("init project"); + let turn_id = Some("client-turn-feedback-0001"); + + let first = persist_direct_claude_assistant_reply_at(root.path(), turn_id, "首轮回复") + .expect("persist first reply"); + let identical = persist_direct_claude_assistant_reply_at(root.path(), turn_id, "首轮回复") + .expect("an identical retry remains idempotent"); + let second = persist_direct_claude_assistant_reply_at(root.path(), turn_id, "反馈后回复") + .expect("a host feedback retry must persist a second reply"); + + assert_eq!(first, identical); + assert_ne!(first, second); + assert!(second.starts_with("direct-codex:client-turn-feedback-0001:assistant:")); + let items = + crate::agent::read_direct_project_history_items_at(root.path()).expect("read history"); + assert_eq!(items.len(), 2); + } + fn test_platform_session() -> crate::platform_session::PlatformSessionSnapshot { crate::platform_session::PlatformSessionSnapshot { user_id: "user-1".to_string(), diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index b9b691b1f..46b109fa5 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6277,7 +6277,8 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **根因 2(缺落盘)**:assistant 回复只存在于 SDK 事件流里。codex 路径由 `finish_direct_project_collect_history` 写进 `.agent/conversations/project.jsonl`,cc 没有对应步骤,所以即使回合收口成功,UI 也读不到回复。 - **根因 3(工具被拒)**:sidecar 用 `permissionMode: 'dontAsk'` 且没有 `allowedTools`,宿主 MCP 工具(`mcp__agc__*`)一律被直接拒绝,模型只能回"没有权限"。 - **根因 4(界面看不到回复)**:聊天区是按 `item.completed` 事件流投影的(codex 路径在 `rawResponseItem/completed` 时下发 `ThreadItem::Message`),只把回复落进 `project.jsonl` 不会让本轮出现在界面上——用户看到"用户气泡 + 本轮结束于 … · 耗时",回复只在重进项目时从历史读出来。 -- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 解析成功后必须①把回复按 `{"type":"message","role":"assistant","id":"direct-codex::assistant","content":[{"type":"output_text","text":…}]}` 落进项目历史,②用**同一个 id** 下发 `ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"})`(落盘失败按回合失败收口);sidecar 按 `mcp__` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。 +- **根因 5(验收反馈复用 assistant ID)**:同一 client turn 进入 `ReviewRequired` 后会再次调用 cc。首次回复已经占用 `direct-codex::assistant`,第二次不同正文沿用该 ID 会被历史层正确拒绝为冲突,随后却被错误投影成 `runtime-unclassified`。真实诊断中可见「写入本项目对话历史失败:…assistant」且历史已经有该条回复。 +- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 每次解析成功后都把实际落盘的回复 item id 同步下发 `ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"})`。首个回复沿用 `direct-codex::assistant`,同一回合的反馈回复遇到内容冲突时追加 `:assistant:`,相同内容仍按原 ID 幂等;落盘失败按回合失败收口。sidecar 按 `mcp__` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。 - **诊断口径**:`agent.direct_turn.host_dropped` / `agent.direct_turn.panic` 里的令牌字段必须写 `tt=`,写 `turnToken=` 会命中脱敏标记,整行变成 ``,离线只剩"说不出原因"的 HostDropped。 -- **验证**:dev 栈里用 CDP 注入真实回合(`node %TEMP%\agc-cdp.mjs `):①读文件轮 `claude-parse-done chars=108`,`.agent/conversations/project.jsonl` 出现 `direct-codex:cdp-…:assistant` 条目,回复内容与 `game/index.html` 前两行(`` / ``)逐字一致(证明宿主工具真的执行了);②聊天视图打开时注入 `只回三个字:收到了`,DOM 断言(`document.body.innerText`)同时出现用户气泡 `11:40:05`、助手回复 `收到了` 与 `本轮结束于 11:40:16 · 耗时 10.7秒`(证明 `item.completed` 实时投影生效,不必重进项目);同一日志不再出现新的 `host_dropped`。`cargo test … -- claude_code_cli::tests direct_turn_failure::tests` 19 passed。 +- **验证**:dev 栈里用 CDP 注入真实回合(`node %TEMP%\agc-cdp.mjs `):①读文件轮 `claude-parse-done chars=108`,`.agent/conversations/project.jsonl` 出现 `direct-codex:cdp-…:assistant` 条目,回复内容与 `game/index.html` 前两行(`` / ``)逐字一致(证明宿主工具真的执行了);②聊天视图打开时注入 `只回三个字:收到了`,DOM 断言(`document.body.innerText`)同时出现用户气泡 `11:40:05`、助手回复 `收到了` 与 `本轮结束于 11:40:16 · 耗时 10.7秒`(证明 `item.completed` 实时投影生效,不必重进项目);同一日志不再出现新的 `host_dropped`。另有 `agent::claude_code_cli::tests::direct_claude_feedback_reply_does_not_fail_on_a_reused_client_turn_id` 回归覆盖同一回合两次不同回复。`cargo test … -- claude_code_cli::tests direct_turn_failure::tests` 19 passed。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`、`apps/ai-game-creator-shell/agent-sidecar/src/index.mjs`。