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 3670370e8..88e86b811 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 @@ -1128,6 +1128,7 @@ pub(crate) async fn direct_game_creator_claude_code_home_chat( struct ClaudeCodeToolState { name: String, arguments: String, + started: bool, completed: bool, } @@ -1137,7 +1138,8 @@ struct ClaudeCodeStreamState { block_item_ids: HashMap<(String, usize), String>, block_tool_ids: HashMap<(String, usize), String>, tool_calls: HashMap, - pending_history: Vec, + history_error: Option, + partial_history: DirectProjectHistoryAccumulator, assistant_texts: Vec, result_text: Option, result_error: Option, @@ -1194,6 +1196,20 @@ fn claude_stream_observe( } impl ClaudeCodeStreamState { + /// 已完成过程与 Codex 共用项目历史出口,收到时即保存;终态失败不能撤掉前面已发生的事实。 + fn persist_completed_item(&mut self, root: &Path, item: &serde_json::Value) { + if let Err(error) = append_direct_project_history_item_at(root, item) { + self.history_error.get_or_insert(error); + } + } + + fn persist_partial_history(&mut self, root: &Path) { + if let Err(error) = persist_direct_project_partial_items_at(root, &mut self.partial_history) + { + self.history_error.get_or_insert(error); + } + } + fn block_item_id(&mut self, message_id: &str, index: usize, kind: &str) -> String { self.block_item_ids .entry((message_id.to_string(), index)) @@ -1212,6 +1228,7 @@ impl ClaudeCodeStreamState { if delta.is_empty() { return; } + self.partial_history.observe_delta(&item_id, &delta); crate::agent::append_thread_event( &crate::agent::thread_id_for_project(root), ThreadEvent::item_delta(item_id, kind, delta.clone()), @@ -1245,6 +1262,7 @@ impl ClaudeCodeStreamState { .or_insert_with(|| ClaudeCodeToolState { name: name.clone(), arguments: String::new(), + started: false, completed: false, }); if !name.trim().is_empty() { @@ -1254,9 +1272,10 @@ impl ClaudeCodeStreamState { if !arguments.is_empty() { entry.arguments = arguments; } - if entry.completed || !needs_update { + if entry.completed || (entry.started && !needs_update) { return; } + entry.started = true; let at = crate::agent::now_ms(); crate::agent::append_thread_event( &crate::agent::thread_id_for_project(root), @@ -1289,6 +1308,7 @@ impl ClaudeCodeStreamState { .or_insert_with(|| ClaudeCodeToolState { name: "MCP 工具".to_string(), arguments: "{}".to_string(), + started: false, completed: false, }); if state.completed { @@ -1309,14 +1329,15 @@ impl ClaudeCodeStreamState { &crate::agent::thread_id_for_project(root), ThreadEvent::item_completed(item, at), ); - self.pending_history.push(serde_json::json!({ + let history_item = serde_json::json!({ "type": "mcpToolCall", "id": tool_id, "tool": state.name.clone(), "arguments": state.arguments.clone(), "result": output, "status": status, - })); + }); + self.persist_completed_item(root, &history_item); claude_stream_observe(observer, TurnObservation::Activity("controlled-tool")); } @@ -1470,6 +1491,8 @@ impl ClaudeCodeStreamState { continue; } let item_id = self.block_item_id(message_id, index, "text"); + self.partial_history + .complete_item(&serde_json::json!({"id": item_id})); let at = crate::agent::now_ms(); crate::agent::append_thread_event( &crate::agent::thread_id_for_project(root), @@ -1483,20 +1506,13 @@ impl ClaudeCodeStreamState { at, ), ); - self.pending_history.push(serde_json::json!({ + self.persist_completed_item(root, &serde_json::json!({ "type": "message", "role": "assistant", "id": self.block_item_ids.get(&(message_id.to_string(), index)).cloned().unwrap_or_default(), "content": [{"type": "output_text", "text": text}], })); - self.assistant_texts.push( - self.pending_history - .last() - .and_then(|item| item.pointer("/content/0/text")) - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - .to_string(), - ); + self.assistant_texts.push(text); claude_stream_observe( observer, TurnObservation::AccumulatedText( @@ -1510,6 +1526,8 @@ impl ClaudeCodeStreamState { continue; } let item_id = self.block_item_id(message_id, index, "thinking"); + self.partial_history + .complete_item(&serde_json::json!({"id": item_id})); let at = crate::agent::now_ms(); crate::agent::append_thread_event( &crate::agent::thread_id_for_project(root), @@ -1522,11 +1540,14 @@ impl ClaudeCodeStreamState { at, ), ); - self.pending_history.push(serde_json::json!({ - "type": "reasoning", - "id": item_id, - "text": text, - })); + self.persist_completed_item( + root, + &serde_json::json!({ + "type": "reasoning", + "id": item_id, + "text": text, + }), + ); claude_stream_observe(observer, TurnObservation::Reasoning(String::new())); } Some("tool_use") => { @@ -1658,8 +1679,12 @@ impl ClaudeCodeStreamState { { self.result_error = claude_result_error_detail(terminal); } - if let Some(detail) = self.result_error { - return Err(format!("Claude Code 返回失败终态:{detail}")); + if let Some(detail) = self.result_error.clone() { + self.persist_partial_history(root); + return Err(self.with_history_error(format!("Claude Code 返回失败终态:{detail}"))); + } + if let Some(error) = &self.history_error { + return Err(format!("Claude Code 过程历史保存失败:{error}")); } let text = self .result_text @@ -1671,9 +1696,6 @@ impl ClaudeCodeStreamState { }) .filter(|value| !value.trim().is_empty()) .ok_or_else(|| "Claude Code 缺少最终回复".to_string())?; - for item in self.pending_history { - append_direct_project_history_item_at(root, &item)?; - } if self.assistant_texts.last().map(String::as_str) != Some(text.as_str()) { let item_id = persist_direct_claude_assistant_reply_at(root, client_turn_id, &text)?; let at = crate::agent::now_ms(); @@ -1692,6 +1714,13 @@ impl ClaudeCodeStreamState { } Ok(text) } + + fn with_history_error(&self, original: String) -> String { + match &self.history_error { + Some(error) => format!("{original};Claude Code 过程历史保存失败:{error}"), + None => original, + } + } } /// DirectProject 使用 Claude Code 自己的 MCP 调用能力;宿主只暴露 loopback @@ -1744,7 +1773,7 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( let mut on_event = |event: &serde_json::Value| { stream_state.observe(root, event, &mut observer); }; - let sidecar = run_sidecar_turn( + let sidecar_result = run_sidecar_turn( root, Some(llm), &home, @@ -1753,7 +1782,14 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( client_turn_id, Some(&mut on_event), ) - .await?; + .await; + let sidecar = match sidecar_result { + Ok(sidecar) => sidecar, + Err(error) => { + stream_state.persist_partial_history(root); + return Err(stream_state.with_history_error(error)); + } + }; direct_turn_trace("claude-sidecar-done"); if let Some(session_id) = sidecar .result @@ -2062,7 +2098,83 @@ mod tests { assert!(observed .iter() .any(|event| matches!(event, TurnObservation::Activity("controlled-tool")))); - assert_eq!(state.pending_history.len(), 2); + let history = read_direct_project_history_items_at(root.path()) + .expect("read process history before result"); + assert_eq!(history.len(), 2); + } + + #[test] + fn failed_claude_turn_keeps_completed_and_partial_process_history() { + let root = tempfile::tempdir().expect("temp root"); + crate::init_local_game_project_at(root.path(), "cc-failure-history", "CC 失败历史") + .expect("init project"); + let mut state = ClaudeCodeStreamState::default(); + let mut observer_fn = |_event| {}; + let mut observer: Option<&mut (dyn FnMut(TurnObservation) + Send)> = Some(&mut observer_fn); + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "message_start", "message": {"id": "partial-message"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "已经读取项目"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "assistant", + "message": {"id": "tool-message", "content": [{"type": "tool_use", "id": "tool-without-args", "name": "mcp__agc__agc_read_project_context"}]} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "user", + "message": {"content": [{"type": "tool_result", "tool_use_id": "tool-without-args", "content": [{"type": "text", "text": "读取完成"}]}]} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({"type": "result", "is_error": true, "error": "上游 429"}), + &mut observer, + ); + + let failure = state + .finish( + root.path(), + &serde_json::json!({"is_error": true}), + Some("cc-failure-turn"), + ) + .expect_err("the terminal result is a failure"); + assert!(failure.contains("Claude Code 返回失败终态")); + let history = read_direct_project_history_items_at(root.path()).expect("read history"); + assert_eq!(history.len(), 2); + assert!(history.iter().any(|item| { + item.get("type").and_then(serde_json::Value::as_str) == Some("message") + && item.to_string().contains("已经读取项目") + })); + assert!(history.iter().any(|item| { + item.get("type").and_then(serde_json::Value::as_str) == Some("mcpToolCall") + && item.to_string().contains("读取完成") + })); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs index c41b19de0..d0d7c0e76 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs @@ -3,7 +3,7 @@ use serde_json::Value; use std::collections::BTreeMap; use std::path::Path; -#[derive(Default)] +#[derive(Default, Debug)] pub(crate) struct DirectProjectHistoryAccumulator { text_by_item_id: BTreeMap, } diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 9c1521023..a2c3916f4 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5,7 +5,7 @@ ## 2026-10-06 AGC 失败终态与 CC/MCP 边界不能吞掉协议、清理和 I/O 错误 - **现象**:DirectProject 的 `turn.completed(status=failed)` 若跨 IPC 丢失 `failure` 载荷,前端会只收口回合而不显示任何说明;Claude Code sidecar 的非 UTF-8 stderr、进程树清理失败、会话表锁损坏,以及 MCP loopback 服务退出、插件能力桥超时、损坏 manifest,会分别被压成空 detail、继续成功取消、伪造 hash projectId 或静默不广告插件。 -- **现行口径**:失败载荷缺失显示明确的协议错误;app-server 只给 `failed` 状态却不带 `error` 对象时,正文保留 `status=failed error=missing` 协议事实;旧历史里的已知通用失败句明确标成“历史错误事件未记录具体错误正文”,不伪装成当前仍在吞错;错误报告队列的稳定分类只用于去重,列表保留脱敏后的完整正文,先到短分类、后到详细宿主错误时用详细正文升级;CC 清理、stderr 解码、会话锁都保留原始正文;后端 `game-creator-agent-progress` 阶段事件由 DirectProject 按项目路径订阅并显示在执行过程卡,不再只显示空白忙态;MCP server / capability bridge 失败写入统一应用日志,MCP manifest / journal / 时间 / metadata 读取失败返回具体错误;完成判定在把素材缺失归因于“未引用平台图”前先验证源码、manifest 和已登记图片的 I/O/PNG 错误;资源生成队列提交异常直接收口失败并恢复可重试草稿。切片文件是可选投影,完整 spritesheet 不因无切片而误失败。 +- **现行口径**:失败载荷缺失显示明确的协议错误;app-server 只给 `failed` 状态却不带 `error` 对象时,正文保留 `status=failed error=missing` 协议事实;旧历史里的已知通用失败句明确标成“历史错误事件未记录具体错误正文”,不伪装成当前仍在吞错;错误报告队列的稳定分类只用于去重,列表保留脱敏后的完整正文,先到短分类、后到详细宿主错误时用详细正文升级;CC 清理、stderr 解码、会话锁都保留原始正文,CC 已完成的工具/回复过程即时写入 DirectProject 历史,失败终态也会保留未完成的部分正文;后端 `game-creator-agent-progress` 阶段事件由 DirectProject 按项目路径订阅并显示在执行过程卡,不再只显示空白忙态;MCP server / capability bridge 失败写入统一应用日志,MCP manifest / journal / 时间 / metadata 读取失败返回具体错误;完成判定在把素材缺失归因于“未引用平台图”前先验证源码、manifest 和已登记图片的 I/O/PNG 错误;资源生成队列提交异常直接收口失败并恢复可重试草稿。切片文件是可选投影,完整 spritesheet 不因无切片而误失败。 - **验证**:`cargo test agent::claude_code_cli::tests::` 14 passed;`cargo test agent::direct_tools_mcp::tests::` 34 passed;`cargo test agent::direct_runtime::tests::` 92 passed;`npx vitest run tests/directThreadChat.test.ts tests/directTurnFailure.test.ts` 64 passed;另有损坏 manifest 回归用例 1 passed。新失败正文仍经既有脱敏边界处理。 - **关联**:`agent/claude_code_cli.rs`、`agent/direct_tools_mcp.rs`、`agent/direct_runtime/mod.rs`、`agent/thread_manager/dispatch.rs`、`chat/conversation/directThreadChat.ts`。