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 4315e5442..ba60c1c92 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 @@ -354,11 +354,9 @@ async fn run_sidecar_turn( let result = match outcome { Ok(result) => result, Err(message) => { - let Some(reason) = timeout_reason else { - return Err(message); - }; - // 先杀进程树:不杀的话 sidecar(连同它守护的 Claude Code CLI)会一直活着, - // stderr 等不到 EOF,这一轮就永远出不了终态。 + // 所有失败都走同一条收口:先杀进程树,再以有界窗口读取 stderr。之前只有 + // 静默超时读取 stderr,cc 的非零退出 / stdout JSON 错误 / RPC error 会丢掉 + // sidecar 给出的真正原因,最终只剩一条 transport 通用句。 kill_claude_code_process_tree(pid); let stderr_detail = match tokio::time::timeout(CLAUDE_CODE_STDERR_DRAIN_TIMEOUT, stderr_task).await { @@ -367,8 +365,23 @@ async fn run_sidecar_turn( } .map(|value| value.trim().chars().take(512).collect::()) .filter(|value| !value.is_empty()) - // 断开 stdout 之后 sidecar 自己会抛 EPIPE;那是收尾噪声,不是失败原因。 - .filter(|value| !value.contains("EPIPE")); + // 断开 stdout 之后 sidecar 自己会抛 EPIPE;过滤该噪声行,但保留同一份 + // stderr 中其它真正的 provider / Node 错误。 + .map(|value| { + value + .lines() + .filter(|line| !line.contains("EPIPE")) + .collect::>() + .join(" ") + }) + .filter(|value| !value.trim().is_empty()); + let reason = timeout_reason.unwrap_or_else(|| { + if message.trim().is_empty() { + "Claude Agent SDK sidecar 执行失败".to_string() + } else { + message + } + }); let detail = stderr_detail .map(|value| format!(":{value}")) .unwrap_or_default(); @@ -760,6 +773,15 @@ fn parse_usage(value: &serde_json::Value) -> Option /// 的上游事实显示成“执行通道已断开”。这里只认结构化的状态位置(`Request rejected (N)` / /// `HTTP N`),其它文本继续保留为 Transport,避免凭关键词猜测。 pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm::LlmError { + let normalized = detail.to_ascii_lowercase(); + if normalized.contains("maximum number of turns") + || normalized.contains("max turns") + || normalized.contains("最大回合数") + { + return platform_llm::LlmError::InvalidRequest(format!( + "codex-app-server-error:session-budget-exceeded detail={detail}" + )); + } if let Some(status_code) = claude_code_failure_status_code(&detail) { return platform_llm::LlmError::Upstream { status_code, @@ -780,8 +802,18 @@ pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm:: } fn claude_code_failure_status_code(detail: &str) -> Option { - ["Request rejected (", "HTTP "].iter().find_map(|marker| { - let tail = detail.split_once(marker)?.1; + let normalized = detail.to_ascii_lowercase(); + [ + "request rejected (", + "http ", + "status ", + "status=", + "status_code=", + "statuscode=", + ] + .iter() + .find_map(|marker| { + let tail = normalized.split_once(marker)?.1; let digits = tail .chars() .take_while(|character| character.is_ascii_digit()) @@ -1600,7 +1632,12 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( match &parsed { Ok(text) => direct_turn_trace(&format!("claude-parse-done chars={}", text.chars().count())), Err(error) => { - eprintln!("[agc-cc-direct] parse failed: {error}"); + // 统一进入应用日志的精确脱敏出口;不再把原始 parse detail 直接写 stderr,避免 + // 凭据/路径随 cc 收尾错误绕过 Runtime 诊断合同。 + app_log!( + "agent.direct_codex.claude_parse_failed detail={}", + crate::agent::redact_agent_runtime_error(root, &error, 600) + ); direct_turn_trace("claude-parse-error"); } } @@ -1712,6 +1749,31 @@ mod tests { claude_code_failure_to_llm_error("Claude Code 缺少最终回复".into()), platform_llm::LlmError::EmptyResponse )); + assert!(matches!( + claude_code_failure_to_llm_error( + "Claude Code 返回失败终态:HTTP 409 session already active".into() + ), + platform_llm::LlmError::Upstream { + status_code: 409, + .. + } + )); + assert!(matches!( + claude_code_failure_to_llm_error( + "Claude Agent SDK 返回错误:status_code=401 invalid token".into() + ), + platform_llm::LlmError::Upstream { + status_code: 401, + .. + } + )); + assert!(matches!( + claude_code_failure_to_llm_error( + "Claude Code 返回失败终态:Reached maximum number of turns (8)".into() + ), + platform_llm::LlmError::InvalidRequest(message) + if message.contains("codex-app-server-error:session-budget-exceeded") + )); } #[test] 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 a1141d90e..c359772ad 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 @@ -344,6 +344,22 @@ fn game_creator_codex_app_server_error_kind_with_machine_detail( if !keys.is_empty() { fields.push(format!("fields={}", keys.join(","))); } + let detail = ["message", "additionalDetails"] + .into_iter() + .filter_map(|field| object.get(field).and_then(serde_json::Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>() + .join(";"); + if !detail.is_empty() { + // 先在 app-server 投影边界做一次无根目录脱敏;终态载荷还会按项目根目录再脱敏。 + let safe_detail = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &detail, + 480, + ); + fields.push(format!("detail={safe_detail}")); + } } let suffix = if fields.is_empty() { String::new() @@ -369,13 +385,34 @@ fn game_creator_codex_app_server_error_http_status( fn game_creator_codex_app_server_connection_error( info: &serde_json::Value, field: &str, + error: &serde_json::Value, ) -> platform_llm::LlmError { match game_creator_codex_app_server_error_http_status(info, field) { - Some(401 | 403) => game_creator_codex_app_server_error_kind("unauthorized"), - Some(413) => game_creator_codex_app_server_error_kind("request-too-large"), + Some(status_code @ (401 | 403)) => { + let platform_llm::LlmError::InvalidRequest(message) = + game_creator_codex_app_server_error_kind_with_machine_detail("unauthorized", error) + else { + unreachable!("native unauthorized projection must remain InvalidRequest"); + }; + platform_llm::LlmError::InvalidRequest(format!("{message} detail=HTTP {status_code}")) + } + Some(413) => { + let platform_llm::LlmError::InvalidRequest(message) = + game_creator_codex_app_server_error_kind_with_machine_detail( + "request-too-large", + error, + ) + else { + unreachable!("native request-too-large projection must remain InvalidRequest"); + }; + platform_llm::LlmError::InvalidRequest(format!("{message} detail=HTTP 413")) + } Some(status_code) => platform_llm::LlmError::Upstream { status_code, - message: "Codex app-server 连接上游失败".to_string(), + message: format!( + "Codex app-server 连接上游失败:{}", + game_creator_codex_app_server_error_display_detail(error) + ), }, None => platform_llm::LlmError::Connectivity { attempts: 1, @@ -422,6 +459,19 @@ fn game_creator_codex_app_server_error_detail(error: &serde_json::Value) -> Stri .to_ascii_lowercase() } +fn game_creator_codex_app_server_error_display_detail(error: &serde_json::Value) -> String { + let Some(error) = error.as_object() else { + return String::new(); + }; + ["message", "additionalDetails", "code"] + .into_iter() + .filter_map(|field| error.get(field).and_then(serde_json::Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>() + .join(";") +} + fn game_creator_codex_app_server_error_detail_indicates_stream_requirement( error: &serde_json::Value, ) -> bool { @@ -494,19 +544,28 @@ fn game_creator_codex_app_server_failed_turn_error( }; } if game_creator_codex_app_server_error_detail_indicates_request_too_large(error) { - return game_creator_codex_app_server_error_kind("request-too-large"); + return game_creator_codex_app_server_error_kind_with_machine_detail( + "request-too-large", + error, + ); } if game_creator_codex_app_server_error_detail_indicates_stream_requirement(error) { - return game_creator_codex_app_server_error_kind("stream-required"); + return game_creator_codex_app_server_error_kind_with_machine_detail( + "stream-required", + error, + ); } if game_creator_codex_app_server_error_detail_indicates_timeout(error) { return platform_llm::LlmError::Connectivity { attempts: 1, - message: "Codex app-server 上游请求超时".to_string(), + message: format!( + "Codex app-server 上游请求超时:{}", + game_creator_codex_app_server_error_display_detail(error) + ), }; } if game_creator_codex_app_server_error_detail_indicates_auth_failure(error) { - return game_creator_codex_app_server_error_kind("unauthorized"); + return game_creator_codex_app_server_error_kind_with_machine_detail("unauthorized", error); } let Some(info) = error.get("codexErrorInfo").filter(|info| !info.is_null()) else { return game_creator_codex_app_server_error_kind_with_machine_detail("other", error); @@ -514,25 +573,44 @@ fn game_creator_codex_app_server_failed_turn_error( if let Some(kind) = info.as_str() { return match kind { "contextWindowExceeded" => { - game_creator_codex_app_server_error_kind("context-window-exceeded") + game_creator_codex_app_server_error_kind_with_machine_detail( + "context-window-exceeded", + error, + ) } "sessionBudgetExceeded" => { - game_creator_codex_app_server_error_kind("session-budget-exceeded") - } - "usageLimitExceeded" => { - game_creator_codex_app_server_error_kind("usage-limit-exceeded") + game_creator_codex_app_server_error_kind_with_machine_detail( + "session-budget-exceeded", + error, + ) } + "usageLimitExceeded" => game_creator_codex_app_server_error_kind_with_machine_detail( + "usage-limit-exceeded", + error, + ), "serverOverloaded" | "internalServerError" => platform_llm::LlmError::Upstream { status_code: 503, - message: "Codex app-server 上游服务暂时不可用".to_string(), + message: format!( + "Codex app-server 上游服务暂时不可用:{}", + game_creator_codex_app_server_error_display_detail(error) + ), }, - "cyberPolicy" => game_creator_codex_app_server_error_kind("cyber-policy"), - "unauthorized" => game_creator_codex_app_server_error_kind("unauthorized"), - "badRequest" => game_creator_codex_app_server_error_kind("bad-request"), - "threadRollbackFailed" => { - game_creator_codex_app_server_error_kind("thread-rollback-failed") + "cyberPolicy" => { + game_creator_codex_app_server_error_kind_with_machine_detail("cyber-policy", error) + } + "unauthorized" => { + game_creator_codex_app_server_error_kind_with_machine_detail("unauthorized", error) + } + "badRequest" => { + game_creator_codex_app_server_error_kind_with_machine_detail("bad-request", error) + } + "threadRollbackFailed" => game_creator_codex_app_server_error_kind_with_machine_detail( + "thread-rollback-failed", + error, + ), + "sandboxError" => { + game_creator_codex_app_server_error_kind_with_machine_detail("sandbox-error", error) } - "sandboxError" => game_creator_codex_app_server_error_kind("sandbox-error"), "other" => game_creator_codex_app_server_error_kind_with_machine_detail("other", error), _ => game_creator_codex_app_server_error_kind_with_machine_detail("other", error), }; @@ -544,7 +622,7 @@ fn game_creator_codex_app_server_failed_turn_error( "responseTooManyFailedAttempts", ] { if info.get(field).is_some() { - return game_creator_codex_app_server_connection_error(info, field); + return game_creator_codex_app_server_connection_error(info, field, error); } } if info.get("activeTurnNotSteerable").is_some() { @@ -6956,13 +7034,8 @@ mod tests { } }); let error = game_creator_codex_app_server_failed_turn_error(&failed_turn); - assert_eq!( - error, - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:context-window-exceeded".to_string() - ) - ); let visible = error.to_string(); + assert!(visible.starts_with("codex-app-server-error:context-window-exceeded")); assert!(!visible.contains(&secret)); assert!(!visible.contains("provider.example")); assert!(!visible.contains("victim")); @@ -6970,38 +7043,26 @@ mod tests { #[test] fn codex_app_server_failed_turn_maps_stable_categories_and_http_status() { - for (info, expected) in [ + for (info, expected_prefix) in [ ( serde_json::json!("usageLimitExceeded"), - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:usage-limit-exceeded".to_string(), - ), + "codex-app-server-error:usage-limit-exceeded", ), ( serde_json::json!("unauthorized"), - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:unauthorized".to_string(), - ), + "codex-app-server-error:unauthorized", ), ( serde_json::json!({"httpConnectionFailed":{"httpStatusCode":413}}), - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:request-too-large".to_string(), - ), + "codex-app-server-error:request-too-large", ), ( serde_json::json!({"httpConnectionFailed":{"httpStatusCode":429}}), - platform_llm::LlmError::Upstream { - status_code: 429, - message: "Codex app-server 连接上游失败".to_string(), - }, + "上游返回 429", ), ( serde_json::json!({"responseStreamDisconnected":{"httpStatusCode":null}}), - platform_llm::LlmError::Connectivity { - attempts: 1, - message: "Codex app-server 连接失败".to_string(), - }, + "Codex app-server 连接失败", ), ] { let turn = serde_json::json!({ @@ -7012,10 +7073,8 @@ mod tests { "codexErrorInfo": info } }); - assert_eq!( - game_creator_codex_app_server_failed_turn_error(&turn), - expected - ); + let actual = game_creator_codex_app_server_failed_turn_error(&turn).to_string(); + assert!(actual.contains(expected_prefix), "{actual}"); } assert_eq!( game_creator_codex_app_server_failed_turn_error( @@ -7040,12 +7099,9 @@ mod tests { "codexErrorInfo": "other" } })); - assert_eq!( - error, - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:unauthorized".to_string() - ) - ); + assert!(error + .to_string() + .starts_with("codex-app-server-error:unauthorized")); } } @@ -7065,12 +7121,9 @@ mod tests { "codexErrorInfo": "other" } })); - assert_eq!( - error, - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:request-too-large".to_string(), - ) - ); + assert!(error + .to_string() + .starts_with("codex-app-server-error:request-too-large")); } } @@ -7083,12 +7136,9 @@ mod tests { "codexErrorInfo": "other" } })); - assert_eq!( - stream_error, - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:stream-required".to_string() - ) - ); + assert!(stream_error + .to_string() + .starts_with("codex-app-server-error:stream-required")); let timeout_error = game_creator_codex_app_server_failed_turn_error(&serde_json::json!({ "status": "failed", "error": { @@ -7096,13 +7146,12 @@ mod tests { "codexErrorInfo": "other" } })); - assert_eq!( - timeout_error, - platform_llm::LlmError::Connectivity { - attempts: 1, - message: "Codex app-server 上游请求超时".to_string() - } - ); + assert!(timeout_error + .to_string() + .contains("Codex app-server 上游请求超时")); + assert!(timeout_error + .to_string() + .contains("provider request timed out")); } #[test] 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 745a9c1c2..6272893d5 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 @@ -805,7 +805,10 @@ impl TurnError { native: native_kind(&detail), } } - LlmError::Upstream { status_code, .. } if *status_code == 409 => { + LlmError::Upstream { + status_code, + message, + } if *status_code == 409 && is_mud_points_upstream_message(message) => { ModelCallKind::PaidCreditsInsufficient } LlmError::Upstream { status_code, .. } => ModelCallKind::UpstreamFailed { @@ -836,6 +839,15 @@ impl TurnError { } } +fn is_mud_points_upstream_message(message: &str) -> bool { + let normalized = message.to_ascii_lowercase(); + message.contains("泥点余额不足") + || message.contains("可消费泥点不足") + || normalized.contains("insufficient_mud_points") + || normalized.contains("insufficient-mud-points") + || normalized.contains("mud points insufficient") +} + /// 桥:深层尚未 typed 的字符串错误落进 [`TurnError::Unclassified`]。 /// /// 只给"这一轮已经开始"的层用。调用级(权限、校验、并发)必须显式构造对应变体。 @@ -1361,6 +1373,27 @@ mod tests { assert!(!projected.is_model_repairable()); } + #[test] + fn non_payment_upstream_409_keeps_the_upstream_status_and_detail() { + let projected = TurnError::from_model_call(&LlmError::Upstream { + status_code: 409, + message: "Claude Code 返回冲突(HTTP 409):session already active".into(), + }); + match projected { + TurnError::ModelCallFailed(payload) => { + assert_eq!( + payload.kind, + ModelCallKind::UpstreamFailed { + status_code: 409, + native: None, + } + ); + assert!(payload.detail.contains("session already active")); + } + other => panic!("expected upstream 409 failure, got {other:?}"), + } + } + #[test] fn upstream_http_status_keeps_codex_style_summary_and_retry_guidance() { let rate_limited = TurnError::from_model_call(&LlmError::Upstream { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index 9e7c8518e..131017a1c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -621,7 +621,6 @@ const ERROR_SENSITIVE_ASSIGNMENT_KEYS: &[&str] = &[ ]; const ERROR_REDACTED_VALUE: &str = "[redacted-secret]"; -const ERROR_REDACTED_KEY: &str = "[redacted-sensitive-field]"; /// Redact an error for display in Runtime state, diagnostics, and tool /// results. This intentionally does not call `sanitize_prompt_context`: the @@ -952,14 +951,11 @@ fn redact_error_sensitive_assignments(line: &str) -> String { if value_start < cursor { break; } - // Do not retain the sensitive field name itself. A warning may be - // serialized to the Agent/UI, and names such as `api_key` or - // `Authorization` are sensitive context even after their values have - // been replaced. Preserve surrounding quotes, separators, and - // whitespace so JSON-ish diagnostics remain readable. + // 保留敏感字段名,只替换值:Authorization/api_key/token 本身是排障分类,值才是秘密。 + // 同时保留引号、分隔符和空白,使 JSON-ish 错误仍然可读。 let key_end = key_start + key_len; output.push_str(&line[cursor..key_start]); - output.push_str(ERROR_REDACTED_KEY); + output.push_str(&line[key_start..key_end]); output.push_str(&line[key_end..value_start]); let (value_end, replacement) = error_assignment_value_replacement(line, value_start); if value_end <= value_start { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs index 7e7a2e385..ca15b25c3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs @@ -405,9 +405,10 @@ mod tests { let identity = crate::sanitize_diagnostic_message(&marked[0], None); assert!(identity.contains("eventId=error-3-1"), "{identity}"); assert!(identity.contains("code=tool-error"), "{identity}"); - assert_eq!( - crate::sanitize_diagnostic_message(&marked[1], None), - "" + let marked_detail = crate::sanitize_diagnostic_message(&marked[1], None); + assert!( + marked_detail.contains("credential rotation failed"), + "{marked_detail}" ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs index ec68232d3..1776283fd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs @@ -96,9 +96,8 @@ impl Drop for TurnReservation { if finalized_by_guard { // 项目侧也留一份脱敏诊断:用户在项目目录里就能看到这一轮的收场, // 不必只依赖 AppData 的应用日志。 - // 字段名用 `tt`(不是 `turnToken`):`turnToken=` 会命中应用日志的凭据标记, - // 整行被替换成 ``,离线就只剩一个 - // 说不出原因的 HostDropped。 + // 保留回合定位字段;错误日志只替换敏感值,不再因字段名命中凭据标记而吞掉整行, + // 这样离线仍能看出 HostDropped 的发生位置。 let failure = TurnError::turn_failed( FailureStage::CodeGeneration, format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index d35778005..adfaa999b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1712,23 +1712,11 @@ pub(crate) fn sanitize_diagnostic_message(value: &str, private_root: Option<&Pat sanitized = sanitized.replace(root.as_ref(), ""); } } - let lowercase = sanitized.to_ascii_lowercase(); - if [ - "authorization", - "bearer ", - "api_key", - "apikey", - "api key", - "x-api-key", - "token=", - "token:", - "credential", - ] - .iter() - .any(|marker| lowercase.contains(marker)) - { - return "".to_string(); - } + // 错误日志是排障材料:只替换敏感值,不能因同一行出现 authorization/token/credential + // 字段就把 HTTP 状态、错误码和其它原因一起删掉。 + // 这里没有项目根时使用一个不会存在的哨兵路径,避免把日志中的普通句点当成项目路径替换。 + sanitized = + agent::redact_agent_runtime_error(Path::new("__agc_no_project_root__"), &sanitized, 2_048); sanitized = redact_unix_absolute_paths(&redact_windows_absolute_paths(&sanitized)); sanitized.chars().take(2_048).collect() } @@ -2058,8 +2046,13 @@ mod diagnostic_log_tests { fn diagnostic_message_redacts_sensitive_values_and_absolute_paths() { assert_eq!( sanitize_diagnostic_message("Authorization: Bearer secret", None), - "" + "Authorization: Bearer [redacted-secret]" ); + let upstream = + sanitize_diagnostic_message("upstream HTTP 401: invalid token; retry-after=30", None); + assert!(upstream.contains("HTTP 401"), "{upstream}"); + assert!(upstream.contains("invalid token"), "{upstream}"); + assert!(upstream.contains("retry-after=30"), "{upstream}"); assert_eq!( sanitize_diagnostic_message(r"failed at C:\private\project\game.json", None), "failed at " diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 7014d6605..01c6618fd 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -253,15 +253,13 @@ function directPlatformFailureDetail(message: string) { return null; } const lower = trimmed.toLowerCase(); - if ( - !( - lower.includes('陶泥儿美术包生成失败') || - lower.includes('平台图片生成任务失败') || - lower.includes('external editor') || - lower.includes('透明美术图集') || - lower.includes('图集切片') - ) - ) { + if (!( + lower.includes('陶泥儿美术包生成失败') || + lower.includes('平台图片生成任务失败') || + lower.includes('external editor') || + lower.includes('透明美术图集') || + lower.includes('图集切片') + )) { return null; } if ( @@ -313,14 +311,12 @@ function directCodexFailureDetail(message: string) { function directRuntimeFailureDetail(message: string) { const trimmed = message.trim(); - if ( - !( - trimmed.startsWith('Codex 已返回,但客户端登记生成产物失败:') || - trimmed.includes('陶泥儿美术包不完整,已终止代码生成') || - trimmed.includes('陶泥儿规范图生成后未形成可用平台合同') || - trimmed.includes('陶泥儿美术包生成返回后未形成可用的已登记平台素材合同') - ) - ) { + if (!( + trimmed.startsWith('Codex 已返回,但客户端登记生成产物失败:') || + trimmed.includes('陶泥儿美术包不完整,已终止代码生成') || + trimmed.includes('陶泥儿规范图生成后未形成可用平台合同') || + trimmed.includes('陶泥儿美术包生成返回后未形成可用的已登记平台素材合同') + )) { return null; } if ( @@ -442,10 +438,10 @@ export function projectRuntimeVisibleError( normalized.includes('network') || normalized.includes('tls') ) { - return `${subject} 服务连接失败,请稍后重试`; + return `${subject} 服务连接失败,请稍后重试${runtimeFailureDetailSuffix(message)}`; } if (normalized.includes('timeout') || normalized.includes('超时')) { - return `${subject} 响应超时,请稍后重试`; + return `${subject} 响应超时,请稍后重试${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('unauthorized') || @@ -453,7 +449,7 @@ export function projectRuntimeVisibleError( normalized.includes('401') || normalized.includes('鉴权') ) { - return `${subject} 鉴权失败,请检查运行时配置`; + return `${subject} 鉴权失败,请检查运行时配置${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('rate limit') || @@ -462,7 +458,7 @@ export function projectRuntimeVisibleError( normalized.includes('额度') || normalized.includes('限流') ) { - return `${subject} 服务繁忙,请稍后重试`; + return `${subject} 服务繁忙,请稍后重试${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('needs-reconciliation') || @@ -470,14 +466,14 @@ export function projectRuntimeVisibleError( normalized.includes('终态未知') || normalized.includes('需要人工核对') ) { - return `${subject} 运行状态需要核对,请打开运行详情后重试`; + return `${subject} 运行状态需要核对,请打开运行详情后重试${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('budget-exhausted') || normalized.includes('预算耗尽') || normalized.includes('预算已耗尽') ) { - return `${subject} 本轮预算已耗尽,请缩小任务范围后重试`; + return `${subject} 本轮预算已耗尽,请缩小任务范围后重试${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('missing expected artifact') || @@ -485,7 +481,7 @@ export function projectRuntimeVisibleError( normalized.includes('缺少预期产物') || normalized.includes('缺少 expected artifact') ) { - return `${subject} 未生成要求的产物,请查看任务要求后重试`; + return `${subject} 未生成要求的产物,请查看任务要求后重试${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('verification') || @@ -494,7 +490,7 @@ export function projectRuntimeVisibleError( normalized.includes('验证未通过') || normalized.includes('验证失败') ) { - return `${subject} 项目验证未通过,请查看运行详情并修复后重试`; + return `${subject} 项目验证未通过,请查看运行详情并修复后重试${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('policy') || @@ -503,7 +499,7 @@ export function projectRuntimeVisibleError( normalized.includes('禁止') || normalized.includes('不允许') ) { - return `${subject} 被项目权限或安全策略阻止,请检查审批配置`; + return `${subject} 被项目权限或安全策略阻止,请检查审批配置${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('落盘失败') || @@ -515,19 +511,18 @@ export function projectRuntimeVisibleError( normalized.includes('未确认历史完整落盘') || normalized.includes('写入本项目对话历史失败') ) { - return `${subject} 保存运行记录失败,请检查项目目录后重试`; + return `${subject} 保存运行记录失败,请检查项目目录后重试${runtimeFailureDetailSuffix(message)}`; } - // 宿主 `Display` 的其余事实句:它们不含上面任何关键字,**不加模式就只会看到最后那句通用文案**。 - // 这里只认宿主写死的句首短语,不回落原文——原文里带 `exitStatus=` / `stderrClass=` 这类内部字段 - // (`TransportClosed` 就是这种)。 + // 宿主 `Display` 的其余事实句:优先识别宿主写死的句首短语;其余错误也保留精确脱敏正文, + // 避免 IPC/进程/操作系统错误只剩一条通用句。 if (visibleMessage.includes('执行通道已断开')) { - return `${subject} 服务连接已断开,请稍后重试`; + return `${subject} 服务连接已断开,请稍后重试${runtimeFailureDetailSuffix(message)}`; } if (visibleMessage.includes('等待模型回合结束达到硬上限')) { - return `${subject} 响应超时,请稍后重试`; + return `${subject} 响应超时,请稍后重试${runtimeFailureDetailSuffix(message)}`; } if (visibleMessage.includes('宿主任务提前结束')) { - return `${subject} 本轮执行已中断,请重试`; + return `${subject} 本轮执行已中断,请重试${runtimeFailureDetailSuffix(message)}`; } const containsInternalDiagnostics = normalized.includes('agentllm.') || @@ -556,7 +551,44 @@ export function projectRuntimeVisibleError( ) { return visibleMessage; } - return `${subject} 执行失败,请稍后重试`; + const safeDetail = runtimeFailureDetail(message); + return safeDetail + ? `${subject} 执行失败${safeDetail}` + : `${subject} 执行失败,请稍后重试`; +} + +/** 精确脱敏普通 Tauri/IPC/网络错误:保留错误码与正文,只替换敏感值。 */ +function runtimeFailureDetail(value: string) { + const trimmed = value.trim(); + if (!trimmed) return null; + const safe = trimmed + .replace( + /|||\$PROJECT_ROOT/giu, + '[已隐藏信息]', + ) + .replace(/\[redacted-sensitive-field\]/giu, '[已隐藏字段]') + .replace(/\[redacted-config\]/giu, '[已隐藏配置]') + .replace(/https?:\/\/[^\s"'<>]+/giu, '[已隐藏链接]') + .replace( + /(?:[A-Z]:\\|\\\\|\/(?:Users|home|var|tmp|private)\/)[^\s"'<>]+/giu, + '[已隐藏路径]', + ) + .replace( + /((?:proxy-authorization|authorization|set-cookie|cookie|access[_ -]?token|refresh[_ -]?token|api[_ -]?key|apikey|client[_ -]?secret|private[_ -]?key|secret[_ -]?key|password|token|secret|bearer)\s*[:=]\s*)(?:Bearer\s+)?(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;,;&}\]]+)/giu, + '$1[已隐藏凭据]', + ) + .replace(/(?:^|\s)kind=[a-z0-9_-]+/giu, ' ') + .replace(/(?:^|\s)(?:fingerprint|sha256)=[^\s]+/giu, ' ') + .replace(/\s+chars=\d+/giu, ' ') + .replace(/\s+/gu, ' ') + .trim() + .slice(0, 420); + if (!safe || safe === trimmed) return safe ? `:${safe}` : ''; + return `:${safe}`; +} + +function runtimeFailureDetailSuffix(value: string) { + return runtimeFailureDetail(value); } export function taskRowsFromManifest( diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnEnqueueFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnEnqueueFailure.ts index 262595b47..b27087055 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnEnqueueFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnEnqueueFailure.ts @@ -1,6 +1,7 @@ import { expectNever } from '../../../../app/expectNever'; import type { TauriInvoke } from '../../../../app/types'; import type { EnqueueError } from '../generated/EnqueueError'; +import { visibleDirectFailureDetail } from './directTurnFailure'; /** 用户可见文案的主语:与既有运行错误横幅同一份口径。 */ const DIRECT_TURN_SUBJECT = '陶泥儿智能创作'; @@ -95,11 +96,10 @@ export function enqueueFailureNotice(failure: EnqueueError): string | null { } /** - * **宿主 / 环境事实**类入队失败在聊天区里的固定句;认不出形状时返回 `null`。 + * **宿主 / 环境事实**类入队失败在聊天区里的分类句,并附上精确脱敏 detail;认不出形状时返回 `null`。 * - * 这类失败的 `detail` 是宿主原文(`stage=` / `code=` / 路径这类机器字段都在里面),只用于 - * `.agent/runtime/errors` 与诊断,**不上屏**;所以这里只按变体给一句用户能看懂的话,不回落 - * 任何字段。 + * 这类失败的 `detail` 是宿主原文;前端只替换凭据值、URL 和路径,保留 `stage=` / `code=` / + * 错误码等排障字段,不因机器字段存在而丢失整条错误。 * * `null` 只给"根本不是这份结构化载荷"的抛出(Tauri / 宿主缺陷,或未来宿主新增的变体): * 那时说不出用户改哪一处能变好,调用方只上报 + 横幅,不写聊天。 @@ -109,9 +109,13 @@ export function enqueueSystemFailureNoticeText( ): string | null { switch (failure.type) { case 'environmentNotReady': - return `${DIRECT_TURN_SUBJECT}:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试`; + return visibleDirectFailureDetail(failure.detail) + ? `${DIRECT_TURN_SUBJECT}:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试:${visibleDirectFailureDetail(failure.detail)}` + : `${DIRECT_TURN_SUBJECT}:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试`; case 'hostStateUnavailable': - return `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,这一轮没有开始;请稍后重试`; + return visibleDirectFailureDetail(failure.detail) + ? `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,这一轮没有开始;请稍后重试:${visibleDirectFailureDetail(failure.detail)}` + : `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,这一轮没有开始;请稍后重试`; // 业务变体由 `enqueueFailureNotice` 认领,这里不重复给句;认不出形状时同理。 // 穷尽性由那个函数的 `default: expectNever` 保证——新增变体时那边会先编译失败。 default: diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts index 93b3ba4c2..19156bf92 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts @@ -9,9 +9,8 @@ * 为什么值得单独一个文件:前两条是**跨侧约定**——身份要和前端自己造的说明(终止 / announce) * 区分开又保持可预期;文案要按变体逐条写死,不能靠对宿主文本做子串匹配。 * - * 载荷里的 `detail` / `cause` / `diagnostic` 是**宿主原文**(走 `stage=` / `exitStatus=` / - * 路径这类机器字段),只用于诊断与分流、**不上屏**:这里只读 `type` / `stage` / `deadline` / - * `kind` 这些枚举字段。 + * 载荷里的 `detail` / `cause` / `diagnostic` 是宿主原文;经过精确脱敏后,用户可见错误会保留其中 + * 的 HTTP 状态、错误码和可行动原因。凭据值、URL、绝对路径和私钥内容不上屏。 */ import { expectNever } from '../../../../app/expectNever'; @@ -27,6 +26,74 @@ const DIRECT_TURN_SUBJECT = '陶泥儿智能创作'; /** 没有本轮开口条目身份时的兜底展示身份前缀(正常路径不会用到)。 */ const DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID = 'direct-thread-turn-failure'; +/** + * 用户可见错误正文的上限。分类前缀必须保留,正文只做有界截断,不能因为命中敏感字段 + * 就把整条错误丢掉:HTTP 状态、操作系统错误码和上游的可行动说明仍然有排障价值。 + */ +const DIRECT_VISIBLE_FAILURE_DETAIL_MAX_CHARS = 420; + +/** + * Rust 侧已经按同一规则脱敏,但跨 IPC 的载荷不能被当成可信输入;这里做最后一道精确收口。 + * 只替换凭据值、URL 和绝对路径,保留错误字段名、HTTP 状态、错误码和其它上下文。 + */ +export function visibleDirectFailureDetail( + detail: string | null | undefined, +): string | null { + const text = typeof detail === 'string' ? detail.trim() : ''; + if (!text) return null; + const safe = text + .replace(//gi, '[已隐藏链接]') + .replace(/|\$PROJECT_ROOT/gi, '[已隐藏路径]') + .replace(/\[redacted-secret\]|/gi, '[已隐藏凭据]') + .replace(/\[redacted-sensitive-field\]/gi, '[已隐藏字段]') + .replace(/\[redacted-config\]/gi, '[已隐藏配置]') + .replace(/\[redacted sensitive context\]/gi, '[已隐藏敏感信息]') + .replace(/https?:\/\/[^\s"'<>]+/giu, '[已隐藏链接]') + .replace( + /(?:[A-Z]:\\|\\\\|\/(?:Users|home|var|tmp|private)\/)[^\s"'<>]+/giu, + '[已隐藏路径]', + ) + .replace( + /((?:proxy-authorization|authorization|set-cookie|cookie|access[_ -]?token|refresh[_ -]?token|api[_ -]?key|apikey|client[_ -]?secret|private[_ -]?key|secret[_ -]?key|password|token|secret|bearer)\s*[:=]\s*)(?:Bearer\s+)?(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;,;&}\]]+)/giu, + '$1[已隐藏凭据]', + ) + .replace(/\s+/gu, ' ') + .trim(); + if (!safe) return null; + return safe.length > DIRECT_VISIBLE_FAILURE_DETAIL_MAX_CHARS + ? `${safe.slice(0, DIRECT_VISIBLE_FAILURE_DETAIL_MAX_CHARS)}…` + : safe; +} + +function withVisibleFailureDetail( + message: string, + detail: string | null | undefined, +) { + const safeDetail = visibleDirectFailureDetail(detail); + return safeDetail ? `${message}:${safeDetail}` : message; +} + +function stripUpstreamPrefix( + detail: string | null | undefined, + statusCode: number, +) { + const safeDetail = visibleDirectFailureDetail(detail); + if (!safeDetail) return null; + return safeDetail + .replace( + new RegExp(`^(?:LLM\\s+)?上游返回\\s+${statusCode}\\s*[::]\\s*`, 'u'), + '', + ) + .replace( + new RegExp( + `^(?:LLM\\s+)?上游\\s+HTTP\\s+${statusCode}\\s*[::]\\s*`, + 'iu', + ), + '', + ) + .trim(); +} + /** 交付阶段标签:与诊断收口文案同一份标签,只有"回合失败"才带。 */ const DIRECT_FAILURE_STAGE_LABELS: Record = { 'art-preparation': '平台资源准备失败', @@ -76,34 +143,67 @@ function directNativeKindText(kind: NativeKind): string { } /** 模型调用失败的可见说明:有原生分类看原生分类,没有就看平台层那一层的分类。 */ -function directModelCallText(kind: ModelCallKind | null | undefined): string { +function directModelCallText( + kind: ModelCallKind | null | undefined, + detail: string | null | undefined, +): string { // 载荷跨 IPC 没有运行时校验:`kind` 缺失时按"没有分类"兜底,别让整条事件订阅在这一步抛错。 if (!kind) { return '智能服务执行失败,请稍后重试'; } switch (kind.type) { case 'responseTimedOut': - return '等待模型回执超时,本轮未完成;请稍后重试'; - case 'connectionFailed': - return '执行通道未能建立或已断开,本轮未完成;请重试,若持续失败请检查项目诊断'; - case 'transportBroken': - case 'streamUnavailable': - return '执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断'; - case 'requestRejected': - return kind.native - ? directNativeKindText(kind.native) - : '智能创作请求无效,请稍后重试'; - case 'upstreamFailed': - return ( - (kind.native ? directNativeKindText(kind.native) : null) ?? - directUpstreamStatusText(kind.statusCode) + return withVisibleFailureDetail( + `等待模型回执超时(已尝试 ${kind.attempts} 次),本轮未完成;请稍后重试`, + detail, ); + case 'connectionFailed': + return withVisibleFailureDetail( + '执行通道连接失败,本轮未完成;请重试,若持续失败请检查项目诊断', + detail, + ); + case 'transportBroken': + return withVisibleFailureDetail( + '执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断', + detail, + ); + case 'streamUnavailable': + return withVisibleFailureDetail( + '流式响应协议不可用,本轮未完成;请检查项目诊断后重试', + detail, + ); + case 'requestRejected': + return withVisibleFailureDetail( + kind.native + ? directNativeKindText(kind.native) + : '智能创作请求无效,请稍后重试', + detail, + ); + case 'upstreamFailed': { + const nativeMessage = kind.native + ? directNativeKindText(kind.native) + : null; + const statusMessage = directUpstreamStatusText(kind.statusCode); + return withVisibleFailureDetail( + nativeMessage ?? statusMessage, + nativeMessage ? detail : stripUpstreamPrefix(detail, kind.statusCode), + ); + } case 'paidCreditsInsufficient': - return '泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。'; + return withVisibleFailureDetail( + '泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。', + detail, + ); case 'emptyResponse': - return '模型未返回内容,请重试;如持续失败请检查项目诊断'; + return withVisibleFailureDetail( + '模型未返回内容,请重试;如持续失败请检查项目诊断', + detail, + ); case 'payloadInvalid': - return '模型回执无法解析,请重试;如持续失败请检查项目诊断'; + return withVisibleFailureDetail( + '模型回执无法解析,请重试;如持续失败请检查项目诊断', + detail, + ); default: { expectNever(kind); return '智能服务执行失败,请稍后重试'; @@ -169,40 +269,64 @@ export function directTurnFailureItemId( * 失败原因的可见文案:按载荷变体逐条写死,不再把宿主原文送进字符串映射。 * * 改造前这里读的是宿主 `Display` 生成的整句原因,再靠 `projectRuntimeVisibleError` 做子串匹配; - * 宿主换一句事实句而前端没跟着加模式时,用户拿到的就是通用兜底。现在载荷直接给 typed 事实, - * 文案由前端在这里按变体拼:**新增变体 / 新增原生分类时这里漏一条,`expectNever` 会让编译失败**。 + * 宿主换一句事实句而前端没跟着加模式时,分类仍来自 typed 载荷,安全 detail 继续保留具体原因。 + * **新增变体 / 新增原生分类时这里漏一条,`expectNever` 会让编译失败**。 * - * `detail` / `cause` / `diagnostic` 一律不上屏:它们是宿主原文,可能带 `exitStatus=` / - * `stderrClass=` 这类内部字段,只用于 `.agent/runtime/errors` 与诊断。 + * `detail` / `cause` / `diagnostic` 先经过精确脱敏再展示:`exitStatus=` / `stderrClass=`、HTTP + * 状态和操作系统错误码等排障字段保留,凭据值、URL 和绝对路径替换为占位符。 */ export function directTurnFailureNoticeText(failure: TurnFailure): string { switch (failure.type) { case 'projectRootUnanchored': - return `${DIRECT_TURN_SUBJECT}:无法确定项目目录,本轮未能开始;请检查项目路径后重试`; + return withVisibleFailureDetail( + `${DIRECT_TURN_SUBJECT}:无法确定项目目录,本轮未能开始;请检查项目路径后重试`, + failure.cause, + ); case 'environmentNotReady': - return `${DIRECT_TURN_SUBJECT}:当前环境未就绪,本轮未完成;请检查本机运行环境后重试`; + return withVisibleFailureDetail( + `${DIRECT_TURN_SUBJECT}:当前环境未就绪,本轮未完成;请检查本机运行环境后重试`, + failure.detail, + ); case 'hostStateUnavailable': - return `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,本轮未完成;请稍后重试`; + return withVisibleFailureDetail( + `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,本轮未完成;请稍后重试`, + failure.detail, + ); case 'modelCallFailed': - return `${DIRECT_TURN_SUBJECT} ${directModelCallText(failure.kind)}`; + return `${DIRECT_TURN_SUBJECT} ${directModelCallText(failure.kind, failure.detail)}`; case 'transportClosed': - return `${DIRECT_TURN_SUBJECT} 服务连接已断开,请稍后重试`; + return withVisibleFailureDetail( + `${DIRECT_TURN_SUBJECT} 服务连接已断开,请稍后重试`, + failure.diagnostic, + ); case 'timedOut': return `${DIRECT_TURN_SUBJECT} ${directTurnDeadlineText(failure.deadline)}`; case 'turnInterrupted': - return `${DIRECT_TURN_SUBJECT} 本轮执行被中断,请重试`; + return withVisibleFailureDetail( + `${DIRECT_TURN_SUBJECT} 本轮执行被中断,请重试`, + failure.detail, + ); case 'superErrorFromStringPlusStage': { // 载荷跨 IPC 没有运行时校验:stage 缺失或是更新后端新增的取值时查不到标签, // 不能把 `undefined` 拼进用户可见文案,回落到通用句。 const stageLabel = DIRECT_FAILURE_STAGE_LABELS[failure.stage]; return stageLabel - ? `${DIRECT_TURN_SUBJECT}:${stageLabel},请检查项目诊断后重试` - : `${DIRECT_TURN_SUBJECT} 执行失败,请稍后重试`; + ? withVisibleFailureDetail( + `${DIRECT_TURN_SUBJECT}:${stageLabel},请检查项目诊断后重试`, + failure.detail, + ) + : withVisibleFailureDetail( + `${DIRECT_TURN_SUBJECT} 执行失败,请稍后重试`, + failure.detail, + ); } case 'unclassified': - return `${DIRECT_TURN_SUBJECT} 执行失败,请稍后重试`; + return withVisibleFailureDetail( + `${DIRECT_TURN_SUBJECT} 执行失败,请稍后重试`, + failure.detail, + ); case 'hostDropped': - return '陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。'; + return '陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),没有收到更具体的错误回执;本轮已按失败收口,请检查应用日志后再重试。'; default: { expectNever(failure); // 结构化映射外(未来宿主的新变体):给一句通用话,绝不能把原文回落给用户。 diff --git a/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts b/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts index 2eb77fcc3..4d522d4ee 100644 --- a/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts @@ -31,4 +31,66 @@ describe('DirectProject 上游失败文案', () => { expect(text).toContain('模型上下文已超限'); expect(text).not.toContain('HTTP 429'); }); + + it('保留上游正文,同时只替换凭据值、链接和本地路径', () => { + const text = directTurnFailureNoticeText({ + type: 'modelCallFailed', + kind: { type: 'upstreamFailed', statusCode: 502, native: null }, + detail: + 'LLM 上游返回 502:upstream overloaded;authorization=Bearer provider-secret;请查看 C:\\Users\\demo\\game;https://provider.example/private', + }); + + expect(text).toContain('HTTP 502'); + expect(text).toContain('upstream overloaded'); + expect(text).toContain('[已隐藏凭据]'); + expect(text).not.toContain('provider-secret'); + expect(text).not.toContain('provider.example'); + expect(text).not.toContain('C:\\Users\\demo'); + }); + + it.each([ + ['connectionFailed', 'connect ECONNRESET 127.0.0.1:3080'], + ['transportBroken', 'IPC write failed: broken pipe;osError=EPIPE'], + [ + 'streamUnavailable', + 'stream tool_calls declared but no tool slot was received', + ], + ] as const)('显示 %s 的真实 detail,而不是通用中断句', (kind, detail) => { + const text = directTurnFailureNoticeText({ + type: 'modelCallFailed', + kind: + kind === 'connectionFailed' + ? { type: 'connectionFailed', attempts: 3 } + : kind === 'transportBroken' + ? { type: 'transportBroken' } + : { type: 'streamUnavailable' }, + detail, + }); + + expect(text).toContain(detail); + expect(text).not.toBe( + '陶泥儿智能创作 执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断', + ); + }); + + it('保留内存不足和 IPC 错误文本', () => { + const text = directTurnFailureNoticeText({ + type: 'transportClosed', + diagnostic: + 'IPC channel closed: out of memory (ENOMEM) while reading app-server response', + }); + + expect(text).toContain('out of memory (ENOMEM)'); + expect(text).toContain('IPC channel closed'); + }); + + it('环境错误保留可行动的宿主 detail', () => { + const text = directTurnFailureNoticeText({ + type: 'environmentNotReady', + detail: 'Codex app-server 启动失败:spawn ENOENT;Node runtime not found', + }); + + expect(text).toContain('spawn ENOENT'); + expect(text).toContain('Node runtime not found'); + }); }); diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 83fa95574..b09bfb23d 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -101,6 +101,15 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 默认选中快照中的全部事件、只由通知中的「查看并报告」打开、poisoned 快照用 fallback 等承诺保持不变; 本次只保证"不该进池的东西不再进池"。 +### 5. DirectProject 失败正文按精确脱敏展示(2026-10-04) + +- `turn.completed.failure` 中的 `detail` / `diagnostic` 不再一律隐藏。前端按 typed 变体展示安全正文,保留 HTTP 状态、错误码、IPC/stdio、操作系统错误(例如 `EPIPE`、`ENOMEM`、`ENOENT`)和上游可行动说明。 +- 脱敏只替换敏感值、完整 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!`。 +- HTTP 409 只有明确包含泥点不足事实时才映射为 `paidCreditsInsufficient`;Claude Code 的普通 409 冲突保留为 `upstreamFailed`。 +- 真正没有可读事实的 `hostDropped` 仍显示宿主任务提前结束,并明确说明只能继续查应用日志;它不再冒充具体网络错误。 + ## 后果与边界 - auth 三命令(`login_client_with_password`、`login_client_with_phone_code`、`send_client_phone_login_code`) diff --git a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md new file mode 100644 index 000000000..1bb203d61 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md @@ -0,0 +1,34 @@ +# AGC 错误具体文本展示实施计划 + +Version: 1.0 +Status: implemented-awaiting-runtime-acceptance +Date: 2026-10-04 +Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md` + +## 修改边界 + +1. 在前端回合失败映射中新增统一的安全 detail 投影,按 typed variant 保留 HTTP 状态、错误分类和脱敏文本。 +2. 对 `ModelCallKind`、`TransportClosed`、`TurnInterrupted`、阶段失败和 `Unclassified` 使用各自载荷,不再无条件返回通用文案。 +3. 检查并补强 Rust 错误脱敏测试,确保敏感值替换而不是整行删除。 +4. 补充 upstream、transport、stream、IPC、内存不足与 HostDropped 边界测试。 +5. 更新 ADR 的当前行为口径。 + +## 实现顺序 + +先锁定脱敏输入输出用例,再实现前端可见文案;随后补 Rust 映射/诊断测试,最后运行类型、编码和差异检查。 + +## 风险与回滚 + +- 风险:直接展示未经精确脱敏的 provider detail 会泄露凭据或项目路径;前端和 Rust 双重检查,发现敏感 assignment 时替换值,不删除整条错误。 +- 风险:错误文本过长遮住分类;保留分类前缀并将 detail 限制在有界字符数。 +- 回滚:恢复 `directTurnFailureNoticeText` 的分类文案,保留 typed detail 和诊断落盘,不改协议。 + +## 验证结果 + +- 前端 DirectProject 错误展示:15 passed。 +- AGC shell TypeScript 类型检查:通过。 +- Rust app-server 上游 detail 映射:6 passed。 +- CC sidecar 状态码/超时映射与非支付 409 回归通过;侧车非零退出、RPC error、解析失败统一补充有界 stderr detail。 +- 真实 AGC dev smoke 复现并定位 `Reached maximum number of turns (8)` → `transport-closed`;修复后客户端已热重编译重启,未再次触发付费 Provider 请求。 +- Rust 应用日志/启动诊断精确脱敏回归:通过。 +- 真实 Provider、IPC 断链和内存压力尚未执行。 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md new file mode 100644 index 000000000..fb6312945 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -0,0 +1,40 @@ +# AGC 错误具体文本展示 + +Version: 1.0 +Status: implemented-awaiting-runtime-acceptance +Date: 2026-10-04 +Parent Spec: `docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md` + +## 目标 + +DirectProject 回合失败在确认不是客户端内部不可归类故障时,向用户显示可行动的脱敏事实:上游 HTTP 状态、上游返回文本、网络/IPC/进程传输原因、流协议错误、内存不足等操作系统错误。保留机器字段用于诊断,但不再用通用“执行通道中断”覆盖可识别原因。 + +## 范围 + +- `turn.completed.failure` 的前端展示:按 typed 变体显示分类、状态码和脱敏后的 `detail` / `diagnostic`。 +- Rust 侧错误文本脱敏:只替换敏感值、URL、绝对路径和私钥内容,保留 HTTP 状态、错误码、字段名和可行动描述。 +- Transport / Stream / IPC / host process / memory exhaustion 的回归测试与错误事件证据。 + +## 不做 + +- 不把 access token、Cookie、API Key、私钥、完整 URL 查询参数、绝对路径原文显示给用户。 +- 不改变上游协议、重试预算、计费和项目写入安全边界。 +- 无法获得任何事实的 `hostDropped` 仍保留为客户端宿主兜底,并明确说明“未收到更具体回执”。 + +## 验收标准 + +1. HTTP 401/403/408/429/5xx 显示状态码与脱敏后的上游错误文本。 +2. `connectionFailed`、`transportBroken`、`streamUnavailable`、`transportClosed` 显示对应分类及安全 detail;detail 不为空时不得落到通用错误句。 +3. 包含 `authorization=...`、`token=...`、Cookie、URL query、Windows/Unix 路径、私钥块的错误,只替换敏感值并保留同一行中的状态码和其它诊断字段。 +4. 包含 `out of memory`、`ENOMEM`、Windows 内存不足文本或 IPC/stdio 失败文本时,用户能看到对应安全文本。 +5. HostDropped 只在确实没有可读错误事实时出现;所有显式捕获的 panic/transport/IPC 错误继续走 typed failure。 +6. 运行前端 DirectProject 错误映射测试、Rust redaction/runtime_error/turn_error 定向测试、TypeScript 类型检查、编码检查和 `git diff --check`。 + +## 当前证据 + +- `npx vitest run tests/directTurnFailure.test.ts`:15 passed。 +- `npx tsc --noEmit -p apps/ai-game-creator-shell/tsconfig.json`:通过。 +- Rust app-server 上游映射定向测试:6 passed;应用日志脱敏测试与启动诊断脱敏测试均通过。 +- CC 错误分类回归:Claude sidecar 状态码/超时映射与非支付 409 保留上游状态的定向测试通过;侧车失败统一补充有界 stderr detail。 +- 真实 AGC dev smoke:客户端使用 `3080`、后端 `8084`、数据库 `3101`、后台 `3103` 启动;真实 CC 回合复现 `Reached maximum number of turns (8)` 被旧代码错误记为 `transport-closed`,修复后 Tauri 已热重编译重启。修复后的真实 Provider 回放未再次发送,避免无必要的付费请求。 +- 真实 Provider、真实 IPC 断链和真实内存压力尚未在本轮执行。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index e44108140..e2fb93655 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -81,7 +81,7 @@ Node/npm 版本探测清空继承环境后,必须设置客户端创建的临 - **网络与凭据只在 Rust**。平台 origin、Bearer/refresh 凭据、OSS 直传票据、Provider 与更新清单请求都由 `src-tauri` 承担;渲染层通过 typed command 提交结构化意图,不再持有 access token,也不再声明 `http:default` 权限(`capabilities/main.json`)。`src/services/clientApi.ts` 与 `fetchClientHttp` 已删除。 - **状态变更由 Rust 事件驱动**。正式状态归 Rust:Direct 活动回合的唯一事实源是 Direct 线程管理器的活动回合快照(`list_direct_active_turns` 只读它),登记、进度内容变化、收口各广播一次 `game-creator-direct-active-turns-changed`;素材生成与插件状态同理。渲染层进入入口时**先订阅、再读一次受控快照**,事件重复或内容未变时保持数组身份,卸载后迟到事件不写回;不恢复任何固定频率轮询(纯 UI 计时器、拖拽重复器与动画 tick 除外)。 - **维护态判定归 Rust,渲染层只订阅**。平台请求的错误分支统一经 `platform_maintenance::watch_platform_response` 分类(只有 `503` 且命中 `MAINTENANCE` 或「维护」才算,对象存储自身的 503 不误伤),命中后广播 `genarrative-client-maintenance-detected`;渲染层由 `clientMaintenance.subscribeClientMaintenanceEvent` 订阅并打开唯一的「系统维护中」弹窗,业务面板各自的错误文案保持不变,同一批并发失败只弹一次。 -- **失败文案只展示宿主给的脱敏摘要**。`turn.completed.failure` 是失败说明的唯一来源,渲染层不做 HTTP 判定、不预读诊断正文;失败线索留在 `.agent/runtime/errors`、应用日志与错误上报池(`5398a53e6` 起用户可见文案不再带诊断引用)。 +- **失败文案展示宿主给的精确脱敏事实**。`turn.completed.failure` 是失败说明的唯一来源,渲染层按 typed 变体展示 HTTP 状态、错误码、IPC/stdio、操作系统错误和上游正文;只替换凭据值、完整 URL、绝对路径与私钥,不再因命中 `authorization/token/credential` 删除整条错误。失败线索仍写入 `.agent/runtime/errors`、应用日志与错误上报池。 - **活动回合之外的两条配套约束**:命令返回 `Ok` 只代表接单成立,整轮收场只由 `turn.completed` 回答;渲染层的队列、忙态与提示都以事件流的这些终态为准。 证据入口:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --offline -- direct_thread_manager --test-threads=1`、`-- platform_maintenance --test-threads=1`、`npx vitest run tests/directActiveTurns.test.tsx tests/maintenanceNotice.test.tsx --root apps/ai-game-creator-shell`。