From 310947ae42fc989f408f19b35c4da7a7e25710c5 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:57:13 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E7=95=99=E8=BF=90=E8=A1=8C=E6=97=B6?= =?UTF-8?q?=E4=B8=8E=E4=BE=A7=E8=BD=A6=E9=94=99=E8=AF=AF=E8=AF=A6=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 展示 JSON-RPC、Codex CLI stderr 和 IPC 连接失败的可行动正文 未知 typed 失败继续精确脱敏并补回归测试 --- .../src/agent/codex_app_server/mod.rs | 100 +++++++++++++++--- .../src-tauri/src/agent/codex_cli.rs | 53 ++++++++-- .../src/features/agent-runtime/model.ts | 4 +- .../chat/conversation/directTurnFailure.ts | 20 +++- .../tests/directThreadChat.test.ts | 14 +-- .../tests/directTurnFailure.test.ts | 10 ++ 6 files changed, 161 insertions(+), 40 deletions(-) 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 c359772ad..5dae39023 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 @@ -407,17 +407,30 @@ fn game_creator_codex_app_server_connection_error( }; platform_llm::LlmError::InvalidRequest(format!("{message} detail=HTTP 413")) } - Some(status_code) => platform_llm::LlmError::Upstream { - status_code, - message: format!( - "Codex app-server 连接上游失败:{}", - game_creator_codex_app_server_error_display_detail(error) - ), - }, - None => platform_llm::LlmError::Connectivity { - attempts: 1, - message: "Codex app-server 连接失败".to_string(), - }, + Some(status_code) => { + let detail = game_creator_codex_app_server_error_display_detail(error); + let detail = if detail.is_empty() { + game_creator_codex_app_server_json_rpc_error_detail(error) + } else { + detail + }; + platform_llm::LlmError::Upstream { + status_code, + message: format!("Codex app-server 连接上游失败({field}):{detail}"), + } + } + None => { + let detail = game_creator_codex_app_server_error_display_detail(error); + let detail = if detail.is_empty() { + game_creator_codex_app_server_json_rpc_error_detail(info) + } else { + detail + }; + platform_llm::LlmError::Connectivity { + attempts: 1, + message: format!("Codex app-server 连接失败({field}):{detail}"), + } + } } } @@ -472,6 +485,41 @@ fn game_creator_codex_app_server_error_display_detail(error: &serde_json::Value) .join(";") } +/// JSON-RPC 请求失败时不要只取可选的 `message`:有些上游只返回 `code` / `data`, +/// 丢掉整个 error 对象会把真实协议错误再次压成“未知错误”。这里保留结构化字段, +/// 最后仍由回合错误投影按项目根目录做一次精确脱敏。 +fn game_creator_codex_app_server_json_rpc_error_detail(error: &serde_json::Value) -> String { + let detail = error + .as_object() + .map(|object| { + ["code", "message", "additionalDetails", "data"] + .into_iter() + .filter_map(|field| object.get(field).map(|value| (field, value))) + .filter_map(|(field, value)| { + let value = match value.as_str() { + Some(value) => value.to_string(), + None if !value.is_null() => serde_json::to_string(value).ok()?, + _ => return None, + }; + let value = value.trim().to_string(); + (!value.is_empty()).then(|| format!("{field}={value}")) + }) + .collect::>() + .join(";") + }) + .unwrap_or_default(); + let detail = if detail.is_empty() { + serde_json::to_string(error).unwrap_or_else(|_| "无法序列化 JSON-RPC error".to_string()) + } else { + detail + }; + crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &detail, + 480, + ) +} + fn game_creator_codex_app_server_error_detail_indicates_stream_requirement( error: &serde_json::Value, ) -> bool { @@ -4742,10 +4790,15 @@ async fn read_game_creator_codex_app_server_stdout( } let message = match serde_json::from_slice::(&buffer) { Ok(message) => message, - Err(_) => { + Err(error) => { + let raw = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &String::from_utf8_lossy(&buffer), + 480, + ); fail_game_creator_codex_app_server_connection( &inner, - "Codex app-server 返回无效 JSON-RPC".to_string(), + format!("Codex app-server 返回无效 JSON-RPC:{error};原文={raw}"), ) .await; return; @@ -4899,10 +4952,7 @@ async fn read_game_creator_codex_app_server_stdout( let result = if let Some(error) = message.get("error") { Err(format!( "Codex app-server JSON-RPC 失败:{}", - error - .get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or("未知错误") + game_creator_codex_app_server_json_rpc_error_detail(error) )) } else { Ok(message @@ -5608,6 +5658,22 @@ pub(crate) fn build_direct_codex_history_prompt( #[cfg(test)] mod tests { + #[test] + fn json_rpc_error_keeps_structured_detail_and_redacts_secret() { + let detail = + super::game_creator_codex_app_server_json_rpc_error_detail(&serde_json::json!({ + "code": -32001, + "data": { + "reason": "out of memory (ENOMEM)", + "api_key": "provider-secret", + }, + })); + + assert!(detail.contains("code=-32001"), "{detail}"); + assert!(detail.contains("out of memory (ENOMEM)"), "{detail}"); + assert!(detail.contains("api_key"), "{detail}"); + assert!(!detail.contains("provider-secret"), "{detail}"); + } use super::*; #[test] 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 707e5e29a..62fe0f7bc 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 @@ -283,6 +283,7 @@ struct CodexCliStderrSummary { byte_len: usize, sha256: String, classification: &'static str, + detail: Option, } pub(crate) fn game_creator_codex_cli_version_identity() -> Result { @@ -487,6 +488,7 @@ where { let mut byte_len = 0_usize; let mut sha256 = Sha256::new(); + let mut preview = Vec::new(); let mut buffer = [0_u8; 8 * 1024]; loop { let count = reader @@ -498,7 +500,20 @@ where } byte_len = byte_len.saturating_add(count); sha256.update(&buffer[..count]); + let remaining = max_bytes.saturating_sub(preview.len()); + preview.extend_from_slice(&buffer[..count.min(remaining)]); } + let detail = if preview.is_empty() { + None + } else { + let text = String::from_utf8_lossy(&preview); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + text.trim(), + 480, + ); + (!detail.trim().is_empty()).then_some(detail) + }; Ok(CodexCliStderrSummary { byte_len, sha256: format!("{:x}", sha256.finalize()), @@ -509,6 +524,7 @@ where } else { "nonempty" }, + detail, }) } @@ -796,23 +812,33 @@ async fn request_game_creator_agent_codex_cli_with_executable( let status = status.expect("non-timeout Codex CLI wait has exit status"); if stderr.classification == "oversized" { return Err(platform_llm::LlmError::Transport(format!( - "Codex CLI Agent stderr 超过 {} 字节上限;stderrClass={};stderrBytes={};stderrSha256={}", + "Codex CLI Agent stderr 超过 {} 字节上限;stderrClass={};stderrBytes={};stderrSha256={}{}", GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES, stderr.classification, stderr.byte_len, - stderr.sha256 + stderr.sha256, + stderr + .detail + .as_deref() + .map(|detail| format!(";stderrDetail={detail}")) + .unwrap_or_default() ))); } if !status.success() { return Err(platform_llm::LlmError::Transport(format!( - "Codex CLI Agent 退出失败(code={});stderrClass={};stderrBytes={};stderrSha256={};请检查 Codex CLI 登录状态、网络和本机配置", + "Codex CLI Agent 退出失败(code={});stderrClass={};stderrBytes={};stderrSha256={}{};请检查 Codex CLI 登录状态、网络和本机配置", status .code() .map(|code| code.to_string()) .unwrap_or_else(|| "none".to_string()), stderr.classification, stderr.byte_len, - stderr.sha256 + stderr.sha256, + stderr + .detail + .as_deref() + .map(|detail| format!(";stderrDetail={detail}")) + .unwrap_or_default() ))); } if let Some(error) = stdin_error { @@ -1126,21 +1152,24 @@ mod tests { } #[tokio::test] - async fn codex_cli_mode_bounds_unterminated_output_and_summarizes_stderr_without_raw_text() { + async fn codex_cli_mode_bounds_unterminated_output_and_keeps_sanitized_stderr() { let output = vec![b'x'; 33]; let error = read_game_creator_codex_cli_output(output.as_slice(), 32) .await .expect_err("unterminated output over the cap must fail"); assert!(error.contains("超过 32 字节上限")); - let stderr = b"private-auth-detail-without-newline"; + let stderr = b"provider connection refused"; let summary = summarize_game_creator_codex_cli_stderr(stderr.as_slice(), 256) .await .expect("summarize stderr"); assert_eq!(summary.classification, "nonempty"); assert_eq!(summary.byte_len, stderr.len()); assert_eq!(summary.sha256, format!("{:x}", Sha256::digest(stderr))); - assert!(!summary.sha256.contains("private-auth-detail")); + assert_eq!( + summary.detail.as_deref(), + Some("provider connection refused") + ); } #[test] @@ -1246,14 +1275,14 @@ printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens #[cfg(unix)] #[tokio::test] - async fn codex_cli_mode_does_not_expose_process_stderr_on_failure() { + async fn codex_cli_mode_preserves_sanitized_process_stderr_on_failure() { use std::os::unix::fs::PermissionsExt; let temp = tempfile::tempdir().expect("temp dir"); let executable = temp.path().join("fake-codex-failure"); std::fs::write( &executable, - "#!/bin/sh\nprintf '%s\\n' 'secret-auth-detail' >&2\nexit 43\n", + "#!/bin/sh\nprintf '%s\\n' 'Authorization: Bearer secret-auth-detail; out of memory (ENOMEM)' >&2\nexit 43\n", ) .expect("write fake codex"); let mut permissions = std::fs::metadata(&executable) @@ -1271,10 +1300,12 @@ printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens .to_string(); assert!(error.contains("code=43")); assert!(error.contains("stderrClass=nonempty")); - assert!(error.contains("stderrBytes=19")); + assert!(error.contains("stderrBytes=")); assert!(error.contains("stderrSha256=")); + assert!(error.contains("out of memory (ENOMEM)")); + assert!(error.contains("[redacted-secret]")); assert!(error.contains("登录状态")); - assert!(!error.contains("secret-auth-detail")); + assert!(!error.contains("Bearer secret-auth-detail")); } #[cfg(unix)] 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 01c6618fd..581b076b5 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 @@ -370,7 +370,7 @@ export function projectRuntimeVisibleError( other: '智能创作执行失败,请查看运行详情后重试', }[codexAppServerKind]; if (detail) { - return `${subject} ${detail}`; + return `${subject} ${detail}${runtimeFailureDetail(message) ?? ''}`; } } const directCodexAppServerKind = visibleMessage.match( @@ -389,7 +389,7 @@ export function projectRuntimeVisibleError( other: '未完成本次执行,请查看项目文件是否已修改后再重试', }[directCodexAppServerKind]; if (detail) { - return `${subject} ${detail}`; + return `${subject} ${detail}${runtimeFailureDetail(message) ?? ''}`; } } if (visibleMessage.includes('codex-app-server-terminal-unknown:')) { 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 19156bf92..43c2ff7b3 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 @@ -149,7 +149,7 @@ function directModelCallText( ): string { // 载荷跨 IPC 没有运行时校验:`kind` 缺失时按"没有分类"兜底,别让整条事件订阅在这一步抛错。 if (!kind) { - return '智能服务执行失败,请稍后重试'; + return withVisibleFailureDetail('智能服务执行失败,请稍后重试', detail); } switch (kind.type) { case 'responseTimedOut': @@ -326,11 +326,23 @@ export function directTurnFailureNoticeText(failure: TurnFailure): string { failure.detail, ); case 'hostDropped': - return '陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),没有收到更具体的错误回执;本轮已按失败收口,请检查应用日志后再重试。'; + return withVisibleFailureDetail( + '陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),没有收到更具体的错误回执;本轮已按失败收口,请检查应用日志后再重试。', + typeof (failure as { detail?: unknown }).detail === 'string' + ? (failure as unknown as { detail: string }).detail + : null, + ); default: { expectNever(failure); - // 结构化映射外(未来宿主的新变体):给一句通用话,绝不能把原文回落给用户。 - return `${DIRECT_TURN_SUBJECT} 执行失败,请稍后重试`; + // 跨 IPC 的未来变体仍可能携带 detail;精确脱敏后保留它,避免新增错误被通用句吞掉。 + const detail = + typeof (failure as { detail?: unknown }).detail === 'string' + ? (failure as unknown as { detail: string }).detail + : null; + return withVisibleFailureDetail( + `${DIRECT_TURN_SUBJECT} 执行失败,请稍后重试`, + detail, + ); } } } diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index 7122aff3a..69c3b5b5f 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -475,7 +475,9 @@ describe('DirectProject 聊天 reducer', () => { const notice = failed.history.at(-1); expect(notice?.itemId).toBe('direct-codex:turn-1:user:failure'); expect(notice?.role).toBe('assistant'); - expect(notice?.text).toBe('陶泥儿智能创作 服务连接已断开,请稍后重试'); + expect(notice?.text).toBe( + '陶泥儿智能创作 服务连接已断开,请稍后重试:DirectProject 收尾历史失败:未确认历史完整落盘', + ); // 本轮开口条目照样按身份拿到边界(失败与正常终态同源)。 expect(selectDirectChatEntries(failed)[0]?.turnEndedAt).toBe(1_000_900); expect(selectDirectChatEntries(failed)[0]?.turnStartedAt).toBe(1_000_000); @@ -502,7 +504,7 @@ describe('DirectProject 聊天 reducer', () => { }), ]); expect(failed.history.at(-1)?.text).toBe( - '陶泥儿智能创作 模型上下文已超限,请缩小任务范围后重试', + '陶泥儿智能创作 模型上下文已超限,请缩小任务范围后重试:codex-app-server-error:context-window-exceeded', ); }); @@ -585,9 +587,9 @@ describe('DirectProject 聊天 reducer', () => { expect(failed.history).toHaveLength(0); }); - it('未知失败变体不抛错,按通用文案收口', () => { - // 跨 IPC 的载荷没有运行时校验:未来宿主的新变体也到得了 reducer。这里只要求 - // "不抛错 + 不把宿主原文当文案",终态照样收口——抛错会连带打断这条订阅之后的所有事件。 + it('未知失败变体不抛错,并保留精确脱敏 detail 收口', () => { + // 跨 IPC 的载荷没有运行时校验:未来宿主的新变体也到得了 reducer。终态照样收口, + // 同时把它携带的错误正文交给统一脱敏出口——抛错会连带打断这条订阅之后的所有事件。 const malformed = { type: 'turn.completed', status: 'failed', @@ -604,7 +606,7 @@ describe('DirectProject 聊天 reducer', () => { expect(failed.turnRunning).toBe(false); expect(failed.turnEndedAt).toBe(2_000); expect(failed.history.at(-1)?.text).toBe( - '陶泥儿智能创作 执行失败,请稍后重试', + '陶泥儿智能创作 执行失败,请稍后重试:宿主原文不得上屏', ); }); diff --git a/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts b/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts index 4d522d4ee..f7ffcb0db 100644 --- a/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts @@ -93,4 +93,14 @@ describe('DirectProject 上游失败文案', () => { expect(text).toContain('spawn ENOENT'); expect(text).toContain('Node runtime not found'); }); + + it('缺少分类时也保留 IPC 传来的错误正文', () => { + const text = directTurnFailureNoticeText({ + type: 'modelCallFailed', + kind: null as never, + detail: 'IPC invoke failed: channel closed (EPIPE)', + }); + + expect(text).toContain('channel closed (EPIPE)'); + }); });