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 db9db4abf..9770d524f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -45,7 +45,7 @@ pub mod tool; pub(crate) use art_manifest::*; use claude_code_cli::*; pub(crate) use claude_code_cli::{ - cancel_direct_claude_code_turn_at, claude_code_failure_to_llm_error, + cancel_direct_claude_code_turn_at, claude_code_failure_to_turn_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, }; 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 1f5b65e88..01c63196c 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 @@ -255,6 +255,7 @@ async fn run_sidecar_turn( let mut terminal = None; let mut idle_rounds = 0_u64; let mut silent_ms = 0_u64; + let mut last_event_detail = None; loop { let line = match tokio::time::timeout(CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT, lines.next_line()) @@ -300,6 +301,13 @@ async fn run_sidecar_turn( "Claude Agent SDK sidecar 输出不是有效 JSON:{error};原文={detail}" )) })?; + last_event_detail = serde_json::to_string(&value).ok().map(|detail| { + crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 480, + ) + }); match value.get("type").and_then(serde_json::Value::as_str) { Some("event") => { if let Some(event) = value.get("event") { @@ -315,12 +323,15 @@ async fn run_sidecar_turn( } Some("error") => { kill_claude_code_process_tree(pid); + let detail = claude_result_error_detail(&value).or_else(|| { + last_event_detail + .clone() + .filter(|detail| !detail.trim().is_empty()) + }); return Err(SidecarTurnFailure::message( - value - .get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or("Claude Agent SDK sidecar 执行失败") - .to_string(), + detail + .map(|detail| format!("Claude Agent SDK sidecar 执行失败:{detail}")) + .unwrap_or_else(|| "Claude Agent SDK sidecar 执行失败".to_string()), )); } _ => {} @@ -329,13 +340,23 @@ async fn run_sidecar_turn( let status = child.wait().await.map_err(|error| { SidecarTurnFailure::message(format!("等待 Claude Agent SDK sidecar 结束失败:{error}")) })?; + let exit_status = status + .code() + .map(|code| code.to_string()) + .unwrap_or_else(|| "signal".to_string()); let result = terminal.ok_or_else(|| { - SidecarTurnFailure::message("Claude Agent SDK sidecar 缺少终态".to_string()) + SidecarTurnFailure::message(format!( + "Claude Agent SDK sidecar 缺少终态;exitStatus={exit_status};已收到 {} 个事件;最后事件={}", + event_count, + last_event_detail + .filter(|detail| !detail.trim().is_empty()) + .unwrap_or_else(|| "<无有效事件>".to_string()) + )) })?; if !status.success() { - return Err(SidecarTurnFailure::message( - "Claude Agent SDK sidecar 非零退出".to_string(), - )); + return Err(SidecarTurnFailure::message(format!( + "Claude Agent SDK sidecar 非零退出;exitStatus={exit_status}" + ))); } Ok(SidecarTurnResult { result }) }; @@ -658,22 +679,37 @@ fn configure_claude_code_environment( } fn claude_result_error_detail(value: &serde_json::Value) -> Option { + fn value_detail(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(detail) => { + let detail = detail.trim(); + (!detail.is_empty()).then(|| detail.chars().take(512).collect()) + } + serde_json::Value::Object(object) => { + let detail = ["message", "detail", "error", "type", "code"] + .into_iter() + .filter_map(|field| object.get(field).and_then(value_detail)) + .collect::>() + .join(";"); + (!detail.is_empty()).then_some(detail) + } + _ => None, + } + } let candidates = [ - value.get("result").and_then(serde_json::Value::as_str), - value.get("error").and_then(serde_json::Value::as_str), - value.get("message").and_then(serde_json::Value::as_str), + value.get("result").and_then(value_detail), + value.get("error").and_then(value_detail), + value.get("message").and_then(value_detail), value .get("errors") .and_then(serde_json::Value::as_array) .and_then(|errors| errors.first()) - .and_then(serde_json::Value::as_str), + .and_then(value_detail), ]; candidates .into_iter() .flatten() - .map(str::trim) .find(|detail| !detail.is_empty()) - .map(|detail| detail.chars().take(512).collect()) } fn configure_claude_code_process(command: &mut tokio::process::Command) { @@ -806,6 +842,30 @@ pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm:: platform_llm::LlmError::Transport(detail) } +/// 把 cc 的失败投影到 Direct 回合错误;平台 `LlmError::Timeout` / `EmptyResponse` 本身没有 +/// detail 字段,因此这两类必须在这里把 sidecar 的原始原因留在统一 `ModelCallFailed.detail` 中, +/// 否则 stderr、超时阶段和缺失字段会在类型转换时丢掉,前端又只能显示固定兜底句。 +pub(crate) fn claude_code_failure_to_turn_error( + detail: String, +) -> super::codex_app_server::turn_error::TurnError { + use super::codex_app_server::turn_error::{ModelCallFailed, ModelCallKind, TurnError}; + + let llm_error = claude_code_failure_to_llm_error(detail.clone()); + match llm_error { + platform_llm::LlmError::Timeout { attempts } => { + TurnError::ModelCallFailed(ModelCallFailed { + kind: ModelCallKind::ResponseTimedOut { attempts }, + detail, + }) + } + platform_llm::LlmError::EmptyResponse => TurnError::ModelCallFailed(ModelCallFailed { + kind: ModelCallKind::EmptyResponse, + detail, + }), + other => TurnError::from_model_call(&other), + } +} + fn claude_code_failure_status_code(detail: &str) -> Option { let normalized = detail.to_ascii_lowercase(); [ @@ -835,8 +895,15 @@ fn parse_claude_code_result( stdout: &[u8], request: &LlmRunRequest, ) -> Result { - let value: serde_json::Value = serde_json::from_slice(stdout).map_err(|_| { - platform_llm::LlmError::Deserialize("Claude Code JSON 输出无效".to_string()) + let value: serde_json::Value = serde_json::from_slice(stdout).map_err(|error| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &String::from_utf8_lossy(stdout), + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Claude Code JSON 输出无效:{error};原文={detail}" + )) })?; if value.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) { let detail = claude_result_error_detail(&value) @@ -863,20 +930,33 @@ fn parse_claude_code_result( } (result, Vec::new()) } else { - let envelope = structured - .or_else(|| serde_json::from_str(&result).ok()) - .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Claude Code structured output 缺失".to_string(), - ) - })?; + let envelope = match structured { + Some(value) => value, + None => serde_json::from_str(&result).map_err(|error| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &result, + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Claude Code structured output JSON 无效:{error};原文={detail}" + )) + })?, + }; let calls = envelope .get("toolCalls") .and_then(serde_json::Value::as_array) .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Claude Code structured output 缺少 toolCalls".to_string(), - ) + let detail = + serde_json::to_string(&envelope).unwrap_or_else(|_| "<不可序列化>".into()); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Claude Code structured output 缺少 toolCalls;原文={detail}" + )) })?; let calls = calls .iter() @@ -886,17 +966,20 @@ fn parse_claude_code_result( .get("name") .and_then(serde_json::Value::as_str) .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Claude Code tool call 缺少 name".to_string(), - ) + platform_llm::LlmError::Deserialize(format!( + "Claude Code tool call 第 {} 项缺少 name;原文={}", + index.saturating_add(1), + serde_json::to_string(call).unwrap_or_else(|_| "<不可序列化>".into()) + )) })?; let arguments = call .get("arguments") .and_then(serde_json::Value::as_str) .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Claude Code tool call 缺少 arguments".to_string(), - ) + platform_llm::LlmError::Deserialize(format!( + "Claude Code tool call 第 {} 项缺少 arguments;name={name}", + index.saturating_add(1) + )) })?; if !request.function_tools.iter().any(|tool| tool.name == name) { return Err(platform_llm::LlmError::Deserialize( @@ -1733,6 +1816,63 @@ mod tests { assert!(error.to_string().contains("Authentication failed")); } + #[test] + fn preserves_claude_invalid_json_and_object_error_details() { + let invalid = parse_claude_code_result( + br#"{"is_error":false,"result":"unterminated""#, + &request(false), + ) + .expect_err("invalid JSON must be rejected"); + let invalid = invalid.to_string(); + assert!(invalid.contains("Claude Code JSON 输出无效"), "{invalid}"); + assert!(invalid.contains("line"), "{invalid}"); + assert!(invalid.contains("unterminated"), "{invalid}"); + + let object_error = parse_claude_code_result( + br#"{"is_error":true,"error":{"type":"invalid_request_error","message":"HTTP 422: bad schema"}}"#, + &request(false), + ) + .expect_err("object error must be rejected"); + assert!( + object_error.to_string().contains("HTTP 422: bad schema"), + "{object_error}" + ); + } + + #[test] + fn cc_failure_projection_preserves_timeout_and_empty_response_detail() { + let timeout = super::claude_code_failure_to_turn_error( + "Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件;stderr=OOM".into(), + ); + match timeout { + super::codex_app_server::turn_error::TurnError::ModelCallFailed(payload) => { + assert!(matches!( + payload.kind, + super::codex_app_server::turn_error::ModelCallKind::ResponseTimedOut { + attempts: 1 + } + )); + assert!(payload.detail.contains("180000")); + assert!(payload.detail.contains("OOM")); + } + other => panic!("expected timed out model failure, got {other:?}"), + } + + let empty = super::claude_code_failure_to_turn_error( + "Claude Code 缺少最终回复;sidecar result 缺少 result 字段".into(), + ); + match empty { + super::codex_app_server::turn_error::TurnError::ModelCallFailed(payload) => { + assert!(matches!( + payload.kind, + super::codex_app_server::turn_error::ModelCallKind::EmptyResponse + )); + assert!(payload.detail.contains("缺少 result 字段")); + } + other => panic!("expected empty response model failure, got {other:?}"), + } + } + #[test] fn claude_failure_reuses_codex_error_categories() { assert!(matches!( 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 5dae39023..4476da562 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 @@ -5428,9 +5428,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( observer, ) .await - .map_err(|detail| { - TurnError::from_model_call(&crate::agent::claude_code_failure_to_llm_error(detail)) - }); + .map_err(crate::agent::claude_code_failure_to_turn_error); } 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_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index bbe154ba8..3dfc50189 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -532,13 +532,14 @@ fn parse_game_creator_codex_cli_response( stdout: &[u8], request: &LlmRunRequest, ) -> Result { - let stdout = std::str::from_utf8(stdout).map_err(|_| { - platform_llm::LlmError::Deserialize("Codex CLI Agent JSONL 不是 UTF-8".to_string()) + let stdout = std::str::from_utf8(stdout).map_err(|error| { + platform_llm::LlmError::Deserialize(format!("Codex CLI Agent JSONL 不是 UTF-8:{error}")) })?; let mut response_id = None; let mut final_message = None; let mut usage = None; let mut completed = false; + let mut last_event_detail = None; for (index, line) in stdout.lines().enumerate() { if line.trim().is_empty() { continue; @@ -554,6 +555,11 @@ fn parse_game_creator_codex_cli_response( index.saturating_add(1) )) })?; + last_event_detail = Some(crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + line, + 480, + )); match event.get("type").and_then(serde_json::Value::as_str) { Some("thread.started") => { response_id = event @@ -592,11 +598,14 @@ fn parse_game_creator_codex_cli_response( } } Some("turn.failed") => { - let detail = event + let mut detail = event .get("error") .or_else(|| event.get("message")) .map(serde_json::Value::to_string) .unwrap_or_default(); + if detail.trim().is_empty() { + detail = serde_json::to_string(&event).unwrap_or_default(); + } let detail = crate::agent::redact_agent_runtime_error( Path::new("__agc_no_project_root__"), detail.trim_matches('"'), @@ -612,9 +621,12 @@ fn parse_game_creator_codex_cli_response( } } if !completed { - return Err(platform_llm::LlmError::Deserialize( - "Codex CLI Agent 缺少 turn.completed 终态".to_string(), - )); + return Err(platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent 缺少 turn.completed 终态;最后事件={}", + last_event_detail + .filter(|detail| !detail.trim().is_empty()) + .unwrap_or_else(|| "<无有效事件>".to_string()) + ))); } let text = final_message .filter(|text| !text.trim().is_empty()) @@ -622,18 +634,30 @@ fn parse_game_creator_codex_cli_response( let tool_calls = if request.function_tools.is_empty() { Vec::new() } else { - let envelope = serde_json::from_str::(&text).map_err(|_| { - platform_llm::LlmError::Deserialize( - "Codex CLI Agent structured output 不是严格 JSON".to_string(), - ) + let envelope = serde_json::from_str::(&text).map_err(|error| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &text, + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent structured output JSON 无效:{error};原文={detail}" + )) })?; let calls = envelope .get("toolCalls") .and_then(serde_json::Value::as_array) .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Codex CLI Agent structured output 缺少 toolCalls".to_string(), - ) + let detail = + serde_json::to_string(&envelope).unwrap_or_else(|_| "<不可序列化>".to_string()); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent structured output 缺少 toolCalls;原文={detail}" + )) })?; calls .iter() @@ -643,17 +667,26 @@ fn parse_game_creator_codex_cli_response( .get("name") .and_then(serde_json::Value::as_str) .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Codex CLI Agent tool call 缺少 name".to_string(), - ) + let detail = serde_json::to_string(call) + .unwrap_or_else(|_| "<不可序列化>".to_string()); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent tool call 第 {} 项缺少 name;原文={detail}", + index.saturating_add(1) + )) })?; let arguments = call .get("arguments") .and_then(serde_json::Value::as_str) .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Codex CLI Agent tool call 缺少 arguments".to_string(), - ) + platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent tool call 第 {} 项缺少 arguments;name={name}", + index.saturating_add(1) + )) })?; if !request.function_tools.iter().any(|tool| tool.name == name) { return Err(platform_llm::LlmError::Deserialize( @@ -1248,6 +1281,15 @@ mod tests { assert!(failed_text.contains("HTTP 502 upstream overloaded")); assert!(!failed_text.contains("fixture-secret")); + let failed_without_message = parse_game_creator_codex_cli_response( + b"{\"type\":\"turn.failed\",\"code\":\"EPIPE\",\"status\":\"closed\"}\n", + &request, + ) + .expect_err("failed terminal without message must retain event detail"); + let failed_without_message = failed_without_message.to_string(); + assert!(failed_without_message.contains("EPIPE")); + assert!(failed_without_message.contains("closed")); + let invalid = parse_game_creator_codex_cli_response( b"{not-json Authorization: Bearer fixture-secret}\n", &request, @@ -1259,6 +1301,35 @@ mod tests { assert!(!invalid_text.contains("fixture-secret")); } + #[test] + fn codex_cli_mode_preserves_missing_terminal_and_structured_output_details() { + let request = tool_request(); + let missing_terminal = parse_game_creator_codex_cli_response( + b"{\"type\":\"item.completed\",\"item\":{\"type\":\"error\",\"message\":\"IPC EPIPE\"}}\n", + &request, + ) + .expect_err("missing terminal must retain the last protocol event"); + let missing_terminal = missing_terminal.to_string(); + assert!(missing_terminal.contains("缺少 turn.completed 终态")); + assert!(missing_terminal.contains("IPC EPIPE")); + + let malformed_event = serde_json::json!({ + "type": "item.completed", + "item": { "type": "agent_message", "text": r#"{"toolCalls":["# } + }); + let malformed_stdout = format!( + "{}\n{{\"type\":\"turn.completed\"}}\n", + serde_json::to_string(&malformed_event).expect("serialize malformed event") + ); + let malformed_structured = + parse_game_creator_codex_cli_response(malformed_stdout.as_bytes(), &request) + .expect_err("malformed structured output must retain parse detail"); + let malformed_structured = malformed_structured.to_string(); + assert!(malformed_structured.contains("structured output JSON 无效")); + assert!(malformed_structured.contains("line")); + assert!(malformed_structured.contains("toolCalls")); + } + #[cfg(unix)] #[tokio::test] async fn codex_cli_mode_builds_ephemeral_json_exec_with_prompt_on_stdin() { diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 1390fd2d7..dcb1a5d35 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -107,7 +107,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 脱敏只替换敏感值、完整 URL、绝对路径和私钥内容;字段名(例如 `Authorization`、`token`、`api_key`)保留,便于判断错误类别。原始凭据值不进入用户文本、应用日志或项目诊断。 - 上游 app-server 映射保留 `message` / `additionalDetails` 的脱敏摘要;因此 401/403/408/429/413/5xx 等状态不会因为分类为 transport 或 native 而丢失正文。 - Claude Code(cc)侧车的非零退出、RPC `error`、stdout JSON/UTF-8 解析失败、stderr 和静默超时也走同一映射;侧车 stderr 只在有界收口窗口内读取并进入同一脱敏 detail,不再只写裸 `eprintln!`。 -- Codex CLI / Claude sidecar 的 JSONL/JSON 解析失败和失败终态保留解析错误、失败事件正文与有界安全片段,不再把无效 JSON、`turn.failed` 或缺失终态统一压成单一类别。 +- Codex CLI / Claude sidecar 的 JSONL/JSON 解析失败、失败终态、缺失终态、超时和空回执都保留解析错误、失败事件正文与有界安全片段;`LlmError` 没有 detail 字段的超时/空回执由 Direct `ModelCallFailed.detail` 补回,不再把这些协议事实统一压成单一类别。 - HTTP 409 只有明确包含泥点不足事实时才映射为 `paidCreditsInsufficient`;Claude Code 的普通 409 冲突保留为 `upstreamFailed`。 - `hostDropped` 携带可选的脱敏 `detail`:panic hook 能取得的负载和位置随原回合占用进入失败事件;普通 Drop 仅记录“退出时未写终态、未观察到 panic”,不推断为网络中断。旧版本无 `detail` 的事件继续可读,界面明确标注旧事件未记录原因。 - DirectProject 控制器的取消待发、终止、附件上传、项目历史读取和发送前置异常也复用同一份前端精确脱敏出口;这些非回合错误不得直接把 `Error.message` 原文写入状态栏、composer 或历史错误行。 diff --git a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md index 76da95c54..48c76d67a 100644 --- a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md @@ -17,7 +17,7 @@ Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md` 8. 项目资源编辑/上传/预览/恢复队列/生成结果回读的 UI 错误统一使用 `normalizeDiagnosticText`,不再直出 `Error.message`。 9. 应用壳、运行配置、认证状态、插件启动、运行预览和策划会话的非结构化错误统一使用 `visibleClientErrorMessage`。 10. 资产导入、邀请码、策划工作区、发布封面/截图和素材命令的 UI 错误沿用同一共享出口。 -11. Codex CLI / Claude sidecar 的解析失败与失败终态保留有界脱敏正文,避免协议错误只剩类别码。 +11. Codex CLI / Claude sidecar 的解析失败、失败终态、缺失终态、超时和空回执保留有界脱敏正文;即使平台 `LlmError` 变体没有 detail 字段,也由统一 Direct `ModelCallFailed.detail` 继续携带原始原因。 ## 实现顺序