diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 110b0b918..fc4843f6d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -43,9 +43,9 @@ pub mod tool; pub(crate) use art_manifest::*; use claude_code_cli::*; pub(crate) use claude_code_cli::{ - cancel_direct_claude_code_turn_at, direct_game_creator_claude_code_chat_at, - direct_game_creator_claude_code_home_chat, game_creator_claude_code_cli_route_error, - game_creator_claude_code_cli_version_identity, + cancel_direct_claude_code_turn_at, claude_code_failure_to_llm_error, + direct_game_creator_claude_code_chat_at, direct_game_creator_claude_code_home_chat, + game_creator_claude_code_cli_route_error, game_creator_claude_code_cli_version_identity, }; pub(crate) use codex_app_server::direct_game_creator_codex_chat_at; pub(crate) use codex_app_server::turn_error::*; 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 65b71e4d6..0ea85c3b7 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 @@ -747,6 +747,46 @@ fn parse_usage(value: &serde_json::Value) -> Option }) } +/// 把 Claude Code/sidecar 的字符串失败投影到与 Codex app-server 相同的 LlmError 分类。 +/// +/// DirectProject 不能把上游 HTTP 错误统统包装成 Transport:那会把 429、401、5xx 等可识别 +/// 的上游事实显示成“执行通道已断开”。这里只认结构化的状态位置(`Request rejected (N)` / +/// `HTTP N`),其它文本继续保留为 Transport,避免凭关键词猜测。 +pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm::LlmError { + if let Some(status_code) = claude_code_failure_status_code(&detail) { + return platform_llm::LlmError::Upstream { + status_code, + message: detail, + }; + } + if detail.contains("Claude Agent SDK sidecar 回合超时") { + return platform_llm::LlmError::Timeout { attempts: 1 }; + } + if detail.contains("Claude Code 缺少最终回复") { + return platform_llm::LlmError::EmptyResponse; + } + if detail.contains("sidecar 输出不是有效 JSON") || detail.contains("stream-json 包含无效 JSON") + { + return platform_llm::LlmError::Deserialize(detail); + } + platform_llm::LlmError::Transport(detail) +} + +fn claude_code_failure_status_code(detail: &str) -> Option { + ["Request rejected (", "HTTP "].iter().find_map(|marker| { + let tail = detail.split_once(marker)?.1; + let digits = tail + .chars() + .take_while(|character| character.is_ascii_digit()) + .collect::(); + if digits.len() != 3 { + return None; + } + let status_code = digits.parse::().ok()?; + (100..=599).contains(&status_code).then_some(status_code) + }) +} + fn parse_claude_code_result( stdout: &[u8], request: &LlmRunRequest, @@ -758,7 +798,7 @@ fn parse_claude_code_result( let detail = claude_result_error_detail(&value) .map(|detail| format!(":{detail}")) .unwrap_or_default(); - return Err(platform_llm::LlmError::Transport(format!( + return Err(claude_code_failure_to_llm_error(format!( "Claude Code 返回失败终态{detail}" ))); } @@ -1156,6 +1196,29 @@ mod tests { assert!(error.to_string().contains("Authentication failed")); } + #[test] + fn claude_failure_reuses_codex_error_categories() { + assert!(matches!( + claude_code_failure_to_llm_error( + "Claude Code 返回失败终态:API Error: Request rejected (429)".into() + ), + platform_llm::LlmError::Upstream { + status_code: 429, + .. + } + )); + assert!(matches!( + claude_code_failure_to_llm_error( + "Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件".into() + ), + platform_llm::LlmError::Timeout { attempts: 1 } + )); + assert!(matches!( + claude_code_failure_to_llm_error("Claude Code 缺少最终回复".into()), + platform_llm::LlmError::EmptyResponse + )); + } + #[test] fn parses_direct_stream_result_and_final_text() { let mut observed = Vec::new(); 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 54a6a023c..a1141d90e 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 @@ -5300,7 +5300,9 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( observer, ) .await - .map_err(|detail| TurnError::from_model_call(&platform_llm::LlmError::Transport(detail))); + .map_err(|detail| { + TurnError::from_model_call(&crate::agent::claude_code_failure_to_llm_error(detail)) + }); } game_creator_codex_app_server_validate_llm_config(&config.llm).map_err(|error| { TurnError::EnvironmentNotReady(EnvironmentNotReady { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs index 707674fbf..745a9c1c2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs @@ -304,7 +304,7 @@ impl ModelCallKind { native, } => match native { Some(native) => native.is_retryable(), - None => *status_code >= 500, + None => matches!(*status_code, 401 | 408 | 429 | 500..=599), }, Self::PaidCreditsInsufficient => false, Self::EmptyResponse => false, @@ -322,9 +322,16 @@ impl ModelCallKind { } Self::EmptyResponse => Some("模型未返回内容"), Self::PayloadInvalid { .. } => Some("模型回执无法解析"), - Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => { + Self::RequestRejected { native } => { native.as_ref().and_then(NativeKind::public_summary) } + Self::UpstreamFailed { + status_code, + native, + } => native + .as_ref() + .and_then(NativeKind::public_summary) + .or_else(|| upstream_status_summary(*status_code)), } } @@ -341,13 +348,40 @@ impl ModelCallKind { Self::EmptyResponse | Self::PayloadInvalid { .. } => { Some("模型未给出可用的回执,请重试;如持续失败请检查项目诊断") } - Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => { - native.as_ref().and_then(NativeKind::recovery_hint) - } + Self::RequestRejected { native } => native.as_ref().and_then(NativeKind::recovery_hint), + Self::UpstreamFailed { + status_code, + native, + } => native + .as_ref() + .and_then(NativeKind::recovery_hint) + .or_else(|| upstream_status_recovery_hint(*status_code)), } } } +fn upstream_status_summary(status_code: u16) -> Option<&'static str> { + Some(match status_code { + 401 => "上游服务拒绝认证(HTTP 401)", + 403 => "上游服务拒绝访问(HTTP 403)", + 408 => "上游请求超时(HTTP 408)", + 429 => "上游服务返回 HTTP 429", + 500..=599 => "上游服务暂时不可用", + _ => return None, + }) +} + +fn upstream_status_recovery_hint(status_code: u16) -> Option<&'static str> { + Some(match status_code { + 401 => "请重新登录陶泥儿后重试;如持续失败请检查项目诊断", + 403 => "请检查当前账号的上游访问权限后重试;如持续失败请检查项目诊断", + 408 => "上游请求超时,请稍后重试;如持续失败请检查项目诊断", + 429 => "上游服务暂时繁忙(HTTP 429),请稍后重试;如持续失败请检查项目诊断", + 500..=599 => "上游服务暂时不可用,请稍后重试;如持续失败请检查项目诊断", + _ => return None, + }) +} + // ══════════════════════════════════════════════════════════════════════════════════════════ // 入队失败:`#[tauri::command]` 的 `Err`,这一轮没有开始也没有进队列 // ══════════════════════════════════════════════════════════════════════════════════════════ @@ -1327,6 +1361,31 @@ mod tests { assert!(!projected.is_model_repairable()); } + #[test] + fn upstream_http_status_keeps_codex_style_summary_and_retry_guidance() { + let rate_limited = TurnError::from_model_call(&LlmError::Upstream { + status_code: 429, + message: "Claude Code 返回失败终态:API Error: Request rejected (429)".into(), + }); + assert_eq!(rate_limited.public_summary(), Some("上游服务返回 HTTP 429")); + assert_eq!( + rate_limited.recovery_hint(), + Some("上游服务暂时繁忙(HTTP 429),请稍后重试;如持续失败请检查项目诊断") + ); + assert!(rate_limited.is_retryable()); + assert!(!rate_limited.is_model_repairable()); + + let unauthorized = TurnError::from_model_call(&LlmError::Upstream { + status_code: 401, + message: "Claude Code 返回失败终态:HTTP 401".into(), + }); + assert_eq!( + unauthorized.public_summary(), + Some("上游服务拒绝认证(HTTP 401)") + ); + assert!(unauthorized.is_retryable()); + } + /// 反馈判据:原生分类里"再跑一次也不会变"的那些不再反馈给模型。 #[test] fn terminal_native_kinds_are_not_fed_back_to_the_model() { diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 02594596b..75a6d0d4f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6288,7 +6288,8 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **根因 3(工具被拒)**:sidecar 用 `permissionMode: 'dontAsk'` 且没有 `allowedTools`,宿主 MCP 工具(`mcp__agc__*`)一律被直接拒绝,模型只能回"没有权限"。 - **根因 4(界面看不到回复)**:聊天区是按 `item.completed` 事件流投影的(codex 路径在 `rawResponseItem/completed` 时下发 `ThreadItem::Message`),只把回复落进 `project.jsonl` 不会让本轮出现在界面上——用户看到"用户气泡 + 本轮结束于 … · 耗时",回复只在重进项目时从历史读出来。 - **根因 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 服务器(权限策略在宿主侧执行)。 +- **根因 6(cc 字符串错误覆盖了上游分类)**:DirectProject 的 Claude Code 路由原本把 sidecar 返回的所有字符串都包装成 `LlmError::Transport`,因此 HTTP 429、401、408、5xx、sidecar 超时、空回执和无效 JSON 都显示成「执行通道未能建立或已断开」。 +- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 每次解析成功后都把实际落盘的回复 item id 同步下发 `ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"})`。首个回复沿用 `direct-codex::assistant`,同一回合的反馈回复遇到内容冲突时追加 `:assistant:`,相同内容仍按原 ID 幂等;落盘失败按回合失败收口。Claude Code 的失败文本先投影到与 Codex 相同的 `LlmError` 分类:HTTP 状态、sidecar 超时、空回执和无效 JSON 分别复用上游、超时、空响应和反序列化语义;上游状态摘要与重试建议按状态码给出。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`。另有 `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`。