From 441b3fcb51b818114991300193a5c2f18faadbdb Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:28:38 +0800 Subject: [PATCH 001/132] =?UTF-8?q?=E7=BB=9F=E4=B8=80=20CC=20=E4=B8=8E=20C?= =?UTF-8?q?odex=20=E8=BF=90=E8=A1=8C=E6=80=81=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让 CC sidecar 逐事件投影增量正文、过程文本和 MCP 工具生命周期。 复用 DirectProject ThreadEvent、ThreadItem、前端工具卡片与历史幂等写入合同。 补充主规范、里程碑计划、实施计划与共享决策记录。 --- .../src-tauri/src/agent/claude_code_cli.rs | 807 +++++++++++++++--- ...ž施计划】CC复用Codex运行态事件-2026-10-04.md | 44 + ...里程碑】CC复用Codex运行态事件-2026-10-04.md | 45 + .../shared-memory/decision-log.md | 8 + ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 44 + 5 files changed, 821 insertions(+), 127 deletions(-) create mode 100644 docs/project-memory/plans/【实施计划】CC复用Codex运行态事件-2026-10-04.md create mode 100644 docs/project-memory/plans/【里程碑】CC复用Codex运行态事件-2026-10-04.md 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 f0f01ad0e..4315e5442 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 @@ -5,6 +5,7 @@ //! protocols are never mixed. use super::*; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::atomic::{AtomicBool, Ordering}; @@ -165,7 +166,6 @@ fn configure_sidecar_command( #[derive(Debug)] struct SidecarTurnResult { - events: Vec, result: serde_json::Value, } @@ -188,6 +188,7 @@ async fn run_sidecar_turn( request: serde_json::Value, timeout_ms: u64, active_turn: Option<&str>, + mut on_event: Option<&mut (dyn FnMut(&serde_json::Value) + Send)>, ) -> Result { let node = resolve_claude_node(root); let mut command = tokio::process::Command::new(node); @@ -250,7 +251,7 @@ async fn run_sidecar_turn( let mut timeout_reason: Option = None; let turn = async { let mut lines = BufReader::new(stdout).lines(); - let mut events = Vec::new(); + let mut event_count = 0_usize; let mut terminal = None; let mut idle_rounds = 0_u64; let mut silent_ms = 0_u64; @@ -275,12 +276,12 @@ async fn run_sidecar_turn( app_log!( "agent.direct_codex.claude_sidecar_idle idleSeconds={} events={}", CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT.as_secs() * idle_rounds, - events.len() + event_count ); if silent_ms >= silence_budget_ms { return Err(SidecarTurnFailure::Silent { silent_ms, - events: events.len(), + events: event_count, }); } continue; @@ -297,7 +298,10 @@ async fn run_sidecar_turn( match value.get("type").and_then(serde_json::Value::as_str) { Some("event") => { if let Some(event) = value.get("event") { - events.push(event.clone()); + event_count = event_count.saturating_add(1); + if let Some(on_event) = on_event.as_deref_mut() { + on_event(event); + } } } Some("done") => { @@ -328,7 +332,7 @@ async fn run_sidecar_turn( "Claude Agent SDK sidecar 非零退出".to_string(), )); } - Ok(SidecarTurnResult { events, result }) + Ok(SidecarTurnResult { result }) }; let outcome = match tokio::time::timeout(CLAUDE_CODE_TURN_MAX_DURATION, turn).await { Ok(Ok(result)) => Ok(result), @@ -925,6 +929,7 @@ pub(in crate::agent) async fn request_game_creator_agent_claude_code_cli( .request_timeout_ms .unwrap_or(config.llm.request_timeout_ms), None, + None, ) .await .map_err(platform_llm::LlmError::Transport)?; @@ -951,16 +956,585 @@ pub(crate) async fn direct_game_creator_claude_code_home_chat( .map_err(|error| error.to_string()) } +#[derive(Debug)] +struct ClaudeCodeToolState { + name: String, + arguments: String, + completed: bool, +} + +#[derive(Default, Debug)] +struct ClaudeCodeStreamState { + current_message_id: String, + block_item_ids: HashMap<(String, usize), String>, + block_tool_ids: HashMap<(String, usize), String>, + tool_calls: HashMap, + pending_history: Vec, + assistant_texts: Vec, + result_text: Option, + result_error: Option, +} + +fn claude_value_text(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Null => String::new(), + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Array(parts) => parts + .iter() + .map(claude_value_text) + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n"), + serde_json::Value::Object(object) => object + .get("text") + .or_else(|| object.get("thinking")) + .or_else(|| object.get("content")) + .map(claude_value_text) + .filter(|part| !part.is_empty()) + .unwrap_or_else(|| serde_json::to_string_pretty(value).unwrap_or_default()), + other => serde_json::to_string_pretty(other).unwrap_or_default(), + } +} + +fn claude_content_text(block: &serde_json::Value) -> String { + block + .get("text") + .or_else(|| block.get("thinking")) + .or_else(|| block.get("content")) + .map(claude_value_text) + .unwrap_or_default() +} + +fn claude_tool_arguments(value: Option<&serde_json::Value>) -> String { + let Some(value) = value else { + return String::new(); + }; + match value { + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Null => String::new(), + other => serde_json::to_string(other).unwrap_or_default(), + } +} + +fn claude_stream_observe( + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + observation: TurnObservation, +) { + if let Some(observer) = observer.as_deref_mut() { + observer(observation); + } +} + +impl ClaudeCodeStreamState { + fn block_item_id(&mut self, message_id: &str, index: usize, kind: &str) -> String { + self.block_item_ids + .entry((message_id.to_string(), index)) + .or_insert_with(|| format!("direct-cc:{message_id}:{kind}:{index}")) + .clone() + } + + fn append_delta( + &mut self, + root: &Path, + item_id: String, + kind: ThreadDeltaKind, + delta: String, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + if delta.is_empty() { + return; + } + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_delta(item_id, kind, delta.clone()), + ); + match kind { + ThreadDeltaKind::Message => { + claude_stream_observe(observer, TurnObservation::AccumulatedText(delta)); + } + ThreadDeltaKind::Reasoning => { + claude_stream_observe(observer, TurnObservation::Reasoning(delta)); + } + } + } + + fn start_tool( + &mut self, + root: &Path, + tool_id: String, + name: String, + arguments: String, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + let arguments = if arguments.trim().is_empty() || arguments.trim() == "{}" { + String::new() + } else { + arguments + }; + let entry = self + .tool_calls + .entry(tool_id.clone()) + .or_insert_with(|| ClaudeCodeToolState { + name: name.clone(), + arguments: String::new(), + completed: false, + }); + if !name.trim().is_empty() { + entry.name = name; + } + let needs_update = !arguments.is_empty() && entry.arguments != arguments; + if !arguments.is_empty() { + entry.arguments = arguments; + } + if entry.completed || !needs_update { + return; + } + let at = crate::agent::now_ms(); + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_started( + ThreadItem::McpToolCall { + item_id: tool_id, + tool: entry.name.clone(), + arguments: entry.arguments.clone(), + output: None, + status: Some("inProgress".to_string()), + at, + }, + at, + ), + ); + claude_stream_observe(observer, TurnObservation::Activity("controlled-tool")); + } + + fn complete_tool( + &mut self, + root: &Path, + tool_id: String, + output: String, + failed: bool, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + let state = self + .tool_calls + .entry(tool_id.clone()) + .or_insert_with(|| ClaudeCodeToolState { + name: "MCP 工具".to_string(), + arguments: "{}".to_string(), + completed: false, + }); + if state.completed { + return; + } + state.completed = true; + let status = if failed { "failed" } else { "completed" }; + let at = crate::agent::now_ms(); + let item = ThreadItem::McpToolCall { + item_id: tool_id.clone(), + tool: state.name.clone(), + arguments: state.arguments.clone(), + output: Some(output.clone()), + status: Some(status.to_string()), + at, + }; + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_completed(item, at), + ); + self.pending_history.push(serde_json::json!({ + "type": "mcpToolCall", + "id": tool_id, + "tool": state.name.clone(), + "arguments": state.arguments.clone(), + "result": output, + "status": status, + })); + claude_stream_observe(observer, TurnObservation::Activity("controlled-tool")); + } + + fn handle_stream_event( + &mut self, + root: &Path, + event: &serde_json::Value, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + let Some(raw_event) = event.get("event") else { + return; + }; + match raw_event.get("type").and_then(serde_json::Value::as_str) { + Some("message_start") => { + self.current_message_id = raw_event + .pointer("/message/id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + } + Some("content_block_start") => { + let index = raw_event + .get("index") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default() as usize; + let Some(block) = raw_event.get("content_block") else { + return; + }; + let kind = block + .get("type") + .and_then(serde_json::Value::as_str) + .unwrap_or("block"); + if kind == "tool_use" { + let tool_id = block + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + if tool_id.is_empty() { + return; + } + self.block_tool_ids + .insert((self.current_message_id.clone(), index), tool_id.clone()); + self.start_tool( + root, + tool_id, + block + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or("MCP 工具") + .to_string(), + claude_tool_arguments(block.get("input")), + observer, + ); + } else if !self.current_message_id.is_empty() { + self.block_item_id(&self.current_message_id.clone(), index, kind); + } + } + Some("content_block_delta") => { + let index = raw_event + .get("index") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default() as usize; + let Some(delta) = raw_event.get("delta") else { + return; + }; + match delta.get("type").and_then(serde_json::Value::as_str) { + Some("text_delta") => { + let item_id = + self.block_item_id(&self.current_message_id.clone(), index, "text"); + self.append_delta( + root, + item_id, + ThreadDeltaKind::Message, + delta + .get("text") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + observer, + ); + } + Some("thinking_delta") => { + let item_id = + self.block_item_id(&self.current_message_id.clone(), index, "thinking"); + self.append_delta( + root, + item_id, + ThreadDeltaKind::Reasoning, + delta + .get("thinking") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + observer, + ); + } + Some("input_json_delta") => { + let Some(tool_id) = self + .block_tool_ids + .get(&(self.current_message_id.clone(), index)) + .cloned() + else { + return; + }; + if let Some(partial) = delta + .get("partial_json") + .and_then(serde_json::Value::as_str) + { + if let Some(tool) = self.tool_calls.get_mut(&tool_id) { + if tool.arguments == "{}" { + tool.arguments.clear(); + } + tool.arguments.push_str(partial); + } + } + } + _ => {} + } + } + _ => {} + } + } + + fn handle_assistant( + &mut self, + root: &Path, + event: &serde_json::Value, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + let message_id = event + .pointer("/message/id") + .and_then(serde_json::Value::as_str) + .unwrap_or_else(|| { + event + .get("uuid") + .and_then(serde_json::Value::as_str) + .unwrap_or("cc-message") + }); + let Some(content) = event + .pointer("/message/content") + .and_then(serde_json::Value::as_array) + else { + return; + }; + for (index, block) in content.iter().enumerate() { + match block.get("type").and_then(serde_json::Value::as_str) { + Some("text") => { + let text = claude_content_text(block); + if text.trim().is_empty() { + continue; + } + let item_id = self.block_item_id(message_id, index, "text"); + let at = crate::agent::now_ms(); + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_completed( + ThreadItem::Message { + item_id, + role: "assistant".to_string(), + text: text.clone(), + at, + }, + at, + ), + ); + self.pending_history.push(serde_json::json!({ + "type": "message", + "role": "assistant", + "id": self.block_item_ids.get(&(message_id.to_string(), index)).cloned().unwrap_or_default(), + "content": [{"type": "output_text", "text": text}], + })); + self.assistant_texts.push( + self.pending_history + .last() + .and_then(|item| item.pointer("/content/0/text")) + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + ); + claude_stream_observe( + observer, + TurnObservation::AccumulatedText( + self.assistant_texts.last().cloned().unwrap_or_default(), + ), + ); + } + Some("thinking") => { + let text = claude_content_text(block); + if text.trim().is_empty() { + continue; + } + let item_id = self.block_item_id(message_id, index, "thinking"); + let at = crate::agent::now_ms(); + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_completed( + ThreadItem::Reasoning { + item_id: item_id.clone(), + text: text.clone(), + at, + }, + at, + ), + ); + self.pending_history.push(serde_json::json!({ + "type": "reasoning", + "id": item_id, + "text": text, + })); + claude_stream_observe(observer, TurnObservation::Reasoning(String::new())); + } + Some("tool_use") => { + let Some(tool_id) = block.get("id").and_then(serde_json::Value::as_str) else { + continue; + }; + self.start_tool( + root, + tool_id.to_string(), + block + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or("MCP 工具") + .to_string(), + claude_tool_arguments(block.get("input")), + observer, + ); + } + _ => {} + } + } + } + + fn handle_user( + &mut self, + root: &Path, + event: &serde_json::Value, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + let Some(content) = event + .pointer("/message/content") + .and_then(serde_json::Value::as_array) + else { + return; + }; + for block in content { + if block.get("type").and_then(serde_json::Value::as_str) != Some("tool_result") { + continue; + } + let Some(tool_id) = block + .get("tool_use_id") + .and_then(serde_json::Value::as_str) + .filter(|id| !id.trim().is_empty()) + else { + continue; + }; + self.complete_tool( + root, + tool_id.to_string(), + claude_content_text(block), + block + .get("is_error") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + observer, + ); + } + } + + fn observe( + &mut self, + root: &Path, + event: &serde_json::Value, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + match event.get("type").and_then(serde_json::Value::as_str) { + Some("stream_event") => self.handle_stream_event(root, event, observer), + Some("assistant") => self.handle_assistant(root, event, observer), + Some("user") => self.handle_user(root, event, observer), + Some("tool_progress") => { + claude_stream_observe(observer, TurnObservation::Activity("controlled-tool")); + } + Some("system") => match event.get("subtype").and_then(serde_json::Value::as_str) { + Some("compact_boundary") => { + claude_stream_observe( + observer, + TurnObservation::Activity("context-compaction"), + ); + } + Some("permission_denied") => { + if let Some(tool_id) = event + .get("tool_use_id") + .and_then(serde_json::Value::as_str) + .filter(|id| !id.trim().is_empty()) + { + self.complete_tool( + root, + tool_id.to_string(), + event + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("工具调用被拒绝") + .to_string(), + true, + observer, + ); + } + } + Some("thinking_tokens") => { + claude_stream_observe(observer, TurnObservation::Reasoning(String::new())); + } + _ => {} + }, + Some("result") => { + if event.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) { + self.result_error = claude_result_error_detail(event); + } else { + self.result_text = event + .get("result") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + } + } + _ => {} + } + } + + fn finish( + mut self, + root: &Path, + terminal: &serde_json::Value, + client_turn_id: Option<&str>, + ) -> Result { + if self.result_error.is_none() + && terminal + .get("is_error") + .and_then(serde_json::Value::as_bool) + == Some(true) + { + self.result_error = claude_result_error_detail(terminal); + } + if let Some(detail) = self.result_error { + return Err(format!("Claude Code 返回失败终态:{detail}")); + } + let text = self + .result_text + .or_else(|| { + terminal + .get("result") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "Claude Code 缺少最终回复".to_string())?; + for item in self.pending_history { + append_direct_project_history_item_at(root, &item)?; + } + if self.assistant_texts.last().map(String::as_str) != Some(text.as_str()) { + let item_id = persist_direct_claude_assistant_reply_at(root, client_turn_id, &text)?; + let at = crate::agent::now_ms(); + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_completed( + ThreadItem::Message { + item_id, + role: "assistant".to_string(), + text: text.clone(), + at, + }, + at, + ), + ); + } + Ok(text) + } +} + /// DirectProject 使用 Claude Code 自己的 MCP 调用能力;宿主只暴露 loopback -/// MCP,关闭所有内置工具。它先作为最小可用执行器接入,后续可在同一观察协议上补充 -/// 更细的 Claude tool_use 活动投影。 +/// MCP,关闭所有内置工具。CC 的 SDK 事件在读取时投影到与 Codex 相同的 Thread Manager 合同。 pub(crate) async fn direct_game_creator_claude_code_chat_at( root: &Path, llm: &GameCreatorLlmConfig, system_prompt: String, user_prompt: String, client_turn_id: Option<&str>, - observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, + mut observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, ) -> Result { direct_turn_trace("claude-executor-enter"); let (mcp_url, mcp_token) = @@ -997,6 +1571,10 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( } } }); + let mut stream_state = ClaudeCodeStreamState::default(); + let mut on_event = |event: &serde_json::Value| { + stream_state.observe(root, event, &mut observer); + }; let sidecar = run_sidecar_turn( root, Some(llm), @@ -1004,6 +1582,7 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( payload, llm.request_timeout_ms, client_turn_id, + Some(&mut on_event), ) .await?; direct_turn_trace("claude-sidecar-done"); @@ -1016,50 +1595,11 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( sessions.insert(session_key, session_id.to_string()); } } - let mut stream = sidecar - .events - .into_iter() - .chain(std::iter::once(sidecar.result)) - .filter_map(|event| serde_json::to_string(&event).ok()) - .collect::>(); - stream.push(String::new()); direct_turn_trace("claude-parse-start"); - let parsed = parse_direct_stream_result(stream.join("\n").as_bytes(), observer); + let parsed = stream_state.finish(root, &sidecar.result, client_turn_id); match &parsed { - Ok(text) => { - direct_turn_trace(&format!("claude-parse-done chars={}", text.chars().count())); - // 项目对话历史是这条对话的单一事实源:回复不落盘,UI 就看不到本轮结果。codex 路径由 - // app-server 的 collect-history 负责写 assistant 条目,cc 没有那一步——只补终态会让 - // 用户看到"回合结束但没有回复"。落盘失败按回合失败收口,不吞。 - let item_id = match persist_direct_claude_assistant_reply_at(root, client_turn_id, text) - { - Ok(item_id) => item_id, - Err(error) => { - eprintln!("[agc-cc-direct] persist assistant failed: {error}"); - direct_turn_trace("claude-parse-error"); - return Err(format!("写入本项目对话历史失败:{error}")); - } - }; - // 聊天区是按 `item.completed` 事件流投影的(codex 路径在 rawResponseItem/completed - // 时下发同款事件),只落盘历史不会让本轮回复出现在界面上——重进项目才看得到。 - // 条目身份与落盘的历史条目保持同一个,重进项目按 id 去重。 - let at = crate::agent::now_ms(); - crate::agent::append_thread_event( - &crate::agent::thread_id_for_project(root), - ThreadEvent::item_completed( - ThreadItem::Message { - item_id, - role: "assistant".to_string(), - text: text.clone(), - at, - }, - at, - ), - ); - } + Ok(text) => direct_turn_trace(&format!("claude-parse-done chars={}", text.chars().count())), Err(error) => { - // 这一段必须走 stderr:AppData 日志的脱敏会把含 turnToken= 的整行替换掉, - // 收尾失败时看不到任何原因(真实案例:2026-10-02 18:19 模型已回复但回合被丢弃)。 eprintln!("[agc-cc-direct] parse failed: {error}"); direct_turn_trace("claude-parse-error"); } @@ -1099,58 +1639,6 @@ fn persist_direct_claude_assistant_reply_at( } } -fn parse_direct_stream_result( - stdout: &[u8], - mut observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, -) -> Result { - let text = std::str::from_utf8(stdout) - .map_err(|_| "Claude Agent SDK stream-json 不是 UTF-8".to_string())?; - let mut result = None; - for line in text.lines().filter(|line| !line.trim().is_empty()) { - let event: serde_json::Value = serde_json::from_str(line) - .map_err(|_| "Claude Agent SDK stream-json 包含无效 JSON".to_string())?; - let event_type = event.get("type").and_then(serde_json::Value::as_str); - if event_type == Some("assistant") { - let content = event - .pointer("/message/content") - .and_then(serde_json::Value::as_array); - if let Some(content) = content { - let visible = content - .iter() - .filter_map(|part| { - (part.get("type").and_then(serde_json::Value::as_str) == Some("text")).then( - || { - part.get("text") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - }, - ) - }) - .collect::(); - if !visible.is_empty() { - if let Some(observer) = observer.as_deref_mut() { - observer(TurnObservation::AgentMessageSegment(visible)); - } - } - } - } else if event_type == Some("result") { - if event.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) { - let detail = claude_result_error_detail(&event) - .map(|detail| format!(":{detail}")) - .unwrap_or_default(); - return Err(format!("Claude Code 返回失败终态{detail}")); - } - result = event - .get("result") - .and_then(serde_json::Value::as_str) - .map(str::to_string); - } - } - result - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| "Claude Code 缺少最终回复".to_string()) -} - #[cfg(test)] mod tests { use super::*; @@ -1227,33 +1715,98 @@ mod tests { } #[test] - fn parses_direct_stream_result_and_final_text() { + fn projects_claude_stream_text_and_mcp_tool_lifecycle_into_thread_events() { + let root = tempfile::tempdir().expect("temp root"); + crate::init_local_game_project_at(root.path(), "cc-stream", "CC 流式投影") + .expect("init project"); + let thread_id = crate::agent::thread_id_for_project(root.path()); + crate::agent::append_thread_event( + &thread_id, + ThreadEvent::turn_started(crate::agent::now_ms()), + ); + let subscription = crate::agent::thread_manager::subscribe_thread(&thread_id); let mut observed = Vec::new(); - let text = parse_direct_stream_result( - r#"{"type":"system","subtype":"init","session_id":"s"} -{"type":"assistant","uuid":"a","message":{"content":[{"type":"text","text":"中间"}]}} -{"type":"result","is_error":false,"result":"最终回复"} -"# - .as_bytes(), - Some(&mut |event| observed.push(event)), - ) - .expect("stream result"); - assert_eq!(text, "最终回复"); - assert!(matches!( - observed.as_slice(), - [TurnObservation::AgentMessageSegment(_)] - )); - } + let mut observer_fn = |event| observed.push(event); + let mut observer: Option<&mut (dyn FnMut(TurnObservation) + Send)> = Some(&mut observer_fn); + let mut state = ClaudeCodeStreamState::default(); - #[test] - fn preserves_direct_stream_terminal_error_detail() { - let error = parse_direct_stream_result( - br#"{"type":"result","is_error":true,"result":"Authentication failed"} -"#, - None, - ) - .expect_err("failed terminal must be rejected"); - assert!(error.contains("Authentication failed")); + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "message_start", "message": {"id": "message-1"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "正在处理"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "assistant", + "message": {"id": "message-1", "content": [{"type": "text", "text": "正在处理"}]} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "assistant", + "message": {"id": "message-2", "content": [{"type": "tool_use", "id": "tool-1", "name": "mcp__agc__agc_read_project_context", "input": {"path": "game/index.html"}}]} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "user", + "message": {"content": [{"type": "tool_result", "tool_use_id": "tool-1", "content": [{"type": "text", "text": "读取成功"}]}]} + }), + &mut observer, + ); + + let events = crate::agent::thread_manager::consume_thread(&subscription.subscription_id) + .expect("consume projected events") + .events; + assert!(events.iter().any(|event| matches!( + event, + ThreadEvent::ItemDelta { kind: ThreadDeltaKind::Message, delta, .. } + if delta == "正在处理" + ))); + assert!(events.iter().any(|event| matches!( + event, + ThreadEvent::ItemStarted { item: ThreadItem::McpToolCall { item_id, .. }, .. } + if item_id == "tool-1" + ))); + assert!(events.iter().any(|event| matches!( + event, + ThreadEvent::ItemCompleted { item: ThreadItem::McpToolCall { item_id, output, status, .. }, .. } + if item_id == "tool-1" + && output.as_deref() == Some("读取成功") + && status.as_deref() == Some("completed") + ))); + assert!(observed.iter().any(|event| matches!( + event, + TurnObservation::AccumulatedText(text) if text == "正在处理" + ))); + assert!(observed + .iter() + .any(|event| matches!(event, TurnObservation::Activity("controlled-tool")))); + assert_eq!(state.pending_history.len(), 2); } #[test] diff --git a/docs/project-memory/plans/【实施计划】CC复用Codex运行态事件-2026-10-04.md b/docs/project-memory/plans/【实施计划】CC复用Codex运行态事件-2026-10-04.md new file mode 100644 index 000000000..56c781ba1 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】CC复用Codex运行态事件-2026-10-04.md @@ -0,0 +1,44 @@ +# 【实施计划】CC复用Codex运行态事件 + +| 字段 | 值 | +| --------- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】CC复用Codex运行态事件-2026-10-04.md` | +| Status | implemented-pending-acceptance | +| Owner | Codex | + +## 修改边界 + +- 允许修改:CC sidecar stdout 事件读取、`claude_code_cli.rs` 事件投影与历史收尾、Rust/前端定向测试、AGC 主技术方案和本里程碑文件。 +- 明确不修改:Codex app-server 协议、外部 API、SpacetimeDB schema、MCP 工具实现、现有前端通用展示组件。 + +## 实现顺序 + +1. 给 sidecar 回合读取增加逐事件回调,同时保留终态和错误收口。 +2. 在 Rust CC adapter 中建立有界、可归属的事件投影:文本/过程增量、工具开始、工具结果、进度与未知帧忽略。 +3. 复用 ThreadEvent/ThreadItem 和现有历史追加逻辑,处理重复、缺失 id、失败结果和最终回复兜底。 +4. 补 Rust parser/adapter、Thread wire 与前端 reducer/工具卡片定向测试。 +5. 运行编码、diff、Rust/TS 定向门禁;若环境允许再做真实 CC Direct smoke。 + +## 验证命令 + +1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --offline --bin genarrative-ai-game-creator-shell agent::claude_code_cli --test-threads=1` +2. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --offline --bin genarrative-ai-game-creator-shell agent::thread_manager::wire --test-threads=1` +3. `npx vitest run tests/directThreadChat.test.tsx tests/appSurface/tool-call-group.suite.ts apps/ai-game-creator-shell/tests/AgentMessageContent.test.tsx --root apps/ai-game-creator-shell` +4. `npm run check:encoding` +5. `git diff --check` + +## 风险与回滚点 + +- SDK 事件类型会随版本增加:只读取白名单字段,未知事件忽略,不能因未知帧打断整轮。 +- 文本/工具事件可能在同一 SDK 消息中并行出现:身份必须由 message id、content block index 或 tool_use id 组成,不能按到达顺序猜测。 +- 真实 Provider/SDK 不可用时不宣称实时 smoke 通过;保留 fixture 测试作为替代证据。 +- 若 Rust 事件投影破坏 Codex 回归,回滚只限 CC adapter 与其测试,不回退 ThreadEvent 合同。 + +## 当前验收证据 + +- Rust adapter `cargo check` 通过;本地缺失随包资源校验通过临时环境开关跳过,未改变最终代码。 +- CC stream fixture 定向测试通过:增量正文、MCP started/completed、观察器状态均已验证。 +- `cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。 +- 前端 Vitest 未执行:当前 worktree 缺少 `@tailwindcss/vite`、`vite` 等 npm 依赖。 +- 真实 CC Provider smoke 未执行:当前 worktree 的随包资源校验素材不完整,且未启动真实项目回合。 +- `npm run check:doc-index` 被既有未分类文件 `docs/technical/【技术方案】Ubuntu离线AI游戏创作部署与传统工作流对比-2026-10-03.md` 阻断。 diff --git a/docs/project-memory/plans/【里程碑】CC复用Codex运行态事件-2026-10-04.md b/docs/project-memory/plans/【里程碑】CC复用Codex运行态事件-2026-10-04.md new file mode 100644 index 000000000..090388077 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】CC复用Codex运行态事件-2026-10-04.md @@ -0,0 +1,45 @@ +# 【里程碑】CC复用Codex运行态事件 + +| 字段 | 值 | +| ----------- | --- | +| Version | 1.0 | +| Status | implemented-pending-acceptance | +| Date | 2026-10-04 | +| Parent Spec | `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` | + +## 目标 + +让 CC DirectProject 的流式文本、过程、MCP 工具调用和工具结果实时进入现有 Thread Manager 事件合同,并由现有聊天 reducer、工具卡片和历史投影统一展示。 + +## 范围 + +- sidecar 事件逐条回调到 Rust。 +- Claude stream event、assistant tool_use、user tool_result、工具进度和终态的安全投影。 +- CC 事件对应的 `ThreadItem` 历史追加与重复事件幂等。 +- Rust adapter、ThreadEvent/ThreadItem 现有合同的定向测试和前端 reducer/工具卡片回归。 + +## 不在范围内 + +- Claude Code 原生权限、MCP server、模型路由、会话恢复和取消语义重构。 +- 新增前端 CC 专用组件、公开 API、OpenAPI 或 SpacetimeDB 迁移。 +- 将未经安全投影的内部控制帧或完整思维链直接展示。 + +## 依赖与前置条件 + +- 现有 `ThreadEvent` / `ThreadItem` / `directThreadChat` 合同保持兼容。 +- Claude Agent SDK 当前 stream-json 消息包含 assistant content blocks 和 user tool_result。 +- 工作树已有的无关未跟踪文档保持不动。 + +## 验收标准 + +- [ ] CC `stream_event` 文本在 `result` 前生成 `item.delta`。 +- [ ] CC 工具调用先生成运行中 `mcpToolCall`,结果生成同身份完成/失败条目。 +- [ ] CC 过程文本使用现有 reasoning 条目,不增加前端 agentMode 分支。 +- [ ] 成功回合历史可重读,重复追加幂等;失败回合不伪造成功终态。 +- [ ] Codex 现有定向测试保持通过。 + +## 证据要求 + +- 自动化:Rust `claude_code_cli` / thread wire / Direct runtime 定向测试,前端 `directThreadChat` 与工具卡片测试。 +- 运行时:若本地 Claude SDK/Provider 可用,执行一次真实 CC Direct 回合;否则用 SDK stream-json fixture 明确记录未完成项。 +- 边界:tool_result 错误、缺失 tool id、未知控制帧、重复结果、取消/失败终态。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c397994dc..6313f5a89 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 2026-10-04 CC DirectProject 复用 Codex 运行态事件合同 + +- 背景:CC sidecar 虽然已经使用 `stream-json` / `includePartialMessages`,但 Rust adapter 把所有事件缓存到 `result` 后才解析,且只处理 assistant/result;前端因此看不到增量正文、过程和 MCP 工具卡片。 +- 决策:sidecar stdout 逐事件回调到 Rust;CC `stream_event` 的文本/思考增量映射到现有 `ThreadEvent::item.delta`,assistant `tool_use` 与 user `tool_result` 映射到同一身份的 `mcpToolCall` `item.started` / `item.completed`,工具进度和 compact/permission 状态复用现有 `TurnObservation` 活动词。完成条目按现有 DirectProject 历史格式幂等追加,Codex 路径和前端组件不分叉。 +- 边界:未知控制帧忽略;未经安全归属的工具结果不伪造成成功;内部思维内容只在 SDK 提供可归属正文块时进入现有 reasoning 过程条目,不把原始协议/凭据送到渲染层。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`、AGC 主技术方案与开发期 SDD 计划;不修改公开 API、SpacetimeDB schema 或 MCP 工具实现。 +- 验证:CC adapter 定向 Rust fixture 覆盖增量文本与 MCP started/completed,`cargo check`(跳过本地缺失的随包资源校验)通过;编码与 `git diff --check` 通过。真实 Provider smoke 与前端 Vitest 受当前 worktree 缺少 npm 依赖/随包资源限制,未执行。 + ## 2026-10-03 AGC 发布版本标签改为由工程内部版本派生,取代「用户可编辑标签」口径 - 背景:用户实机验收指出发布面板「项目版本」显示 v6,而 AGC 工程内部只有 4 条正式版本记录(资源总览「项目版本」栏目 4 张卡,顶栏「智能体修订」下拉同样只有这 4 条)。核实:面板值来自本地清单 `manifest.projectVersion` 这个可编辑标量,它被三条链路反复钉到**平台** `game_distribution_version.version_number` 上——发布成功回写(`apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs:1531-1535`)、打开面板回读绑定回填(`:536-541`)、用户手改(`:944-965`);而 `manifest.versions` 从头到尾不参与该值。`publicationRevision` 只做 CAS,与任何版本号都无推导关系(`module-game-distribution/src/domain.rs:27-40` 的版本号解析只比 `max_existing` 与 `requested`)。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index b9c3bb849..e44108140 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,49 @@ # AI 游戏创作智能体 App 实施计划 +## 2026-10-04 Codex 与 CC DirectProject 运行态事件统一 + +### 目标 + +CC(Claude Agent SDK)执行器与 Codex app-server 共用 DirectProject 的聊天运行态合同。CC 上游收到的增量正文、过程文本、MCP 工具调用和工具结果必须在事件到达时投影为现有 `ThreadEvent` / `ThreadItem`,由同一套前端 reducer、工具卡片和历史投影消费。 + +### 非目标 + +- 不把 Codex JSON-RPC 改造成 Claude 协议,也不替换现有 CC 隔离环境、loopback MCP、权限策略或会话恢复。 +- 不把 Claude Code 原始协议或完整诊断 JSON 直接暴露给渲染层;渲染层继续只接收脱敏后的 Thread Manager 事件。 +- 不展示未经宿主投影的内部控制帧、凭据、完整请求体或无法确认归属的工具结果。 + +### 入口与边界 + +- CC sidecar 继续使用 `stream-json` / `includePartialMessages`,逐条把 SDK 消息交给 Rust 事件投影器。 +- Rust 侧在 sidecar stdout 读取期间投影 `stream_event` 文本/思考增量、assistant `tool_use`、user `tool_result`、工具进度和终态;不得等待 `result` 后再批量发送。 +- 正式聊天事实源仍是 `.agent/conversations/project.jsonl` 与 Thread Manager 运行态事件。工具卡片的实时和历史显示必须继续走同一个 `ThreadItem` 投影。 +- `ThreadEvent::ItemDelta` 只承载可见增量正文或过程文本;工具调用使用 `item.started` / `item.completed` 和现有 `mcpToolCall` 条目形状。 + +### 必须成立的行为 + +1. CC 首个文本或过程增量到达后,前端能在回合收口前看到对应正文/过程条目;连续增量保持同一条目身份并按顺序追加。 +2. CC 发起 MCP 工具时,前端立即出现与 Codex 相同的运行中工具卡片;工具返回时同一身份转为完成或失败并显示结果。 +3. CC 正常收口后,已完成的正文、过程和工具条目可以从项目历史重读;重复事件或同回合重试保持幂等,不产生重复卡片。 +4. CC 上游失败、取消或事件格式无法识别时,保留已经收到的安全运行态事件,终态仍由现有 `turn.completed` 失败合同收口;不把不完整控制帧当成成功回复。 +5. Codex 路径行为不变,前端不增加按 agentMode 分叉的第二套展示组件或状态机。 + +### 契约与迁移 + +- 不修改公开 HTTP API、OpenAPI、SpacetimeDB schema 或持久化表。 +- 复用现有 `ThreadEvent`、`ThreadItem`、`item.delta`、`mcpToolCall` 和 DirectProject 历史格式;新增的 CC 事件适配只属于 Rust adapter 内部。 +- Claude 内部 thinking 只有在 SDK 提供可安全归属的正文块时才映射到现有 reasoning 过程条目;控制元数据和凭据不进入历史或前端事件。 + +### 验收标准与证据 + +| 条款 | 验收方式 | 证据 | +| --- | --- | --- | +| 增量正文/过程实时投影 | Rust CC adapter 单测 + 前端 reducer 定向测试 | CC stream event fixture 在 `result` 前产生 `item.delta` | +| 工具开始/结果/失败 | Rust SDK assistant/user fixture + 工具卡片测试 | 同一 tool id 的 started/completed 合并为一张卡片 | +| 历史回读与幂等 | 历史追加/重放测试 | 重复完成事件不重复写入或渲染 | +| 失败关闭 | CC 终态错误 fixture + Direct 回合测试 | 失败仍由 `turn.completed` 收口,未确认结果不标成功 | +| Codex 回归 | 现有 Direct thread / chat 测试 | Codex 测试集无行为变化 | +| 工程门禁 | 定向 Rust/TS、编码、diff 检查 | 实际命令输出与未验证项记录 | + ## 2026-10-02 退役自建 Agent Runtime 与 CLI 执行面 自建 Agent Runtime(`agent/runtime_driver`、`runtime_protocol`、`runtime_tools`、`runtime_actions`、`runtime_state`、`runtime_adapter`、`agent/prompt.rs`、`agent_native_tools.rs`、`collaboration.rs`、`delegation.rs`、`goal.rs`、`context_compaction.rs`、`isolated_agent.rs`、`tool_plan_handoff/`、`user_input.rs`)与它的 Tauri 命令、`--agent-*` CLI、真实 e2e harness、CI job 一并退役,历史由 Git 保存,见 `docs/adr/【ADR】退役AGC独立Agent Runtime与CLI执行面-2026-10-02.md`。本文正文里所有关于 `--agent-run` / `--agent-task` / `--agent-runner-status` / `agent-run:smoke` / `agent-runtime-real-e2e.mjs`、后台单 Agent 队列、`read_game_creator_agent_runtime(s)`、Supervisor 自主协作与 Goal 生命周期的描述均已失效,不再作为实现依据。外部 Runner(`--agent-runner`)仅保留编辑器桥 RPC 与 `runner.attach_gui_owner` + GUI owner 参与锁 / watchdog。AGC 现役链路以 DirectProject(`agent/direct_*` + codex app-server)、资源工作台与本文其后的 DirectProject 相关条目为准。 From 3b246fabd1bd56a834592392628ce3143c48881f Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:38:06 +0800 Subject: [PATCH 002/132] =?UTF-8?q?=E6=94=B6=E6=95=9B=20AGC=20=E4=B8=8E=20?= =?UTF-8?q?CC=20=E9=94=99=E8=AF=AF=E8=AF=A6=E6=83=85=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一上游、IPC、侧车和宿主失败的脱敏正文展示 修正 CC 回合预算和非支付 409 分类 保留 stderr 与日志中的可行动错误并补定向回归 --- .../src-tauri/src/agent/claude_code_cli.rs | 82 +++++++- .../src/agent/codex_app_server/mod.rs | 195 +++++++++++------- .../src/agent/codex_app_server/turn_error.rs | 35 +++- .../src/agent/generation/prompt_context.rs | 10 +- .../src-tauri/src/agent/runtime_error.rs | 7 +- .../src/agent/thread_manager/dispatch.rs | 5 +- .../src-tauri/src/main.rs | 29 +-- .../src/features/agent-runtime/model.ts | 100 ++++++--- .../conversation/directTurnEnqueueFailure.ts | 16 +- .../chat/conversation/directTurnFailure.ts | 194 +++++++++++++---- .../tests/directTurnFailure.test.ts | 62 ++++++ ...GC命令错误结构化与错误报告口径-2026-10-01.md | 9 + ...实施计划】AGC错误具体文本展示-2026-10-04.md | 34 +++ ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 40 ++++ ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 15 files changed, 629 insertions(+), 191 deletions(-) create mode 100644 docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md create mode 100644 docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md 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`。 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 003/132] =?UTF-8?q?=E4=BF=9D=E7=95=99=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E4=B8=8E=E4=BE=A7=E8=BD=A6=E9=94=99=E8=AF=AF=E8=AF=A6?= =?UTF-8?q?=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)'); + }); }); From e58dede9012048cc634c771f870db08fe2628b4c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:59:43 +0800 Subject: [PATCH 004/132] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=80=81=E7=BB=88=E6=80=81=E9=94=99=E8=AF=AF=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留 terminal-unknown 与原生分类后的连接状态和上游消息 新增客户端错误正文回归用例 --- .../src/features/agent-runtime/model.ts | 4 +-- .../tests/agentRuntimeModel.test.ts | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts 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 581b076b5..2f3c92b21 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 @@ -393,7 +393,7 @@ export function projectRuntimeVisibleError( } } if (visibleMessage.includes('codex-app-server-terminal-unknown:')) { - return `${subject}服务或网络在执行中断开,最终状态未知;请先检查项目文件是否已修改,再决定是否重试`; + return `${subject}服务或网络在执行中断开,最终状态未知;请先检查项目文件是否已修改,再决定是否重试${runtimeFailureDetail(message) ?? ''}`; } const directCodexDetail = directCodexFailureDetail(visibleMessage); if (directCodexDetail) { @@ -415,7 +415,7 @@ export function projectRuntimeVisibleError( !retryAttemptText || !maxRetriesText ) { - return `${subject} 执行失败,请稍后重试`; + return `${subject} 执行失败${runtimeFailureDetail(message) ?? ',请稍后重试'}`; } const httpStatus = Number.parseInt(httpStatusText, 10); const retryAttempt = Number.parseInt(retryAttemptText, 10); diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts new file mode 100644 index 000000000..97c924321 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { projectRuntimeVisibleError } from '../src/features/agent-runtime/model'; + +describe('Agent Runtime 错误正文', () => { + it('终态未知时保留连接层的具体 detail', () => { + const text = projectRuntimeVisibleError( + 'codex-app-server-terminal-unknown: HTTP 503 upstream overloaded;https://provider.example/private', + '陶泥儿智能创作', + ); + + expect(text).toContain('HTTP 503'); + expect(text).toContain('upstream overloaded'); + expect(text).not.toContain('provider.example'); + }); + + it('原生分类映射不吞掉后续的状态和错误正文', () => { + const text = projectRuntimeVisibleError( + 'provider failed kind=codex-app-server-unauthorized status=401 detail=invalid token', + '陶泥儿智能创作', + ); + + expect(text).toContain('鉴权失败'); + expect(text).toContain('status=401'); + expect(text).toContain('detail=invalid token'); + }); +}); From 315d734b39e504f0ec4933e7d6bef5f051348a07 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:54:44 +0800 Subject: [PATCH 005/132] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E6=8F=90=E5=89=8D=E7=BB=93=E6=9D=9F=E9=94=99=E8=AF=AF=E8=AF=A6?= =?UTF-8?q?=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让 panic hook 与宿主 Drop 失败事件携带脱敏观测正文 兼容旧 hostDropped 载荷并补齐前后端回归与文档 --- .../src/agent/codex_app_server/turn_error.rs | 17 ++- .../src/agent/direct_runtime/user_input.rs | 2 +- .../src/agent/thread_manager/dispatch.rs | 101 +++++++++++++++--- .../src-tauri/src/agent/thread_manager/mod.rs | 9 +- .../agent/thread_manager/turn_completion.rs | 22 ++-- .../src/agent/thread_manager/wire/failure.rs | 21 +++- .../chat/conversation/directTurnFailure.ts | 12 +-- .../chat/generated/HostDropped.ts | 6 ++ .../chat/generated/TurnFailure.ts | 7 +- .../tests/directTurnFailure.test.ts | 18 ++++ ...GC命令错误结构化与错误报告口径-2026-10-01.md | 2 +- ...实施计划】AGC错误具体文本展示-2026-10-04.md | 7 +- ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 6 +- 13 files changed, 187 insertions(+), 43 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/HostDropped.ts 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 6272893d5..4cdb326c7 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 @@ -1244,9 +1244,24 @@ mod tests { // 没有字段可读的变体只带判别键。 assert_eq!( - serde_json::to_value(TurnFailure::HostDropped).expect("serialize"), + serde_json::to_value(TurnFailure::HostDropped(crate::agent::HostDropped { + detail: None, + })) + .expect("serialize"), serde_json::json!({ "type": "hostDropped" }) ); + let historical: TurnFailure = + serde_json::from_value(serde_json::json!({ "type": "hostDropped" })) + .expect("old failure remains readable"); + assert!( + matches!(historical, TurnFailure::HostDropped(payload) if payload.detail.is_none()) + ); + let current: TurnFailure = serde_json::from_value(serde_json::json!({ + "type": "hostDropped", "detail": "宿主任务提前退出:IPC EPIPE", + })) + .expect("current failure remains readable"); + assert!(matches!(current, TurnFailure::HostDropped(payload) + if payload.detail.as_deref() == Some("宿主任务提前退出:IPC EPIPE"))); } /// 平台层 `LlmError` 到 typed 分类的映射,逐条对齐改造前的判据。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index 2fbc2d2f6..39167f3a5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -160,7 +160,7 @@ mod tests { TurnFailure::TurnInterrupted(payload) => payload.detail.clone(), TurnFailure::SuperErrorFromStringPlusStage(payload) => payload.detail.clone(), TurnFailure::Unclassified(payload) => payload.detail.clone(), - TurnFailure::HostDropped => String::new(), + TurnFailure::HostDropped(payload) => payload.detail.clone().unwrap_or_default(), } } 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 1776283fd..9e34a3643 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 @@ -33,6 +33,8 @@ pub(crate) struct TurnReservation { thread_id: String, token: String, user_item_id: Option, + /// panic hook 在析构之前记录可用负载;随占用保存,future 取消时不依赖 task-local 仍可访问。 + panic_detail: std::sync::Arc>>, /// Rust 侧会话保活:只在一条 Direct 回合存活期间运行,随这一轮的占用同生共死。 /// /// 渲染层不再按固定间隔触发续期(见 `useDirectProjectChatController` 的说明):会话保活的 @@ -47,6 +49,7 @@ impl TurnReservation { thread_id: thread_id.to_string(), token: dispatched.token.clone(), user_item_id: dispatched.pending.user_item_id(), + panic_detail: std::sync::Arc::new(std::sync::Mutex::new(None)), _session_keepalive: crate::auth_session::spawn_client_session_keepalive(), } } @@ -86,8 +89,16 @@ impl TurnReservation { impl Drop for TurnReservation { fn drop(&mut self) { // 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。 - // 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。 - let finalized_by_guard = self.finish_if_unfinished(TurnCompletion::host_dropped()); + // 这里至少把 Drop 当下能观测到的收场事实带进失败载荷,不能只给一个空分类。 + let panic_detail = self.panic_detail.lock().ok().and_then(|slot| slot.clone()); + let host_drop_detail = panic_detail.as_deref().unwrap_or(if std::thread::panicking() { + "DirectProject 宿主任务 panic,未能写下终态;具体 panic 负载请查看应用日志" + } else { + "DirectProject 宿主任务退出时未写下终态,Drop 未观察到 panic;可能为 future 被取消、丢弃或提前返回" + }); + let finalized_by_guard = self.finish_if_unfinished( + TurnCompletion::host_dropped_with_detail(Path::new(&self.thread_id), host_drop_detail), + ); // 兜底一旦真的收口,就说明这一轮**从未写下终态**:深层既没成功也没失败地退出了。 // 这条必须留应用日志,否则离线只剩一个 `phase=working` 的账本,无从判断是哪一层 // 提前退出(真实案例:2026-10-02 连续两轮只留下 working 账本,errors/ 与 @@ -124,20 +135,27 @@ impl Drop for TurnReservation { // 运行段经 task-local 携带回合身份,panic hook 据此把 panic 位置与负载写进应用日志。 // task-local 而非 thread-local:多线程 runtime 下 future 会跨 worker 迁移。 tokio::task_local! { - static DIRECT_TURN_PANIC_CONTEXT: Option<(String, String)>; + static DIRECT_TURN_PANIC_CONTEXT: Option; +} + +#[derive(Clone)] +struct DirectTurnPanicContext { + thread_id: String, + token: String, + detail: std::sync::Arc>>, } /// 兜底诊断:Direct 回合任务 panic 时,把位置与负载写进应用日志。 /// -/// 没有它时,panic 只会让 `TurnReservation::drop` 把回合收成 `HostDropped`——那是"说不出原因" -/// 的分类,离线只留一个 `phase=working` 的账本,`.agent/runtime/errors/` 与 `application.log` -/// 全空,无法判断是哪一层退出的(真实案例:2026-10-02 连续两轮如此)。 +/// 没有它时,panic 只会让 `TurnReservation::drop` 把回合收成没有 panic detail 的 `HostDropped`, +/// 离线只留一个 `phase=working` 的账本,`.agent/runtime/errors/` 与 `application.log` 全空, +/// 无法判断是哪一层退出的(真实案例:2026-10-02 连续两轮如此)。 fn ensure_direct_turn_panic_hook() { static ONCE: std::sync::Once = std::sync::Once::new(); ONCE.call_once(|| { let previous = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { - if let Ok(Some((thread_id, token))) = DIRECT_TURN_PANIC_CONTEXT.try_with(Clone::clone) { + if let Ok(Some(context)) = DIRECT_TURN_PANIC_CONTEXT.try_with(Clone::clone) { let location = info .location() .map(|location| { @@ -149,16 +167,27 @@ fn ensure_direct_turn_panic_hook() { ) }) .unwrap_or_else(|| "未知位置".to_string()); + let detail = format!( + "DirectProject 宿主任务 panic:{};位置={location}", + direct_turn_panic_detail(info.payload()) + ); + if let Ok(mut slot) = context.detail.lock() { + *slot = Some(crate::agent::redact_agent_runtime_error( + Path::new(&context.thread_id), + &detail, + 480, + )); + } #[cfg(not(test))] app_log!( "agent.direct_turn.panic threadId={} tt={} location={} payload={}", - thread_id, - token, + context.thread_id, + context.token, location, info.payload_as_str().unwrap_or("未知 panic 负载") ); #[cfg(test)] - let _ = (thread_id, token, location); + let _ = (context.thread_id, context.token, location); } previous(info); })); @@ -184,7 +213,11 @@ pub(crate) fn kick_queue_dispatch(root: &Path) { let reservation = TurnReservation::resume(&thread_id, &dispatched); let root = root.to_path_buf(); ensure_direct_turn_panic_hook(); - let panic_context = Some((thread_id.clone(), dispatched.token.clone())); + let panic_context = Some(DirectTurnPanicContext { + thread_id: thread_id.clone(), + token: dispatched.token.clone(), + detail: std::sync::Arc::clone(&reservation.panic_detail), + }); tauri::async_runtime::spawn(DIRECT_TURN_PANIC_CONTEXT.scope(panic_context, async move { run_dispatched_direct_turn(root, dispatched, reservation, release_identity_generation) .await; @@ -239,7 +272,7 @@ async fn run_dispatched_direct_turn( crate::agent::codex_app_server::emit_direct_thread_user_item(&root, &canonical_user_item); let capture = crate::analytics::gui::capture_writer_context(); let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id.clone()); - // 回合体 panic 不能落到 `TurnReservation` 的 Drop 兜底:那会把这一轮收成"说不出原因"的 + // 回合体 panic 不能落到 `TurnReservation` 的 Drop 兜底:那会把这一轮收成只有 Drop 观测事实的 // `HostDropped`,症状正是用户看到的「本轮执行已中断,请重试」,而项目侧诊断与应用日志 // **一行都不会有**(真实案例:2026-10-02 连续两轮)。这里把 panic 转成分类失败, // 走与正常失败同一条收口:先写脱敏诊断,再写终态。 @@ -311,6 +344,47 @@ mod tests { }; use uuid::Uuid; + #[tokio::test] + async fn panic_before_explicit_terminal_keeps_payload_in_drop_failure() { + let thread = unique_thread("panic-detail"); + let subscription = watch(&thread); + let reservation = TurnReservation::accept_for_test(&thread, "turn-panic-detail"); + ensure_direct_turn_panic_hook(); + let context = DirectTurnPanicContext { + thread_id: thread.clone(), + token: reservation.token.clone(), + detail: std::sync::Arc::clone(&reservation.panic_detail), + }; + let outcome = DIRECT_TURN_PANIC_CONTEXT + .scope( + Some(context), + std::panic::AssertUnwindSafe(async move { + let _reservation = reservation; + panic!( + "IPC EPIPE; Authorization: Bearer fixture-secret; out of memory (ENOMEM)" + ); + }) + .catch_unwind(), + ) + .await; + assert!(outcome.is_err()); + let events = pending(&subscription); + let payload = events + .iter() + .find_map(|event| match event { + ThreadEvent::TurnCompleted { + failure: Some(TurnFailure::HostDropped(payload)), + .. + } => Some(payload), + _ => None, + }) + .expect("drop failure must include panic detail"); + let detail = payload.detail.as_deref().expect("panic detail"); + assert!(detail.contains("IPC EPIPE"), "{detail}"); + assert!(detail.contains("out of memory (ENOMEM)"), "{detail}"); + assert!(!detail.contains("fixture-secret"), "{detail}"); + } + /// 订阅并把 bootstrap 拿掉:之后的 `consume` 只返回这次订阅之后产生的事件。 fn watch(thread_id: &str) -> String { let bootstrap = subscribe_thread(thread_id); @@ -442,7 +516,8 @@ mod tests { matches!( events.get(terminal), Some(ThreadEvent::TurnCompleted { status, failure: Some(failure), .. }) - if *status == TurnCompletedStatus::Failed && matches!(failure, TurnFailure::HostDropped) + if *status == TurnCompletedStatus::Failed + && matches!(failure, TurnFailure::HostDropped(_)) ), "{events:?}" ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs index da0fdff89..6d61660be 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs @@ -1259,7 +1259,12 @@ mod tests { manager.append("thread-1", ThreadEvent::turn_started(1_000)); manager.append( "thread-1", - ThreadEvent::turn_completed_failed(crate::agent::TurnFailure::HostDropped, FIXED_AT_MS), + ThreadEvent::turn_completed_failed( + crate::agent::TurnFailure::HostDropped(crate::agent::HostDropped { + detail: Some("宿主任务提前结束".into()), + }), + FIXED_AT_MS, + ), ); let bootstrap = manager.subscribe("thread-1"); @@ -1269,7 +1274,7 @@ mod tests { if *status == TurnCompletedStatus::Failed && failure.as_ref().is_some_and(|failure| matches!( failure, - crate::agent::TurnFailure::HostDropped + crate::agent::TurnFailure::HostDropped(_) )) && *at == Some(FIXED_AT_MS) )); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs index e97280e1b..864d28acd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs @@ -18,7 +18,7 @@ use std::path::Path; use crate::agent::{ThreadEvent, TurnError, TurnErrorClassified, TurnOutcome, Unclassified}; -use super::wire::TurnFailure; +use super::wire::{HostDropped, TurnFailure}; /// 一轮的收场:正常收场只有前三档,失败必须带载荷。 /// @@ -56,9 +56,17 @@ impl TurnCompletion { /// 宿主任务提前结束(panic / future 被丢弃 / 取消)的兜底终态。 /// - /// 这类收场说不出原因;能说清原因的一律走 [`Self::failed`]。 + /// 旧调用方仍可构造没有正文的兼容载荷;生产 Drop 路径使用 + /// [`Self::host_dropped_with_detail`],把能观测到的收场事实带给前端。 pub(crate) fn host_dropped() -> Self { - Self::Failed(TurnFailure::HostDropped) + Self::Failed(TurnFailure::HostDropped(HostDropped { detail: None })) + } + + pub(crate) fn host_dropped_with_detail(history_root: &Path, detail: &str) -> Self { + let detail = crate::agent::redact_agent_runtime_error(history_root, detail, 480); + Self::Failed(TurnFailure::HostDropped(HostDropped { + detail: (!detail.trim().is_empty()).then_some(detail), + })) } } @@ -107,7 +115,7 @@ pub(crate) fn turn_terminal( } } session_completion(session_status).unwrap_or_else(|| { - // 收尾阶段的 `status` 认不出来(当前不可能发生):宁可报一条说不出原因的失败,也不冒充 + // 收尾阶段的 `status` 认不出来(当前不可能发生):宁可报一条原因缺失的失败,也不冒充 // 正常收场;载荷照样从 typed 错误投影,保持"只在一处拼载荷"。 let error = TurnError::Unclassified(Unclassified { detail: format!("收尾阶段给出的回合终态无法识别:{session_status}"), @@ -292,12 +300,12 @@ stderrClass=nonempty;stderrBytes=1000"; )); } - /// 兜底终态:说不出原因的那一种只给分类,不冒充真实原因。 + /// 兼容终态:没有额外 detail 的旧构造仍能收口;生产 Drop 路径会带观测到的原因。 #[test] - fn host_dropped_terminal_only_carries_the_classification() { + fn host_dropped_terminal_keeps_optional_detail_shape() { assert_eq!( TurnCompletion::host_dropped(), - TurnCompletion::Failed(TurnFailure::HostDropped) + TurnCompletion::Failed(TurnFailure::HostDropped(HostDropped { detail: None })) ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs index deb44460a..508932fb2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs @@ -20,8 +20,8 @@ use crate::agent::{ /// 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 /// /// 载荷直接携带**typed 变体**:没有 `kind` 粗分类、也没有预拼的 `message`。前端按变体选语气、 -/// 按变体拼文案;宿主原始事实(`detail` / `cause` / `diagnostic`)留在字段里,只用于分流与诊断、 -/// 不直接上屏。 +/// 按变体拼文案;宿主原始事实(`detail` / `cause` / `diagnostic`)留在字段里,由前端精确脱敏后 +/// 展示,或用于诊断。 /// /// 唯一投影点是 [`crate::agent::TurnError::classify`]:控制流(返修要求)落进 /// [`crate::agent::TurnErrorClassified::ShouldContinue`],所以控制流既不会出现在这里,前端也 @@ -45,6 +45,19 @@ pub(crate) enum TurnFailure { TurnInterrupted(TurnInterrupted), SuperErrorFromStringPlusStage(SuperErrorFromStringPlusStage), Unclassified(Unclassified), - /// 宿主任务提前结束(panic / 被取消):说不出原因的那一种兜底。 - HostDropped, + /// 宿主任务提前结束(panic / 被取消):带上宿主能观测到的具体收场原因。 + /// + /// `detail` 设为可选是为了兼容旧版本已经落盘的 `{ "type": "hostDropped" }` 事件; + /// 新事件由占用对象 Drop 路径补上 `panic` 或 `future 被取消/丢弃`。 + HostDropped(HostDropped), +} + +/// 宿主任务提前结束时可观测到的收场事实。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct HostDropped { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub(crate) detail: Option, } 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 43c2ff7b3..365209038 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 @@ -326,12 +326,12 @@ export function directTurnFailureNoticeText(failure: TurnFailure): string { failure.detail, ); case 'hostDropped': - return withVisibleFailureDetail( - '陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),没有收到更具体的错误回执;本轮已按失败收口,请检查应用日志后再重试。', - typeof (failure as { detail?: unknown }).detail === 'string' - ? (failure as unknown as { detail: string }).detail - : null, - ); + return failure.detail + ? withVisibleFailureDetail( + `${DIRECT_TURN_SUBJECT} 宿主任务提前结束,本轮未写下终态`, + failure.detail, + ) + : `${DIRECT_TURN_SUBJECT} 宿主任务提前结束;旧错误事件未记录具体原因,请检查应用日志`; default: { expectNever(failure); // 跨 IPC 的未来变体仍可能携带 detail;精确脱敏后保留它,避免新增错误被通用句吞掉。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/HostDropped.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/HostDropped.ts new file mode 100644 index 000000000..8f22b1b79 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/HostDropped.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 宿主任务提前结束时可观测到的收场事实。 + */ +export type HostDropped = { detail?: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailure.ts index a0f8a2996..67bbbb4e5 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailure.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { EnvironmentNotReady } from "./EnvironmentNotReady"; +import type { HostDropped } from "./HostDropped"; import type { HostStateUnavailable } from "./HostStateUnavailable"; import type { ModelCallFailed } from "./ModelCallFailed"; import type { ProjectRootUnanchored } from "./ProjectRootUnanchored"; @@ -13,11 +14,11 @@ import type { Unclassified } from "./Unclassified"; * 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 * * 载荷直接携带**typed 变体**:没有 `kind` 粗分类、也没有预拼的 `message`。前端按变体选语气、 - * 按变体拼文案;宿主原始事实(`detail` / `cause` / `diagnostic`)留在字段里,只用于分流与诊断、 - * 不直接上屏。 + * 按变体拼文案;宿主原始事实(`detail` / `cause` / `diagnostic`)留在字段里,由前端精确脱敏后 + * 展示,或用于诊断。 * * 唯一投影点是 [`crate::agent::TurnError::classify`]:控制流(返修要求)落进 * [`crate::agent::TurnErrorClassified::ShouldContinue`],所以控制流既不会出现在这里,前端也 * 不需要为它写分支。 */ -export type TurnFailure = { "type": "projectRootUnanchored" } & ProjectRootUnanchored | { "type": "environmentNotReady" } & EnvironmentNotReady | { "type": "hostStateUnavailable" } & HostStateUnavailable | { "type": "modelCallFailed" } & ModelCallFailed | { "type": "transportClosed" } & TransportClosed | { "type": "timedOut" } & TimedOut | { "type": "turnInterrupted" } & TurnInterrupted | { "type": "superErrorFromStringPlusStage" } & SuperErrorFromStringPlusStage | { "type": "unclassified" } & Unclassified | { "type": "hostDropped" }; +export type TurnFailure = { "type": "projectRootUnanchored" } & ProjectRootUnanchored | { "type": "environmentNotReady" } & EnvironmentNotReady | { "type": "hostStateUnavailable" } & HostStateUnavailable | { "type": "modelCallFailed" } & ModelCallFailed | { "type": "transportClosed" } & TransportClosed | { "type": "timedOut" } & TimedOut | { "type": "turnInterrupted" } & TurnInterrupted | { "type": "superErrorFromStringPlusStage" } & SuperErrorFromStringPlusStage | { "type": "unclassified" } & Unclassified | { "type": "hostDropped" } & HostDropped; diff --git a/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts b/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts index f7ffcb0db..e50cf9335 100644 --- a/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts @@ -103,4 +103,22 @@ describe('DirectProject 上游失败文案', () => { expect(text).toContain('channel closed (EPIPE)'); }); + + it('宿主提前结束时保留观测事实并精确脱敏', () => { + const text = directTurnFailureNoticeText({ + type: 'hostDropped', + detail: + 'panic: IPC EPIPE;Authorization: Bearer fixture-secret;out of memory (ENOMEM)', + }); + expect(text).toContain('IPC EPIPE'); + expect(text).toContain('out of memory (ENOMEM)'); + expect(text).not.toContain('fixture-secret'); + expect(text).not.toContain('没有收到更具体'); + }); + + it('旧宿主提前结束事件明确说明未记录原因', () => { + const text = directTurnFailureNoticeText({ type: 'hostDropped' }); + expect(text).toContain('旧错误事件未记录具体原因'); + expect(text).not.toContain('服务连接'); + }); }); diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index b09bfb23d..5081c7f22 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -108,7 +108,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 上游 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` 仍显示宿主任务提前结束,并明确说明只能继续查应用日志;它不再冒充具体网络错误。 +- `hostDropped` 携带可选的脱敏 `detail`:panic hook 能取得的负载和位置随原回合占用进入失败事件;普通 Drop 仅记录“退出时未写终态、未观察到 panic”,不推断为网络中断。旧版本无 `detail` 的事件继续可读,界面明确标注旧事件未记录原因。 ## 后果与边界 diff --git a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md index 1bb203d61..9498c5bde 100644 --- a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md @@ -1,6 +1,6 @@ # AGC 错误具体文本展示实施计划 -Version: 1.0 +Version: 1.1 Status: implemented-awaiting-runtime-acceptance Date: 2026-10-04 Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md` @@ -12,6 +12,7 @@ Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md` 3. 检查并补强 Rust 错误脱敏测试,确保敏感值替换而不是整行删除。 4. 补充 upstream、transport、stream、IPC、内存不足与 HostDropped 边界测试。 5. 更新 ADR 的当前行为口径。 +6. HostDropped 的可选 detail 由 Rust 单点脱敏;panic hook 把可读负载和位置关联到原回合占用,Drop 与旧无字段事件分别验证,不更改回合排队、重试或计费行为。 ## 实现顺序 @@ -25,10 +26,12 @@ Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md` ## 验证结果 -- 前端 DirectProject 错误展示:15 passed。 +- 前端 DirectProject 错误展示:18 个定向测试通过;含 HostDropped detail / 旧载荷兼容。 - 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 应用日志/启动诊断精确脱敏回归:通过。 +- Rust HostDropped panic detail + 旧无字段载荷兼容回归:定向通过。 - 真实 Provider、IPC 断链和内存压力尚未执行。 +- 2026-10-05 当前代码真实 `npm run agc` 构建启动通过;客户端与 Runner 已启动,前端/后台/API/worker/数据库健康检查通过,停止本次自有客户端后保留原有后端。 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index fb6312945..b423a4beb 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -1,6 +1,6 @@ # AGC 错误具体文本展示 -Version: 1.0 +Version: 1.1 Status: implemented-awaiting-runtime-acceptance Date: 2026-10-04 Parent Spec: `docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md` @@ -19,7 +19,7 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 不把 access token、Cookie、API Key、私钥、完整 URL 查询参数、绝对路径原文显示给用户。 - 不改变上游协议、重试预算、计费和项目写入安全边界。 -- 无法获得任何事实的 `hostDropped` 仍保留为客户端宿主兜底,并明确说明“未收到更具体回执”。 +- 无法获得更深原因时仅展示宿主真实观测事实,不把 Drop 伪造成网络中断;不回填旧版本缺失的原因。 ## 验收标准 @@ -27,7 +27,7 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, 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。 +5. HostDropped 保留 panic hook 可取得的负载和位置,普通 Drop 明确仅观测到任务未写终态退出;显式捕获的 panic/transport/IPC 错误继续走 typed failure。旧无 detail 载荷可重放,新 detail 脱敏后进入同一条失败事件。 6. 运行前端 DirectProject 错误映射测试、Rust redaction/runtime_error/turn_error 定向测试、TypeScript 类型检查、编码检查和 `git diff --check`。 ## 当前证据 From 7369956762b0b0d194b985ee95147da5365e6c18 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:10:32 +0800 Subject: [PATCH 006/132] =?UTF-8?q?=E6=94=B6=E6=95=9B=20Direct=20=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=99=A8=E9=94=99=E8=AF=AF=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一取消、上传、历史读取和前置异常的精确脱敏展示 保留 IPC 与操作系统错误并补充回归和文档 --- .../useDirectProjectChatController.ts | 35 ++++++++++++------- .../chat/conversation/directTurnFailure.ts | 10 ++++++ .../tests/directTurnFailure.test.ts | 24 ++++++++++++- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...实施计划】AGC错误具体文本展示-2026-10-04.md | 2 ++ 5 files changed, 58 insertions(+), 14 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 46705025a..d97a6945a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -35,6 +35,10 @@ import { enqueueSystemFailureNoticeText, invokeTurnEnqueue, } from '../conversation/directTurnEnqueueFailure'; +import { + visibleDirectControllerError, + visibleDirectFailureDetail, +} from '../conversation/directTurnFailure'; import type { HistorySlice } from '../generated/HistorySlice'; import type { QueueRemovalOutcome } from '../generated/QueueRemovalOutcome'; import type { ThreadItem } from '../generated/ThreadItem'; @@ -322,9 +326,7 @@ export function useDirectProjectChatController({ } catch (error) { if (projectPathRef.current !== nextProjectPath) return; setComposerNotice( - `取消待发消息失败:${ - error instanceof Error ? error.message : String(error) - }`, + visibleDirectControllerError(error, '取消待发消息失败'), ); } } @@ -394,7 +396,7 @@ export function useDirectProjectChatController({ } catch (error) { if (projectPathRef.current === projectPath) { setAttachmentNotice( - error instanceof Error ? error.message : String(error), + visibleDirectControllerError(error, '文件上传失败'), ); } return []; @@ -491,7 +493,13 @@ export function useDirectProjectChatController({ // 写权限门等前置步骤抛出时不能只留一个未处理的 rejection:回合会静默失败, // 这一轮的任何说明都没有出口。 if (projectPathRef.current === nextProjectPath) { - onRuntimeError(error instanceof Error ? error.message : String(error)); + onRuntimeError( + projectRuntimeVisibleError( + error instanceof Error ? error.message : String(error), + '陶泥儿智能创作', + true, + ), + ); } draftSafe = true; } finally { @@ -625,9 +633,7 @@ export function useDirectProjectChatController({ setComposerNotice(message); } } catch (error) { - onRuntimeError( - `终止失败:${error instanceof Error ? error.message : String(error)}`, - ); + onRuntimeError(visibleDirectControllerError(error, '终止失败')); setComposerNotice(''); } finally { setTurnCancelling(false); @@ -680,10 +686,10 @@ export function useDirectProjectChatController({ if (pages.error) throw pages.error; setStatusNotice(''); } catch (error) { - const message = error instanceof Error ? error.message : String(error); if (projectPathRef.current !== nextProjectPath) return; + const message = visibleDirectControllerError(error, '项目对话读取失败'); onRuntimeError(message); - setStatusNotice(`项目对话读取失败:${message}`); + setStatusNotice(message); } } @@ -734,9 +740,12 @@ export function useDirectProjectChatController({ } catch (error) { // 与加载成功路径同样按世代守卫:切项目后在飞的读取失败不能算到新项目头上。 if (isStaleLoad()) return; - const message = - (error instanceof Error ? error.message : String(error)) || - DIRECT_HISTORY_LOAD_ERROR_FALLBACK; + const detail = visibleDirectFailureDetail( + error instanceof Error ? error.message : String(error), + ); + const message = detail + ? `读取更早的对话历史失败:${detail}` + : DIRECT_HISTORY_LOAD_ERROR_FALLBACK; historyErrorRef.current = message; setHistoryError(message); } finally { 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 365209038..261cbda9d 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 @@ -65,6 +65,16 @@ export function visibleDirectFailureDetail( : safe; } +/** 控制器非回合错误的统一可见正文:只替换敏感值,空错误也保留专用未分类提示。 */ +export function visibleDirectControllerError( + error: unknown, + subject: string, +): string { + const raw = error instanceof Error ? error.message : String(error ?? ''); + const detail = visibleDirectFailureDetail(raw); + return detail ? `${subject}:${detail}` : `${subject}:未提供具体错误正文`; +} + function withVisibleFailureDetail( message: string, detail: string | null | undefined, diff --git a/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts b/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts index e50cf9335..1533f6999 100644 --- a/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { directTurnFailureNoticeText } from '../src/view/project-development/chat/conversation/directTurnFailure'; +import { + directTurnFailureNoticeText, + visibleDirectControllerError, +} from '../src/view/project-development/chat/conversation/directTurnFailure'; describe('DirectProject 上游失败文案', () => { it.each([401, 403, 408, 429, 500, 502, 503, 504])( @@ -121,4 +124,23 @@ describe('DirectProject 上游失败文案', () => { expect(text).toContain('旧错误事件未记录具体原因'); expect(text).not.toContain('服务连接'); }); + + it('控制器错误也保留正文并只替换敏感值', () => { + const text = visibleDirectControllerError( + new Error( + 'IPC invoke failed: EPIPE;Authorization: Bearer fixture-secret;C:\\Users\\demo\\game', + ), + '终止失败', + ); + expect(text).toContain('EPIPE'); + expect(text).toContain('[已隐藏凭据]'); + expect(text).not.toContain('fixture-secret'); + expect(text).not.toContain('C:\\Users\\demo'); + }); + + it('空控制器错误使用专用未分类正文提示', () => { + expect(visibleDirectControllerError(new Error(''), '读取失败')).toBe( + '读取失败:未提供具体错误正文', + ); + }); }); diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 5081c7f22..b2028681d 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -109,6 +109,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - Claude Code(cc)侧车的非零退出、RPC `error`、stdout JSON/UTF-8 解析失败、stderr 和静默超时也走同一映射;侧车 stderr 只在有界收口窗口内读取并进入同一脱敏 detail,不再只写裸 `eprintln!`。 - 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 9498c5bde..a59da4db2 100644 --- a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md @@ -13,6 +13,7 @@ Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md` 4. 补充 upstream、transport、stream、IPC、内存不足与 HostDropped 边界测试。 5. 更新 ADR 的当前行为口径。 6. HostDropped 的可选 detail 由 Rust 单点脱敏;panic hook 把可读负载和位置关联到原回合占用,Drop 与旧无字段事件分别验证,不更改回合排队、重试或计费行为。 +7. DirectProject 控制器的取消、上传、历史读取和前置异常统一复用前端精确脱敏出口,保留 IPC/OS 正文并替换凭据值、URL 与路径。 ## 实现顺序 @@ -33,5 +34,6 @@ Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md` - 真实 AGC dev smoke 复现并定位 `Reached maximum number of turns (8)` → `transport-closed`;修复后客户端已热重编译重启,未再次触发付费 Provider 请求。 - Rust 应用日志/启动诊断精确脱敏回归:通过。 - Rust HostDropped panic detail + 旧无字段载荷兼容回归:定向通过。 +- DirectProject 控制器错误正文:新增脱敏与空错误专用提示回归,前端定向测试通过。 - 真实 Provider、IPC 断链和内存压力尚未执行。 - 2026-10-05 当前代码真实 `npm run agc` 构建启动通过;客户端与 Runner 已启动,前端/后台/API/worker/数据库健康检查通过,停止本次自有客户端后保留原有后端。 From 715b74824faba1cb2e0ee774fffe2868a88629a0 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:17:09 +0800 Subject: [PATCH 007/132] =?UTF-8?q?=E6=94=B6=E6=95=9B=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8F=B0=E9=94=99=E8=AF=AF=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一资源编辑、上传、预览和恢复队列的诊断脱敏 保留错误状态与正文并补充当前错误处理文档 --- .../src/view/project-development/index.tsx | 95 +++++++++---------- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...实施计划】AGC错误具体文本展示-2026-10-04.md | 2 + 3 files changed, 50 insertions(+), 48 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 7ed5989c5..699dc0588 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -281,6 +281,7 @@ import { } from '../../features/resource-canvas/resourceVersionReplacementTransport'; import { useResourceCanvasGenerationPlaceholders } from '../../features/resource-canvas/useResourceCanvasGenerationPlaceholders'; import { createUiDesignDocFromImages } from '../../features/ui-editor/uiDesignResourceBridge'; +import { normalizeDiagnosticText } from '../../services/errorReporting'; import { currentPlatformSessionGeneration, requestPlatformSessionRefresh, @@ -516,6 +517,18 @@ function resolveResourceCardDragMoves({ */ const EMPTY_RESOURCE_CANVAS_HISTORY = createResourceCanvasHistory(); +function projectDevelopmentErrorMessage( + error: unknown, + fallback = '未提供具体错误正文', +) { + const raw = error instanceof Error ? error.message : String(error ?? ''); + const safe = normalizeDiagnosticText(raw.replace(/\s+/gu, ' ').trim()).slice( + 0, + 400, + ); + return safe || fallback; +} + function isPlatformAuthenticationRequired(error: unknown) { const message = error instanceof Error ? error.message : String(error); return ( @@ -2795,9 +2808,7 @@ export default function ProjectDevelopmentView({ } onNotice({ tone: 'error', - message: `在浏览器打开失败:${ - error instanceof Error ? error.message : String(error) - }`, + message: `在浏览器打开失败:${projectDevelopmentErrorMessage(error)}`, }); } }, [embeddedPreviewUrl, onNotice]); @@ -4038,13 +4049,10 @@ export default function ProjectDevelopmentView({ commitId, }); } catch (error) { - setResourceWorkbenchNotice( - options.readBackFailureNotice - ? options.readBackFailureNotice(error) - : error instanceof Error - ? error.message - : String(error), - ); + const notice = options.readBackFailureNotice + ? options.readBackFailureNotice(error) + : projectDevelopmentErrorMessage(error); + setResourceWorkbenchNotice(projectDevelopmentErrorMessage(notice)); } }, [manifest.projectId, onManifestChange, projectPath], @@ -4129,9 +4137,7 @@ export default function ProjectDevelopmentView({ // 原生已经写入成功,只是整份 manifest 没读回来:说清"已保存、刷新失败", // 不给用户"保存失败、其实已落盘"的错觉。 readBackFailureNotice: (error) => - `标签已保存,但刷新失败:${ - error instanceof Error ? error.message : String(error) - }`, + `标签已保存,但刷新失败:${projectDevelopmentErrorMessage(error)}`, }, ); }, @@ -4702,8 +4708,7 @@ export default function ProjectDevelopmentView({ } const viewport = resolveViewport(event); const category = viewport?.dataset.resourceSectionScroll as - | ResourceCategory - | undefined; + ResourceCategory | undefined; if (!viewport || !category) { return; } @@ -4723,8 +4728,7 @@ export default function ProjectDevelopmentView({ const event = rawEvent as WebKitGestureEvent; const viewport = resolveViewport(event); const category = viewport?.dataset.resourceSectionScroll as - | ResourceCategory - | undefined; + ResourceCategory | undefined; if (!viewport || !category) { return; } @@ -4883,8 +4887,7 @@ export default function ProjectDevelopmentView({ .querySelectorAll('[data-resource-section-scroll]') .forEach((viewport) => { const category = viewport.dataset.resourceSectionScroll as - | ResourceCategory - | undefined; + ResourceCategory | undefined; if (!category) { return; } @@ -4904,8 +4907,7 @@ export default function ProjectDevelopmentView({ ?.querySelectorAll('[data-resource-section-scroll]') .forEach((viewport) => { const category = viewport.dataset.resourceSectionScroll as - | ResourceCategory - | undefined; + ResourceCategory | undefined; if (!category) { return; } @@ -5080,7 +5082,7 @@ export default function ProjectDevelopmentView({ ) { return { status: 'stale' as const }; } - const message = error instanceof Error ? error.message : String(error); + const message = projectDevelopmentErrorMessage(error); setPendingResourceEditsLoadState('failed'); setPendingResourceEditsError(message); return { status: 'failed' as const, error: message }; @@ -5431,7 +5433,7 @@ export default function ProjectDevelopmentView({ savedCount += 1; onNotice(`已保存 ${result.destinationPath}`); } catch (error) { - onNotice(error instanceof Error ? error.message : String(error)); + onNotice(projectDevelopmentErrorMessage(error, '保存素材失败')); break; } } @@ -5496,7 +5498,7 @@ export default function ProjectDevelopmentView({ } } catch (error) { setResourcePanelNotice( - error instanceof Error ? error.message : String(error), + projectDevelopmentErrorMessage(error, '上传素材失败'), ); } finally { setResourcePanelUploading(false); @@ -6502,7 +6504,7 @@ export default function ProjectDevelopmentView({ } catch (error) { if (canvasOpenEpochRef.current === openEpoch) { setResourceWorkbenchNotice( - error instanceof Error ? error.message : String(error), + projectDevelopmentErrorMessage(error, '打开 UI 编辑器失败'), ); } } @@ -6731,9 +6733,11 @@ export default function ProjectDevelopmentView({ } } catch (error) { if (!isCurrentRecoveryProject(actionProject)) return; - const message = error instanceof Error ? error.message : String(error); + const rawMessage = + error instanceof Error ? error.message : String(error); + const message = projectDevelopmentErrorMessage(error); let displayMessage = message; - if (message.includes('service-identity-confirmation-required')) { + if (rawMessage.includes('service-identity-confirmation-required')) { try { const confirmation = await invoke( @@ -6764,10 +6768,7 @@ export default function ProjectDevelopmentView({ displayMessage = `请确认“${confirmation.serviceOrigin}”仍是该 operation 的原服务后再继续`; } catch (confirmationError) { if (!isCurrentRecoveryProject(actionProject)) return; - displayMessage = - confirmationError instanceof Error - ? confirmationError.message - : String(confirmationError); + displayMessage = projectDevelopmentErrorMessage(confirmationError); } } setPendingResourceEditActionErrors((current) => { @@ -6843,7 +6844,7 @@ export default function ProjectDevelopmentView({ shouldResume = true; } catch (error) { if (!isCurrentRecoveryProject(actionProject)) return; - const message = error instanceof Error ? error.message : String(error); + const message = projectDevelopmentErrorMessage(error); setResourceEditServiceIdentityConfirmations((current) => { if (!current.has(pending.operationId)) return current; const next = new Map(current); @@ -6924,7 +6925,7 @@ export default function ProjectDevelopmentView({ } } catch (error) { if (!isCurrentRecoveryProject(actionProject)) return; - const message = error instanceof Error ? error.message : String(error); + const message = projectDevelopmentErrorMessage(error); setPendingResourceEditActionErrors((current) => { const next = new Map(current); next.set(pending.operationId, message); @@ -6979,16 +6980,16 @@ export default function ProjectDevelopmentView({ dependencyLayoutSettled: dependencyLayout.settled, dependencyPositioned: Boolean( intent.resourceId && - dependencyLayout.layout.positions.some( - (position) => position.resourceId === intent.resourceId, - ), + dependencyLayout.layout.positions.some( + (position) => position.resourceId === intent.resourceId, + ), ), typeLayoutSettled: typeLayout.settled, typePositioned: Boolean( intent.resourceId && - typeLayout.layout.positions.some( - (position) => position.resourceId === intent.resourceId, - ), + typeLayout.layout.positions.some( + (position) => position.resourceId === intent.resourceId, + ), ), visible: targetVisible, domRendered: Boolean(card), @@ -8071,8 +8072,7 @@ export default function ProjectDevelopmentView({ ? { ...current, status: 'failed', - errorMessage: - error instanceof Error ? error.message : String(error), + errorMessage: projectDevelopmentErrorMessage(error), } : current, ); @@ -8199,7 +8199,7 @@ export default function ProjectDevelopmentView({ resourceQuickEditRequestRef.current = null; setResourceWorkbenchNotice('快速编辑已产出新素材,正在同步资源与布局…'); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = projectDevelopmentErrorMessage(error); settleResourceEditSubmission(request.operationId, { status: 'failed', error: message, @@ -8343,8 +8343,7 @@ export default function ProjectDevelopmentView({ ? { ...current, status: 'failed', - errorMessage: - error instanceof Error ? error.message : String(error), + errorMessage: projectDevelopmentErrorMessage(error), } : current, ); @@ -8480,7 +8479,7 @@ export default function ProjectDevelopmentView({ }); setResourceWorkbenchNotice('生成动画已产出新素材,正在同步资源与布局…'); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = projectDevelopmentErrorMessage(error); settleResourceEditSubmission(request.operationId, { status: 'failed', error: message, @@ -8798,7 +8797,7 @@ export default function ProjectDevelopmentView({ }); } catch (error) { setResourceWorkbenchNotice( - error instanceof Error ? error.message : String(error), + projectDevelopmentErrorMessage(error, '同步生成结果失败'), ); return; } @@ -9174,7 +9173,7 @@ export default function ProjectDevelopmentView({ } } catch (error) { setResourceWorkbenchNotice( - error instanceof Error ? error.message : String(error), + projectDevelopmentErrorMessage(error, '上传素材失败'), ); } finally { setResourceBottomToolbarUploading(false); @@ -10110,7 +10109,7 @@ export default function ProjectDevelopmentView({ Boolean(selectedResource?.manifestAssetId) || Boolean( selectedResource && - isResourceDocumentPreviewable(selectedResource), + isResourceDocumentPreviewable(selectedResource), ) || selectedResourceOpensUiEditor || /* diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index b2028681d..e2f3e88c4 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -110,6 +110,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - HTTP 409 只有明确包含泥点不足事实时才映射为 `paidCreditsInsufficient`;Claude Code 的普通 409 冲突保留为 `upstreamFailed`。 - `hostDropped` 携带可选的脱敏 `detail`:panic hook 能取得的负载和位置随原回合占用进入失败事件;普通 Drop 仅记录“退出时未写终态、未观察到 panic”,不推断为网络中断。旧版本无 `detail` 的事件继续可读,界面明确标注旧事件未记录原因。 - DirectProject 控制器的取消待发、终止、附件上传、项目历史读取和发送前置异常也复用同一份前端精确脱敏出口;这些非回合错误不得直接把 `Error.message` 原文写入状态栏、composer 或历史错误行。 +- AGC 项目资源编辑、上传、预览、恢复队列和生成结果回读的面板错误统一经过 `normalizeDiagnosticText`,保留状态码与安全正文,替换凭据、URL、路径和长标识;资源业务提示不再直接展示 `Error.message`。 ## 后果与边界 diff --git a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md index a59da4db2..703c85068 100644 --- a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md @@ -14,6 +14,7 @@ Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md` 5. 更新 ADR 的当前行为口径。 6. HostDropped 的可选 detail 由 Rust 单点脱敏;panic hook 把可读负载和位置关联到原回合占用,Drop 与旧无字段事件分别验证,不更改回合排队、重试或计费行为。 7. DirectProject 控制器的取消、上传、历史读取和前置异常统一复用前端精确脱敏出口,保留 IPC/OS 正文并替换凭据值、URL 与路径。 +8. 项目资源编辑/上传/预览/恢复队列/生成结果回读的 UI 错误统一使用 `normalizeDiagnosticText`,不再直出 `Error.message`。 ## 实现顺序 @@ -35,5 +36,6 @@ Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md` - Rust 应用日志/启动诊断精确脱敏回归:通过。 - Rust HostDropped panic detail + 旧无字段载荷兼容回归:定向通过。 - DirectProject 控制器错误正文:新增脱敏与空错误专用提示回归,前端定向测试通过。 +- 资源工作台错误正文:项目开发页所有用户可见资源错误路径完成统一脱敏收口,TypeScript 与编码检查覆盖。 - 真实 Provider、IPC 断链和内存压力尚未执行。 - 2026-10-05 当前代码真实 `npm run agc` 构建启动通过;客户端与 Runner 已启动,前端/后台/API/worker/数据库健康检查通过,停止本次自有客户端后保留原有后端。 From c5e04cfa361bc902d6ca724c6ad0044e8d04b45c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:23:31 +0800 Subject: [PATCH 008/132] =?UTF-8?q?=E6=94=B6=E6=95=9B=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E9=94=99=E8=AF=AF=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一生成队列、资源替换和预览错误的脱敏展示 保留 HTTP 与 IPC 正文并补充资源任务回归 --- .../resourceCanvasAssetGenerationQueue.ts | 44 +++++++++++++++---- .../resourceVersionReplacementModel.ts | 6 ++- .../projectAssetCommandErrorMessage.ts | 3 ++ .../useProjectResourceCardPreviews.ts | 4 +- ...resourceCanvasAssetGenerationQueue.test.ts | 19 ++++++++ .../resourceVersionReplacementModel.test.ts | 8 ++++ 6 files changed, 72 insertions(+), 12 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts index e8e867daf..6d9b66c0d 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts @@ -1,3 +1,4 @@ +import { normalizeDiagnosticText } from '../../services/errorReporting'; import { applyLocalProjectAssetGenerationRecords, type LocalProjectAssetGenerationTaskRecord, @@ -89,9 +90,27 @@ export type ResourceCanvasAssetGenerationQueue = { }; function errorMessage(error: unknown) { - if (typeof error === 'string' && error.trim()) return error; - if (error instanceof Error && error.message) return error.message; - return '生成素材失败'; + const raw = + typeof error === 'string' + ? error + : error instanceof Error + ? error.message + : String(error ?? ''); + const safe = normalizeDiagnosticText(raw.replace(/\s+/gu, ' ').trim()).slice( + 0, + 400, + ); + return safe || '生成素材失败'; +} + +function safeGenerationRecord( + record: LocalProjectAssetGenerationTaskRecord, +): LocalProjectAssetGenerationTaskRecord { + return { + ...record, + phaseDetail: errorMessage(record.phaseDetail), + error: record.error ? errorMessage(record.error) : null, + }; } function isTerminalStatus(status: string) { @@ -145,7 +164,12 @@ export function createResourceCanvasAssetGenerationQueue( task: ResourceCanvasAssetGenerationTask, record: LocalProjectAssetGenerationTaskRecord, ) { - deps.replaceTask(mergeLocalProjectAssetGenerationRecord(task, record)); + deps.replaceTask( + mergeLocalProjectAssetGenerationRecord( + task, + safeGenerationRecord(record), + ), + ); } async function dispatch( @@ -251,14 +275,16 @@ export function createResourceCanvasAssetGenerationQueue( if (record) { missingRecordPolls = 0; readFailed = false; - replaceFromRecord(task, record); - if (isTerminalStatus(record.status)) { + const safeRecord = safeGenerationRecord(record); + replaceFromRecord(task, safeRecord); + if (isTerminalStatus(safeRecord.status)) { return { taskId: task.taskId, projectId: task.projectId, - status: record.status === 'completed' ? 'completed' : 'failed', - record, - error: record.error, + status: + safeRecord.status === 'completed' ? 'completed' : 'failed', + record: safeRecord, + error: safeRecord.error, }; } } else { diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts index b1508be6e..fb39281c9 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts @@ -1,4 +1,5 @@ import type { EditorAsset } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes'; +import { normalizeDiagnosticText } from '../../services/errorReporting'; import type { ProjectResource } from '../../view/project-development/resourceProjectionModel'; import { resourceCanvasAssetKind, @@ -256,8 +257,9 @@ export function resourceReplacementPickerAssets( * 不认识的错误原样透出:宁可显示后端原文,也不把原因吞掉换成"替换失败"这种无信息文案。 */ export function resourceVersionReplacementErrorMessage(error: unknown): string { - const message = - error instanceof Error ? error.message : String(error ?? '').trim(); + const message = normalizeDiagnosticText( + error instanceof Error ? error.message : String(error ?? '').trim(), + ).slice(0, 400); if (!message) return '替换素材失败'; if (message.includes('project-revision-conflict')) { return '项目已被其它操作改动,请重试替换'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/projectAssetCommandErrorMessage.ts b/apps/ai-game-creator-shell/src/view/project-development/projectAssetCommandErrorMessage.ts index 352515ba4..2c48a18af 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/projectAssetCommandErrorMessage.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/projectAssetCommandErrorMessage.ts @@ -8,6 +8,8 @@ * 口径与 `resourceVersionReplacementErrorMessage` 里同样的两条映射逐字一致, * 三条链路给用户的说法必须一样。 */ +import { normalizeDiagnosticText } from '../../services/errorReporting'; + export function projectAssetCommandErrorMessage( error: unknown, fallback: string, @@ -20,6 +22,7 @@ export function projectAssetCommandErrorMessage( } else if (error !== null && error !== undefined) { message = String(error).trim(); } + message = normalizeDiagnosticText(message).slice(0, 400); if (!message) { return fallback; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts index 508a16e11..6b255b682 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts @@ -8,6 +8,7 @@ import { } from 'react'; import type { ProjectResourceCanvasLayoutMode } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { normalizeDiagnosticText } from '../../services/errorReporting'; import { cancelLocalProjectResourcePreviewScope, createProjectResourcePreviewRequestId, @@ -108,7 +109,8 @@ function previewReadErrorMessage( kind: ProjectResourceCardPreviewKind, error: unknown, ): { error: string; retryable: boolean } { - const message = error instanceof Error ? error.message : String(error); + const rawMessage = error instanceof Error ? error.message : String(error); + const message = normalizeDiagnosticText(rawMessage).slice(0, 400); const kindLabel = resourceReadKindLabel(kind); if (message.includes('项目权限策略要求用户确认')) { return { diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts index 89b49797d..3e2491016 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts @@ -540,6 +540,25 @@ describe('本地排队驱动器', () => { record: null, }); }); + + test('生成提交错误正文保留状态并精确脱敏', async () => { + const harness = createHarness(); + harness.startError.set( + 'task-a', + new Error( + 'HTTP 502 upstream overloaded;Authorization: Bearer fixture-secret;C:\\Users\\demo\\game', + ), + ); + const queue = createResourceCanvasAssetGenerationQueue(harness.deps); + + await expect(queue.submit(localTask('task-a', 10))).rejects.toThrow( + 'HTTP 502 upstream overloaded', + ); + const failed = harness.tasks().find((task) => task.taskId === 'task-a'); + expect(failed?.error).toContain('HTTP 502 upstream overloaded'); + expect(failed?.error).not.toContain('fixture-secret'); + expect(failed?.error).not.toContain('C:\\Users\\demo'); + }); }); describe('非预期 IPC 形状 / 失败下的健壮性', () => { diff --git a/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts b/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts index fca11932e..33babee0c 100644 --- a/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts @@ -231,6 +231,14 @@ describe('资源替换的展示层口径', () => { expect(resourceVersionReplacementErrorMessage(new Error('磁盘满了'))).toBe( '磁盘满了', ); + const sensitive = resourceVersionReplacementErrorMessage( + new Error( + 'HTTP 502 upstream overloaded;Authorization: Bearer fixture-secret;C:\\Users\\demo\\game', + ), + ); + expect(sensitive).toContain('HTTP 502 upstream overloaded'); + expect(sensitive).not.toContain('fixture-secret'); + expect(sensitive).not.toContain('C:\\Users\\demo'); expect(resourceVersionReplacementErrorMessage(undefined)).toBe( '替换素材失败', ); From 9740c39fe48d9b25f6049e39c001355eb3babd02 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:39:20 +0800 Subject: [PATCH 009/132] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=A3=B3=E5=AE=A2=E6=88=B7=E7=AB=AF=E9=94=99=E8=AF=AF=E6=AD=A3?= =?UTF-8?q?=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让运行配置、认证、插件、预览和策划错误复用统一脱敏出口 保留 typed 认证分流并补充长期错误处理口径 --- apps/ai-game-creator-shell/src/App.tsx | 26 ++++----- .../src/app/AuthenticatedClient.tsx | 17 +++--- .../runtime-config/RuntimeConfigDialog.tsx | 54 +++++-------------- .../src/services/errorReporting.ts | 13 +++++ ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...实施计划】AGC错误具体文本展示-2026-10-04.md | 1 + 6 files changed, 47 insertions(+), 65 deletions(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 145a3fb0a..171042b4b 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -88,6 +88,7 @@ import { type ResourceReferenceInsertManyEventDetail, } from './features/project-workspace/resourceReferences'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; +import { visibleClientErrorMessage } from './services/errorReporting'; import { setAgcPluginProjectPath, startAvailableAgcEditorPlugins, @@ -247,9 +248,7 @@ export function App({ return; } setWorkspaceStatus( - `编辑器插件未就绪:${ - error instanceof Error ? error.message : String(error) - }`, + `编辑器插件未就绪:${visibleClientErrorMessage(error)}`, ); }); return () => { @@ -564,8 +563,9 @@ export function App({ if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) { return; } - const message = error instanceof Error ? error.message : String(error); - if (isRuntimeConfigMissingError(message)) { + const rawMessage = error instanceof Error ? error.message : String(error); + const message = visibleClientErrorMessage(error); + if (isRuntimeConfigMissingError(rawMessage)) { requestRuntimeConfigOpen(); } setProjectChatError(message); @@ -916,7 +916,7 @@ export function App({ setPublishProgress({ phase: 'failed', message: '发布失败', - detail: error instanceof Error ? error.message : String(error), + detail: visibleClientErrorMessage(error), }); } } @@ -1111,7 +1111,7 @@ export function App({ } catch (error) { if (planningStartMode) { setProjectChatError( - `策划会话无法恢复:${error instanceof Error ? error.message : String(error)}`, + `策划会话无法恢复:${visibleClientErrorMessage(error)}`, ); } } @@ -1249,7 +1249,7 @@ export function App({ if (projectScopeVersionRef.current !== projectScopeVersion) { return; } - const message = error instanceof Error ? error.message : String(error); + const message = visibleClientErrorMessage(error); setWorkspaceStatus(message); if (announceToChat) { setMessages((current) => [ @@ -1413,9 +1413,7 @@ export function App({ } catch (error) { if (localProjectPathRef.current === input.projectPath) { setProjectChatError( - `DirectProject 对话权限检查失败:${ - error instanceof Error ? error.message : String(error) - }`, + `DirectProject 对话权限检查失败:${visibleClientErrorMessage(error)}`, ); } return false; @@ -1568,9 +1566,7 @@ export function App({ } catch (error) { onRunNotice?.({ tone: 'error', - message: `运行游戏失败:${ - error instanceof Error ? error.message : String(error) - }`, + message: `运行游戏失败:${visibleClientErrorMessage(error)}`, }); } } @@ -1922,7 +1918,7 @@ export function App({ .catch((error) => { if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) return; - setProjectChatError(String(error)); + setProjectChatError(visibleClientErrorMessage(error)); designReplyAnimation.discardUnpersisted(); }) .finally(() => { diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 96d8b5c54..0d6220044 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -34,6 +34,7 @@ import { import { captureClientError, installWebviewLogBridge, + visibleClientErrorMessage, } from '../services/errorReporting'; import type { AuthNetworkFailure } from '../services/generated/AuthNetworkFailure'; import type { AuthResponseInvalid } from '../services/generated/AuthResponseInvalid'; @@ -101,7 +102,7 @@ export class ClientRuntimeErrorBoundary extends Component< return { errorMessage: error instanceof Error && error.message.trim() - ? error.message + ? visibleClientErrorMessage(error) : '登录后页面发生未知错误', }; } @@ -179,7 +180,7 @@ export function AuthenticatedClient({ setServerSelection(next); return true; } catch (error) { - setLoginStatus(error instanceof Error ? error.message : String(error)); + setLoginStatus(visibleClientErrorMessage(error)); return false; } } @@ -268,7 +269,7 @@ export function AuthenticatedClient({ setAuthStatus('unauthenticated'); // 启动检查超时等我们自己的失败:改动前会显示原因 + 重试按钮,这里恢复同样的可见反馈。 if (!(error instanceof ClientAuthErrorWrapper)) { - const text = error instanceof Error ? error.message : String(error); + const text = visibleClientErrorMessage(error); setAuthCheckError(text); setLoginStatus(text); throw error; @@ -543,7 +544,7 @@ export function AuthenticatedClient({ } catch (error) { // 本地运行时超时等我们自己的失败也要离开「正在发送验证码」并给出原因,改动前就是如此。 if (!(error instanceof ClientAuthErrorWrapper)) { - setLoginStatus(error instanceof Error ? error.message : String(error)); + setLoginStatus(visibleClientErrorMessage(error)); throw error; } const failure = error.error; @@ -770,7 +771,7 @@ export function AuthenticatedClient({ } catch (error) { // 本地运行时超时等我们自己的失败也要离开「正在登录」并给出原因,改动前就是如此。 if (!(error instanceof ClientAuthErrorWrapper)) { - setLoginStatus(error instanceof Error ? error.message : String(error)); + setLoginStatus(visibleClientErrorMessage(error)); throw error; } const failure = error.error; @@ -1029,11 +1030,7 @@ export function AuthenticatedClient({ normalizeClientServerBaseUrl(customServerUrl); persistServerSelection(); } catch (error) { - setLoginStatus( - error instanceof Error - ? error.message - : String(error), - ); + setLoginStatus(visibleClientErrorMessage(error)); } }} /> diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index c7488f27b..7861eae67 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -37,6 +37,7 @@ import { gameCreatorLlmReasoningEfforts, } from '../../app/types'; import { checkForAppUpdateResult } from '../../services/appUpdate'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { notifyLlmConfigChanged } from '../../services/llmModelCatalog'; import { AGC_PLUGIN_STATE_CHANGED_EVENT, @@ -83,12 +84,7 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = { }; type RuntimeSettingsSection = - | 'general' - | 'workspace' - | 'agents' - | 'extensions' - | 'advanced' - | 'about'; + 'general' | 'workspace' | 'agents' | 'extensions' | 'advanced' | 'about'; type RuntimeConfigToast = { tone: 'success' | 'error'; @@ -301,9 +297,7 @@ export function RuntimeConfigDialog({ }) .catch((error) => { if (!disposed) { - setAgcPluginsStatus( - error instanceof Error ? error.message : String(error), - ); + setAgcPluginsStatus(visibleClientErrorMessage(error)); } }); return () => { @@ -358,9 +352,7 @@ export function RuntimeConfigDialog({ ); setAgcPluginsStatus(''); } catch (error) { - setAgcPluginsStatus( - error instanceof Error ? error.message : String(error), - ); + setAgcPluginsStatus(visibleClientErrorMessage(error)); } } @@ -377,9 +369,7 @@ export function RuntimeConfigDialog({ ); setAgcPluginsStatus(''); } catch (error) { - setAgcPluginsStatus( - error instanceof Error ? error.message : String(error), - ); + setAgcPluginsStatus(visibleClientErrorMessage(error)); } finally { setAgcPluginsBusy(false); } @@ -395,9 +385,7 @@ export function RuntimeConfigDialog({ ); setAgcPluginsStatus('插件已重新加载'); } catch (error) { - setAgcPluginsStatus( - error instanceof Error ? error.message : String(error), - ); + setAgcPluginsStatus(visibleClientErrorMessage(error)); } finally { setAgcPluginsBusy(false); } @@ -416,9 +404,7 @@ export function RuntimeConfigDialog({ await readClientExtensions(); setAgcPluginsStatus(enabled ? '内置插件已启用' : '内置插件已禁用'); } catch (error) { - setAgcPluginsStatus( - error instanceof Error ? error.message : String(error), - ); + setAgcPluginsStatus(visibleClientErrorMessage(error)); } finally { setAgcPluginsBusy(false); } @@ -442,9 +428,7 @@ export function RuntimeConfigDialog({ setClientExtensionsStatus(''); } catch (error) { setClientExtensionsLoadState('error'); - setClientExtensionsStatus( - error instanceof Error ? error.message : String(error), - ); + setClientExtensionsStatus(visibleClientErrorMessage(error)); } finally { setClientExtensionsBusy(false); } @@ -487,9 +471,7 @@ export function RuntimeConfigDialog({ : '未发现可管理的扩展', ); } catch (error) { - setClientExtensionsStatus( - error instanceof Error ? error.message : String(error), - ); + setClientExtensionsStatus(visibleClientErrorMessage(error)); } finally { setClientExtensionsBusy(false); } @@ -531,9 +513,7 @@ export function RuntimeConfigDialog({ ); cancelRenameClientExtension(); } catch (error) { - setClientExtensionsStatus( - error instanceof Error ? error.message : String(error), - ); + setClientExtensionsStatus(visibleClientErrorMessage(error)); } finally { setClientExtensionsBusy(false); } @@ -561,9 +541,7 @@ export function RuntimeConfigDialog({ await readClientExtensions(); await readAgcPlugins(); } catch (error) { - setClientExtensionsStatus( - error instanceof Error ? error.message : String(error), - ); + setClientExtensionsStatus(visibleClientErrorMessage(error)); } finally { setClientExtensionsBusy(false); } @@ -584,9 +562,7 @@ export function RuntimeConfigDialog({ } setClientExtensionsStatus('扩展已移除'); } catch (error) { - setClientExtensionsStatus( - error instanceof Error ? error.message : String(error), - ); + setClientExtensionsStatus(visibleClientErrorMessage(error)); } finally { setClientExtensionsBusy(false); } @@ -632,9 +608,7 @@ export function RuntimeConfigDialog({ setRuntimeConfigStatus(`已读取:${result.path}`); onLog?.('runtime_config.read'); } catch (error) { - setRuntimeConfigStatus( - error instanceof Error ? error.message : String(error), - ); + setRuntimeConfigStatus(visibleClientErrorMessage(error)); } finally { runtimeConfigBusyRef.current = false; setRuntimeConfigBusy(false); @@ -689,7 +663,7 @@ export function RuntimeConfigDialog({ }); onLog?.('runtime_config.save'); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = visibleClientErrorMessage(error); setRuntimeConfigStatus(message); setRuntimeConfigToast({ tone: 'error', diff --git a/apps/ai-game-creator-shell/src/services/errorReporting.ts b/apps/ai-game-creator-shell/src/services/errorReporting.ts index c22a703ab..08d8d53da 100644 --- a/apps/ai-game-creator-shell/src/services/errorReporting.ts +++ b/apps/ai-game-creator-shell/src/services/errorReporting.ts @@ -76,6 +76,19 @@ export function normalizeDiagnosticText(value: string) { .replace(/\b[0-9a-f]{8,}\b/giu, ''); } +/** 用户可见的普通客户端错误正文:保留状态码/原因,替换凭据、URL、路径和长标识。 */ +export function visibleClientErrorMessage( + error: unknown, + fallback = '未提供具体错误正文', +) { + const raw = error instanceof Error ? error.message : String(error ?? ''); + const safe = normalizeDiagnosticText(raw.replace(/\s+/gu, ' ').trim()).slice( + 0, + 400, + ); + return safe || fallback; +} + export async function captureClientError( error: unknown, context: Partial = {}, diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index e2f3e88c4..2db046ff5 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -111,6 +111,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - `hostDropped` 携带可选的脱敏 `detail`:panic hook 能取得的负载和位置随原回合占用进入失败事件;普通 Drop 仅记录“退出时未写终态、未观察到 panic”,不推断为网络中断。旧版本无 `detail` 的事件继续可读,界面明确标注旧事件未记录原因。 - DirectProject 控制器的取消待发、终止、附件上传、项目历史读取和发送前置异常也复用同一份前端精确脱敏出口;这些非回合错误不得直接把 `Error.message` 原文写入状态栏、composer 或历史错误行。 - AGC 项目资源编辑、上传、预览、恢复队列和生成结果回读的面板错误统一经过 `normalizeDiagnosticText`,保留状态码与安全正文,替换凭据、URL、路径和长标识;资源业务提示不再直接展示 `Error.message`。 +- 应用壳、运行配置、认证状态、插件启动、运行预览和策划会话的普通客户端错误统一经过 `visibleClientErrorMessage`;结构化认证拒绝仍按 typed reason 分流,非结构化错误保留安全正文后再进入状态提示或错误上报。 ## 后果与边界 diff --git a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md index 703c85068..5f4d8f4dd 100644 --- a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md @@ -15,6 +15,7 @@ Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md` 6. HostDropped 的可选 detail 由 Rust 单点脱敏;panic hook 把可读负载和位置关联到原回合占用,Drop 与旧无字段事件分别验证,不更改回合排队、重试或计费行为。 7. DirectProject 控制器的取消、上传、历史读取和前置异常统一复用前端精确脱敏出口,保留 IPC/OS 正文并替换凭据值、URL 与路径。 8. 项目资源编辑/上传/预览/恢复队列/生成结果回读的 UI 错误统一使用 `normalizeDiagnosticText`,不再直出 `Error.message`。 +9. 应用壳、运行配置、认证状态、插件启动、运行预览和策划会话的非结构化错误统一使用 `visibleClientErrorMessage`。 ## 实现顺序 From 698d0b9b1e6956ad28ed3ee634e13670ab7a8f99 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:43:36 +0800 Subject: [PATCH 010/132] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=A3=B3=E4=B8=8E=E5=B7=A5=E4=BD=9C=E5=8C=BA=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收敛项目目录、模板、钱包、导入和更新提示的客户端错误脱敏 复用 visibleClientErrorMessage 并补齐资源页调用链 --- .../src/components/AppUpdateNotice.tsx | 3 ++- .../features/app-shell/ProjectCreation.tsx | 3 ++- .../features/app-shell/useAccountWallet.ts | 9 +++++---- .../app-shell/useHomeProjectCreation.ts | 17 ++++++++++------- .../app-shell/useProjectCreationDirectory.ts | 17 +++++------------ .../features/app-shell/useRecentProjects.ts | 19 ++++++------------- .../ConversationModelSelect.tsx | 7 +++++-- .../project-workspace/importDesignFiles.ts | 5 ++--- .../template-library/useTemplateLibrary.ts | 3 ++- .../src/view/project-development/index.tsx | 9 ++------- 10 files changed, 41 insertions(+), 51 deletions(-) diff --git a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx index 27ce8c6e2..2d09967e3 100644 --- a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx +++ b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx @@ -9,6 +9,7 @@ import { installAppUpdate, subscribeToAppUpdate, } from '../services/appUpdate'; +import { visibleClientErrorMessage } from '../services/errorReporting'; type DownloadState = 'idle' | 'downloading' | 'completed' | 'error'; @@ -60,7 +61,7 @@ export function AppUpdateNotice() { await installAppUpdate(setDownloadProgress); setDownloadState('completed'); } catch (error) { - setDownloadError(error instanceof Error ? error.message : String(error)); + setDownloadError(visibleClientErrorMessage(error, '下载更新失败')); setDownloadState('error'); } } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx index 925a01cd9..66ff41f08 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx @@ -19,6 +19,7 @@ import { } from 'react'; import { closeDialogOnEscape } from '../../app/dialogs'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import type { RecentProjectRow } from './model'; import type { HomeProjectCreationController } from './useHomeProjectCreation'; import type { RecentProjectsController } from './useRecentProjects'; @@ -236,7 +237,7 @@ export function ProjectsPage({ await recentProjects.refreshRecentWorkspace(renamingPath); cancelRename(); } catch (error) { - setRenameError(error instanceof Error ? error.message : '重命名项目失败'); + setRenameError(visibleClientErrorMessage(error, '重命名项目失败')); } finally { setRenameSaving(false); } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts index 1d6f0a339..5c5017ba2 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts @@ -14,6 +14,7 @@ import { getClientProfileWalletLedger, redeemClientProfileRewardCode, } from '../../services/accountHost'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { useWalletStore } from '../../stores/useWalletStore'; export function useAccountWallet(currentUserId: string) { @@ -144,7 +145,7 @@ export function useAccountWallet(currentUserId: string) { } setWalletLedger(null); setWalletLedgerError( - error instanceof Error ? error.message : '读取泥点账单失败', + visibleClientErrorMessage(error, '读取泥点账单失败'), ); } finally { if ( @@ -202,7 +203,7 @@ export function useAccountWallet(currentUserId: string) { currentUserIdRef.current === snapshotOwnerUserId ) { setRechargeError( - error instanceof Error ? error.message : '读取泥点购买信息失败', + visibleClientErrorMessage(error, '读取泥点购买信息失败'), ); } } finally { @@ -266,7 +267,7 @@ export function useAccountWallet(currentUserId: string) { redeemLifecycleRef.current === lifecycle && currentUserIdRef.current === owner ) { - setRedeemCodeError(error instanceof Error ? error.message : '兑换失败'); + setRedeemCodeError(visibleClientErrorMessage(error, '兑换失败')); } } finally { if ( @@ -317,7 +318,7 @@ export function useAccountWallet(currentUserId: string) { rechargeLifecycleRef.current === rechargeLifecycle && currentUserIdRef.current === snapshotOwnerUserId ) { - setRechargeError(error instanceof Error ? error.message : '充值失败'); + setRechargeError(visibleClientErrorMessage(error, '充值失败')); } } finally { if ( diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 92be119ec..8848f6f06 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -36,7 +36,10 @@ import { createClientOperation, transitionClientOperation, } from '../../services/clientOperation'; -import { appendWebviewLog } from '../../services/errorReporting'; +import { + appendWebviewLog, + visibleClientErrorMessage, +} from '../../services/errorReporting'; import type { HomeAttachmentDraft, HomeCreationType, @@ -141,7 +144,7 @@ export async function uploadLocalFilesAsAttachments( fileName: file.name, mediaType, status: 'failed', - error: error instanceof Error ? error.message : String(error), + error: visibleClientErrorMessage(error), size: file.size, }); } @@ -584,7 +587,7 @@ export function useHomeProjectCreation({ analytics, ); } catch (error) { - setStatus(error instanceof Error ? error.message : String(error)); + setStatus(visibleClientErrorMessage(error)); } finally { if (projectActionRef.current === 'creating') { projectActionRef.current = null; @@ -757,7 +760,7 @@ export function useHomeProjectCreation({ analytics, ); } catch (error) { - setStatus(error instanceof Error ? error.message : String(error)); + setStatus(visibleClientErrorMessage(error)); } finally { if (projectActionRef.current === 'opening') { projectActionRef.current = null; @@ -786,7 +789,7 @@ export function useHomeProjectCreation({ } await createProjectFromProjectPage(pendingProject.projectPath, true); } catch (error) { - setStatus(error instanceof Error ? error.message : String(error)); + setStatus(visibleClientErrorMessage(error)); } } @@ -1033,7 +1036,7 @@ export function useHomeProjectCreation({ setProjectAction(null); await openProject(selectedPath, 'open', analytics); } catch (error) { - setStatus(error instanceof Error ? error.message : String(error)); + setStatus(visibleClientErrorMessage(error)); } finally { if (projectActionRef.current === 'opening') { projectActionRef.current = null; @@ -1075,7 +1078,7 @@ export function useHomeProjectCreation({ analytics, ); } catch (error) { - setStatus(error instanceof Error ? error.message : String(error)); + setStatus(visibleClientErrorMessage(error)); } finally { if (projectActionRef.current === 'creating') { projectActionRef.current = null; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useProjectCreationDirectory.ts b/apps/ai-game-creator-shell/src/features/app-shell/useProjectCreationDirectory.ts index f7b9c2146..55e581bf9 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useProjectCreationDirectory.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useProjectCreationDirectory.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { resolveTauriInvoke } from '../../app/tauri'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { readWorkspacePreferences, setProjectCreationDirectory, @@ -52,9 +53,7 @@ export function useProjectCreationDirectory() { }); } catch (error) { if (!disposed) { - setProjectCreationDirectoryStatus( - error instanceof Error ? error.message : String(error), - ); + setProjectCreationDirectoryStatus(visibleClientErrorMessage(error)); } } if (disposed) { @@ -73,9 +72,7 @@ export function useProjectCreationDirectory() { } } catch (error) { if (!disposed) { - setProjectCreationDirectoryStatus( - error instanceof Error ? error.message : String(error), - ); + setProjectCreationDirectoryStatus(visibleClientErrorMessage(error)); } } }; @@ -116,9 +113,7 @@ export function useProjectCreationDirectory() { applyWorkspacePreferencesSnapshot(snapshot.projectCreationDirectory); setProjectCreationDirectoryStatus('已更新项目创建目录'); } catch (error) { - setProjectCreationDirectoryStatus( - error instanceof Error ? error.message : String(error), - ); + setProjectCreationDirectoryStatus(visibleClientErrorMessage(error)); } finally { pickInFlightRef.current = false; setProjectCreationDirectoryBusy(false); @@ -132,9 +127,7 @@ export function useProjectCreationDirectory() { setProjectCreationDirectoryStatus('已恢复默认位置'); }) .catch((error) => { - setProjectCreationDirectoryStatus( - error instanceof Error ? error.message : String(error), - ); + setProjectCreationDirectoryStatus(visibleClientErrorMessage(error)); }); }, []); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts index 5f905aa68..d2dfb6e6a 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts @@ -9,6 +9,7 @@ import { import { resolveTauriInvoke } from '../../app/tauri'; import type { LocalProjectDirectoryStatus, TauriInvoke } from '../../app/types'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { isAbsoluteProjectPath, projectPathHasControlCharacter, @@ -158,9 +159,7 @@ export function useRecentProjects(setStatus: Dispatch>) { }); } catch (error) { if (!disposed) { - setStatusRef.current( - error instanceof Error ? error.message : String(error), - ); + setStatusRef.current(visibleClientErrorMessage(error)); } } if (disposed) { @@ -179,9 +178,7 @@ export function useRecentProjects(setStatus: Dispatch>) { } } catch (error) { if (!disposed) { - setStatusRef.current( - error instanceof Error ? error.message : String(error), - ); + setStatusRef.current(visibleClientErrorMessage(error)); } } }; @@ -304,9 +301,7 @@ export function useRecentProjects(setStatus: Dispatch>) { } }) .catch((error) => { - setStatusRef.current( - error instanceof Error ? error.message : String(error), - ); + setStatusRef.current(visibleClientErrorMessage(error)); }); } @@ -355,9 +350,7 @@ export function useRecentProjects(setStatus: Dispatch>) { }); }) .catch((error) => { - setStatusRef.current( - error instanceof Error ? error.message : String(error), - ); + setStatusRef.current(visibleClientErrorMessage(error)); }); } @@ -382,7 +375,7 @@ export function useRecentProjects(setStatus: Dispatch>) { }); setStatus('已打开项目目录'); } catch (error) { - setStatus(error instanceof Error ? error.message : String(error)); + setStatus(visibleClientErrorMessage(error)); } } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index 2bc9df75c..da8c8fc2f 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -10,6 +10,7 @@ import { import { resolveTauriInvoke } from '../../app/tauri'; import type { GameCreatorAppConfigView } from '../../app/types'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { cachedLlmModelCatalog, type ClientLlmModel, @@ -48,8 +49,10 @@ class ModelSelectionConfigError extends Error {} * 渲染层错误类型,也不把服务端原文(可能含内部地址)直接铺给用户。 */ function modelCatalogErrorMessage(error: unknown) { - if (error instanceof LlmModelCatalogConfigError) return error.message; - const message = error instanceof Error ? error.message : String(error ?? ''); + if (error instanceof LlmModelCatalogConfigError) { + return visibleClientErrorMessage(error); + } + const message = visibleClientErrorMessage(error, '模型列表加载失败'); if (message.includes('authentication-required')) { return '模型列表加载失败:登录已失效,请重新登录'; } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/importDesignFiles.ts b/apps/ai-game-creator-shell/src/features/project-workspace/importDesignFiles.ts index f8c4bf9f8..f912c5682 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/importDesignFiles.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/importDesignFiles.ts @@ -1,4 +1,5 @@ import type { TauriInvoke } from '../../app/types'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; /** 策划附件只写工作区;返回界面提示,不生成回合附件或资源清单。 */ export async function importDesignFiles( @@ -18,9 +19,7 @@ export async function importDesignFiles( }); imported += 1; } catch (error) { - failures.push( - `${file.name}:${error instanceof Error ? error.message : String(error)}`, - ); + failures.push(`${file.name}:${visibleClientErrorMessage(error)}`); } } const summary = `已导入 ${imported} 个文件到策划工作区`; diff --git a/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts index fab31d47a..47d817c42 100644 --- a/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts +++ b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts @@ -14,6 +14,7 @@ import { beginProjectOpenAnalytics, type ProjectOpenAnalytics, } from '../../services/clientAnalytics'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { currentPlatformSessionGeneration, subscribePlatformSessionGeneration, @@ -45,7 +46,7 @@ type UseTemplateLibraryOptions = { }; function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); + return visibleClientErrorMessage(error); } export function useTemplateLibrary({ diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 699dc0588..3f99aa3b6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -281,7 +281,7 @@ import { } from '../../features/resource-canvas/resourceVersionReplacementTransport'; import { useResourceCanvasGenerationPlaceholders } from '../../features/resource-canvas/useResourceCanvasGenerationPlaceholders'; import { createUiDesignDocFromImages } from '../../features/ui-editor/uiDesignResourceBridge'; -import { normalizeDiagnosticText } from '../../services/errorReporting'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { currentPlatformSessionGeneration, requestPlatformSessionRefresh, @@ -521,12 +521,7 @@ function projectDevelopmentErrorMessage( error: unknown, fallback = '未提供具体错误正文', ) { - const raw = error instanceof Error ? error.message : String(error ?? ''); - const safe = normalizeDiagnosticText(raw.replace(/\s+/gu, ' ').trim()).slice( - 0, - 400, - ); - return safe || fallback; + return visibleClientErrorMessage(error, fallback); } function isPlatformAuthenticationRequired(error: unknown) { From bfd7324434ccbb8c75ed329c9318721408377094 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:45:56 +0800 Subject: [PATCH 011/132] =?UTF-8?q?=E6=94=B6=E6=95=9B=E5=89=A9=E4=BD=99?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一更新、模型目录、错误上报和语音入口的敏感信息脱敏 保留真实错误正文并补齐共享错误处理出口 --- .../src/components/error-report/ErrorReportDialog.tsx | 5 ++--- .../features/project-workspace/ConversationModelSelect.tsx | 2 +- apps/ai-game-creator-shell/src/services/appUpdate.ts | 5 ++--- .../components/DirectProjectComposer/ComposerControls.tsx | 5 ++--- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx b/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx index c5f1c0a36..74bd413a2 100644 --- a/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx +++ b/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx @@ -7,6 +7,7 @@ import { getPendingClientErrorEvents, readApplicationDiagnosticLogs, submitErrorReportBatch, + visibleClientErrorMessage, } from '../../services/errorReporting'; import { ThemedModal } from '../modal/ThemedModal'; @@ -116,9 +117,7 @@ export function ErrorReportDialog({ window.setTimeout(onClose, 700); void ackClientErrorEventsWithRetry(selectedEvents); } catch (error) { - setStatus( - error instanceof Error ? error.message : '提交失败,请稍后再试', - ); + setStatus(visibleClientErrorMessage(error, '提交失败,请稍后再试')); } finally { setBusy(false); } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index da8c8fc2f..fa0eb0f7f 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -283,7 +283,7 @@ export function ConversationModelSelect({ setNotice(''); setError( error instanceof ModelSelectionConfigError - ? error.message + ? visibleClientErrorMessage(error) : '模型列表加载失败', ); } diff --git a/apps/ai-game-creator-shell/src/services/appUpdate.ts b/apps/ai-game-creator-shell/src/services/appUpdate.ts index 12e82a9ec..97af09a26 100644 --- a/apps/ai-game-creator-shell/src/services/appUpdate.ts +++ b/apps/ai-game-creator-shell/src/services/appUpdate.ts @@ -6,6 +6,7 @@ import { import { appUpdateCheckEnabled } from '../app/featureFlags'; import { resolveTauriInvoke } from '../app/tauri'; +import { visibleClientErrorMessage } from './errorReporting'; /** 更新提示所需的元数据;清单请求、版本比较、下载、校验与安装都由官方更新插件在原生侧完成。 */ export type AppUpdateInfo = { @@ -41,9 +42,7 @@ function toAppUpdateInfo(update: Update): AppUpdateInfo { } function appUpdateFailureMessage(error: unknown): string { - const text = - error instanceof Error ? error.message.trim() : String(error ?? '').trim(); - return text || '更新服务未返回原因'; + return visibleClientErrorMessage(error, '更新服务未返回原因'); } async function runAppUpdateCheck(): Promise { diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx index 3db8b8f3b..aa496af9a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx @@ -30,6 +30,7 @@ import { DEFAULT_COMPOSER_REASONING_EFFORT, normalizeComposerReasoningEffort, } from '../../../../../features/project-workspace/composerReasoningEffort'; +import { visibleClientErrorMessage } from '../../../../../services/errorReporting'; import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; import { resolveSpeechRecognitionCtor, @@ -271,9 +272,7 @@ export function ComposerVoiceButton({ recognitionRef.current = null; setRecording(false); onNoticeRef.current( - error instanceof Error && error.message - ? error.message - : '语音输入启动失败,请稍后重试', + visibleClientErrorMessage(error, '语音输入启动失败,请稍后重试'), ); } } From 21b42b3b8f33a66fd0a5b7608c4c31fba24dde57 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:50:23 +0800 Subject: [PATCH 012/132] =?UTF-8?q?=E6=94=B6=E6=95=9B=E5=89=A9=E4=BD=99=20?= =?UTF-8?q?AGC=20=E9=9D=A2=E6=9D=BF=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一资产导入、邀请码、策划工作区和发布面板的错误脱敏 保留状态码与正文并同步错误处理文档 --- .../src/components/AssetImporter/index.tsx | 16 +++++++-------- .../GameDistributionPublishPanel.tsx | 20 +++++++------------ .../features/app-shell/useAccountReferral.ts | 5 ++--- .../DesignWorkspacePanel.tsx | 15 ++++---------- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...实施计划】AGC错误具体文本展示-2026-10-04.md | 1 + 6 files changed, 23 insertions(+), 35 deletions(-) diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx index 243018a69..5fdf8bdc9 100644 --- a/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx @@ -11,6 +11,7 @@ import { readEditorAssetLibrary, readEditorAssetPreview, } from '../../services/editorAssetHost'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { ThemedModal } from '../modal/ThemedModal'; import { type AssetImporterSettings, @@ -204,13 +205,14 @@ export function AssetImporter({ ? remoteResult.reason : null; setError( - cause instanceof Error - ? cause.message - : '部分素材来源加载失败,仍可继续使用其他来源。', + visibleClientErrorMessage( + cause, + '部分素材来源加载失败,仍可继续使用其他来源。', + ), ); } } catch (cause) { - setError(cause instanceof Error ? cause.message : String(cause)); + setError(visibleClientErrorMessage(cause)); } finally { setLoading(false); } @@ -229,7 +231,7 @@ export function AssetImporter({ }, [open, refresh, settings]); const refreshProjectAfterImportFailure = async (cause: unknown) => { - const importError = cause instanceof Error ? cause.message : String(cause); + const importError = visibleClientErrorMessage(cause); await refresh(PROJECT_ASSETS_PATH); setError(importError); }; @@ -391,9 +393,7 @@ export function AssetImporter({ setLoading(true); setError(null); void loadRemote() - .catch((cause) => - setError(cause instanceof Error ? cause.message : String(cause)), - ) + .catch((cause) => setError(visibleClientErrorMessage(cause))) .finally(() => setLoading(false)); } }; diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx index 51dc44f27..67d9bfd0d 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx @@ -10,6 +10,7 @@ import { IMAGE_MODEL_GPT_IMAGE_2 } from '../../../../../src/components/image-edi import { resolveTauriInvoke } from '../../app/tauri'; import type { LocalProjectExportPackageResult } from '../../app/types'; import { uploadPlatformMediaAsset } from '../../services/assetDirectUpload'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { type GameDistributionPublicationImageDraft, type GameDistributionPublicationReadResult, @@ -62,10 +63,7 @@ type PanelScreenshotEntry = { * `unavailable` 时禁止提交:恢复失败(网络、401、5xx)不能退化成首次发布,否则会多建作品。 */ type PanelPublicationState = - | 'checking' - | 'first-publish' - | 'update' - | 'unavailable'; + 'checking' | 'first-publish' | 'update' | 'unavailable'; function resolvePanelImageLabel(kind: PanelImageKind) { return kind === 'cover' ? '游戏封面' : '游戏截图'; @@ -513,11 +511,7 @@ export function GameDistributionPublishPanel({ const assetObjectId = await resolveAssetObjectId(file, 'cover'); setCover(buildPanelImageAsset(file, 'cover', assetObjectId)); } catch (uploadError) { - setError( - uploadError instanceof Error - ? uploadError.message - : '封面上传失败,请重试', - ); + setError(visibleClientErrorMessage(uploadError, '封面上传失败,请重试')); } finally { setUploadingLabel(''); if (coverInputRef.current) coverInputRef.current.value = ''; @@ -551,9 +545,7 @@ export function GameDistributionPublishPanel({ } catch (generationError) { setCoverGenerationDetail(''); setError( - generationError instanceof Error - ? generationError.message - : '生成游戏封面失败,请重试', + visibleClientErrorMessage(generationError, '生成游戏封面失败,请重试'), ); } finally { setGeneratingCover(false); @@ -968,7 +960,9 @@ export function GameDistributionPublishPanel({ {cover ? null : ( - {'封面会作为游戏广场卡片与详情页首图,建议 16:9 的 JPG/PNG/WebP,最大 6MB。'} + { + '封面会作为游戏广场卡片与详情页首图,建议 16:9 的 JPG/PNG/WebP,最大 6MB。' + } )} {coverGenerationDetail ? ( diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountReferral.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountReferral.ts index 76c99d35c..bf121ec03 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountReferral.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountReferral.ts @@ -4,6 +4,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import type { PlatformProfileReferralCopyState } from '../../../../../packages/shared/src/components/PlatformProfileReferralModal'; import type { ProfileReferralInviteCenterResponse } from '../../../../../packages/shared/src/contracts/runtime'; import { getClientProfileReferralInviteCenter } from '../../services/accountHost'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; export type AccountReferralPanel = 'invite' | 'community'; @@ -65,9 +66,7 @@ export function useAccountReferral(currentUserId: string) { } setCenter(null); setInviteUrl(null); - setError( - loadError instanceof Error ? loadError.message : '读取邀请码失败', - ); + setError(visibleClientErrorMessage(loadError, '读取邀请码失败')); } finally { if ( lifecycleRef.current === lifecycle && diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/DesignWorkspacePanel.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/DesignWorkspacePanel.tsx index c651a5764..ce46b8434 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/DesignWorkspacePanel.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/DesignWorkspacePanel.tsx @@ -12,6 +12,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { resolveTauriInvoke } from '../../app/tauri'; import type { DesignView, DesignWorkspaceEntry } from '../../app/types'; import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { canSubscribeTauriEvents, subscribeTauriEvent, @@ -250,9 +251,7 @@ export function DesignWorkspacePanel({ : null, ); } catch (nextError) { - setError( - nextError instanceof Error ? nextError.message : String(nextError), - ); + setError(visibleClientErrorMessage(nextError)); if (showLoading) setFiles([]); } finally { if (showLoading) setLoading(false); @@ -343,9 +342,7 @@ export function DesignWorkspacePanel({ setPreviewText(content); } catch (nextError) { setPreviewText(''); - setError( - nextError instanceof Error ? nextError.message : String(nextError), - ); + setError(visibleClientErrorMessage(nextError)); } finally { setPreviewLoading(false); } @@ -394,11 +391,7 @@ export function DesignWorkspacePanel({ hydrateSession: true, }); } catch (nextError) { - setError( - nextError instanceof Error - ? nextError.message - : String(nextError), - ); + setError(visibleClientErrorMessage(nextError)); } finally { setDebugPreparing(false); } diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 2db046ff5..0c8b2a1e7 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -112,6 +112,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - DirectProject 控制器的取消待发、终止、附件上传、项目历史读取和发送前置异常也复用同一份前端精确脱敏出口;这些非回合错误不得直接把 `Error.message` 原文写入状态栏、composer 或历史错误行。 - AGC 项目资源编辑、上传、预览、恢复队列和生成结果回读的面板错误统一经过 `normalizeDiagnosticText`,保留状态码与安全正文,替换凭据、URL、路径和长标识;资源业务提示不再直接展示 `Error.message`。 - 应用壳、运行配置、认证状态、插件启动、运行预览和策划会话的普通客户端错误统一经过 `visibleClientErrorMessage`;结构化认证拒绝仍按 typed reason 分流,非结构化错误保留安全正文后再进入状态提示或错误上报。 +- 资产导入、邀请码、策划工作区、发布封面/截图和素材命令的用户可见错误也复用该出口,避免业务面板把原始 `Error.message` 重新带回界面。 ## 后果与边界 diff --git a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md index 5f4d8f4dd..ead45363b 100644 --- a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md @@ -16,6 +16,7 @@ Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md` 7. DirectProject 控制器的取消、上传、历史读取和前置异常统一复用前端精确脱敏出口,保留 IPC/OS 正文并替换凭据值、URL 与路径。 8. 项目资源编辑/上传/预览/恢复队列/生成结果回读的 UI 错误统一使用 `normalizeDiagnosticText`,不再直出 `Error.message`。 9. 应用壳、运行配置、认证状态、插件启动、运行预览和策划会话的非结构化错误统一使用 `visibleClientErrorMessage`。 +10. 资产导入、邀请码、策划工作区、发布封面/截图和素材命令的 UI 错误沿用同一共享出口。 ## 实现顺序 From b3a0f16b9ce9c83a1ca768f16f166495f0711343 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:00:02 +0800 Subject: [PATCH 013/132] =?UTF-8?q?=E8=84=B1=E6=95=8F=E7=AD=96=E5=88=92?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E9=94=99=E8=AF=AF=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 恢复持久策划会话时复用客户端错误展示出口 --- apps/ai-game-creator-shell/src/App.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 171042b4b..0b6661de5 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -501,7 +501,11 @@ export function App({ setDesignAgentView(view); designAgentActiveRef.current = true; setDesignAgentActive(true); - setProjectChatError(view.session.lastError ?? ''); + setProjectChatError( + view.session.lastError + ? visibleClientErrorMessage(view.session.lastError) + : '', + ); setChatAgentBusy(view.running); const conversation = designMessagesToChat(view); setMessages(conversation); From da8f424eb055ea35f72fea0afd460d4fd3a5ae18 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:03:46 +0800 Subject: [PATCH 014/132] =?UTF-8?q?=E8=84=B1=E6=95=8F=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E9=94=99=E8=AF=AF=E4=B8=8A=E6=8A=A5=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 错误池消息与 stack 统一隐藏凭据、URL、路径和长标识 补充错误上报敏感正文回归 --- .../src/services/errorReporting.ts | 8 +++++-- .../tests/errorReporting.test.ts | 22 ++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/services/errorReporting.ts b/apps/ai-game-creator-shell/src/services/errorReporting.ts index 08d8d53da..7e7368b66 100644 --- a/apps/ai-game-creator-shell/src/services/errorReporting.ts +++ b/apps/ai-game-creator-shell/src/services/errorReporting.ts @@ -106,8 +106,12 @@ export async function captureClientError( } else { errorValue = new Error(String(error)); } - const message = errorValue.message || '未知客户端错误'; - const stack = errorValue.stack?.slice(0, 8_000); + const message = + normalizeDiagnosticText(errorValue.message || '').slice(0, 400) || + '未知客户端错误'; + const stack = errorValue.stack + ? normalizeDiagnosticText(errorValue.stack).slice(0, 8_000) + : undefined; return reportClientError({ source: context.source ?? 'client', message, diff --git a/apps/ai-game-creator-shell/tests/errorReporting.test.ts b/apps/ai-game-creator-shell/tests/errorReporting.test.ts index 104ea9fc0..707d99f9a 100644 --- a/apps/ai-game-creator-shell/tests/errorReporting.test.ts +++ b/apps/ai-game-creator-shell/tests/errorReporting.test.ts @@ -122,6 +122,26 @@ describe('客户端错误报告池', () => { }); }); + it('错误池保留状态和正文但不携带凭据、URL 或本地路径', async () => { + await captureClientError( + new Error( + 'HTTP 502 upstream overloaded;Authorization: Bearer fixture-secret;https://provider.example/private C:\\Users\\demo\\game', + ), + { source: 'test' }, + ); + + const [, args] = + vi + .mocked(invoke) + .mock.calls.find(([command]) => command === 'report_client_error') ?? + []; + const serialized = JSON.stringify(args); + expect(serialized).toContain('HTTP 502 upstream overloaded'); + expect(serialized).not.toContain('fixture-secret'); + expect(serialized).not.toContain('provider.example'); + expect(serialized).not.toContain('C:\\Users\\demo'); + }); + it('限制当前进程错误池最多保留 100 条', async () => { for (let index = 0; index < 101; index += 1) { await captureClientError(new Error(`错误 ${index}`), { source: 'test' }); @@ -243,7 +263,7 @@ describe('客户端错误报告池', () => { expect(invoke).toHaveBeenCalledWith('report_client_error', { source: 'unhandledrejection', message: 'IPC 桥接异常', - stack: original.stack, + stack: normalizeDiagnosticText(original.stack ?? ''), action: undefined, page: undefined, }); From 00af3d9c88e0d40b5f27b34578d88b3a898afcc0 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:13:18 +0800 Subject: [PATCH 015/132] =?UTF-8?q?=E4=BF=9D=E7=95=99=20CLI=20=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E8=A7=A3=E6=9E=90=E9=94=99=E8=AF=AF=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐 Codex CLI 与 Claude sidecar 失败终态和无效 JSON 细节 添加脱敏协议错误回归 --- .../src-tauri/src/agent/claude_code_cli.rs | 13 +++-- .../src-tauri/src/agent/codex_cli.rs | 50 +++++++++++++++++-- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...实施计划】AGC错误具体文本展示-2026-10-04.md | 1 + 4 files changed, 56 insertions(+), 9 deletions(-) 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 ba60c1c92..1f5b65e88 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 @@ -290,10 +290,15 @@ async fn run_sidecar_turn( if line.trim().is_empty() { continue; } - let value: serde_json::Value = serde_json::from_str(&line).map_err(|_| { - SidecarTurnFailure::message( - "Claude Agent SDK sidecar 输出不是有效 JSON".to_string(), - ) + let value: serde_json::Value = serde_json::from_str(&line).map_err(|error| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &line, + 480, + ); + SidecarTurnFailure::message(format!( + "Claude Agent SDK sidecar 输出不是有效 JSON:{error};原文={detail}" + )) })?; match value.get("type").and_then(serde_json::Value::as_str) { Some("event") => { 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 62fe0f7bc..bbe154ba8 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 @@ -543,9 +543,14 @@ fn parse_game_creator_codex_cli_response( if line.trim().is_empty() { continue; } - let event = serde_json::from_str::(line).map_err(|_| { + let event = serde_json::from_str::(line).map_err(|error| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + line, + 480, + ); platform_llm::LlmError::Deserialize(format!( - "Codex CLI Agent JSONL 第 {} 行无效", + "Codex CLI Agent JSONL 第 {} 行无效:{error};原文={detail}", index.saturating_add(1) )) })?; @@ -587,9 +592,21 @@ fn parse_game_creator_codex_cli_response( } } Some("turn.failed") => { - return Err(platform_llm::LlmError::Transport( - "Codex CLI Agent 返回失败终态".to_string(), - )); + let detail = event + .get("error") + .or_else(|| event.get("message")) + .map(serde_json::Value::to_string) + .unwrap_or_default(); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + detail.trim_matches('"'), + 480, + ); + return Err(platform_llm::LlmError::Transport(if detail.is_empty() { + "Codex CLI Agent 返回失败终态".to_string() + } else { + format!("Codex CLI Agent 返回失败终态:{detail}") + })); } _ => {} } @@ -1219,6 +1236,29 @@ mod tests { ); } + #[test] + fn codex_cli_mode_preserves_failed_terminal_and_invalid_json_details() { + let request = LlmRunRequest::single_turn("系统", "任务"); + let failed = parse_game_creator_codex_cli_response( + b"{\"type\":\"turn.failed\",\"error\":{\"message\":\"HTTP 502 upstream overloaded\",\"api_key\":\"fixture-secret\"}}\n", + &request, + ) + .expect_err("failed terminal must be returned as an error"); + let failed_text = failed.to_string(); + assert!(failed_text.contains("HTTP 502 upstream overloaded")); + assert!(!failed_text.contains("fixture-secret")); + + let invalid = parse_game_creator_codex_cli_response( + b"{not-json Authorization: Bearer fixture-secret}\n", + &request, + ) + .expect_err("invalid JSONL must preserve a bounded safe line detail"); + let invalid_text = invalid.to_string(); + assert!(invalid_text.contains("JSONL 第 1 行无效")); + assert!(invalid_text.contains("原文=")); + assert!(!invalid_text.contains("fixture-secret")); + } + #[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 0c8b2a1e7..1390fd2d7 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -107,6 +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` 或缺失终态统一压成单一类别。 - 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 ead45363b..76da95c54 100644 --- a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md @@ -17,6 +17,7 @@ Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md` 8. 项目资源编辑/上传/预览/恢复队列/生成结果回读的 UI 错误统一使用 `normalizeDiagnosticText`,不再直出 `Error.message`。 9. 应用壳、运行配置、认证状态、插件启动、运行预览和策划会话的非结构化错误统一使用 `visibleClientErrorMessage`。 10. 资产导入、邀请码、策划工作区、发布封面/截图和素材命令的 UI 错误沿用同一共享出口。 +11. Codex CLI / Claude sidecar 的解析失败与失败终态保留有界脱敏正文,避免协议错误只剩类别码。 ## 实现顺序 From ad23dbf5549e53dc61a0ed6b1192563f25ee3a28 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:37:37 +0800 Subject: [PATCH 016/132] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Mac=20release=20?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E7=9A=84=20Git=20=E5=BF=83=E8=B7=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 macOS Jenkins Checkout 增加 SSH 保活与 fetch 心跳。\n增加 fetch 超时诊断并补充生产运维门禁。\n更新 Mac 构建运维说明。 --- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 + ...kinsfile.ai-game-creator-shell-macos-build | 38 ++++++++++++++++++- scripts/check-production-ops-guardrails.mjs | 22 +++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 73b6b7d87..dc13e3894 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -908,6 +908,8 @@ Mac 单架构(arm64)构建脚本位于 `jenkins/Jenkinsfile.ai-game-creator- Job 名为 `Genarrative-Agc-MacOS-Build`,SCM 直接读取仓库内上述 Jenkinsfile,参数为 `SOURCE_BRANCH`、`COMMIT_HASH`、`AGC_UPDATE_CHANNEL`、`AGC_RELEASE_VERSION`、`AGC_RELEASE_DRY_RUN`、`AGC_UPDATE_RELEASE_NOTES`、`OSSUTIL_BIN`、`CARGO_BUILD_JOBS`、`NOTIFICATION_EMAILS`。渠道参数是基础名(不含系统,默认 `dev`),脚本不接受 `dev-mac` 这类系统后缀,写入分区固定推导为 `-mac`:这与 Windows Job 的 `-win` 对称,也延续已发布客户端的端点。 +Checkout 阶段的远端 `git fetch` 必须带 SSH `ConnectTimeout`、`ServerAliveInterval`/`ServerAliveCountMax`,并在前台以 20 秒间隔输出心跳;Mac agent 通过 WebSocket 接入 Jenkins,长时间没有 stdout 会被 Jetty idle timeout 断开,日志通常表现为 `ClosedChannelException`,不能误判成 Rust 或签名构建失败。心跳包装还设置 15 分钟可配置上限 `AGC_GIT_FETCH_TIMEOUT_SECONDS`,超时会输出 fetch 诊断并失败关闭。 + 该 Job 的职责是构建并发布 `-mac` 分区更新:执行 `npm ci` 后使用锁文件校验并补齐两种 macOS Codex 原生依赖,再调用 `scripts/build-macos-ci.mjs`(AGC 应用目录下)生成 arm64 单架构 app、arm64 隔离 smoke、arm64 DMG(`<产品名>_<版本>_aarch64.dmg`)与分区清单 `latest.json`,用产物内烘焙的公钥复核更新包签名(`verify-updater-signature.mjs`),最后按 `AGC_RELEASE_DRY_RUN` 决定是否上传 OSS。产物名(`*.app`、updater 归档、DMG、卷名)一律从渠道安装身份派生的产品名推导(`dev` 为开发版,`release` 复用正式产品名,自定义渠道带渠道后缀),校验脚本从包内 `Info.plist` 读取可执行名,改产品名或换渠道都不会让入口静默找错对象;隔离 smoke 用 `ditto --clone` 复制副本(实测整轮 6.8 秒,此前整包复制约 1 分钟),并在构建前删除本次将写出的 DMG/更新包/签名,保证归档产物一定来自本次构建。归档限 `artifacts/` 下的 DMG、SHA-256、`latest.json`、更新包签名、更新摘要、非敏感构建清单和源码 commit;不归档用户 HOME、Jenkins secret、原始工作目录或全量日志。 发布凭据全部走 Jenkins 全局凭据,并在 `withCredentials` 内注入当前进程:`AgcUpdaterSigningKey`(与 `AgcUpdaterSigningKeyPassword`)映射为 `TAURI_SIGNING_PRIVATE_KEY` / `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`,`AliyunAccessKeyId` / `AliyunaccessKeySecret` 映射为 `AGC_OSS_ACCESS_KEY_ID` / `AGC_OSS_ACCESS_KEY_SECRET`;私钥与凭据不写入 workspace、日志或归档产物。上传顺序为更新包、签名、首装包,三者全部成功后才覆盖 `agc/-mac/latest.json` 指针;`AGC_RELEASE_DRY_RUN` 与 Windows 渠道对称:默认关闭即真发布,勾选后才退化为演练(只打印将上传的对象、不写任何 OSS 对象)。本 Job 是正式发布入口,调度器在发号后与 Windows 一起触发它,并额外传 `SKIP_IF_SUPERSEDED=true`——Mac 节点是日常办公机,离线期间排队的旧构建在节点回来后若已被源码分支推进,直接跳过而不发布过期版本。Mac 节点需要 `ossutil`(实测 1.7.19 原生 arm64 可用,装在 `~/.local/bin`,已在 Job 的 PATH 内),可用 `OSSUTIL_BIN` 指定命令名或绝对路径。首次发布建议显式指定 `AGC_RELEASE_VERSION`,避免按渠道高水位递增时出现版本链回退。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build index ae31d1ad9..f56cee042 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build @@ -62,13 +62,47 @@ pipeline { SOURCE_BRANCH="${SOURCE_BRANCH:-master}" COMMIT_HASH="${COMMIT_HASH:-}" SKIP_IF_SUPERSEDED="${SKIP_IF_SUPERSEDED:-false}" - export GIT_SSH_COMMAND="ssh -i \\"$GIT_SSH_KEY\\" -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" + # Mac 节点通过 WebSocket 接入 Jenkins;远端 Git fetch 长时间没有 stdout 时, + # Jenkins/Jetty 的 idle timeout 会先断开 agent channel,留下 ClosedChannelException。 + # SSH 自身开启保活,并由前台心跳包装 fetch,避免把网络等待误判成 agent 失联。 + export GIT_SSH_COMMAND="ssh -i \\"$GIT_SSH_KEY\\" -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=30 -o ServerAliveInterval=20 -o ServerAliveCountMax=6" if [ ! -d .git ]; then git init git remote add origin "$GIT_REMOTE_URL" fi test "$(git remote get-url origin)" = "$GIT_REMOTE_URL" - git fetch --no-tags origin "+refs/heads/$SOURCE_BRANCH:refs/remotes/origin/$SOURCE_BRANCH" + fetch_log="$(mktemp "${TMPDIR:-/tmp}/agc-git-fetch.XXXXXX")" + fetch_pid="" + cleanup_fetch() { + if [ -n "${fetch_pid}" ] && kill -0 "${fetch_pid}" 2>/dev/null; then + kill "${fetch_pid}" 2>/dev/null || true + wait "${fetch_pid}" 2>/dev/null || true + fi + rm -f "${fetch_log}" + } + trap cleanup_fetch EXIT INT TERM + git fetch --no-tags origin "+refs/heads/$SOURCE_BRANCH:refs/remotes/origin/$SOURCE_BRANCH" >"${fetch_log}" 2>&1 & + fetch_pid=$! + fetch_started_at="$(date +%s)" + fetch_timeout_seconds="${AGC_GIT_FETCH_TIMEOUT_SECONDS:-900}" + while kill -0 "${fetch_pid}" 2>/dev/null; do + fetch_elapsed=$(( $(date +%s) - fetch_started_at )) + if [ "${fetch_elapsed}" -ge "${fetch_timeout_seconds}" ]; then + echo "[agc-macos] git fetch 超时(${fetch_timeout_seconds}s),终止并输出诊断:${SOURCE_BRANCH}" + kill "${fetch_pid}" 2>/dev/null || true + wait "${fetch_pid}" 2>/dev/null || true + cat "${fetch_log}" + exit 1 + fi + echo "[agc-macos] git fetch 仍在进行(${fetch_elapsed}s)" + sleep 20 + done + if ! wait "${fetch_pid}"; then + cat "${fetch_log}" + exit 1 + fi + cat "${fetch_log}" + fetch_pid="" ref="refs/remotes/origin/$SOURCE_BRANCH" if [ -n "$COMMIT_HASH" ]; then case "$COMMIT_HASH" in *[!0-9a-fA-F]* ) echo 'COMMIT_HASH 必须为十六进制'; exit 1;; esac diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index d412db2c3..37ecd3ce6 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -7725,6 +7725,28 @@ for (const [file, content] of [ } } +for (const [snippet, reason] of [ + [ + '-o ServerAliveInterval=20 -o ServerAliveCountMax=6', + 'Mac AGC Checkout 的 Git SSH 必须启用保活,避免 WebSocket idle timeout 断开 agent。', + ], + [ + 'echo "[agc-macos] git fetch 仍在进行', + 'Mac AGC Checkout 的 git fetch 必须持续输出心跳,避免长时间无 stdout 被误判为 agent 失联。', + ], + [ + 'AGC_GIT_FETCH_TIMEOUT_SECONDS', + 'Mac AGC Checkout 的 git fetch 必须有可配置的超时和失败诊断。', + ], +]) { + if (!agcMacosPipelineContent.includes(snippet)) { + failed = true; + console.error( + `[check:production-ops] jenkins/Jenkinsfile.ai-game-creator-shell-macos-build ${reason}`, + ); + } +} + // 更新签名私钥只允许经 Jenkins 凭据绑定注入构建进程:既不得把凭据本身写进 Jenkinsfile, // 也不得回显到日志或进归档(对应「AGC 更新发布管线渠道化」第 5 条的可静态复核部分)。 for (const [file, content] of [ From a56859ff98e6fe6bce26b8097186dc28eab41c6b Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:14:08 +0800 Subject: [PATCH 017/132] =?UTF-8?q?=E6=94=B6=E6=95=9B=20CLI=20=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E9=94=99=E8=AF=AF=E5=85=B7=E4=BD=93=E6=96=87=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留 Claude sidecar 的解析、失败终态和退出状态 保留 Codex CLI 的失败事件与结构化输出 detail 补回 cc 超时和空回执的统一错误正文 补充脱敏协议错误回归测试与错误处理文档 --- .../src-tauri/src/agent.rs | 2 +- .../src-tauri/src/agent/claude_code_cli.rs | 206 +++++++++++++++--- .../src/agent/codex_app_server/mod.rs | 4 +- .../src-tauri/src/agent/codex_cli.rs | 109 +++++++-- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 2 +- ...实施计划】AGC错误具体文本展示-2026-10-04.md | 2 +- 6 files changed, 267 insertions(+), 58 deletions(-) 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` 继续携带原始原因。 ## 实现顺序 From 0956ab0213b96d08bb2a784e37c7d089efcd06a9 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:44:39 +0800 Subject: [PATCH 018/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E9=9D=9E=E5=9B=9E=E5=90=88=E9=94=99=E8=AF=AF=E5=85=B7=E4=BD=93?= =?UTF-8?q?=E6=96=87=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留策划、External Editor、账户和发布错误正文 补充模型目录、工具桥、素材上传与错误报告网络因链 修复客户端登录和 UI 保存的具体错误丢失 更新错误展示口径与回归测试 --- .../src-tauri/src/account_api.rs | 72 +++++++++++++++---- .../src-tauri/src/agent/design_runtime.rs | 20 ++++-- .../src-tauri/src/agent/direct_tools_mcp.rs | 8 +-- .../src/agent/generation/canvas_generation.rs | 40 ++++++++++- .../agent/generation/loop_orchestration.rs | 21 +++++- .../src-tauri/src/agent/tool/error.rs | 12 ++-- .../src-tauri/src/assets.rs | 2 +- .../src-tauri/src/config.rs | 26 ++++--- .../src-tauri/src/error_report/submit.rs | 6 +- .../src/game_distribution_publish.rs | 68 ++++++++++++++---- .../src-tauri/src/game_package_upload.rs | 10 ++- .../src-tauri/src/llm_catalog.rs | 16 ++--- .../src-tauri/src/platform_asset_upload.rs | 12 ++-- .../src/project/resource_editor/error.rs | 20 +++--- .../src/app/AuthenticatedClient.tsx | 49 ++++++++++--- .../chat/conversation/directTurnFailure.ts | 2 +- .../src/view/ui-editor/useUiEditorPage.ts | 14 ++-- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...实施计划】AGC错误具体文本展示-2026-10-04.md | 1 + 19 files changed, 295 insertions(+), 105 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/account_api.rs b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs index 9c2caee3e..f9204a992 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/account_api.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs @@ -15,7 +15,7 @@ use shared_contracts::runtime::{ ProfileRechargeCenterResponse, ProfileReferralInviteCenterResponse, ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse, }; -use std::time::Duration; +use std::{path::Path, time::Duration}; use url::Url; const HTTP_TIMEOUT: Duration = Duration::from_secs(30); @@ -36,7 +36,35 @@ fn build_client() -> Result { .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() - .map_err(|_| "创建账户网络客户端失败".to_string()) + .map_err(|error| format!("创建账户网络客户端失败:{error}")) +} + +fn describe_account_request_failure(error: &reqwest::Error) -> String { + let kind = if error.is_timeout() { + "请求超时" + } else if error.is_connect() { + "连接失败" + } else if error.is_body() { + "响应正文读取失败" + } else if error.is_request() { + "请求发送失败" + } else { + "网络请求失败" + }; + let mut causes = Vec::new(); + let mut source = std::error::Error::source(error); + while let Some(current) = source { + let text = current.to_string(); + if !text.is_empty() && !causes.contains(&text) { + causes.push(text); + } + source = current.source(); + } + if causes.is_empty() { + kind.to_string() + } else { + format!("{kind}:{}", causes.join(" → ")) + } } fn endpoint(snapshot: &PlatformSessionSnapshot, segments: &[&str]) -> Result { @@ -86,6 +114,21 @@ fn error_code(body: &str) -> Option { error_field(body, "code") } +fn safe_error_body_detail(body: &str) -> Option { + let value = serde_json::from_str::(body).ok(); + let detail = value + .as_ref() + .and_then(|value| value.get("error").or(Some(value))) + .and_then(|value| serde_json::to_string(value).ok()) + .or_else(|| (!body.trim().is_empty()).then(|| body.trim().to_string()))?; + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 600, + ); + (!detail.trim().is_empty()).then_some(detail) +} + fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { crate::platform_maintenance::watch_platform_response(status.as_u16(), body); if status == StatusCode::UNAUTHORIZED { @@ -94,22 +137,26 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { if status == StatusCode::FORBIDDEN { return format!( "permission-denied: {}", - error_message(body).unwrap_or_else(|| "当前账号无权执行此操作".to_string()) + error_message(body) + .or_else(|| safe_error_body_detail(body)) + .unwrap_or_else(|| "当前账号无权执行此操作".to_string()) ); } let detail = error_message(body) .or_else(|| error_code(body)) + .or_else(|| safe_error_body_detail(body)) .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); format!("{fallback}:{detail}") } fn unwrap_envelope(body: &str, fallback: &str) -> Result { - let value: Value = - serde_json::from_str(body).map_err(|_| format!("{fallback}:服务端响应不是合法 JSON"))?; + let value: Value = serde_json::from_str(body) + .map_err(|error| format!("{fallback}:服务端响应不是合法 JSON:{error}"))?; if value.get("ok").and_then(Value::as_bool) == Some(false) { let detail = error_message(body) .or_else(|| error_code(body)) - .unwrap_or_else(|| "服务器未返回错误信息".to_string()); + .or_else(|| safe_error_body_detail(body)) + .unwrap_or_else(|| "服务端返回 ok=false,但未提供 error/code/message 详情".to_string()); return Err(format!("{fallback}:{detail}")); } Ok(value.get("data").cloned().unwrap_or(value)) @@ -157,22 +204,21 @@ async fn request_json( request = request.json(&body); } let response = request.send().await.map_err(|error| { - if error.is_timeout() { - format!("{fallback}:请求超时,请稍后重试") - } else { - format!("{fallback}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试") - } + format!( + "{fallback}:{};请确认配套后端或 API 代理已启动后重试", + describe_account_request_failure(&error) + ) })?; let status = response.status(); let text = response .text() .await - .map_err(|_| format!("{fallback}:读取响应失败"))?; + .map_err(|error| format!("{fallback}:读取响应失败:{error}"))?; if !status.is_success() { return Err(map_http_error(status, &text, fallback)); } let data = unwrap_envelope(&text, fallback)?; - serde_json::from_value(data).map_err(|_| format!("{fallback}:响应格式无效")) + serde_json::from_value(data).map_err(|error| format!("{fallback}:响应格式无效:{error}")) } #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index 3f8c99995..53441925d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -1247,8 +1247,17 @@ async fn finish_design_command( // panic 负载可能包含路径或内容片段,公开文案固定;位置和负载由 panic hook 写进私有 // design_debug(task-local 提供项目根),不进入用户可见消息。 -fn design_panic_error(_payload: Box) -> String { - DESIGN_PANIC_PUBLIC_ERROR.to_string() +fn design_panic_error(payload: Box) -> String { + let raw = payload + .downcast_ref::<&str>() + .map(|value| (*value).to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_default(); + let detail = redact_agent_runtime_error(Path::new("__agc_no_project_root__"), &raw, 600); + if detail.trim().is_empty() { + return DESIGN_PANIC_PUBLIC_ERROR.to_string(); + } + format!("策划运行发生内部错误:{detail};本轮已中断,可直接重试;若反复出现请反馈。") } #[cfg(test)] @@ -2808,10 +2817,9 @@ mod tests { .expect("panic 必须转成可恢复视图而不是向上传播"); assert!(!view.running); assert!(view.can_retry); - assert_eq!( - view.session.last_error.as_deref(), - Some(DESIGN_PANIC_PUBLIC_ERROR) - ); + let last_error = view.session.last_error.as_deref().expect("panic detail"); + assert!(last_error.contains("注入的策划工具 panic"), "{last_error}"); + assert!(last_error.contains("本轮已中断"), "{last_error}"); let session = read_design_session(&root) .expect("read session") .expect("session exists"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 28aded7d3..51c33c5f2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -910,7 +910,7 @@ fn build_direct_tool_bridge_client() -> Result { .timeout(std::time::Duration::from_secs(1_800)) .redirect(reqwest::redirect::Policy::none()) .build() - .map_err(|_| "创建客户端工具桥连接失败".to_string()) + .map_err(|error| format!("创建客户端工具桥连接失败:{error}")) } async fn call_client_tool_bridge(tool: &str, arguments: &Value) -> Value { @@ -922,7 +922,7 @@ async fn call_client_tool_bridge(tool: &str, arguments: &Value) -> Value { .json(&json!({ "tool": tool, "arguments": arguments })) .send() .await - .map_err(|_| "连接客户端受控工具桥失败".to_string())?; + .map_err(|error| format!("连接客户端受控工具桥失败:{error}"))?; if !response.status().is_success() { return Err(format!( "客户端受控工具桥返回 HTTP {}", @@ -938,12 +938,12 @@ async fn call_client_tool_bridge(tool: &str, arguments: &Value) -> Value { let bytes = response .bytes() .await - .map_err(|_| "读取客户端受控工具桥响应失败".to_string())?; + .map_err(|error| format!("读取客户端受控工具桥响应失败:{error}"))?; if bytes.len() > DIRECT_TOOLS_MCP_MAX_BRIDGE_RESPONSE_BYTES { return Err("客户端受控工具桥响应超过大小上限".to_string()); } let value = serde_json::from_slice::(&bytes) - .map_err(|_| "客户端受控工具桥响应格式无效".to_string())?; + .map_err(|error| format!("客户端受控工具桥响应格式无效:{error}"))?; if !value.is_object() || !value.get("content").is_some_and(Value::is_array) || !value.get("isError").is_some_and(Value::is_boolean) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 245491074..96181bc03 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -1144,6 +1144,27 @@ fn collect_external_http_error_fields(value: &serde_json::Value, output: &mut Ve } } +fn summarize_external_generation_failure(generation: &serde_json::Value) -> String { + let source = generation.get("error").unwrap_or(generation); + let mut fields = Vec::new(); + collect_external_http_error_fields(source, &mut fields); + let detail = if fields.is_empty() { + match source { + serde_json::Value::String(value) => value.trim().to_string(), + _ => serde_json::to_string(source).unwrap_or_default(), + } + } else { + fields.join(";") + }; + let detail = redact_secret_tokens(&redact_absolute_path_tokens(&detail)); + let detail = detail.trim().chars().take(600).collect::(); + if detail.is_empty() { + "平台图片生成任务返回 failed,但响应缺少 error 详情".to_string() + } else { + detail + } +} + pub(crate) fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 { external_editor_response_data(payload) .get("pollAfterMs") @@ -1273,8 +1294,7 @@ async fn wait_for_external_generation_result_inner( return Ok(result); } Some("failed") => { - let error = json_string_field(generation, "error") - .unwrap_or_else(|| "服务器未返回错误信息".to_string()); + let error = summarize_external_generation_failure(generation); return Err(format!( "平台图片生成任务失败:{error};operationId={operation_id}" )); @@ -8523,6 +8543,21 @@ mod canvas_generation_tests { ColorType, ImageEncoder, }; + #[test] + fn failed_generation_without_error_field_keeps_response_envelope_detail() { + let detail = summarize_external_generation_failure(&serde_json::json!({ + "status": "failed", + "code": "provider-rejected", + "message": "上游拒绝请求", + "operationId": "private-operation-id", + "api_key": "private-key" + })); + assert!(detail.contains("provider-rejected"), "{detail}"); + assert!(detail.contains("上游拒绝请求"), "{detail}"); + assert!(!detail.contains("private-operation-id"), "{detail}"); + assert!(!detail.contains("private-key"), "{detail}"); + } + #[test] fn generation_kind_catalog_accepts_only_canonical_manifest_kinds() { assert_eq!( @@ -12751,6 +12786,7 @@ mod canvas_generation_tests { .await; server.join().expect("join accepted failure fixture"); assert!(error.contains("平台图片生成任务失败"), "{error}"); + assert!(error.contains("provider rejected request"), "{error}"); assert!(request_receiver .recv_timeout(Duration::from_secs(1)) .expect("failed operation request") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs index de0390d4f..c8612377b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs @@ -1,4 +1,5 @@ use super::*; +use std::path::Path; pub(crate) struct AgentProgressEmitter<'a> { app: &'a tauri::AppHandle, @@ -104,12 +105,30 @@ pub(crate) fn game_creator_agent_llm_error_public_summary( platform_llm::LlmError::Deserialize(_) => ("deserialize".to_string(), None), }; let raw = error.to_string(); + let detail = redact_agent_runtime_error(Path::new("__agc_no_project_root__"), &raw, 1200); let http_status = http_status .map(|status| format!(" httpStatus={status}")) .unwrap_or_default(); format!( - "kind={kind}{http_status} fingerprint={:x} chars={}", + "kind={kind}{http_status} detail={detail} fingerprint={:x} chars={}", Sha256::digest(raw.as_bytes()), raw.chars().count() ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_summary_keeps_safe_error_detail() { + let summary = + game_creator_agent_llm_error_public_summary(&platform_llm::LlmError::Upstream { + status_code: 502, + message: "upstream overloaded; operationId=private-op; api_key=private-key".into(), + }); + assert!(summary.contains("kind=upstream-502"), "{summary}"); + assert!(summary.contains("upstream overloaded"), "{summary}"); + assert!(!summary.contains("private-key"), "{summary}"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs index 2606a2a68..1a45e4235 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs @@ -185,8 +185,8 @@ impl ToolFailure for ResourceCompletionRejection { /// 通道,平台原文必须作为数据原样带出(不是在产生点拼进前缀)。这里只负责记录平台给的事实, /// 前缀由使用它的工具在自己的变体里加,两个工具共用这一份载体。 /// -/// `to_user_msg` 只给平台 `error` 原文,平台没给就回「服务器未返回错误信息」。`phase_detail` -/// 是结构化字段,只进诊断 sidecar 给开发者/LLM 看,不参与用户文案。 +/// `to_user_msg` 优先给平台 `error` 原文,缺失时展示同一终态的 `phase_detail`;两者都没有 +/// 才说明响应没有提供失败正文。结构化字段也会留在诊断 sidecar。 #[derive(serde::Serialize, Debug)] pub(crate) struct RemoteResourceEditFailure { /// 平台 `error` 字段原文;平台没给时为空。 @@ -197,10 +197,10 @@ pub(crate) struct RemoteResourceEditFailure { impl ToolFailure for RemoteResourceEditFailure { fn to_user_msg(&self) -> String { - match &self.server_message { - Some(server_message) => server_message.clone(), - None => "服务器未返回错误信息".to_string(), - } + self.server_message + .clone() + .or_else(|| self.phase_detail.clone()) + .unwrap_or_else(|| "平台已返回失败终态,但没有提供 error 或 phaseDetail".to_string()) } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index c45f849b8..5a3724c90 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -419,7 +419,7 @@ async fn create_private_external_editor_api_credentials_from_platform_session( let payload = response .json::() .await - .map_err(|_| "创建本机陶泥儿开发者 Key 响应格式无效".to_string())?; + .map_err(|error| format!("创建本机陶泥儿开发者 Key 响应格式无效:{error}"))?; let api_key = payload .get("apiKey") .or_else(|| payload.pointer("/data/apiKey")) diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 05f66dd3b..3e224b6ca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -4432,30 +4432,28 @@ pub(crate) async fn fetch_custom_llm_models( .redirect(reqwest::redirect::Policy::none()) .timeout(std::time::Duration::from_secs(15)) .build() - .map_err(|_| "初始化模型列表请求失败".to_string())?; + .map_err(|error| format!("初始化模型列表请求失败:{error}"))?; let mut response = client .get(url) .bearer_auth(llm.api_key.trim()) .send() .await - .map_err(|error| { - if error.is_timeout() { - "模型列表请求超时,请重试".to_string() - } else { - "无法连接模型端点,请检查 API 地址和网络".to_string() - } - })?; + .map_err(|error| format!("无法连接模型端点:{error};请检查 API 地址和网络"))?; if !response.status().is_success() { - return Err(format!( - "模型列表读取失败(HTTP {})", - response.status().as_u16() - )); + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + let detail = body.trim().chars().take(600).collect::(); + return Err(if detail.is_empty() { + format!("模型列表读取失败(HTTP {})", status.as_u16()) + } else { + format!("模型列表读取失败(HTTP {}):{detail}", status.as_u16()) + }); } let mut body = Vec::new(); while let Some(chunk) = response .chunk() .await - .map_err(|_| "读取模型列表响应失败或超时".to_string())? + .map_err(|error| format!("读取模型列表响应失败或超时:{error}"))? { if body.len() + chunk.len() > 1024 * 1024 { return Err("模型列表响应超过 1 MiB 上限".to_string()); @@ -4471,7 +4469,7 @@ pub(crate) async fn fetch_custom_llm_models( data: Vec, } let models: Models = serde_json::from_slice(&body) - .map_err(|_| "模型端点需返回 OpenAI 兼容的 data[].id 列表".to_string())?; + .map_err(|error| format!("模型端点需返回 OpenAI 兼容的 data[].id 列表:{error}"))?; let ids = models .data .into_iter() diff --git a/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs b/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs index 54698314f..33f6cb46f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs @@ -134,7 +134,7 @@ pub async fn submit_error_report( .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() - .map_err(|_| "创建错误报告客户端失败".to_string())?; + .map_err(|error| format!("创建错误报告客户端失败:{error}"))?; validate_session(&snapshot)?; let response = client .post(endpoint(&snapshot)?) @@ -144,12 +144,12 @@ pub async fn submit_error_report( .json(&payload) .send() .await - .map_err(|_| "错误报告提交失败:无法连接登录服务,请稍后重试".to_string())?; + .map_err(|error| format!("错误报告提交失败:无法连接登录服务:{error}"))?; let status = response.status(); let body = response .text() .await - .map_err(|_| "错误报告提交失败:读取响应失败".to_string())?; + .map_err(|error| format!("错误报告提交失败:读取响应失败:{error}"))?; validate_session(&snapshot)?; if !status.is_success() { return Err(response_error(status, &body)); diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index b3ee96410..d3b2c9436 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -315,6 +315,14 @@ fn parse_error_payload(body: &str) -> (Option, Option) { .get("message") .and_then(Value::as_str) .map(str::to_string); + let message = message.or_else(|| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); + (!detail.trim().is_empty()).then_some(detail) + }); (code, message) } @@ -324,7 +332,7 @@ fn response_data(body: &str) -> Result { if value.get("ok").and_then(Value::as_bool) == Some(false) { let (code, message) = parse_error_payload(body); return Err(server_error_detail(code, message, || { - "服务器未返回错误信息".to_string() + "服务端响应 ok=false,但未提供 error/code/message 详情".to_string() })); } Ok(value.get("data").cloned().unwrap_or(value)) @@ -397,11 +405,10 @@ async fn request_json( request = request.header(CONTENT_TYPE, "application/json").json(&body); } let response = request.send().await.map_err(|error| { - if error.is_timeout() { - format!("{fallback}:请求超时,请稍后重试") - } else { - format!("{fallback}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试") - } + format!( + "{fallback}:{};请确认配套后端或 API 代理已启动后重试", + describe_distribution_request_failure(&error) + ) })?; let status = response.status(); let text = response @@ -422,6 +429,34 @@ fn build_client() -> Result { .map_err(|error| format!("创建陶泥儿网络客户端失败:{error}")) } +fn describe_distribution_request_failure(error: &reqwest::Error) -> String { + let kind = if error.is_timeout() { + "请求超时" + } else if error.is_connect() { + "连接失败" + } else if error.is_body() { + "响应正文读取失败" + } else if error.is_request() { + "请求发送失败" + } else { + "网络请求失败" + }; + let mut causes = Vec::new(); + let mut source = std::error::Error::source(error); + while let Some(current) = source { + let text = current.to_string(); + if !text.is_empty() && !causes.contains(&text) { + causes.push(text); + } + source = current.source(); + } + if causes.is_empty() { + kind.to_string() + } else { + format!("{kind}:{}", causes.join(" → ")) + } +} + fn normalized_origin(value: &str) -> String { value.trim().trim_end_matches('/').to_string() } @@ -467,11 +502,10 @@ async fn request_json_for_read( ) .timeout(HTTP_TIMEOUT); let response = request.send().await.map_err(|error| { - GameDistributionReadError::Unavailable(if error.is_timeout() { - format!("{fallback}:请求超时,请稍后重试") - } else { - format!("{fallback}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试") - }) + GameDistributionReadError::Unavailable(format!( + "{fallback}:{};请确认配套后端或 API 代理已启动后重试", + describe_distribution_request_failure(&error) + )) })?; let status = response.status(); let text = response.text().await.map_err(|error| { @@ -1008,7 +1042,11 @@ async fn resolve_cover_queue( .error .clone() .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "服务器未返回错误信息".to_string())); + .unwrap_or_else(|| { + format!( + "封面生成任务返回 failed,但没有提供 error 详情;operationId={operation_id}" + ) + })); } return Ok(initial); } @@ -1041,7 +1079,11 @@ async fn resolve_cover_queue( return Err(status .error .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "服务器未返回错误信息".to_string())); + .unwrap_or_else(|| { + format!( + "封面生成任务返回 failed,但没有提供 error 详情;operationId={operation_id}" + ) + })); } if status.status == "completed" { let result = status.result.unwrap_or(Value::Null); diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs index d90d18c3d..679ed41f5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs @@ -129,7 +129,15 @@ fn parse_server_error(status: u16, body: &str) -> (Option, Option LlmModelsResponse { fn parse_catalog_payload(bytes: &[u8]) -> Result { let value: Value = serde_json::from_slice(bytes) - .map_err(|_| "模型列表响应不是有效 JSON,请稍后重试".to_string())?; + .map_err(|error| format!("模型列表响应不是有效 JSON:{error}"))?; // The platform API normally returns the versioned response envelope. Keep // accepting the raw payload for older dev proxies and local fixtures, but // never accept an error envelope as a successful catalog. if value.get("ok").is_some() { let envelope: ApiEnvelope = serde_json::from_value(value) - .map_err(|_| "模型列表响应格式无效,请稍后重试".to_string())?; + .map_err(|error| format!("模型列表响应格式无效:{error}"))?; if !envelope.ok { let message = envelope .error @@ -104,7 +104,7 @@ fn parse_catalog_payload(bytes: &[u8]) -> Result { serde_json::from_value::(value) .map(map_catalog) - .map_err(|_| "模型列表响应格式无效,请稍后重试".to_string()) + .map_err(|error| format!("模型列表响应格式无效:{error}")) } async fn read_bounded_body( @@ -123,7 +123,7 @@ async fn read_bounded_body( .next() .await .transpose() - .map_err(|_| "读取模型列表响应失败或超时,请重试".to_string())? + .map_err(|error| format!("读取模型列表响应失败或超时:{error}"))? { if body.len().saturating_add(chunk.len()) > max_bytes { return Err("模型列表响应超过 1 MiB 上限".to_string()); @@ -152,13 +152,7 @@ async fn fetch_game_creator_llm_models( ) .send() .await - .map_err(|error| { - if error.is_timeout() { - "模型列表请求超时,请重试".to_string() - } else { - "无法连接模型服务,请检查网络后重试".to_string() - } - })?; + .map_err(|error| format!("无法连接模型服务:{error};请检查网络后重试"))?; let status = response.status(); let body = read_bounded_body(response, MODEL_CATALOG_MAX_BYTES).await?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs index 0fa86e867..ad2034739 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs @@ -217,19 +217,19 @@ async fn request_ticket( })) .send() .await - .map_err(|_| "创建素材上传凭证失败:无法连接登录服务,请稍后重试".to_string())?; + .map_err(|error| format!("创建素材上传凭证失败:无法连接登录服务:{error}"))?; let status = response.status(); let body = response .text() .await - .map_err(|_| "创建素材上传凭证失败:读取响应失败".to_string())?; + .map_err(|error| format!("创建素材上传凭证失败:读取响应失败:{error}"))?; validate_session(snapshot)?; if !status.is_success() { return Err(response_error(status, &body, "创建素材上传凭证")); } let payload = serde_json::from_str::(&body) .map(response_data) - .map_err(|_| "创建素材上传凭证失败:响应格式无效".to_string())?; + .map_err(|error| format!("创建素材上传凭证失败:响应格式无效:{error}"))?; let upload = payload .get("upload") .ok_or_else(|| "创建素材上传凭证失败:响应缺少 upload".to_string())?; @@ -356,19 +356,19 @@ async fn confirm_object( })) .send() .await - .map_err(|_| "确认素材资产失败:无法连接登录服务,请稍后重试".to_string())?; + .map_err(|error| format!("确认素材资产失败:无法连接登录服务:{error}"))?; let status = response.status(); let body = response .text() .await - .map_err(|_| "确认素材资产失败:读取响应失败".to_string())?; + .map_err(|error| format!("确认素材资产失败:读取响应失败:{error}"))?; validate_session(snapshot)?; if !status.is_success() { return Err(response_error(status, &body, "确认素材资产")); } let payload = serde_json::from_str::(&body) .map(response_data) - .map_err(|_| "确认素材资产失败:响应格式无效".to_string())?; + .map_err(|error| format!("确认素材资产失败:响应格式无效:{error}"))?; let asset = payload .get("assetObject") .ok_or_else(|| "确认素材资产失败:响应缺少 assetObject".to_string())?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs index fc66e2b8a..fb90b7ca0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs @@ -21,17 +21,19 @@ pub(crate) enum ResourceEditError { } impl ResourceEditError { - /// 面向用户与模型的文案。 - /// - /// 只给平台 `error` 原文,平台没给就回「服务器未返回错误信息」。「谁失败了」这类前缀由使用者 - /// 自己加(工具面用各工具自己的前缀,桌面命令面用「资源编辑生成失败」),这一层不替它们定调; - /// `phase_detail` 只进诊断,不进用户文案。 + /// 面向用户与模型的文案:优先给平台 `error` 原文,缺失时展示同一终态里的 + /// `phaseDetail`,两者都缺失才说明响应没有提供失败正文。前缀由使用者自己加。 pub(crate) fn to_user_msg(&self) -> String { match self { - Self::RemoteGenerationFailed { server_message, .. } => match server_message { - Some(server_message) => server_message.clone(), - None => "服务器未返回错误信息".to_string(), - }, + Self::RemoteGenerationFailed { + server_message, + phase_detail, + } => server_message + .clone() + .or_else(|| phase_detail.clone()) + .unwrap_or_else(|| { + "平台已返回失败终态,但没有提供 error 或 phaseDetail".to_string() + }), Self::Other(message) => message.clone(), } } diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 0d6220044..205b6cc2c 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -371,8 +371,14 @@ export function AuthenticatedClient({ const payload = failure as AuthNetworkFailure; const text = payload.reason === 'timeout' - ? '登录服务响应超时,请检查服务器地址和网络后重试' - : '无法连接登录服务,请确认配套后端或 API 代理已启动后重试'; + ? visibleClientErrorMessage( + error, + '登录服务响应超时,请检查服务器地址和网络后重试', + ) + : visibleClientErrorMessage( + error, + '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + ); setAuthCheckError(text); setLoginStatus(text); throw error; @@ -444,7 +450,10 @@ export function AuthenticatedClient({ default: { expectNever(failure); // 结构化映射外(Tauri 级失败等):仍然原样抛出上报,但界面给一行泛化提示,别停在空白状态。 - const text = '登录服务返回了未识别的错误,请重试或提交错误报告'; + const text = visibleClientErrorMessage( + error, + '登录服务返回了未识别的错误,请重试或提交错误报告', + ); setAuthCheckError(text); setLoginStatus(text); throw error; @@ -637,8 +646,14 @@ export function AuthenticatedClient({ const payload = failure as AuthNetworkFailure; setLoginStatus( payload.reason === 'timeout' - ? '发送验证码失败:登录服务响应超时,请检查服务器地址和网络后重试' - : '发送验证码失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + ? `发送验证码失败:${visibleClientErrorMessage( + error, + '登录服务响应超时,请检查服务器地址和网络后重试', + )}` + : `发送验证码失败:${visibleClientErrorMessage( + error, + '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + )}`, ); throw error; } @@ -701,7 +716,12 @@ export function AuthenticatedClient({ } default: { expectNever(failure); - setLoginStatus('登录服务返回了未识别的错误,请重试或提交错误报告'); + setLoginStatus( + visibleClientErrorMessage( + error, + '登录服务返回了未识别的错误,请重试或提交错误报告', + ), + ); throw error; } } @@ -864,8 +884,14 @@ export function AuthenticatedClient({ const payload = failure as AuthNetworkFailure; setLoginStatus( payload.reason === 'timeout' - ? '登录失败:登录服务响应超时,请检查服务器地址和网络后重试' - : '登录失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + ? `登录失败:${visibleClientErrorMessage( + error, + '登录服务响应超时,请检查服务器地址和网络后重试', + )}` + : `登录失败:${visibleClientErrorMessage( + error, + '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + )}`, ); throw error; } @@ -928,7 +954,12 @@ export function AuthenticatedClient({ } default: { expectNever(failure); - setLoginStatus('登录服务返回了未识别的错误,请重试或提交错误报告'); + setLoginStatus( + visibleClientErrorMessage( + error, + '登录服务返回了未识别的错误,请重试或提交错误报告', + ), + ); throw error; } } 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 261cbda9d..9fb1ad3ca 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 @@ -216,7 +216,7 @@ function directModelCallText( ); default: { expectNever(kind); - return '智能服务执行失败,请稍后重试'; + return withVisibleFailureDetail('智能服务执行失败,请稍后重试', detail); } } } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index c88d3e111..88de5c4b1 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -51,6 +51,7 @@ import { useUiEditorState, } from '../../features/ui-editor/useUiEditorState'; import { beginUiSaveAnalytics } from '../../services/clientAnalytics'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { cancelLocalProjectResourcePreviewScope, createProjectResourcePreviewRequestId, @@ -82,8 +83,7 @@ import { useUiEditorNodeFocus } from './useUiEditorNodeFocus'; const SEPARATION_IMPORT_BATCH_SIZE = 100; export type UiEditorSaveResult = - | { status: 'saved' } - | { status: 'failed'; message: string }; + { status: 'saved' } | { status: 'failed'; message: string }; export type UiEditorSaveAndGenerateResult = | { status: 'generated'; result: UiDesignCodeGenerationResult } @@ -1359,9 +1359,13 @@ export function useUiEditorSession( analytics?.record(result.status === 'saved'); return { status: 'saved' as const }; }); - } catch { - setSaveError('保存失败,请稍后重试。'); - return { status: 'failed' as const, message: '保存失败,请稍后重试。' }; + } catch (cause) { + const message = visibleClientErrorMessage( + cause, + '保存失败,请稍后重试。', + ); + setSaveError(message); + return { status: 'failed' as const, message }; } finally { setIsSaving(false); } diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index dcb1a5d35..bdd038d4a 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -108,6 +108,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 上游 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 解析失败、失败终态、缺失终态、超时和空回执都保留解析错误、失败事件正文与有界安全片段;`LlmError` 没有 detail 字段的超时/空回执由 Direct `ModelCallFailed.detail` 补回,不再把这些协议事实统一压成单一类别。 +- 账户、模型目录、External Editor/资源编辑、发布、素材上传、错误报告与客户端受控工具桥共用同一原则:网络错误保留底层因链,JSON/协议解析保留 serde 原因,HTTP 错误保留状态码与安全正文;只有响应完全没有正文时才说明“未提供 error/code/message”,不得退成“无法连接/格式无效/服务器未返回错误信息”。 - 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 48c76d67a..aa68e85f3 100644 --- a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md @@ -18,6 +18,7 @@ Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md` 9. 应用壳、运行配置、认证状态、插件启动、运行预览和策划会话的非结构化错误统一使用 `visibleClientErrorMessage`。 10. 资产导入、邀请码、策划工作区、发布封面/截图和素材命令的 UI 错误沿用同一共享出口。 11. Codex CLI / Claude sidecar 的解析失败、失败终态、缺失终态、超时和空回执保留有界脱敏正文;即使平台 `LlmError` 变体没有 detail 字段,也由统一 Direct `ModelCallFailed.detail` 继续携带原始原因。 +12. 扩展统一正文口径到账户、模型目录、External Editor/资源编辑、发布、素材上传、错误报告与客户端受控工具桥,保留 HTTP 状态、响应安全片段、JSON 解析原因、网络因链和 IPC/桥接原因。 ## 实现顺序 From d303428a6fd9c3cd9d22d873c419f9b92ba3ea7c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:47:41 +0800 Subject: [PATCH 019/132] =?UTF-8?q?=E4=BF=9D=E7=95=99=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E4=BF=9D=E5=AD=98=E9=94=99=E8=AF=AF=E6=AD=A3?= =?UTF-8?q?=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模型配置写回失败时展示具体 IPC 或配置错误 --- .../project-workspace/ConversationModelSelect.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index fa0eb0f7f..158defded 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -198,8 +198,10 @@ export function ConversationModelSelect({ ), }), ); - } catch { - throw new ModelSelectionConfigError('模型选择保存失败'); + } catch (error) { + throw new ModelSelectionConfigError( + visibleClientErrorMessage(error, '模型选择保存失败'), + ); } if ( persisted.config.selectedModelId !== next || @@ -382,8 +384,10 @@ export function ConversationModelSelect({ setNotice(''); } markReady(true); - } catch { - if (mountedRef.current) setError('模型选择保存失败'); + } catch (error) { + if (mountedRef.current) { + setError(visibleClientErrorMessage(error, '模型选择保存失败')); + } markReady(false); } finally { if (mountedRef.current) setBusy(false); From f2e1e3c0eb3ef304a56ab4e9fe71db8c2311b066 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:51:08 +0800 Subject: [PATCH 020/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=B8=8E=E9=9D=A2=E6=9D=BF=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留模型发现、推理档和插件面板错误详情 补充字体预览与更新检查的安全错误文案 --- apps/ai-game-creator-shell/src-tauri/src/config.rs | 6 +++++- .../src/components/AssetImporter/FontImporterPreview.tsx | 5 ++++- .../src/features/plugins/PluginPanelHost.tsx | 6 ++++-- .../src/features/runtime-config/CustomLlmSettings.tsx | 3 ++- .../src/features/runtime-config/RuntimeConfigDialog.tsx | 4 +++- .../components/DirectProjectComposer/ComposerControls.tsx | 8 ++++---- 6 files changed, 22 insertions(+), 10 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 3e224b6ca..76e3a730f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -4442,7 +4442,11 @@ pub(crate) async fn fetch_custom_llm_models( if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - let detail = body.trim().chars().take(600).collect::(); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); return Err(if detail.is_empty() { format!("模型列表读取失败(HTTP {})", status.as_u16()) } else { diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/FontImporterPreview.tsx b/apps/ai-game-creator-shell/src/components/AssetImporter/FontImporterPreview.tsx index d50831d61..a66649621 100644 --- a/apps/ai-game-creator-shell/src/components/AssetImporter/FontImporterPreview.tsx +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/FontImporterPreview.tsx @@ -7,6 +7,7 @@ import { type LoadedUiEditorFontFace, loadUiEditorFontFace, } from '../../features/ui-editor/useUiEditorFontFaces'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import type { AssetImporterPreviewProps } from './utils'; export function FontImporterPreview({ @@ -52,7 +53,9 @@ export function FontImporterPreview({ relativePath: selected.asset?.localPath, cause, }); - if (!cancelled) setError('字体样张加载失败'); + if (!cancelled) { + setError(visibleClientErrorMessage(cause, '字体样张加载失败')); + } } })(); return () => { diff --git a/apps/ai-game-creator-shell/src/features/plugins/PluginPanelHost.tsx b/apps/ai-game-creator-shell/src/features/plugins/PluginPanelHost.tsx index 08546c230..98fc9d61b 100644 --- a/apps/ai-game-creator-shell/src/features/plugins/PluginPanelHost.tsx +++ b/apps/ai-game-creator-shell/src/features/plugins/PluginPanelHost.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import type { AgcPluginPanel } from '../../app/types'; import { ThemedModal } from '../../components/modal/ThemedModal'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { readAgcPluginPanel } from '../../services/pluginHost'; export type PluginPanelHostProps = { @@ -30,8 +31,9 @@ export function PluginPanelHost({ (result) => { if (active) setHtml(result.html); }, - () => { - if (active) setError('插件面板加载失败'); + (error) => { + if (active) + setError(visibleClientErrorMessage(error, '插件面板加载失败')); }, ); return () => { diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/CustomLlmSettings.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/CustomLlmSettings.tsx index b75938007..5a2d53dda 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/CustomLlmSettings.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/CustomLlmSettings.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField'; import { resolveTauriInvoke } from '../../app/tauri'; import type { GameCreatorLlmConfig } from '../../app/types'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; export function CustomLlmSettings({ llm, @@ -54,7 +55,7 @@ export function CustomLlmSettings({ } catch (error) { if (epoch !== requestEpoch.current) return; setStatus(''); - setError(typeof error === 'string' ? error : '模型列表读取失败,请重试'); + setError(visibleClientErrorMessage(error, '模型列表读取失败,请重试')); } finally { if (epoch === requestEpoch.current) setBusy(false); } diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index 7861eae67..8575f1243 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -706,7 +706,9 @@ export function RuntimeConfigDialog({ } } catch (error) { // 结果型入口已经收口了检查失败;这里只兜住调用方自身的意外抛错。 - setAppUpdateStatus('检查更新失败,请稍后重试'); + setAppUpdateStatus( + `检查更新失败:${visibleClientErrorMessage(error, '请稍后重试')}`, + ); } finally { setAppUpdateChecking(false); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx index aa496af9a..0602849ff 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx @@ -342,9 +342,9 @@ export function ComposerReasoningEffortSelect({ normalizeComposerReasoningEffort(view?.config?.llm?.reasoningEffort), ); }) - .catch(() => { + .catch((error) => { if (cancelled || !mountedRef.current) return; - setNotice('推理档读取失败'); + setNotice(visibleClientErrorMessage(error, '推理档读取失败')); }); return () => { cancelled = true; @@ -378,10 +378,10 @@ export function ComposerReasoningEffortSelect({ normalizeComposerReasoningEffort(view?.config?.llm?.reasoningEffort), ); }) - .catch(() => { + .catch((error) => { if (!mountedRef.current) return; setEffort(previous); - setNotice('推理档保存失败'); + setNotice(visibleClientErrorMessage(error, '推理档保存失败')); }) .finally(() => { if (mountedRef.current) { From 6312320cab4d4c5209ddcf84d38e28bc592f3a62 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:55:52 +0800 Subject: [PATCH 021/132] =?UTF-8?q?=E6=94=B6=E6=95=9B=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E4=B8=8E=E7=BC=96=E8=BE=91=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留模型预览和资源编辑的安全错误详情 --- .../view/project-development/ResourceEditSurface.tsx | 7 ++----- .../view/project-development/ResourceModelViewer.tsx | 12 ++++-------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceEditSurface.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceEditSurface.tsx index bd1a89b66..dffe46055 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceEditSurface.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceEditSurface.tsx @@ -1,6 +1,7 @@ import { ChevronLeft, LoaderCircle, Sparkles } from 'lucide-react'; import { type FormEvent, useState } from 'react'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { type LocalProjectResourceEditKind, resourceEditPromptMaxLength, @@ -50,11 +51,7 @@ export function ResourceEditSurface({ try { await onSubmit({ prompt: normalizedPrompt, assetName: normalizedName }); } catch (submitError) { - setError( - submitError instanceof Error - ? submitError.message - : String(submitError), - ); + setError(visibleClientErrorMessage(submitError, '资源编辑失败,请重试')); setSubmitting(false); } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx index 4b06525cc..7ce5774cc 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { addResourceModelLights, disposeResourceModelObject, @@ -61,9 +62,8 @@ export function ResourceModelViewer({ void (async () => { try { const THREE = await import('three'); - const { OrbitControls } = await import( - 'three/examples/jsm/controls/OrbitControls.js' - ); + const { OrbitControls } = + await import('three/examples/jsm/controls/OrbitControls.js'); if (disposed) { return; } @@ -154,11 +154,7 @@ export function ResourceModelViewer({ } catch (viewerError) { if (!disposed) { setStatus('failed'); - setError( - viewerError instanceof Error - ? viewerError.message - : String(viewerError), - ); + setError(visibleClientErrorMessage(viewerError, '模型预览加载失败')); } } })(); From cb42e46f0fd628dce119b3d965796dc0531cb1cb Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:58:35 +0800 Subject: [PATCH 022/132] =?UTF-8?q?=E4=BF=9D=E7=95=99=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E6=8F=90=E4=BA=A4=E7=9A=84=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留错误报告 HTTP code、message 与安全响应片段 --- .../src-tauri/src/error_report/submit.rs | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs b/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs index 33f6cb46f..96a0692b1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs @@ -12,7 +12,7 @@ use reqwest::StatusCode; use serde_json::Value; use sha2::{Digest, Sha256}; use shared_contracts::error_reports::{CreateErrorReportBatchRequest, ErrorReportLogInput, Event}; -use std::time::Duration; +use std::{path::Path, time::Duration}; use url::Url; const HTTP_TIMEOUT: Duration = Duration::from_secs(60); @@ -78,13 +78,28 @@ fn response_error(status: StatusCode, body: &str) -> String { let detail = serde_json::from_str::(body) .ok() .and_then(|value| { - value - .get("error") - .and_then(|error| error.get("message")) + let error = value.get("error").unwrap_or(&value); + error + .get("message") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string) + .or_else(|| { + error + .get("code") + .and_then(Value::as_str) + .map(str::to_string) + }) + .or_else(|| serde_json::to_string(error).ok()) + }) + .or_else(|| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); + (!detail.trim().is_empty()).then_some(detail) }) .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); format!("错误报告提交失败:{detail}") @@ -92,7 +107,7 @@ fn response_error(status: StatusCode, body: &str) -> String { fn response_data(body: &str) -> Result { let value: Value = serde_json::from_str(body) - .map_err(|_| "错误报告提交失败:响应不是合法 JSON".to_string())?; + .map_err(|error| format!("错误报告提交失败:响应不是合法 JSON:{error}"))?; Ok(value.get("data").cloned().unwrap_or(value)) } From d9802bfa2224c542042a4e1f57c590729ce26132 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:04:18 +0800 Subject: [PATCH 023/132] =?UTF-8?q?=E4=BF=9D=E7=95=99=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E5=A4=B1=E8=B4=A5=E7=9A=84=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 展示脱敏后的 IPC、Provider 与宿主失败原因 补充首页创建错误正文回归测试 --- .../features/app-shell/homeCreationFailure.ts | 26 ++++++++++++++++--- .../tests/homeCreationFailure.test.ts | 16 ++++++++---- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/app-shell/homeCreationFailure.ts b/apps/ai-game-creator-shell/src/features/app-shell/homeCreationFailure.ts index 2ac9e3fbe..f8e7bca7a 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/homeCreationFailure.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/homeCreationFailure.ts @@ -11,6 +11,8 @@ * 落回本模块的专用兜底,并按「工作区是否已经建好」分成两条,避免用户重做一遍。 */ +import { visibleClientErrorMessage } from '../../services/errorReporting'; + /** 未分类失败的专用兜底文案。 */ export const HOME_CREATION_UNCLASSIFIED_FAILURE = '创建未完成,请重试'; @@ -64,6 +66,19 @@ function isHomeCreationTransportText(text: string): boolean { ); } +function safeHomeCreationDetail(error: unknown, code?: string) { + const detail = visibleClientErrorMessage(error, '').trim(); + if (!detail) return ''; + if (!code) return detail; + const withoutCode = detail + .replace(code, '') + .replace(/^\s*[::;;,,-]\s*/u, '') + .trim(); + return withoutCode === code || /[::;;,,-]\s*$/u.test(withoutCode) + ? '' + : withoutCode; +} + /** * 由宿主错误算出用户可见文案。 * @@ -76,20 +91,25 @@ export function homeCreationFailureMessage( ): string { const projectCreated = options.projectCreated === true; const text = homeCreationDiagnosticText(error); + const safeDetail = safeHomeCreationDetail(error); if (isHomeCreationTransportText(text)) { - return projectCreated + const base = projectCreated ? HOME_CREATION_AFTER_PROJECT_IPC_FAILURE : HOME_CREATION_IPC_FAILURE; + return safeDetail ? `${base}(${safeDetail})` : base; } const code = HOME_CREATION_HOST_CODE.exec(text)?.[0]; if (code) { - return projectCreated + const base = projectCreated ? `工作区已创建;未能进入项目开发(${code});请从「已创建的工作区」打开后重试。` : `创建未完成(${code});请重试。`; + const detail = safeHomeCreationDetail(error, code); + return detail ? `${base}(${detail})` : base; } - return projectCreated + const base = projectCreated ? HOME_CREATION_AFTER_PROJECT_FAILURE : HOME_CREATION_UNCLASSIFIED_FAILURE; + return safeDetail ? `${base}(${safeDetail})` : base; } /** 包装成带证据的创建失败;控制器负责把 `diagnostic` 写进日志后再抛出。 */ diff --git a/apps/ai-game-creator-shell/tests/homeCreationFailure.test.ts b/apps/ai-game-creator-shell/tests/homeCreationFailure.test.ts index 11c1db813..5f45ba17d 100644 --- a/apps/ai-game-creator-shell/tests/homeCreationFailure.test.ts +++ b/apps/ai-game-creator-shell/tests/homeCreationFailure.test.ts @@ -28,7 +28,7 @@ it('宿主机器码原样显示,不落回通用文案', () => { it('调用通道失联使用专用 IPC 文案,不冒充业务原因', () => { expect(homeCreationFailureMessage(new Error('Failed to fetch'))).toBe( - HOME_CREATION_IPC_FAILURE, + `${HOME_CREATION_IPC_FAILURE}(Failed to fetch)`, ); expect( homeCreationFailureMessage( @@ -37,12 +37,14 @@ it('调用通道失联使用专用 IPC 文案,不冒充业务原因', () => { ), { projectCreated: true }, ), - ).toBe(HOME_CREATION_AFTER_PROJECT_IPC_FAILURE); + ).toBe( + `${HOME_CREATION_AFTER_PROJECT_IPC_FAILURE}(IPC custom protocol failed, Tauri will now use the postMessage interface instead TypeError: Failed to fetch)`, + ); }); it('拿不到任何机器码时使用专用兜底,并按是否已建好工作区分文案', () => { expect(homeCreationFailureMessage(new Error('自动创建测试结束'))).toBe( - HOME_CREATION_UNCLASSIFIED_FAILURE, + `${HOME_CREATION_UNCLASSIFIED_FAILURE}(自动创建测试结束)`, ); expect(homeCreationFailureMessage(undefined)).toBe( HOME_CREATION_UNCLASSIFIED_FAILURE, @@ -51,7 +53,9 @@ it('拿不到任何机器码时使用专用兜底,并按是否已建好工作 homeCreationFailureMessage(new Error('design runtime unavailable'), { projectCreated: true, }), - ).toBe(HOME_CREATION_AFTER_PROJECT_FAILURE); + ).toBe( + `${HOME_CREATION_AFTER_PROJECT_FAILURE}(design runtime unavailable)`, + ); }); it('宿主原始文本只进诊断,不进用户文案', () => { @@ -60,7 +64,9 @@ it('宿主原始文本只进诊断,不进用户文案', () => { { projectCreated: false }, ); expect(failure).toBeInstanceOf(HomeCreationFailure); - expect(failure.userMessage).toBe(HOME_CREATION_UNCLASSIFIED_FAILURE); + expect(failure.userMessage).toBe( + `${HOME_CREATION_UNCLASSIFIED_FAILURE}(Provider 返回 500:)`, + ); expect(failure.diagnostic).toBe( 'Provider 返回 500:https://provider.example/private', ); From 520dfc443c2a6dc24bf7d1d383cbddd14ea75c0a Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:15:22 +0800 Subject: [PATCH 024/132] =?UTF-8?q?=E4=BF=9D=E7=95=99=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E4=B8=8E=E6=A8=A1=E5=9E=8B=E7=9B=AE=E5=BD=95=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E9=94=99=E8=AF=AF=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留浏览器 stderr、退出状态和 DevTools 握手原因 保留 Codex model-catalog 子进程 stderr 与解析错误 补充浏览器错误诊断回归验证 --- .../agent/codex_app_server/model_catalog.rs | 31 +++-- .../src-tauri/src/browser/process.rs | 116 +++++++++++++----- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...实施计划】AGC错误具体文本展示-2026-10-04.md | 1 + 4 files changed, 107 insertions(+), 42 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs index 68adf5720..a718f0d3d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs @@ -105,7 +105,7 @@ async fn read_bounded(mut stream: impl AsyncRead + Unpin, limit: usize) -> Resul let count = stream .read(&mut buffer) .await - .map_err(|_| "model-catalog-read-failed")?; + .map_err(|error| format!("model-catalog-read-failed: {error}"))?; if count == 0 { return Ok(output); } @@ -132,31 +132,46 @@ async fn export_catalog( if cancel.is_some_and(|flag| flag.load(Ordering::Acquire)) { return Err("model-catalog-cancelled".into()); } - let mut child = command.spawn().map_err(|_| "model-catalog-spawn-failed")?; + let mut child = command + .spawn() + .map_err(|error| format!("model-catalog-spawn-failed: {error}"))?; let tree = match OwnedProcessTree::attach(&child) { Ok(tree) => tree, - Err(_) => { + Err(error) => { let _ = child.start_kill(); let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; - return Err("model-catalog-process-owner-unavailable".into()); + return Err(format!("model-catalog-process-owner-unavailable: {error}")); } }; let result = if let (Some(stdout), Some(stderr)) = (child.stdout.take(), child.stderr.take()) { let collected = async { - let (stdout, _, status) = tokio::try_join!( + let (stdout, stderr, status) = tokio::try_join!( read_bounded(stdout, MAX_CATALOG_BYTES), read_bounded(stderr, 64 * 1024), async { child .wait() .await - .map_err(|_| "model-catalog-wait-failed".to_string()) + .map_err(|error| format!("model-catalog-wait-failed: {error}")) }, )?; if !status.success() { - return Err("model-catalog-export-failed".into()); + let detail = String::from_utf8_lossy(&stderr).trim().to_string(); + let detail = crate::sanitize_diagnostic_message( + &detail, + Some(Path::new("__agc_no_project_root__")), + ); + return Err(if detail.is_empty() { + format!("model-catalog-export-failed: exitStatus={status}") + } else { + format!( + "model-catalog-export-failed: exitStatus={status}; stderr={}", + detail.chars().take(1200).collect::() + ) + }); } - serde_json::from_slice(&stdout).map_err(|_| "model-catalog-json-invalid".into()) + serde_json::from_slice(&stdout) + .map_err(|error| format!("model-catalog-json-invalid: {error}")) }; tokio::select! { biased; diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs index 712c41fbd..bcce384f0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs @@ -111,38 +111,47 @@ fn parse_devtools_ws_url(line: &str) -> Option { Some(ws.to_string()) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] enum OwnedBrowserLaunchError { - SpawnFailed, + SpawnFailed(String), WsTimeout, - WsFailed, + WsFailed(String), ConnectTimeout, - ConnectFailed, + ConnectFailed(String), } impl OwnedBrowserLaunchError { - fn stage(self) -> BrowserLaunchStage { + fn stage(&self) -> BrowserLaunchStage { match self { - Self::SpawnFailed => BrowserLaunchStage::Spawn, - Self::WsTimeout | Self::WsFailed => BrowserLaunchStage::WsHandshake, - Self::ConnectTimeout | Self::ConnectFailed => BrowserLaunchStage::CdpConnect, + Self::SpawnFailed(_) => BrowserLaunchStage::Spawn, + Self::WsTimeout | Self::WsFailed(_) => BrowserLaunchStage::WsHandshake, + Self::ConnectTimeout | Self::ConnectFailed(_) => BrowserLaunchStage::CdpConnect, } } - fn is_recoverable(self) -> bool { + fn is_recoverable(&self) -> bool { matches!( self, - Self::WsTimeout | Self::WsFailed | Self::ConnectTimeout | Self::ConnectFailed + Self::WsTimeout | Self::WsFailed(_) | Self::ConnectTimeout | Self::ConnectFailed(_) ) } - fn code(self) -> &'static str { + fn code(&self) -> &'static str { match self { - Self::SpawnFailed => "browser-spawn-failed", + Self::SpawnFailed(_) => "browser-spawn-failed", Self::WsTimeout => "browser-ws-timeout", - Self::WsFailed => "browser-ws-failed", + Self::WsFailed(_) => "browser-ws-failed", Self::ConnectTimeout => "browser-cdp-connect-timeout", - Self::ConnectFailed => "browser-cdp-connect-failed", + Self::ConnectFailed(_) => "browser-cdp-connect-failed", + } + } + + fn detail(&self) -> Option<&str> { + match self { + Self::SpawnFailed(detail) | Self::WsFailed(detail) | Self::ConnectFailed(detail) => { + Some(detail) + } + Self::WsTimeout | Self::ConnectTimeout => None, } } } @@ -166,13 +175,14 @@ impl BrowserLaunchStage { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] struct BrowserLaunchFailure { code: &'static str, stage: BrowserLaunchStage, recoverable: bool, subprocess_exited: bool, cleanup_confirmed: bool, + detail: Option, } impl BrowserLaunchFailure { @@ -183,6 +193,7 @@ impl BrowserLaunchFailure { recoverable: false, subprocess_exited: true, cleanup_confirmed: true, + detail: None, } } @@ -197,11 +208,17 @@ impl BrowserLaunchFailure { recoverable: error.is_recoverable(), subprocess_exited, cleanup_confirmed, + detail: error.detail().map(|detail| { + crate::sanitize_diagnostic_message(detail, None) + .chars() + .take(1200) + .collect() + }), } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] struct BrowserRecoveryFailure { first: BrowserLaunchFailure, final_attempt: Option, @@ -211,16 +228,26 @@ impl BrowserRecoveryFailure { fn diagnostic(self) -> String { match self.final_attempt { Some(final_attempt) => format!( - "browser-recovery-failed: initial-stage={} final-stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=true initial-cause={} final-cause={}", + "browser-recovery-failed: initial-stage={} final-stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=true initial-cause={} final-cause={}{}{}", self.first.stage.as_str(), final_attempt.stage.as_str(), final_attempt.subprocess_exited, final_attempt.cleanup_confirmed, self.first.code, final_attempt.code, + self.first + .detail + .as_deref() + .map(|detail| format!(" initial-detail={detail}")) + .unwrap_or_default(), + final_attempt + .detail + .as_deref() + .map(|detail| format!(" final-detail={detail}")) + .unwrap_or_default(), ), None => format!( - "{}: stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=false cause={}", + "{}: stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=false cause={}{}", if self.first.recoverable { "browser-recovery-blocked" } else { @@ -230,6 +257,11 @@ impl BrowserRecoveryFailure { self.first.subprocess_exited, self.first.cleanup_confirmed, self.first.code, + self.first + .detail + .as_deref() + .map(|detail| format!(" detail={detail}")) + .unwrap_or_default(), ), } } @@ -271,7 +303,7 @@ async fn devtools_ws_url_from_stderr( let stderr = child .stderr .take() - .ok_or(OwnedBrowserLaunchError::SpawnFailed)?; + .ok_or_else(|| OwnedBrowserLaunchError::SpawnFailed("browser stderr 未建立".into()))?; let mut reader = futures::io::BufReader::new(stderr); let mut captured: Vec = Vec::new(); let mut exited = Box::pin(child.wait()).fuse(); @@ -279,18 +311,18 @@ async fn devtools_ws_url_from_stderr( loop { let mut line = Vec::new(); futures::select! { - _status = exited => return Err(OwnedBrowserLaunchError::WsFailed), + _status = exited => return Err(OwnedBrowserLaunchError::WsFailed(safe_browser_detail(&captured))), result = reader.read_until(b'\n', &mut line).fuse() => { - let count = result.map_err(|_| OwnedBrowserLaunchError::WsFailed)?; + let count = result.map_err(|error| OwnedBrowserLaunchError::WsFailed(format!("读取 browser stderr 失败:{error};{}", safe_browser_detail(&captured))))?; if count == 0 { - return Err(OwnedBrowserLaunchError::WsFailed); + return Err(OwnedBrowserLaunchError::WsFailed(safe_browser_detail(&captured))); } captured.extend_from_slice(&line); if captured.len() > MAX_LAUNCH_STDERR_BYTES { - return Err(OwnedBrowserLaunchError::WsFailed); + return Err(OwnedBrowserLaunchError::WsFailed(format!("browser stderr 超过大小上限;{}", safe_browser_detail(&captured)))); } let Ok(text) = std::str::from_utf8(&line) else { - return Err(OwnedBrowserLaunchError::WsFailed); + return Err(OwnedBrowserLaunchError::WsFailed(format!("browser stderr 不是 UTF-8;{}", safe_browser_detail(&captured)))); }; if let Some(url) = parse_devtools_ws_url(text) { return Ok(url); @@ -306,6 +338,17 @@ async fn devtools_ws_url_from_stderr( } } +fn safe_browser_detail(bytes: &[u8]) -> String { + let text = String::from_utf8_lossy(bytes).trim().to_string(); + if text.is_empty() { + return "未收到 browser stderr 具体正文".to_string(); + } + crate::sanitize_diagnostic_message(&text, None) + .chars() + .take(1200) + .collect() +} + fn spawn_stderr_drain(mut reader: BrowserStderrReader) -> tokio::task::JoinHandle<()> { // 持续排空 stderr:浏览器日志写满管道会整体 stall。内容有界、不留存。 tokio::spawn(async move { @@ -484,9 +527,9 @@ async fn launch_owned_browser( ) -> Result { let child = match config.launch() { Ok(child) => child, - Err(_) => { + Err(error) => { return Err(BrowserLaunchFailure::from_launch_error( - OwnedBrowserLaunchError::SpawnFailed, + OwnedBrowserLaunchError::SpawnFailed(error.to_string()), true, true, )); @@ -511,7 +554,9 @@ async fn launch_owned_browser( return Err(cleanup_failed_launch( process, temp, - OwnedBrowserLaunchError::SpawnFailed, + OwnedBrowserLaunchError::SpawnFailed( + "browser root identity unavailable".into(), + ), false, ) .await); @@ -528,13 +573,15 @@ async fn launch_owned_browser( return Err(cleanup_failed_launch( process, temp, - OwnedBrowserLaunchError::SpawnFailed, + OwnedBrowserLaunchError::SpawnFailed( + "browser process tree not covered by Job".into(), + ), tree_reaped, ) .await); } } - Err(_) => { + Err(error) => { let tree_reaped = match (child.inner.id(), root_identity.as_deref()) { (Some(root_pid), Some(root_identity)) => { super::sweep::kill_browser_process_tree(root_pid, root_identity).is_ok() @@ -545,7 +592,7 @@ async fn launch_owned_browser( return Err(cleanup_failed_launch( process, temp, - OwnedBrowserLaunchError::SpawnFailed, + OwnedBrowserLaunchError::SpawnFailed(format!("创建 browser Job 失败:{error}")), tree_reaped, ) .await); @@ -575,12 +622,12 @@ async fn launch_owned_browser( .await; let (browser, mut handler) = match connected { Ok(Ok(pair)) => pair, - Ok(Err(_)) => { + Ok(Err(error)) => { drain_task.abort(); return Err(cleanup_failed_launch( process, temp, - OwnedBrowserLaunchError::ConnectFailed, + OwnedBrowserLaunchError::ConnectFailed(error.to_string()), true, ) .await); @@ -828,7 +875,7 @@ mod health_tests { if attempt == 0 { drop(temporary); Err(BrowserLaunchFailure::from_launch_error( - OwnedBrowserLaunchError::WsFailed, + OwnedBrowserLaunchError::WsFailed("fixture ws failure".into()), true, true, )) @@ -876,7 +923,7 @@ mod health_tests { async fn consecutive_browser_failures_keep_both_recovery_stages_and_safe_diagnostics() { let failure = launch_with_one_recovery(|| async { Err::<(), BrowserLaunchFailure>(BrowserLaunchFailure::from_launch_error( - OwnedBrowserLaunchError::WsFailed, + OwnedBrowserLaunchError::WsFailed("fixture ws failure".into()), true, true, )) @@ -889,6 +936,7 @@ mod health_tests { assert!(diagnostic.contains("subprocess-exited=true")); assert!(diagnostic.contains("cleanup-confirmed=true")); assert!(diagnostic.contains("recovery-retried=true")); + assert!(diagnostic.contains("fixture ws failure")); assert!(!diagnostic.contains("/tmp")); } diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index bdd038d4a..50a3ff361 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -109,6 +109,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - Claude Code(cc)侧车的非零退出、RPC `error`、stdout JSON/UTF-8 解析失败、stderr 和静默超时也走同一映射;侧车 stderr 只在有界收口窗口内读取并进入同一脱敏 detail,不再只写裸 `eprintln!`。 - Codex CLI / Claude sidecar 的 JSONL/JSON 解析失败、失败终态、缺失终态、超时和空回执都保留解析错误、失败事件正文与有界安全片段;`LlmError` 没有 detail 字段的超时/空回执由 Direct `ModelCallFailed.detail` 补回,不再把这些协议事实统一压成单一类别。 - 账户、模型目录、External Editor/资源编辑、发布、素材上传、错误报告与客户端受控工具桥共用同一原则:网络错误保留底层因链,JSON/协议解析保留 serde 原因,HTTP 错误保留状态码与安全正文;只有响应完全没有正文时才说明“未提供 error/code/message”,不得退成“无法连接/格式无效/服务器未返回错误信息”。 +- 浏览器启动/DevTools 握手与 Codex model-catalog 子进程失败保留 stderr、退出状态、解析错误和阶段;稳定机器码仍用于分类,但不能单独成为用户可见正文。 - 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 aa68e85f3..3bc04036e 100644 --- a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md @@ -19,6 +19,7 @@ Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md` 10. 资产导入、邀请码、策划工作区、发布封面/截图和素材命令的 UI 错误沿用同一共享出口。 11. Codex CLI / Claude sidecar 的解析失败、失败终态、缺失终态、超时和空回执保留有界脱敏正文;即使平台 `LlmError` 变体没有 detail 字段,也由统一 Direct `ModelCallFailed.detail` 继续携带原始原因。 12. 扩展统一正文口径到账户、模型目录、External Editor/资源编辑、发布、素材上传、错误报告与客户端受控工具桥,保留 HTTP 状态、响应安全片段、JSON 解析原因、网络因链和 IPC/桥接原因。 +13. 浏览器启动/DevTools 握手与 Codex model-catalog 子进程错误保留 stderr、退出状态、解析原因和阶段信息,机器码只作为分类字段。 ## 实现顺序 From f6b1e0fe6632ebc662abb53168d928d81bfb3603 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:20:03 +0800 Subject: [PATCH 025/132] =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E6=8F=A1=E6=89=8B=E8=B6=85=E6=97=B6=E5=85=B7=E4=BD=93?= =?UTF-8?q?=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留 DevTools stderr 作为超时诊断 detail --- .../src-tauri/src/browser/process.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs index bcce384f0..ac46bbbda 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs @@ -114,7 +114,7 @@ fn parse_devtools_ws_url(line: &str) -> Option { #[derive(Debug, Clone, PartialEq, Eq)] enum OwnedBrowserLaunchError { SpawnFailed(String), - WsTimeout, + WsTimeout(String), WsFailed(String), ConnectTimeout, ConnectFailed(String), @@ -124,7 +124,7 @@ impl OwnedBrowserLaunchError { fn stage(&self) -> BrowserLaunchStage { match self { Self::SpawnFailed(_) => BrowserLaunchStage::Spawn, - Self::WsTimeout | Self::WsFailed(_) => BrowserLaunchStage::WsHandshake, + Self::WsTimeout(_) | Self::WsFailed(_) => BrowserLaunchStage::WsHandshake, Self::ConnectTimeout | Self::ConnectFailed(_) => BrowserLaunchStage::CdpConnect, } } @@ -132,14 +132,14 @@ impl OwnedBrowserLaunchError { fn is_recoverable(&self) -> bool { matches!( self, - Self::WsTimeout | Self::WsFailed(_) | Self::ConnectTimeout | Self::ConnectFailed(_) + Self::WsTimeout(_) | Self::WsFailed(_) | Self::ConnectTimeout | Self::ConnectFailed(_) ) } fn code(&self) -> &'static str { match self { Self::SpawnFailed(_) => "browser-spawn-failed", - Self::WsTimeout => "browser-ws-timeout", + Self::WsTimeout(_) => "browser-ws-timeout", Self::WsFailed(_) => "browser-ws-failed", Self::ConnectTimeout => "browser-cdp-connect-timeout", Self::ConnectFailed(_) => "browser-cdp-connect-failed", @@ -151,7 +151,8 @@ impl OwnedBrowserLaunchError { Self::SpawnFailed(detail) | Self::WsFailed(detail) | Self::ConnectFailed(detail) => { Some(detail) } - Self::WsTimeout | Self::ConnectTimeout => None, + Self::WsTimeout(detail) => Some(detail), + Self::ConnectTimeout => None, } } } @@ -334,7 +335,9 @@ async fn devtools_ws_url_from_stderr( match tokio::time::timeout(timeout, read).await { Ok(Ok(url)) => Ok((url, reader)), Ok(Err(error)) => Err(error), - Err(_) => Err(OwnedBrowserLaunchError::WsTimeout), + Err(_) => Err(OwnedBrowserLaunchError::WsTimeout(safe_browser_detail( + &captured, + ))), } } From 1a1f3df95188eb90c974e0af13a11a5f293a1d98 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:23:01 +0800 Subject: [PATCH 026/132] =?UTF-8?q?=E4=BF=9D=E7=95=99=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E8=AF=95=E7=8E=A9=20IPC=20=E9=94=99=E8=AF=AF=E6=AD=A3?= =?UTF-8?q?=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充固定试玩和 runner-v1 的 CDP 查询与输入原因 --- .../src-tauri/src/browser/playtest/mod.rs | 14 +++++----- .../src-tauri/src/browser/playtest/runner.rs | 27 +++++++++++-------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs index 1df7ccf9a..8d18975df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs @@ -643,10 +643,10 @@ pub(super) async fn read_playable_web_game_state( let evaluated = tokio::time::timeout(remaining, page.evaluate(READ_PLAYABLE_GAME_STATE_SCRIPT)) .await .map_err(|_| "读取固定试玩状态超时".to_string())? - .map_err(|_| "读取固定试玩状态失败".to_string())?; + .map_err(|error| format!("读取固定试玩状态失败:{error}"))?; let surface = evaluated .into_value::() - .map_err(|_| "解析固定试玩状态面读取结果失败".to_string())?; + .map_err(|error| format!("解析固定试玩状态面读取结果失败:{error}"))?; if surface.content_length > MAX_PLAYABLE_GAME_STATE_JSON_CHARS { return Err("固定试玩状态 JSON 超过大小上限".to_string()); } @@ -675,7 +675,7 @@ pub(super) async fn click_playtest_control( let mut elements = tokio::time::timeout(remaining, page.find_elements(selector)) .await .map_err(|_| format!("固定试玩动作 {action} 超时"))? - .map_err(|_| format!("固定试玩控件 {action} 不存在或查询失败"))?; + .map_err(|error| format!("固定试玩控件 {action} 不存在或查询失败:{error}"))?; if elements.len() != 1 { return Err(format!( "固定试玩控件 {action} 必须唯一,实际数量为 {}", @@ -690,7 +690,7 @@ pub(super) async fn click_playtest_control( tokio::time::timeout(remaining, element.scroll_into_view()) .await .map_err(|_| format!("固定试玩动作 {action} 超时"))? - .map_err(|_| format!("固定试玩控件 {action} 无法滚动到可见区域"))?; + .map_err(|error| format!("固定试玩控件 {action} 无法滚动到可见区域:{error}"))?; let remaining = playtest_remaining(deadline)?; let evaluated = tokio::time::timeout( @@ -699,7 +699,7 @@ pub(super) async fn click_playtest_control( ) .await .map_err(|_| format!("固定试玩动作 {action} 超时"))? - .map_err(|_| format!("固定试玩控件 {action} 可见性/禁用态读取失败"))?; + .map_err(|error| format!("固定试玩控件 {action} 可见性/禁用态读取失败:{error}"))?; let probe = evaluated .result .value @@ -708,7 +708,7 @@ pub(super) async fn click_playtest_control( .as_str() .ok_or_else(|| format!("固定试玩控件 {action} 可见性/禁用态结果无效"))?; let probe = serde_json::from_str::(probe) - .map_err(|_| format!("固定试玩控件 {action} 可见性/禁用态结果无效"))?; + .map_err(|error| format!("固定试玩控件 {action} 可见性/禁用态结果无效:{error}"))?; if !probe.is_html_element { return Err(format!("固定试玩控件 {action} 必须是 HTMLElement")); } @@ -723,7 +723,7 @@ pub(super) async fn click_playtest_control( tokio::time::timeout(remaining, element.click()) .await .map_err(|_| format!("固定试玩动作 {action} 超时"))? - .map_err(|_| format!("固定试玩控件 {action} 不可点击"))?; + .map_err(|error| format!("固定试玩控件 {action} 不可点击:{error}"))?; Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/runner.rs index 2119e7de5..b3eb67080 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/runner.rs @@ -135,15 +135,16 @@ async fn snapshot(page: &Page) -> Result<(PlayableWebGameState, RunnerState), St let raw: Value = page .evaluate(READ_PLAYABLE_GAME_STATE_SCRIPT) .await - .map_err(|_| "runner-v1 读取状态失败")? + .map_err(|error| format!("runner-v1 读取状态失败:{error}"))? .into_value() - .map_err(|_| "runner-v1 状态读取结果无效")?; + .map_err(|error| format!("runner-v1 状态读取结果无效:{error}"))?; let text = raw .get("content") .and_then(Value::as_str) .ok_or("runner-v1 状态接口缺失或超限")?; let base = parse_playable_web_game_state(text, BrowserPlaytestScenario::RunnerV1)?; - let value: Value = serde_json::from_str(text).map_err(|_| "runner-v1 JSON 无效")?; + let value: Value = + serde_json::from_str(text).map_err(|error| format!("runner-v1 JSON 无效:{error}"))?; Ok((base, parse_runner_state(&value)?)) } @@ -151,7 +152,7 @@ async fn control_center(page: &Page, selector: &'static str) -> Result<(f64, f64 let mut elements = page .find_elements(selector) .await - .map_err(|_| "runner-v1 查询控件失败")?; + .map_err(|error| format!("runner-v1 查询控件失败:{error}"))?; if elements.len() != 1 { return Err("runner-v1 真实控件必须唯一".into()); } @@ -159,11 +160,11 @@ async fn control_center(page: &Page, selector: &'static str) -> Result<(f64, f64 element .scroll_into_view() .await - .map_err(|_| "runner-v1 控件无法进入视口")?; + .map_err(|error| format!("runner-v1 控件无法进入视口:{error}"))?; let probe = element .call_js_fn(PROBE_PLAYTEST_CONTROL_SCRIPT, false) .await - .map_err(|_| "runner-v1 控件检查失败")?; + .map_err(|error| format!("runner-v1 控件检查失败:{error}"))?; let probe: PlaytestControlProbe = serde_json::from_str( probe .result @@ -172,14 +173,18 @@ async fn control_center(page: &Page, selector: &'static str) -> Result<(f64, f64 .and_then(Value::as_str) .ok_or("runner-v1 控件信息缺失")?, ) - .map_err(|_| "runner-v1 控件信息无效")?; + .map_err(|error| format!("runner-v1 控件信息无效:{error}"))?; if !probe.is_html_element || !probe.visible || probe.disabled { return Err("runner-v1 控件必须可见且可用".into()); } let point = element.call_js_fn("function(){const r=this.getBoundingClientRect();return JSON.stringify({x:r.left+r.width/2,y:r.top+r.height/2});}", false) - .await.map_err(|_| "runner-v1 控件坐标读取失败")?.result.value.ok_or("runner-v1 控件坐标缺失")?; + .await + .map_err(|error| format!("runner-v1 控件坐标读取失败:{error}"))? + .result + .value + .ok_or("runner-v1 控件坐标缺失")?; let point: Value = serde_json::from_str(point.as_str().ok_or("runner-v1 控件坐标结果无效")?) - .map_err(|_| "runner-v1 控件坐标 JSON 无效")?; + .map_err(|error| format!("runner-v1 控件坐标 JSON 无效:{error}"))?; Ok(( point["x"].as_f64().ok_or("runner-v1 x 坐标无效")?, point["y"].as_f64().ok_or("runner-v1 y 坐标无效")?, @@ -196,7 +201,7 @@ async fn touch(page: &Page, point: Option<(f64, f64)>) -> Result<(), String> { }; page.execute(RunnerTouchInput { kind, points }) .await - .map_err(|_| "runner-v1 真实触摸输入失败")?; + .map_err(|error| format!("runner-v1 真实触摸输入失败:{error}"))?; Ok(()) } @@ -242,7 +247,7 @@ async fn held_input( input.windows_virtual_key_code = Some(if jump { 32 } else { 40 }); page.execute(input) .await - .map_err(|_| "runner-v1 键盘输入失败")?; + .map_err(|error| format!("runner-v1 键盘输入失败:{error}"))?; Ok(()) } From 11cf7a49fce6241351d6889d66b7a2eb10807e97 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:28:47 +0800 Subject: [PATCH 027/132] =?UTF-8?q?=E4=BF=9D=E7=95=99=20Web=20=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E9=A2=84=E6=A3=80=E9=94=99=E8=AF=AF=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 展示脱敏后的宿主预检和 IPC 具体原因 补充环境预检错误回归测试 --- .../features/app-shell/homeWebPreflight.ts | 25 +++++++++++++++++-- .../tests/homeWebPreflight.test.tsx | 2 +- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts b/apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts index 976be5cbd..172b3eabb 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts @@ -1,4 +1,5 @@ import type { ProjectStartMode, TauriInvoke } from '../../app/types'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import type { HomeCreationType } from '../../view/home/useHomeDraftStore'; /** @@ -28,12 +29,32 @@ function knownPreflightCode(value: unknown): string | undefined { } export function homeWebPreflightErrorMessage(value: unknown): string { + const visible = visibleClientErrorMessage(value, ''); + if ( + visible.startsWith('Web 游戏环境预检未通过(') || + visible.startsWith(HOME_WEB_PREFLIGHT_IPC_FAILURE) + ) { + return visible; + } if (value instanceof Error && value.message === HOME_WEB_PREFLIGHT_FAILURE) { return HOME_WEB_PREFLIGHT_FAILURE; } const code = knownPreflightCode(value); - if (code) return `Web 游戏环境预检未通过(${code});尚未开始生成。`; - return HOME_WEB_PREFLIGHT_IPC_FAILURE; + const safeDetail = visible + .replace(/Web 游戏环境预检未通过[::]?/u, '') + .replace(code ?? '', '') + .replace(/[;;]?\s*尚未启动生成[。.]?/u, '') + .replace(/^\s*[;::,,-]\s*/u, '') + .replace(/[;;]\s*$/u, '') + .trim(); + if (code) { + return safeDetail + ? `Web 游戏环境预检未通过(${code}):${safeDetail};尚未开始生成。` + : `Web 游戏环境预检未通过(${code});尚未开始生成。`; + } + return safeDetail + ? `${HOME_WEB_PREFLIGHT_IPC_FAILURE}(${safeDetail})` + : HOME_WEB_PREFLIGHT_IPC_FAILURE; } function isHostPreflightError(value: unknown): boolean { diff --git a/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx b/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx index 99325fff0..171e0b567 100644 --- a/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx +++ b/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx @@ -101,7 +101,7 @@ it('distinguishes an IPC failure from a host preflight failure', async () => { 'direct-build', ); }); - expect(status).toBe(HOME_WEB_PREFLIGHT_IPC_FAILURE); + expect(status).toBe(`${HOME_WEB_PREFLIGHT_IPC_FAILURE}(Failed to fetch)`); expect(calls).toEqual([ 'preflight_web_game_creation', 'preflight_web_game_creation', From f3fbf51a1736874d124faa1bff6742b8ef4df770 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:42:26 +0800 Subject: [PATCH 028/132] =?UTF-8?q?=E4=BF=9D=E7=95=99=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E9=A2=84=E6=A3=80=E5=92=8C=20Web=20=E6=9E=84=E5=BB=BA=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留 Node npm stdout stderr 退出状态和超时原因 预检回执同时返回机器码与脱敏诊断 补充环境失败正文回归测试 --- .../src-tauri/src/environment_check.rs | 200 ++++++++++++++---- .../src/environment_check/web_creation.rs | 118 +++++------ ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...实施计划】AGC错误具体文本展示-2026-10-04.md | 1 + 4 files changed, 224 insertions(+), 96 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs index 5025dc08c..e187a12a1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs @@ -22,6 +22,77 @@ const SCHEMA: &str = "agc-node-runtime.v1"; const PROBE_TIMEOUT: Duration = Duration::from_secs(10); const MAX_PROBE_BYTES: u64 = 4096; +/// 进程输出持续排空但只保留有界尾部;失败收口前精确脱敏,不能把 stderr 整份丢弃。 +#[derive(Default)] +struct CapturedProcessOutput { + bytes: Vec, + total: usize, +} + +async fn capture_process_output( + mut reader: impl tokio::io::AsyncRead + Unpin, + output: &mut CapturedProcessOutput, + limit: usize, +) -> Result<(), std::io::Error> { + let mut buffer = vec![0u8; 4096]; + loop { + let count = reader.read(&mut buffer).await?; + if count == 0 { + return Ok(()); + } + output.total = output.total.saturating_add(count); + output.bytes.extend_from_slice(&buffer[..count]); + let excess = output.bytes.len().saturating_sub(limit); + if excess > 0 { + output.bytes.drain(..excess); + } + } +} + +fn environment_failure(code: &str, detail: impl std::fmt::Display) -> String { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail.to_string(), + 1800, + ); + format!("{code}: {detail}") +} + +fn process_failure_output( + code: &str, + detail: impl std::fmt::Display, + stdout: &CapturedProcessOutput, + stderr: &CapturedProcessOutput, +) -> String { + // 优先保留 stderr(npm/Node 的具体故障通常在这里),stdout 留作构建报错的补充。 + environment_failure( + code, + format!( + "{detail};stdoutBytes={};stderrBytes={};stderr={};stdout={}", + stdout.total, + stderr.total, + String::from_utf8_lossy(&stderr.bytes), + String::from_utf8_lossy(&stdout.bytes), + ), + ) +} + +fn blocked_environment_report(error: &str) -> Value { + let candidate = error.split([':', ';']).next().unwrap_or_default(); + let code = if candidate.contains('-') + && candidate + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + candidate + } else { + "environment-check-unclassified" + }; + let diagnostic = + crate::agent::redact_agent_runtime_error(Path::new("__agc_no_project_root__"), error, 1800); + json!({"status":"blocked", "code":code, "diagnostic":diagnostic}) +} + #[derive(Clone, Debug)] pub(crate) struct NodeRuntime { pub node: PathBuf, @@ -357,11 +428,14 @@ fn valid_version(value: &str) -> bool { async fn probe_version(runtime: &NodeRuntime, npm: bool) -> Result { // npm 初始化也会查询用户目录;显式隔离,避免依赖 Windows 后备查询。 - let home = tempfile::tempdir().map_err(|_| "runtime-probe-home-unavailable")?; + let home = tempfile::tempdir() + .map_err(|error| environment_failure("runtime-probe-home-unavailable", error))?; let config = home.path().join("user.npmrc"); let global_config = home.path().join("global.npmrc"); - fs::write(&config, b"").map_err(|_| "runtime-probe-home-unavailable")?; - fs::write(&global_config, b"").map_err(|_| "runtime-probe-home-unavailable")?; + fs::write(&config, b"") + .map_err(|error| environment_failure("runtime-probe-home-unavailable", error))?; + fs::write(&global_config, b"") + .map_err(|error| environment_failure("runtime-probe-home-unavailable", error))?; let mut command = tokio::process::Command::new(&runtime.node); command .env_clear() @@ -377,7 +451,7 @@ async fn probe_version(runtime: &NodeRuntime, npm: bool) -> Result Result MAX_PROBE_BYTES { - return Err("runtime-version-output-too-large"); - } - let status = child - .wait() - .await - .map_err(|_| "runtime-version-wait-failed")?; + let (out, err, status) = tokio::join!( + capture_process_output(stdout, &mut output, MAX_PROBE_BYTES as usize), + capture_process_output(stderr, &mut error_output, MAX_PROBE_BYTES as usize), + child.wait(), + ); + out.map_err(|error| environment_failure("runtime-version-output-failed", error))?; + err.map_err(|error| environment_failure("runtime-version-output-failed", error))?; + let status = + status.map_err(|error| environment_failure("runtime-version-wait-failed", error))?; if !status.success() { - return Err("runtime-version-failed"); + return Err(process_failure_output( + "runtime-version-failed", + format!("exitStatus={status}"), + &output, + &error_output, + )); + } + if output.total as u64 > MAX_PROBE_BYTES { + return Err(process_failure_output( + "runtime-version-output-too-large", + format!("版本输出超过 {MAX_PROBE_BYTES} 字节上限"), + &output, + &error_output, + )); } Ok(()) }) @@ -421,15 +508,23 @@ async fn probe_version(runtime: &NodeRuntime, npm: bool) -> Result Value { let result = tokio::task::spawn_blocking(move || resolve_node_runtime(&root)).await; let runtime = match result { Ok(Ok(runtime)) => runtime, - Ok(Err(code)) => { - return json!({"status":"blocked","code":code,"elapsedMs":started.elapsed().as_millis()}) + Ok(Err(error)) => { + let mut blocked = blocked_environment_report(&error); + blocked["elapsedMs"] = json!(started.elapsed().as_millis()); + return blocked; } - Err(_) => { - return json!({"status":"blocked","code":"runtime-check-failed","elapsedMs":started.elapsed().as_millis()}) + Err(error) => { + return blocked_environment_report(&environment_failure( + "runtime-check-failed", + error, + )); } }; let (node, npm) = tokio::join!( @@ -458,8 +558,10 @@ pub(crate) async fn check_environment(root: &Path) -> Value { (Ok(node), Ok(npm)) => { json!({"status":"ready","source":runtime.source,"nodeVersion":node,"npmVersion":npm,"elapsedMs":started.elapsed().as_millis()}) } - (Err(code), _) | (_, Err(code)) => { - json!({"status":"blocked","code":code,"elapsedMs":started.elapsed().as_millis()}) + (Err(error), _) | (_, Err(error)) => { + let mut blocked = blocked_environment_report(&error); + blocked["elapsedMs"] = json!(started.elapsed().as_millis()); + blocked } } }; @@ -469,8 +571,10 @@ pub(crate) async fn check_environment(root: &Path) -> Value { Ok(browser) => { json!({"status":"ready","kind":browser.kind,"product":browser.product,"protocolVersion":browser.protocol_version,"elapsedMs":started.elapsed().as_millis()}) } - Err(code) => { - json!({"status":"blocked","code":code,"elapsedMs":started.elapsed().as_millis()}) + Err(error) => { + let mut blocked = blocked_environment_report(&error); + blocked["elapsedMs"] = json!(started.elapsed().as_millis()); + blocked } } }; @@ -623,6 +727,28 @@ mod tests { assert!(!valid_version("v24.0.0\nSECRET")); } + #[test] + fn runtime_probe_failure_keeps_bounded_redacted_process_detail() { + let stdout = CapturedProcessOutput { + bytes: b"npm version probe".to_vec(), + total: 18, + }; + let stderr = CapturedProcessOutput { + bytes: b"EACCES: permission denied; Authorization: Bearer private-token; C:\\Users\\private\\npmrc".to_vec(), + total: 91, + }; + let detail = process_failure_output( + "runtime-version-failed", + "exitStatus=code:1", + &stdout, + &stderr, + ); + assert!(detail.contains("runtime-version-failed"), "{detail}"); + assert!(detail.contains("EACCES"), "{detail}"); + assert!(!detail.contains("private-token"), "{detail}"); + assert!(!detail.contains("C:\\Users\\private"), "{detail}"); + } + #[test] fn diagnostic_path_removes_runtime_and_project_entries_but_keeps_other_tools() { let project = tempfile::tempdir().unwrap(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs index 018e9ff92..5c3d1ee48 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs @@ -179,24 +179,6 @@ pub(crate) fn record_new_web_scaffold_at(root: &Path, project_id: &str) -> Resul write_receipt(root, &receipt) } -async fn drain_output( - mut reader: R, -) -> Result<(), std::io::Error> { - // 保留输出大小界限;正文不进入预检回执,也不因管道满而阻塞子进程。 - let mut total = 0usize; - let mut buffer = [0u8; 4096]; - loop { - let count = reader.read(&mut buffer).await?; - if count == 0 { - return Ok(()); - } - total = total.saturating_add(count); - if total > MAX_COMMAND_OUTPUT { - total = MAX_COMMAND_OUTPUT; - } - } -} - async fn host_npm( runtime: &NodeRuntime, cwd: &Path, @@ -205,11 +187,14 @@ async fn host_npm( online: bool, owner: Option<(&Path, &PreparationOwner)>, ) -> Result<(), String> { - let home = tempfile::tempdir().map_err(|_| "web-build-home-unavailable")?; + let home = tempfile::tempdir() + .map_err(|error| environment_failure("web-build-home-unavailable", error))?; let config = home.path().join("user.npmrc"); let global_config = home.path().join("global.npmrc"); - fs::write(&config, b"").map_err(|_| "web-build-home-unavailable")?; - fs::write(&global_config, b"").map_err(|_| "web-build-home-unavailable")?; + fs::write(&config, b"") + .map_err(|error| environment_failure("web-build-home-unavailable", error))?; + fs::write(&global_config, b"") + .map_err(|error| environment_failure("web-build-home-unavailable", error))?; let mut execution = NpmExecution::new(); if let Some((root, owner)) = owner { record_execution(root, owner, Some(execution.clone()))?; @@ -251,9 +236,11 @@ async fn host_npm( use std::os::unix::process::CommandExt; command.as_std_mut().process_group(0); } - let mut child = command.spawn().map_err(|_| "web-build-start-failed")?; - let tree = - OwnedNpmTree::assign(&child, &execution).map_err(|_| "web-build-process-guard-failed")?; + let mut child = command + .spawn() + .map_err(|error| environment_failure("web-build-start-failed", error))?; + let tree = OwnedNpmTree::assign(&child, &execution) + .map_err(|error| environment_failure("web-build-process-guard-failed", error))?; execution.process = Some(ProcessIdentity::capture( child.id().ok_or("web-build-process-identity-missing")?, )?); @@ -265,17 +252,28 @@ async fn host_npm( stdin .write_all(b"GO\n") .await - .map_err(|_| "web-build-gate-failed")?; + .map_err(|error| environment_failure("web-build-gate-failed", error))?; drop(stdin); let stdout = child.stdout.take().ok_or("web-build-output-missing")?; let stderr = child.stderr.take().ok_or("web-build-output-missing")?; + let mut output = CapturedProcessOutput::default(); + let mut error_output = CapturedProcessOutput::default(); let result = tokio::time::timeout(timeout, async { - let (out, err, status) = - tokio::join!(drain_output(stdout), drain_output(stderr), child.wait()); - out.map_err(|_| "web-build-output-failed")?; - err.map_err(|_| "web-build-output-failed")?; - if !status.map_err(|_| "web-build-wait-failed")?.success() { - return Err("web-build-command-failed"); + let (out, err, status) = tokio::join!( + capture_process_output(stdout, &mut output, MAX_COMMAND_OUTPUT), + capture_process_output(stderr, &mut error_output, MAX_COMMAND_OUTPUT), + child.wait() + ); + out.map_err(|error| environment_failure("web-build-output-failed", error))?; + err.map_err(|error| environment_failure("web-build-output-failed", error))?; + let status = status.map_err(|error| environment_failure("web-build-wait-failed", error))?; + if !status.success() { + return Err(process_failure_output( + "web-build-command-failed", + format!("exitStatus={status}"), + &output, + &error_output, + )); } Ok(()) }) @@ -285,10 +283,19 @@ async fn host_npm( let _ = child.kill().await; let _ = child.wait().await; } - tree.terminate() - .map_err(|_| "web-build-subtree-not-reaped")?; + tree.terminate().map_err(|error| { + environment_failure( + "web-build-subtree-not-reaped", + format!("{error};原执行错误={result:?}"), + ) + })?; let deadline = Instant::now() + Duration::from_secs(5); - while !tree.empty().map_err(|_| "web-build-subtree-not-reaped")? { + while !tree.empty().map_err(|error| { + environment_failure( + "web-build-subtree-not-reaped", + format!("{error};原执行错误={result:?}"), + ) + })? { if Instant::now() >= deadline { return Err("web-build-subtree-not-reaped".into()); } @@ -297,9 +304,14 @@ async fn host_npm( if let Some((root, owner)) = owner { record_execution(root, owner, None)?; } - result - .map_err(|_| "web-build-timeout")? - .map_err(str::to_string) + result.map_err(|_| { + process_failure_output( + "web-build-timeout", + format!("npm 执行超过 {} ms", timeout.as_millis()), + &output, + &error_output, + ) + })? } /// 浏览器验证失败的宿主错误码。 @@ -349,11 +361,10 @@ fn browser_validation_failure_code(error: &str) -> &'static str { } } -fn browser_validation_diagnostic(error: &str) -> Option<&str> { - (error.starts_with("browser-recovery-") - || error.starts_with("browser-launch-failed:") - || error.starts_with("browser-cleanup-unconfirmed:")) - .then_some(error) +fn browser_validation_diagnostic(error: &str) -> Option { + let diagnostic = + crate::agent::redact_agent_runtime_error(Path::new("__agc_no_project_root__"), error, 1800); + (!diagnostic.is_empty()).then_some(diagnostic) } pub(crate) async fn host_web_creation_preflight() -> Value { @@ -415,19 +426,8 @@ pub(crate) async fn host_web_creation_preflight() -> Value { } .await; let mut result = run.unwrap_or_else(|error| { - let (code, diagnostic) = error - .split_once(";diagnostic=") - .map_or((error.as_str(), None), |(code, diagnostic)| { - (code, Some(diagnostic)) - }); - let mut blocked = json!({ - "schemaVersion": "agc-web-creation-preflight.v1", - "status": "blocked", - "code": code, - }); - if let Some(diagnostic) = diagnostic { - blocked["diagnostic"] = json!(diagnostic); - } + let mut blocked = blocked_environment_report(&error); + blocked["schemaVersion"] = json!("agc-web-creation-preflight.v1"); blocked }); result["elapsedMs"] = json!(started.elapsed().as_millis()); @@ -623,12 +623,12 @@ mod tests { "web-preflight-browser-recovery-failed" ); assert_eq!( - browser_validation_diagnostic(recovery_diagnostic), + browser_validation_diagnostic(recovery_diagnostic).as_deref(), Some(recovery_diagnostic) ); assert_eq!( - browser_validation_diagnostic("启动浏览器失败:原始错误"), - None + browser_validation_diagnostic("启动浏览器失败:原始错误").as_deref(), + Some("启动浏览器失败:原始错误") ); assert_eq!( browser_validation_failure_code("宿主已停止本轮浏览器验证"), @@ -640,7 +640,7 @@ mod tests { "web-preflight-browser-cleanup-failed" ); assert_eq!( - browser_validation_diagnostic(cleanup_diagnostic), + browser_validation_diagnostic(cleanup_diagnostic).as_deref(), Some(cleanup_diagnostic) ); assert_eq!( diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 50a3ff361..e91109988 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -110,6 +110,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - Codex CLI / Claude sidecar 的 JSONL/JSON 解析失败、失败终态、缺失终态、超时和空回执都保留解析错误、失败事件正文与有界安全片段;`LlmError` 没有 detail 字段的超时/空回执由 Direct `ModelCallFailed.detail` 补回,不再把这些协议事实统一压成单一类别。 - 账户、模型目录、External Editor/资源编辑、发布、素材上传、错误报告与客户端受控工具桥共用同一原则:网络错误保留底层因链,JSON/协议解析保留 serde 原因,HTTP 错误保留状态码与安全正文;只有响应完全没有正文时才说明“未提供 error/code/message”,不得退成“无法连接/格式无效/服务器未返回错误信息”。 - 浏览器启动/DevTools 握手与 Codex model-catalog 子进程失败保留 stderr、退出状态、解析错误和阶段;稳定机器码仍用于分类,但不能单独成为用户可见正文。 +- Node/npm 环境探测和 Web scaffold 构建持续排空并保存有界 stdout/stderr 尾部,失败返回退出状态、超时阶段和脱敏正文;环境预检回执同时下发 `code` 与 `diagnostic`。 - 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 3bc04036e..ffe252f3d 100644 --- a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md @@ -20,6 +20,7 @@ Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md` 11. Codex CLI / Claude sidecar 的解析失败、失败终态、缺失终态、超时和空回执保留有界脱敏正文;即使平台 `LlmError` 变体没有 detail 字段,也由统一 Direct `ModelCallFailed.detail` 继续携带原始原因。 12. 扩展统一正文口径到账户、模型目录、External Editor/资源编辑、发布、素材上传、错误报告与客户端受控工具桥,保留 HTTP 状态、响应安全片段、JSON 解析原因、网络因链和 IPC/桥接原因。 13. 浏览器启动/DevTools 握手与 Codex model-catalog 子进程错误保留 stderr、退出状态、解析原因和阶段信息,机器码只作为分类字段。 +14. Node/npm 探测与 Web scaffold 构建失败保留有界脱敏 stdout/stderr、退出状态和超时原因;`preflight_web_game_creation` 不再只返回机器码。 ## 实现顺序 From 997f6bb9102bb2f34feee84a0c1743ad2bccb68c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:03:21 +0800 Subject: [PATCH 029/132] =?UTF-8?q?=E4=BF=9D=E7=95=99=20app-server=20?= =?UTF-8?q?=E5=B5=8C=E5=A5=97=E9=94=99=E8=AF=AF=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留 codexErrorInfo 的有界脱敏结构化值 补充真实 400/401 机器字段错误回归测试 更新 AGC 错误展示决策与验收证据 --- .../src/agent/codex_app_server/mod.rs | 56 +++++++++++++++++++ ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 5 +- 3 files changed, 60 insertions(+), 2 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 4476da562..db3d8a0a1 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 @@ -343,6 +343,26 @@ fn game_creator_codex_app_server_error_kind_with_machine_detail( .collect::>(); if !keys.is_empty() { fields.push(format!("fields={}", keys.join(","))); + // `codexErrorInfo` is often the only field returned by app-server for a + // failed turn. Keeping just its key (the old behaviour) turns a real + // HTTP/connection failure into `other detail=fields=codexErrorInfo`. + // Preserve the bounded structured value as diagnostic text; the same + // redaction boundary below removes credentials, URLs and paths while + // retaining status codes and actionable provider fields. + let structured_details = keys + .iter() + .filter_map(|key| { + let value = object.get(key)?; + let serialized = serde_json::to_string(value).ok()?; + let safe = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &serialized, + 360, + ); + (!safe.trim().is_empty()).then(|| format!("{key}={safe}")) + }) + .collect::>(); + fields.extend(structured_details); } let detail = ["message", "additionalDetails"] .into_iter() @@ -7105,6 +7125,42 @@ mod tests { assert!(!visible.contains("victim")); } + #[test] + fn codex_app_server_failed_turn_keeps_nested_error_info_details() { + let failed_turn = serde_json::json!({ + "status": "failed", + "error": { + "codexErrorInfo": { + "httpConnectionFailed": { + "httpStatusCode": 400, + "message": "provider rejected the request" + }, + "responseStreamConnectionFailed": { + "retryable": false + } + } + } + }); + let visible = game_creator_codex_app_server_failed_turn_error(&failed_turn).to_string(); + assert!(visible.contains("httpStatusCode"), "{visible}"); + assert!(visible.contains("400"), "{visible}"); + assert!( + visible.contains("provider rejected the request"), + "{visible}" + ); + } + + #[test] + fn codex_app_server_failed_turn_keeps_machine_only_error_info_value() { + let failed_turn = serde_json::json!({ + "status": "failed", + "error": { "codexErrorInfo": "other" } + }); + let visible = game_creator_codex_app_server_failed_turn_error(&failed_turn).to_string(); + assert!(visible.contains("fields=codexErrorInfo"), "{visible}"); + assert!(visible.contains("codexErrorInfo=\"other\""), "{visible}"); + } + #[test] fn codex_app_server_failed_turn_maps_stable_categories_and_http_status() { for (info, expected_prefix) in [ diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index e91109988..1f2e79f57 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -106,6 +106,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - `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 而丢失正文。 +- app-server 失败对象只有 `codexErrorInfo` 嵌套字段时,也保留其有界 JSON 值(例如 `httpStatusCode`、连接阶段和 `retryable`);不能只留下 `fields=codexErrorInfo`,否则真实 400/401 会再次退化成分类兜底。 - Claude Code(cc)侧车的非零退出、RPC `error`、stdout JSON/UTF-8 解析失败、stderr 和静默超时也走同一映射;侧车 stderr 只在有界收口窗口内读取并进入同一脱敏 detail,不再只写裸 `eprintln!`。 - Codex CLI / Claude sidecar 的 JSONL/JSON 解析失败、失败终态、缺失终态、超时和空回执都保留解析错误、失败事件正文与有界安全片段;`LlmError` 没有 detail 字段的超时/空回执由 Direct `ModelCallFailed.detail` 补回,不再把这些协议事实统一压成单一类别。 - 账户、模型目录、External Editor/资源编辑、发布、素材上传、错误报告与客户端受控工具桥共用同一原则:网络错误保留底层因链,JSON/协议解析保留 serde 原因,HTTP 错误保留状态码与安全正文;只有响应完全没有正文时才说明“未提供 error/code/message”,不得退成“无法连接/格式无效/服务器未返回错误信息”。 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index b423a4beb..76f760f71 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -13,6 +13,7 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - `turn.completed.failure` 的前端展示:按 typed 变体显示分类、状态码和脱敏后的 `detail` / `diagnostic`。 - Rust 侧错误文本脱敏:只替换敏感值、URL、绝对路径和私钥内容,保留 HTTP 状态、错误码、字段名和可行动描述。 +- app-server `codexErrorInfo` 只有嵌套机器字段时,保留经有界脱敏的结构化值;`fields=codexErrorInfo` 不能成为唯一正文。 - Transport / Stream / IPC / host process / memory exhaustion 的回归测试与错误事件证据。 ## 不做 @@ -34,7 +35,7 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - `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;应用日志脱敏测试与启动诊断脱敏测试均通过。 +- Rust app-server 上游映射定向测试:8 passed;覆盖嵌套 `codexErrorInfo` 与只有机器字段的错误值;应用日志脱敏测试与启动诊断脱敏测试均通过。 - 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 断链和真实内存压力尚未在本轮执行。 +- 真实 AGC 项目诊断发现真实请求 HTTP 400 曾被摘要为 `codex-app-server-error:other detail=fields=codexErrorInfo`,另有 401 仅显示 `codex-app-server-error:unauthorized`;该漏损已在 app-server 投影边界修复。真实 Provider、真实 IPC 断链和真实内存压力仍未在本轮主动制造。 From fd5d11eb0b0a76d9dbbf852f8a32197dad49807c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:07:30 +0800 Subject: [PATCH 030/132] =?UTF-8?q?=E4=BF=9D=E7=95=99=E6=9C=AA=E7=9F=A5=20?= =?UTF-8?q?app-server=20=E9=94=99=E8=AF=AF=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留非对象和未知形状错误的脱敏内容 补充错误投影回归测试与验收证据 --- .../src/agent/codex_app_server/mod.rs | 43 +++++++++++++++++++ ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 4 +- 3 files changed, 46 insertions(+), 2 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 db3d8a0a1..41f9d248b 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 @@ -315,6 +315,7 @@ fn game_creator_codex_app_server_error_kind_with_machine_detail( error: &serde_json::Value, ) -> platform_llm::LlmError { let mut fields = Vec::new(); + let mut preserved_detail = false; if let Some(object) = error.as_object() { if let Some(code) = object.get("code").and_then(serde_json::Value::as_str) { if !code.is_empty() @@ -362,6 +363,7 @@ fn game_creator_codex_app_server_error_kind_with_machine_detail( (!safe.trim().is_empty()).then(|| format!("{key}={safe}")) }) .collect::>(); + preserved_detail |= !structured_details.is_empty(); fields.extend(structured_details); } let detail = ["message", "additionalDetails"] @@ -379,6 +381,34 @@ fn game_creator_codex_app_server_error_kind_with_machine_detail( 480, ); fields.push(format!("detail={safe_detail}")); + preserved_detail = true; + } + } else if !error.is_null() { + let serialized = + serde_json::to_string(error).unwrap_or_else(|_| "<无法序列化 error>".into()); + let safe_detail = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &serialized, + 360, + ); + if !safe_detail.trim().is_empty() { + fields.push(format!("error={safe_detail}")); + preserved_detail = true; + } + } + if !preserved_detail { + // Unknown object shapes and machine-only errors must still carry a bounded + // safe payload. Returning only the native kind recreates the old opaque + // fallback when app-server adds a field this version does not know yet. + let serialized = + serde_json::to_string(error).unwrap_or_else(|_| "<无法序列化 error>".into()); + let safe_detail = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &serialized, + 360, + ); + if !safe_detail.trim().is_empty() && safe_detail != "{}" { + fields.push(format!("error={safe_detail}")); } } let suffix = if fields.is_empty() { @@ -7161,6 +7191,19 @@ mod tests { assert!(visible.contains("codexErrorInfo=\"other\""), "{visible}"); } + #[test] + fn codex_app_server_failed_turn_keeps_non_object_error_body() { + let failed_turn = serde_json::json!({ + "status": "failed", + "error": "provider returned a structured failure body" + }); + let visible = game_creator_codex_app_server_failed_turn_error(&failed_turn).to_string(); + assert!( + visible.contains("provider returned a structured failure body"), + "{visible}" + ); + } + #[test] fn codex_app_server_failed_turn_maps_stable_categories_and_http_status() { for (info, expected_prefix) in [ diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 1f2e79f57..4eaee544e 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -107,6 +107,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 脱敏只替换敏感值、完整 URL、绝对路径和私钥内容;字段名(例如 `Authorization`、`token`、`api_key`)保留,便于判断错误类别。原始凭据值不进入用户文本、应用日志或项目诊断。 - 上游 app-server 映射保留 `message` / `additionalDetails` 的脱敏摘要;因此 401/403/408/429/413/5xx 等状态不会因为分类为 transport 或 native 而丢失正文。 - app-server 失败对象只有 `codexErrorInfo` 嵌套字段时,也保留其有界 JSON 值(例如 `httpStatusCode`、连接阶段和 `retryable`);不能只留下 `fields=codexErrorInfo`,否则真实 400/401 会再次退化成分类兜底。 +- app-server 的 `error` 若是字符串或未知 JSON 形状,同样保留有界脱敏正文;只有真正为空的错误对象才允许没有更深正文。 - Claude Code(cc)侧车的非零退出、RPC `error`、stdout JSON/UTF-8 解析失败、stderr 和静默超时也走同一映射;侧车 stderr 只在有界收口窗口内读取并进入同一脱敏 detail,不再只写裸 `eprintln!`。 - Codex CLI / Claude sidecar 的 JSONL/JSON 解析失败、失败终态、缺失终态、超时和空回执都保留解析错误、失败事件正文与有界安全片段;`LlmError` 没有 detail 字段的超时/空回执由 Direct `ModelCallFailed.detail` 补回,不再把这些协议事实统一压成单一类别。 - 账户、模型目录、External Editor/资源编辑、发布、素材上传、错误报告与客户端受控工具桥共用同一原则:网络错误保留底层因链,JSON/协议解析保留 serde 原因,HTTP 错误保留状态码与安全正文;只有响应完全没有正文时才说明“未提供 error/code/message”,不得退成“无法连接/格式无效/服务器未返回错误信息”。 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index 76f760f71..181745ef3 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -13,7 +13,7 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - `turn.completed.failure` 的前端展示:按 typed 变体显示分类、状态码和脱敏后的 `detail` / `diagnostic`。 - Rust 侧错误文本脱敏:只替换敏感值、URL、绝对路径和私钥内容,保留 HTTP 状态、错误码、字段名和可行动描述。 -- app-server `codexErrorInfo` 只有嵌套机器字段时,保留经有界脱敏的结构化值;`fields=codexErrorInfo` 不能成为唯一正文。 +- app-server `codexErrorInfo` 只有嵌套机器字段时,保留经有界脱敏的结构化值;`error` 是字符串或未知 JSON 形状时也保留正文;`fields=codexErrorInfo` 不能成为唯一正文。 - Transport / Stream / IPC / host process / memory exhaustion 的回归测试与错误事件证据。 ## 不做 @@ -35,7 +35,7 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - `npx vitest run tests/directTurnFailure.test.ts`:15 passed。 - `npx tsc --noEmit -p apps/ai-game-creator-shell/tsconfig.json`:通过。 -- Rust app-server 上游映射定向测试:8 passed;覆盖嵌套 `codexErrorInfo` 与只有机器字段的错误值;应用日志脱敏测试与启动诊断脱敏测试均通过。 +- Rust app-server 上游映射定向测试:9 passed;覆盖嵌套 `codexErrorInfo`、只有机器字段和非对象错误正文;应用日志脱敏测试与启动诊断脱敏测试均通过。 - CC 错误分类回归:Claude sidecar 状态码/超时映射与非支付 409 保留上游状态的定向测试通过;侧车失败统一补充有界 stderr detail。 - 真实 AGC dev smoke:客户端使用 `3080`、后端 `8084`、数据库 `3101`、后台 `3103` 启动;真实 CC 回合复现 `Reached maximum number of turns (8)` 被旧代码错误记为 `transport-closed`,修复后 Tauri 已热重编译重启。修复后的真实 Provider 回放未再次发送,避免无必要的付费请求。 - 真实 AGC 项目诊断发现真实请求 HTTP 400 曾被摘要为 `codex-app-server-error:other detail=fields=codexErrorInfo`,另有 401 仅显示 `codex-app-server-error:unauthorized`;该漏损已在 app-server 投影边界修复。真实 Provider、真实 IPC 断链和真实内存压力仍未在本轮主动制造。 From 777ad7ebf53313082ee420b33f102a2d1324d1ed Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:24:34 +0800 Subject: [PATCH 031/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E4=B8=8E=20MCP=20=E9=94=99=E8=AF=AF=E6=AD=A3?= =?UTF-8?q?=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留素材资源模型目录和资源编辑底层错误 保留 Direct MCP stdio 写入读取与超时原因 补充错误展示验收与未通过夹具记录 --- .../src-tauri/src/agent/direct_tools_mcp.rs | 9 ++-- .../src/agent/generation/canvas_generation.rs | 10 ++--- .../src/game_distribution_publish.rs | 11 ++++- .../src-tauri/src/llm_catalog.rs | 2 +- .../src-tauri/src/platform_asset_upload.rs | 10 ++--- .../src-tauri/src/project/resource_editor.rs | 45 +++++++++++-------- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 2 + 8 files changed, 55 insertions(+), 35 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 51c33c5f2..e83d173e4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -1579,7 +1579,7 @@ fn read_bounded_line(reader: &mut R) -> Result>, Stri loop { let available = reader .fill_buf() - .map_err(|_| "读取 MCP 请求失败".to_string())?; + .map_err(|error| format!("读取 MCP 请求失败:{error}"))?; if available.is_empty() { return Ok((!bytes.is_empty()).then_some(bytes)); } @@ -1607,11 +1607,12 @@ fn write_direct_tools_mcp_response( writer: &mut impl Write, response: &Value, ) -> Result<(), String> { - serde_json::to_writer(&mut *writer, response).map_err(|_| "写入 MCP 响应失败".to_string())?; + serde_json::to_writer(&mut *writer, response) + .map_err(|error| format!("写入 MCP 响应失败:序列化失败:{error}"))?; writer .write_all(b"\n") .and_then(|_| writer.flush()) - .map_err(|_| "写入 MCP 响应失败".to_string()) + .map_err(|error| format!("写入 MCP 响应失败:{error}")) } #[cfg(not(test))] @@ -1705,7 +1706,7 @@ async fn run_direct_tools_mcp_stdio() -> Result<(), String> { let stdin = std::io::stdin(); read_direct_tools_mcp_requests(BufReader::new(stdin.lock()), sender); }) - .map_err(|_| "启动 MCP 请求读取失败".to_string())?; + .map_err(|error| format!("启动 MCP 请求读取失败:{error}"))?; let stdout = std::io::stdout(); dispatch_direct_tools_mcp_requests(receiver, &mut stdout.lock(), move |request| { let root = root.clone(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 96181bc03..9d846db13 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -975,7 +975,7 @@ async fn accepted_generation_is_authoritatively_failed_once( ), ) .await - .map_err(|_| "查询平台图片生成任务超时".to_string())??; + .map_err(|_| format!("查询平台图片生成任务超时(3000 ms);operationId={operation_id}"))??; access.validate_frozen_session()?; let generation = platform_generation_status_data(&payload); match json_string_field(generation, "status").as_deref() { @@ -2112,11 +2112,11 @@ async fn upload_manifest_asset_remote_reference_at( }; let upload_url = validate_external_asset_download_url(&ticket.host, access.api_base_url(), true) - .map_err(|_| "当前账号参考图上传地址不安全".to_string())?; + .map_err(|error| format!("当前账号参考图上传地址无效:{error}"))?; let upload_client = build_external_asset_download_client(&upload_url, access.api_base_url(), true) .await - .map_err(|_| "无法创建当前账号参考图上传客户端".to_string())?; + .map_err(|error| format!("无法创建当前账号参考图上传客户端:{error}"))?; access.validate_frozen_session()?; let form = ticket .form_fields @@ -2129,13 +2129,13 @@ async fn upload_manifest_asset_remote_reference_at( // 请求发出前就结束生命周期。 .file_name(file_name.clone()) .mime_str(&source.media_type) - .map_err(|_| "参考图媒体类型不能用于上传".to_string())?; + .map_err(|error| format!("参考图媒体类型不能用于上传:{error}"))?; let upload_response = upload_client .post(upload_url) .multipart(form.part("file", part)) .send() .await - .map_err(|_| "上传当前账号参考图失败".to_string())?; + .map_err(|error| format!("上传当前账号参考图失败:{error}"))?; if upload_response.status().as_u16() != ticket.success_action_status { return Err(format!( "上传当前账号参考图失败:HTTP {}", diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index d3b2c9436..5a3c3a5f9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -1053,7 +1053,11 @@ async fn resolve_cover_queue( let started_at = tokio::time::Instant::now(); for _ in 0..COVER_QUEUE_MAX_POLLS { if started_at.elapsed() > COVER_QUEUE_TIMEOUT { - return Err("生成游戏封面超时,请稍后重试".to_string()); + return Err(format!( + "生成游戏封面超时(超过 {} 秒);operationId={operation_id},当前状态={}", + COVER_QUEUE_TIMEOUT.as_secs(), + queue.status.as_deref().unwrap_or("unknown") + )); } tokio::time::sleep(COVER_QUEUE_POLL_INTERVAL).await; let status = request_json( @@ -1100,7 +1104,10 @@ async fn resolve_cover_queue( }); } } - Err("生成游戏封面超时,请稍后重试".to_string()) + Err(format!( + "生成游戏封面超时(轮询 {} 次仍未完成);operationId={operation_id}", + COVER_QUEUE_MAX_POLLS + )) } #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs index 37e612415..b228415bf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs @@ -187,7 +187,7 @@ pub(crate) async fn load_game_creator_llm_models() -> Result Result { - let url = Url::parse(host.trim()).map_err(|_| "素材上传地址无效".to_string())?; + let url = Url::parse(host.trim()).map_err(|error| format!("素材上传地址无效:{error}"))?; let hostname = url.host_str().unwrap_or_default().to_ascii_lowercase(); let is_local = url.scheme() == "http" && matches!(hostname.as_str(), "127.0.0.1" | "localhost"); let is_oss = url.scheme() == "https" && hostname.ends_with(".aliyuncs.com"); @@ -307,7 +307,7 @@ async fn upload_object( .timeout(HTTP_TIMEOUT) .redirect(reqwest::redirect::Policy::none()) .build() - .map_err(|_| "创建素材上传客户端失败".to_string())?; + .map_err(|error| format!("创建素材上传客户端失败:{error}"))?; let mut form = Form::new(); for (key, value) in &ticket.form_fields { form = form.text(key.clone(), value.clone()); @@ -315,13 +315,13 @@ async fn upload_object( let part = Part::bytes(input.bytes.clone()) .file_name(input.file_name.clone()) .mime_str(&input.content_type) - .map_err(|_| "素材媒体类型无效".to_string())?; + .map_err(|error| format!("素材媒体类型无效:{error}"))?; let response = client .post(upload_url) .multipart(form.part("file", part)) .send() .await - .map_err(|_| "上传素材失败:无法访问素材存储,请检查网络后重试".to_string())?; + .map_err(|error| format!("上传素材失败:无法访问素材存储:{error};请检查网络后重试"))?; validate_session(snapshot)?; if response.status().as_u16() != ticket.success_action_status { return Err(format!( @@ -403,7 +403,7 @@ pub(crate) async fn upload_platform_media_asset( .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() - .map_err(|_| "创建素材上传客户端失败".to_string())?; + .map_err(|error| format!("创建素材上传客户端失败:{error}"))?; let ticket = request_ticket(&client, &snapshot, &input).await?; upload_object(&snapshot, &ticket, &input).await?; confirm_object(&client, &snapshot, &ticket, &input).await diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 4aa2ec76e..0db3f084d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -1766,7 +1766,7 @@ async fn request_resource_edit_upload_ticket( ) .send() .await - .map_err(|_| "result-unknown: 创建源资源上传凭证未取得确定响应".to_string())?; + .map_err(|error| format!("result-unknown: 创建源资源上传凭证请求失败:{error}"))?; let mut post_response_session_error = access.validate_frozen_session().err(); if response.status() == reqwest::StatusCode::UNAUTHORIZED { if let Some(error) = post_response_session_error { @@ -1798,7 +1798,7 @@ async fn request_resource_edit_upload_ticket( let payload = response .json::() .await - .map_err(|_| "result-unknown: 解析源资源上传凭证失败".to_string())?; + .map_err(|error| format!("result-unknown: 解析源资源上传凭证失败:{error}"))?; if post_response_session_error.is_none() { post_response_session_error = access.validate_frozen_session().err(); } @@ -1877,11 +1877,11 @@ async fn upload_resource_edit_source( .ok_or_else(|| "源媒体上传缺少文件内容".to_string())?; let upload_url = validate_external_asset_download_url(&ticket.host, access.api_base_url(), true) - .map_err(|_| "源资源上传地址不安全".to_string())?; + .map_err(|error| format!("源资源上传地址无效:{error}"))?; access.validate_frozen_session()?; let client = build_external_asset_download_client(&upload_url, access.api_base_url(), true) .await - .map_err(|_| "无法创建源资源上传客户端".to_string())?; + .map_err(|error| format!("无法创建源资源上传客户端:{error}"))?; access.validate_frozen_session()?; let mut form = Form::new(); for (key, value) in &ticket.form_fields { @@ -1891,14 +1891,14 @@ async fn upload_resource_edit_source( let part = Part::bytes(bytes.clone()) .file_name(file_name) .mime_str(&source.media_type) - .map_err(|_| "源资源媒体类型不能用于上传".to_string())?; + .map_err(|error| format!("源资源媒体类型不能用于上传:{error}"))?; access.validate_frozen_session()?; let response = client .post(upload_url) .multipart(form.part("file", part)) .send() .await - .map_err(|_| "result-unknown: 上传源资源未取得确定响应".to_string())?; + .map_err(|error| format!("result-unknown: 上传源资源请求失败:{error}"))?; let post_response_session_error = access.validate_frozen_session().err(); if response.status().as_u16() != ticket.success_action_status { if response.status().is_server_error() { @@ -1953,7 +1953,7 @@ async fn confirm_resource_edit_source( ) .send() .await - .map_err(|_| "result-unknown: 确认源资源上传未取得确定响应".to_string())?; + .map_err(|error| format!("result-unknown: 确认源资源上传请求失败:{error}"))?; let mut post_response_session_error = access.validate_frozen_session().err(); if response.status() == reqwest::StatusCode::UNAUTHORIZED { if let Some(error) = post_response_session_error { @@ -1985,7 +1985,7 @@ async fn confirm_resource_edit_source( let payload = response .json::() .await - .map_err(|_| "result-unknown: 解析源资源确认响应失败".to_string())?; + .map_err(|error| format!("result-unknown: 解析源资源确认响应失败:{error}"))?; if post_response_session_error.is_none() { post_response_session_error = access.validate_frozen_session().err(); } @@ -2065,7 +2065,7 @@ async fn register_resource_edit_source_image( ) .send() .await - .map_err(|_| "result-unknown: 登记源图片项目资源未取得确定响应".to_string())?; + .map_err(|error| format!("result-unknown: 登记源图片项目资源请求失败:{error}"))?; let mut post_response_session_error = access.validate_frozen_session().err(); if response.status() == reqwest::StatusCode::UNAUTHORIZED { if let Some(error) = post_response_session_error { @@ -2097,7 +2097,7 @@ async fn register_resource_edit_source_image( let payload = response .json::() .await - .map_err(|_| "result-unknown: 解析源图片项目资源响应失败".to_string())?; + .map_err(|error| format!("result-unknown: 解析源图片项目资源响应失败:{error}"))?; if post_response_session_error.is_none() { post_response_session_error = access.validate_frozen_session().err(); } @@ -2910,8 +2910,8 @@ async fn wait_for_resource_edit_remote( ) .send() .await - .map_err(|_| { - ResourceEditError::Other("result-unknown: 查询资源编辑任务失败".to_string()) + .map_err(|error| { + ResourceEditError::Other(format!("result-unknown: 查询资源编辑任务请求失败:{error}")) })?; access .validate_frozen_session() @@ -2932,9 +2932,14 @@ async fn wait_for_resource_edit_remote( response.status().as_u16() ))); } - let payload = response.json::().await.map_err(|_| { - ResourceEditError::Other("result-unknown: 资源编辑任务响应无法解析".to_string()) - })?; + let payload = response + .json::() + .await + .map_err(|error| { + ResourceEditError::Other(format!( + "result-unknown: 资源编辑任务响应无法解析:{error}" + )) + })?; access .validate_frozen_session() .map_err(ResourceEditError::Other)?; @@ -3435,7 +3440,9 @@ async fn prepare_remote_resource_edit( let client = crate::http_client::agc_main_site_client_builder() .timeout(Duration::from_secs(35 * 60)) .build() - .map_err(|_| ResourceEditError::Other("无法创建资源编辑 HTTP 客户端".to_string()))?; + .map_err(|error| { + ResourceEditError::Other(format!("无法创建资源编辑 HTTP 客户端:{error}")) + })?; let download_source = if ledger.phase == ResourceEditLedgerPhase::RemoteCompleted { if let Some(legacy_public_path) = ledger.remote_legacy_public_path.clone() { serde_json::json!({ "imageSrc": legacy_public_path }) @@ -3597,8 +3604,10 @@ async fn prepare_remote_resource_edit( media_read_limit(&input.edit_kind), ) .await - .map_err(|_| { - ResourceEditError::Other("result-unknown: 远端资源编辑结果下载或换签失败".to_string()) + .map_err(|error| { + ResourceEditError::Other(format!( + "result-unknown: 远端资源编辑结果下载或换签失败:{error}" + )) })? .ok_or_else(|| ResourceEditError::Other("远端资源编辑结果缺少可下载媒体".to_string()))?; access diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 4eaee544e..aa6692527 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -111,6 +111,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - Claude Code(cc)侧车的非零退出、RPC `error`、stdout JSON/UTF-8 解析失败、stderr 和静默超时也走同一映射;侧车 stderr 只在有界收口窗口内读取并进入同一脱敏 detail,不再只写裸 `eprintln!`。 - Codex CLI / Claude sidecar 的 JSONL/JSON 解析失败、失败终态、缺失终态、超时和空回执都保留解析错误、失败事件正文与有界安全片段;`LlmError` 没有 detail 字段的超时/空回执由 Direct `ModelCallFailed.detail` 补回,不再把这些协议事实统一压成单一类别。 - 账户、模型目录、External Editor/资源编辑、发布、素材上传、错误报告与客户端受控工具桥共用同一原则:网络错误保留底层因链,JSON/协议解析保留 serde 原因,HTTP 错误保留状态码与安全正文;只有响应完全没有正文时才说明“未提供 error/code/message”,不得退成“无法连接/格式无效/服务器未返回错误信息”。 +- 素材直传、参考图直传、资源编辑上传/轮询、模型目录初始化和 Direct MCP stdio 的 `send`/读写/序列化失败,不得用 `map_err(|_| 固定句)` 丢掉 `reqwest`、serde 或 OS/IPC 正文;固定句只能作为事实前缀,底层原因必须接在后面。 - 浏览器启动/DevTools 握手与 Codex model-catalog 子进程失败保留 stderr、退出状态、解析错误和阶段;稳定机器码仍用于分类,但不能单独成为用户可见正文。 - Node/npm 环境探测和 Web scaffold 构建持续排空并保存有界 stdout/stderr 尾部,失败返回退出状态、超时阶段和脱敏正文;环境预检回执同时下发 `code` 与 `diagnostic`。 - HTTP 409 只有明确包含泥点不足事实时才映射为 `paidCreditsInsufficient`;Claude Code 的普通 409 冲突保留为 `upstreamFailed`。 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index 181745ef3..9d346443f 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -14,6 +14,7 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - `turn.completed.failure` 的前端展示:按 typed 变体显示分类、状态码和脱敏后的 `detail` / `diagnostic`。 - Rust 侧错误文本脱敏:只替换敏感值、URL、绝对路径和私钥内容,保留 HTTP 状态、错误码、字段名和可行动描述。 - app-server `codexErrorInfo` 只有嵌套机器字段时,保留经有界脱敏的结构化值;`error` 是字符串或未知 JSON 形状时也保留正文;`fields=codexErrorInfo` 不能成为唯一正文。 +- 素材/参考图/资源编辑上传、资源编辑轮询、模型目录初始化和 Direct MCP stdio 的网络、JSON、写入、读写失败保留底层错误正文;不能由 `map_err(|_| 固定句)` 把它们压成无因的连接或失败提示。 - Transport / Stream / IPC / host process / memory exhaustion 的回归测试与错误事件证据。 ## 不做 @@ -39,3 +40,4 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - CC 错误分类回归:Claude sidecar 状态码/超时映射与非支付 409 保留上游状态的定向测试通过;侧车失败统一补充有界 stderr detail。 - 真实 AGC dev smoke:客户端使用 `3080`、后端 `8084`、数据库 `3101`、后台 `3103` 启动;真实 CC 回合复现 `Reached maximum number of turns (8)` 被旧代码错误记为 `transport-closed`,修复后 Tauri 已热重编译重启。修复后的真实 Provider 回放未再次发送,避免无必要的付费请求。 - 真实 AGC 项目诊断发现真实请求 HTTP 400 曾被摘要为 `codex-app-server-error:other detail=fields=codexErrorInfo`,另有 401 仅显示 `codex-app-server-error:unauthorized`;该漏损已在 app-server 投影边界修复。真实 Provider、真实 IPC 断链和真实内存压力仍未在本轮主动制造。 +- 继续扫描发现素材/参考图/资源编辑上传、模型目录初始化和 Direct MCP stdio 仍有固定 `map_err(|_| ...)` 丢正文,已改为保留底层网络、serde、写入和 OS 错误;平台素材上传定向 3 项、模型目录定向 4 项、Direct MCP 断输出定向 1 项通过。资源编辑全套并行测试本轮 54 项通过、11 项因共享 fixture/本地端口或账号环境超时失败,不能把该套结果记为全绿。 From 8b063c607fe8f944e0b8d691990c8926cb6a15c8 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:30:08 +0800 Subject: [PATCH 032/132] =?UTF-8?q?=E8=A1=A5=E5=85=A8=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E5=93=8D=E5=BA=94=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留素材上传服务端 code 和脱敏响应体 保留资源编辑非 400 响应与解析失败原因 通过资源编辑拒绝原因定向测试 --- .../src-tauri/src/platform_asset_upload.rs | 39 ++++++++++++++++--- .../src-tauri/src/project/resource_editor.rs | 19 ++++++--- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs index da18b0fc0..0b4f53aa4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs @@ -169,17 +169,32 @@ fn response_error(status: StatusCode, body: &str, action: &str) -> String { if status == StatusCode::FORBIDDEN { return format!("permission-denied: {action}权限不足"); } - let detail = serde_json::from_str::(body) - .ok() - .and_then(|value| { - value - .get("error") - .and_then(|error| error.get("message")) + let parsed = serde_json::from_str::(body).ok(); + let error = parsed.as_ref().and_then(|value| value.get("error")); + let detail = error + .and_then(|value| value.get("message")) + .or_else(|| parsed.as_ref().and_then(|value| value.get("message"))) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .or_else(|| { + error + .and_then(|value| value.get("code")) + .or_else(|| parsed.as_ref().and_then(|value| value.get("code"))) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string) }) + .or_else(|| { + let safe = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); + (!safe.is_empty()).then_some(safe) + }) .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); format!("{action}失败:{detail}") } @@ -450,4 +465,16 @@ mod tests { assert_eq!(payload["assetObjectId"], "asset-1"); assert!(!payload.to_string().contains("token")); } + + #[test] + fn response_error_keeps_server_code_and_redacted_body_when_message_is_missing() { + let detail = response_error( + StatusCode::BAD_REQUEST, + r#"{"error":{"code":"INVALID_ASSET","additionalDetails":"token=secret provider rejected"}}"#, + "确认素材资产", + ); + assert!(detail.contains("INVALID_ASSET"), "{detail}"); + assert!(!detail.contains("token=secret"), "{detail}"); + assert!(detail.contains("provider rejected"), "{detail}"); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 0db3f084d..189ad10fb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -2823,20 +2823,27 @@ async fn submit_resource_edit_remote( if let Some(error) = post_response_session_error.as_ref() { return Err(error.clone()); } - return Err(format!( - "result-unknown: 资源编辑请求返回不确定响应 HTTP {}", - status.as_u16() - )); + let reason = editor_api_rejection_reason(response).await; + return Err(match reason { + Some(reason) => format!( + "result-unknown: 资源编辑请求返回不确定响应 HTTP {}:{reason}", + status.as_u16() + ), + None => format!( + "result-unknown: 资源编辑请求返回不确定响应 HTTP {}", + status.as_u16() + ), + }); } let payload = match response.json::().await { Ok(payload) => payload, - Err(_) => { + Err(error) => { update_resource_edit_phase( root, ledger, ResourceEditLedgerPhase::ReconciliationRequired, )?; - return Err("result-unknown: 资源编辑响应无法解析".to_string()); + return Err(format!("result-unknown: 资源编辑响应无法解析:{error}")); } }; if post_response_session_error.is_none() { From cecc26d024f8ad7d86817d80c7db1c492b21dc2b Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:35:52 +0800 Subject: [PATCH 033/132] =?UTF-8?q?=E4=BC=A0=E6=92=AD=E6=8F=92=E4=BB=B6=20?= =?UTF-8?q?IPC=20=E5=92=8C=E5=AA=92=E4=BD=93=E8=A7=A3=E6=9E=90=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 插件 stdout 读取错误送达挂起 RPC 保留 UTF-8 图片和账本 JSON 解析原因 同步错误传播约定文档 --- .../src/agent/generation/canvas_generation.rs | 2 +- .../src-tauri/src/plugin_host.rs | 34 ++++++++++++++----- .../src-tauri/src/project/resource_editor.rs | 20 +++++------ ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 5 files changed, 39 insertions(+), 19 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 9d846db13..745cbd6c7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -1968,7 +1968,7 @@ fn read_validated_platform_art_reference_at( } let bytes = fs::read(&source_path).map_err(|error| format!("读取参考素材失败:{error}"))?; let decoded = image::load_from_memory(&bytes) - .map_err(|_| format!("参考素材不是可解析图片:{}", source.local_path))?; + .map_err(|error| format!("参考素材不是可解析图片:{}:{error}", source.local_path))?; Ok((bytes, decoded)) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs index 7694d53f3..4dd4e1b1e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs @@ -160,7 +160,7 @@ struct RunningPlugin { #[cfg(windows)] _job: crate::process_session::WindowsProcessJob, stdin: Arc>, - lines: Option>, + lines: Option>>, pending: PendingRpc, registrations: Arc>, next_request_id: u64, @@ -627,9 +627,18 @@ fn spawn_plugin(manifest: &PluginManifest, root: &Path) -> Result { + if sender.send(Ok(line)).is_err() { + break; + } + } + Ok(None) => break, + Err(error) => { + let _ = sender.send(Err(error)); + break; + } } } }); @@ -652,7 +661,7 @@ fn read_bounded_rpc_line(reader: &mut impl BufRead) -> Result, St loop { let buffer = reader .fill_buf() - .map_err(|_| "读取插件输出失败".to_string())?; + .map_err(|error| format!("读取插件输出失败:{error}"))?; if buffer.is_empty() { return if bytes.is_empty() { Ok(None) @@ -670,7 +679,7 @@ fn read_bounded_rpc_line(reader: &mut impl BufRead) -> Result, St if newline.is_some() { return String::from_utf8(bytes) .map(Some) - .map_err(|_| "插件输出不是 UTF-8".to_string()); + .map_err(|error| format!("插件输出不是 UTF-8:{error}")); } } } @@ -1437,10 +1446,18 @@ impl PluginHost { let manifest = record.manifest.clone(); let root = root.to_path_buf(); thread::spawn(move || { - while let Ok(line) = lines.recv() { + let mut terminal_error = None; + while let Ok(line_result) = lines.recv() { if !active.load(Ordering::SeqCst) { break; } + let line = match line_result { + Ok(line) => line, + Err(error) => { + terminal_error = Some(error); + break; + } + }; let Ok(envelope) = serde_json::from_str::(&line) else { continue; }; @@ -1495,8 +1512,9 @@ impl PluginHost { } if let Ok(mut waiting) = pending.lock() { let remaining = std::mem::take(&mut *waiting); + let terminal_error = terminal_error.unwrap_or_else(|| "插件进程已退出".to_string()); for (_, sender) in remaining { - let _ = sender.send(Err("插件进程已退出".to_string())); + let _ = sender.send(Err(terminal_error.clone())); } } }); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 189ad10fb..6e3c05987 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -1454,8 +1454,8 @@ fn resolve_resource_edit_source( RESOURCE_EDIT_TEXT_MAX_BYTES, "源文本资源", )?; - let text = - String::from_utf8(bytes).map_err(|_| "文本资源必须使用 UTF-8 编码".to_string())?; + let text = String::from_utf8(bytes) + .map_err(|error| format!("文本资源必须使用 UTF-8 编码:{error}"))?; return Ok(ResourceEditSourceSnapshot { canonical_resource_id, source_path: Some(path), @@ -1507,7 +1507,7 @@ fn resolve_resource_edit_source( let (source_width, source_height) = if input.edit_kind == LocalProjectResourceEditKind::CharacterAnimation { let decoded = image::load_from_memory(&bytes) - .map_err(|_| "生成角色动画前无法解析源图片尺寸".to_string())?; + .map_err(|error| format!("生成角色动画前无法解析源图片尺寸:{error}"))?; (Some(decoded.width()), Some(decoded.height())) } else { (None, None) @@ -2029,8 +2029,8 @@ async fn register_resource_edit_source_image( .bytes .as_deref() .ok_or_else(|| "登记源图片缺少文件内容".to_string())?; - let decoded = - image::load_from_memory(bytes).map_err(|_| "登记源图片前无法解析图片尺寸".to_string())?; + let decoded = image::load_from_memory(bytes) + .map_err(|error| format!("登记源图片前无法解析图片尺寸:{error}"))?; let endpoint = format!( "/api/external/v1/editor/projects/{}/resources", percent_encode_query_component(remote_project_id) @@ -2313,7 +2313,7 @@ async fn ensure_resource_edit_source_reference( .as_deref() .ok_or_else(|| "登记源图片缺少文件内容".to_string())?; let decoded = image::load_from_memory(bytes) - .map_err(|_| "登记源图片前无法解析图片尺寸".to_string())?; + .map_err(|error| format!("登记源图片前无法解析图片尺寸:{error}"))?; let resource_id = if let Some(resource_id) = ledger.source_remote_resource_id.clone() { resource_id } else { @@ -2755,7 +2755,7 @@ async fn submit_resource_edit_remote( .as_deref() .ok_or_else(|| "资源编辑账本缺少请求正文".to_string())?; let mut body_value = serde_json::from_str::(body) - .map_err(|_| "资源编辑账本请求正文无效".to_string())?; + .map_err(|error| format!("资源编辑账本请求正文无效:{error}"))?; if editor_api_mode() == EditorApiMode::PlatformAccount { if let Some(inputs) = body_value.as_object_mut().and_then(|body| { body.entry("generationInputs") @@ -3165,7 +3165,7 @@ fn validate_downloaded_media( return Err("抠图结果必须是带透明通道的 PNG".to_string()); } let decoded = image::load_from_memory_with_format(bytes, image::ImageFormat::Png) - .map_err(|_| "抠图结果不是有效的 PNG".to_string())?; + .map_err(|error| format!("抠图结果不是有效的 PNG:{error}"))?; if !decoded.color().has_alpha() { return Err("抠图结果缺少透明通道".to_string()); } @@ -4745,7 +4745,7 @@ fn write_resource_edit_result_binding( | LocalProjectResourceEditKind::BackgroundRemoval ) { let decoded = image::load_from_memory(&bytes) - .map_err(|_| "派生图片 binding 无法解析尺寸".to_string())?; + .map_err(|error| format!("派生图片 binding 无法解析尺寸:{error}"))?; (Some(decoded.width()), Some(decoded.height())) } else { (None, None) @@ -5878,7 +5878,7 @@ pub(crate) async fn derive_local_project_resource_typed( Err(error) => Err(error), Ok(bytes) => (|| { let content = std::str::from_utf8(&bytes) - .map_err(|_| "派生文本不是 UTF-8".to_string()) + .map_err(|error| format!("派生文本不是 UTF-8:{error}")) .map_err(ResourceEditError::Other)?; let (media_type, extension) = validate_text_derivative( &input.edit_kind, diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index aa6692527..9e814a1d7 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -112,6 +112,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - Codex CLI / Claude sidecar 的 JSONL/JSON 解析失败、失败终态、缺失终态、超时和空回执都保留解析错误、失败事件正文与有界安全片段;`LlmError` 没有 detail 字段的超时/空回执由 Direct `ModelCallFailed.detail` 补回,不再把这些协议事实统一压成单一类别。 - 账户、模型目录、External Editor/资源编辑、发布、素材上传、错误报告与客户端受控工具桥共用同一原则:网络错误保留底层因链,JSON/协议解析保留 serde 原因,HTTP 错误保留状态码与安全正文;只有响应完全没有正文时才说明“未提供 error/code/message”,不得退成“无法连接/格式无效/服务器未返回错误信息”。 - 素材直传、参考图直传、资源编辑上传/轮询、模型目录初始化和 Direct MCP stdio 的 `send`/读写/序列化失败,不得用 `map_err(|_| 固定句)` 丢掉 `reqwest`、serde 或 OS/IPC 正文;固定句只能作为事实前缀,底层原因必须接在后面。 +- 插件 stdout RPC 读取线程不得吞掉 `fill_buf`、UTF-8 或大小上限错误后伪装成“插件进程已退出”;读线程通过挂起 RPC 的错误通道传播真实原因。 - 浏览器启动/DevTools 握手与 Codex model-catalog 子进程失败保留 stderr、退出状态、解析错误和阶段;稳定机器码仍用于分类,但不能单独成为用户可见正文。 - Node/npm 环境探测和 Web scaffold 构建持续排空并保存有界 stdout/stderr 尾部,失败返回退出状态、超时阶段和脱敏正文;环境预检回执同时下发 `code` 与 `diagnostic`。 - HTTP 409 只有明确包含泥点不足事实时才映射为 `paidCreditsInsufficient`;Claude Code 的普通 409 冲突保留为 `upstreamFailed`。 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index 9d346443f..b2a498180 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -15,6 +15,7 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - Rust 侧错误文本脱敏:只替换敏感值、URL、绝对路径和私钥内容,保留 HTTP 状态、错误码、字段名和可行动描述。 - app-server `codexErrorInfo` 只有嵌套机器字段时,保留经有界脱敏的结构化值;`error` 是字符串或未知 JSON 形状时也保留正文;`fields=codexErrorInfo` 不能成为唯一正文。 - 素材/参考图/资源编辑上传、资源编辑轮询、模型目录初始化和 Direct MCP stdio 的网络、JSON、写入、读写失败保留底层错误正文;不能由 `map_err(|_| 固定句)` 把它们压成无因的连接或失败提示。 +- 插件 stdout RPC 的 `fill_buf`、UTF-8 和响应大小错误通过挂起 RPC 传播,不能在读取线程里吞掉后统一说“插件进程已退出”。 - Transport / Stream / IPC / host process / memory exhaustion 的回归测试与错误事件证据。 ## 不做 From d5ce9efbe570627e32d46b90d51597b462ce44cf Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:43:34 +0800 Subject: [PATCH 034/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E4=BF=9D=E7=95=99=20?= =?UTF-8?q?CLI=20Runner=20=E5=92=8C=E6=9C=AC=E5=9C=B0=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留 Codex CLI stderr 与 Agent Runner 协议错误 补充本地素材字体和域名解析底层原因 校准 Runner 脱敏回归测试 --- .../src-tauri/src/agent/codex_cli.rs | 24 +++++++++++-------- .../src-tauri/src/assets.rs | 2 +- .../src-tauri/src/commands.rs | 18 ++++++++------ .../src-tauri/src/runner/client.rs | 8 +++---- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 6 files changed, 32 insertions(+), 22 deletions(-) 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 3dfc50189..7fb2b7dda 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 @@ -149,10 +149,10 @@ fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result(&value) - .map_err(|_| "内置 Codex CLI 完整性清单无效".to_string()) + .map_err(|error| format!("内置 Codex CLI 完整性清单无效:{error}")) })?; if manifest.schema_version != codex_bundle::SCHEMA || manifest.platform != layout.platform @@ -167,9 +167,9 @@ fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result 120 { return Err("返回了无法识别的版本信息".to_string()); @@ -494,7 +494,7 @@ where let count = reader .read(&mut buffer) .await - .map_err(|_| "读取 Codex CLI Agent stderr 失败".to_string())?; + .map_err(|error| format!("读取 Codex CLI Agent stderr 失败:{error}"))?; if count == 0 { break; } @@ -846,14 +846,18 @@ async fn request_game_creator_agent_codex_cli_with_executable( }; let stdout = stdout_task .await - .map_err(|_| { - platform_llm::LlmError::Transport("Codex CLI Agent stdout 读取任务失败".to_string()) + .map_err(|error| { + platform_llm::LlmError::Transport(format!( + "Codex CLI Agent stdout 读取任务失败:{error}" + )) })? .map_err(platform_llm::LlmError::Transport)?; let stderr = stderr_task .await - .map_err(|_| { - platform_llm::LlmError::Transport("Codex CLI Agent stderr 读取任务失败".to_string()) + .map_err(|error| { + platform_llm::LlmError::Transport(format!( + "Codex CLI Agent stderr 读取任务失败:{error}" + )) })? .map_err(platform_llm::LlmError::Transport)?; if timed_out { diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 5a3724c90..f7881313c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -1238,7 +1238,7 @@ pub(crate) async fn build_external_asset_download_client( .map(|addresses| addresses.collect::>()) }) .await - .map_err(|_| "解析画板资产下载域名的任务异常".to_string())? + .map_err(|error| format!("解析画板资产下载域名的任务异常:{error}"))? .map_err(|error| format!("解析画板资产下载域名失败:{error}"))?; if addresses.is_empty() { return Err("画板资产下载域名没有可用地址".to_string()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 9bcdfd7ee..ea9d974da 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -295,7 +295,8 @@ fn validate_local_asset_import_requirements( // leave the early validation path unable to reach the one-shot UAC // repair entry. crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地导入文件")?; - let metadata = fs::symlink_metadata(path).map_err(|_| "读取本地文件失败".to_string())?; + let metadata = + fs::symlink_metadata(path).map_err(|error| format!("读取本地文件失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("只能导入普通文件".to_string()); } @@ -1031,14 +1032,15 @@ fn read_registered_ui_editor_font( } let target = resolve_local_project_path(root, &asset.local_path)?; prepare_game_creator_private_path_for_read(&target, false, "项目字体")?; - let metadata = fs::symlink_metadata(&target).map_err(|_| "读取项目字体失败".to_string())?; + let metadata = + fs::symlink_metadata(&target).map_err(|error| format!("读取项目字体失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("项目字体必须是普通文件".to_string()); } if metadata.len() > UI_EDITOR_FONT_MAX_FILE_SIZE { return Err("项目字体超过 8 MiB 限制".to_string()); } - let bytes = fs::read(&target).map_err(|_| "读取项目字体失败".to_string())?; + let bytes = fs::read(&target).map_err(|error| format!("读取项目字体失败:{error}"))?; let source_file_name = target .file_name() .and_then(|value| value.to_str()) @@ -1089,14 +1091,15 @@ pub(crate) fn import_ui_editor_local_fonts( for source in source_paths { let path = Path::new(source.trim()); crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地字体")?; - let metadata = fs::symlink_metadata(path).map_err(|_| "读取本地字体失败".to_string())?; + let metadata = + fs::symlink_metadata(path).map_err(|error| format!("读取本地字体失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("只能导入普通字体文件".to_string()); } if metadata.len() > UI_EDITOR_FONT_MAX_FILE_SIZE { return Err("字体超过 8 MiB 限制".to_string()); } - let bytes = fs::read(path).map_err(|_| "读取本地字体失败".to_string())?; + let bytes = fs::read(path).map_err(|error| format!("读取本地字体失败:{error}"))?; let source_file_name = path .file_name() .and_then(|value| value.to_str()) @@ -3146,7 +3149,8 @@ pub(crate) fn import_local_project_assets_for_agent_with_write_permit( if metadata.file_type().is_symlink() || !metadata.is_file() { return Err(format!("本地素材只能是普通文件:{normalized}")); } - let bytes = fs::read(&source).map_err(|_| format!("读取本地资源失败:{normalized}"))?; + let bytes = fs::read(&source) + .map_err(|error| format!("读取本地资源失败:{normalized}:{error}"))?; let file_type = agent_local_project_file_type(&normalized, &bytes)?; if metadata.len() > file_type.max_file_size { return Err(format!( @@ -3200,7 +3204,7 @@ pub(crate) fn import_local_project_assets_for_agent_with_write_permit( if target.exists() { prepare_game_creator_private_path_for_read(&target, false, "目标资源")?; let existing_bytes = - fs::read(&target).map_err(|_| "读取目标资源失败".to_string())?; + fs::read(&target).map_err(|error| format!("读取目标资源失败:{error}"))?; if existing_bytes != bytes { return Err(format!("本地资源目标已存在且内容不同:{local_path}")); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 781a5d4a4..e07f929ce 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -472,8 +472,8 @@ fn send_external_agent_runner_request_with_protocol_and_id_and_timeouts( method: method.to_string(), params, }; - let payload = - serde_json::to_vec(&request).map_err(|_| "序列化 Agent Runner 请求失败".to_string())?; + let payload = serde_json::to_vec(&request) + .map_err(|error| format!("序列化 Agent Runner 请求失败:{error}"))?; let address = SocketAddrV4::new(Ipv4Addr::LOCALHOST, endpoint.port).into(); let mut stream = TcpStream::connect_timeout(&address, connect_timeout) .map_err(|error| format!("连接 Agent Runner 失败:{error}"))?; @@ -489,7 +489,7 @@ fn send_external_agent_runner_request_with_protocol_and_id_and_timeouts( let response_payload = read_external_agent_runner_frame(&mut stream) .map_err(|error| format!("读取 Agent Runner 响应失败:{error}"))?; let response = serde_json::from_slice::(&response_payload) - .map_err(|_| "解析 Agent Runner 响应失败".to_string())?; + .map_err(|error| format!("解析 Agent Runner 响应失败:{error}"))?; if response.protocol_version != protocol_version { return Err("Agent Runner 响应协议版本不兼容".to_string()); } @@ -1708,7 +1708,7 @@ mod diagnostic_log_tests { "agent.runner.failed: request failed https://example.invalid/api?value=1", config_dir, ), - "agent.runner.failed: request failed https://example.invalid/api?" + "agent.runner.failed: request failed " ); assert_eq!( sanitize_agent_runner_output("error password=hunter2", config_dir), diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 9e814a1d7..43624394a 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -113,6 +113,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 账户、模型目录、External Editor/资源编辑、发布、素材上传、错误报告与客户端受控工具桥共用同一原则:网络错误保留底层因链,JSON/协议解析保留 serde 原因,HTTP 错误保留状态码与安全正文;只有响应完全没有正文时才说明“未提供 error/code/message”,不得退成“无法连接/格式无效/服务器未返回错误信息”。 - 素材直传、参考图直传、资源编辑上传/轮询、模型目录初始化和 Direct MCP stdio 的 `send`/读写/序列化失败,不得用 `map_err(|_| 固定句)` 丢掉 `reqwest`、serde 或 OS/IPC 正文;固定句只能作为事实前缀,底层原因必须接在后面。 - 插件 stdout RPC 读取线程不得吞掉 `fill_buf`、UTF-8 或大小上限错误后伪装成“插件进程已退出”;读线程通过挂起 RPC 的错误通道传播真实原因。 +- Codex CLI/Agent Runner 的 stderr、子任务 join、请求序列化/响应解析和本地资源/字体读取错误保留底层 `std::io`、serde 与 UTF-8 原因;路径安全校验仍单独使用稳定的安全拒绝码。 - 浏览器启动/DevTools 握手与 Codex model-catalog 子进程失败保留 stderr、退出状态、解析错误和阶段;稳定机器码仍用于分类,但不能单独成为用户可见正文。 - Node/npm 环境探测和 Web scaffold 构建持续排空并保存有界 stdout/stderr 尾部,失败返回退出状态、超时阶段和脱敏正文;环境预检回执同时下发 `code` 与 `diagnostic`。 - HTTP 409 只有明确包含泥点不足事实时才映射为 `paidCreditsInsufficient`;Claude Code 的普通 409 冲突保留为 `upstreamFailed`。 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index b2a498180..ffde9ee82 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -16,6 +16,7 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - app-server `codexErrorInfo` 只有嵌套机器字段时,保留经有界脱敏的结构化值;`error` 是字符串或未知 JSON 形状时也保留正文;`fields=codexErrorInfo` 不能成为唯一正文。 - 素材/参考图/资源编辑上传、资源编辑轮询、模型目录初始化和 Direct MCP stdio 的网络、JSON、写入、读写失败保留底层错误正文;不能由 `map_err(|_| 固定句)` 把它们压成无因的连接或失败提示。 - 插件 stdout RPC 的 `fill_buf`、UTF-8 和响应大小错误通过挂起 RPC 传播,不能在读取线程里吞掉后统一说“插件进程已退出”。 +- Codex CLI/Agent Runner 及本地素材/字体读取保留 stderr、join、序列化/解析、UTF-8 和 OS 错误正文;路径边界拒绝仍保留稳定安全码。 - Transport / Stream / IPC / host process / memory exhaustion 的回归测试与错误事件证据。 ## 不做 From 6ecad74efe90e7738878fd2c3e946b094a93dc39 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:48:04 +0800 Subject: [PATCH 035/132] =?UTF-8?q?=E4=BF=9D=E7=95=99=20Provider=20proxy?= =?UTF-8?q?=20=E5=BA=95=E5=B1=82=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 传播请求体改写和上游发送失败正文 补充 Provider proxy 错误收口约定 --- .../src/agent/codex_provider_proxy.rs | 49 +++++++++++++------ ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs index bdc90be02..4d45af75d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs @@ -147,11 +147,11 @@ fn normalize_codex_provider_upstream(value: &str) -> Result { Ok(value.to_string()) } -fn proxy_error(status: StatusCode, message: &'static str) -> Response { +fn proxy_error(status: StatusCode, message: impl Into) -> Response { Response::builder() .status(status) .header("content-type", "text/plain; charset=utf-8") - .body(Body::from(message)) + .body(message.into()) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -175,18 +175,19 @@ fn is_codex_account_limit_header(name: &HeaderName) -> bool { name.as_str().to_ascii_lowercase().starts_with("x-codex-") } -fn parallel_direct_request(body: &[u8]) -> Result, &'static str> { - let mut value: serde_json::Value = - serde_json::from_slice(body).map_err(|_| "provider request JSON invalid")?; +fn parallel_direct_request(body: &[u8]) -> Result, String> { + let mut value: serde_json::Value = serde_json::from_slice(body) + .map_err(|error| format!("provider request JSON invalid:{error}"))?; let object = value .as_object_mut() - .ok_or("provider request must be an object")?; + .ok_or_else(|| "provider request must be an object".to_string())?; // SDK 的未知模型回退目录默认关闭并行。Direct 的宿主已负责许可、依赖和冲突, // 因此明确请求标准 Responses 并行能力;不伪造模型名称或工具的只读标记。 object.insert("parallel_tool_calls".into(), serde_json::Value::Bool(true)); - let bytes = serde_json::to_vec(&value).map_err(|_| "provider request JSON invalid")?; + let bytes = serde_json::to_vec(&value) + .map_err(|error| format!("provider request JSON serialization failed:{error}"))?; if bytes.len() > CODEX_PROVIDER_PROXY_MAX_REQUEST_BYTES { - return Err("provider request too large"); + return Err("provider request too large".to_string()); } Ok(bytes) } @@ -233,15 +234,21 @@ async fn proxy_codex_provider_request( let (parts, body) = request.into_parts(); let body = match to_bytes(body, CODEX_PROVIDER_PROXY_MAX_REQUEST_BYTES).await { Ok(body) => body, - Err(_) => return proxy_error(StatusCode::PAYLOAD_TOO_LARGE, "provider request too large"), + Err(error) => { + return proxy_error( + StatusCode::PAYLOAD_TOO_LARGE, + format!("provider request body read failed:{error}"), + ) + } }; let body = if state.parallel_tool_calls { match tokio::task::spawn_blocking(move || parallel_direct_request(&body)).await { Ok(Ok(bytes)) => axum::body::Bytes::from(bytes), - _ => { + Ok(Err(error)) => return proxy_error(StatusCode::BAD_REQUEST, error), + Err(error) => { return proxy_error( - StatusCode::BAD_REQUEST, - "provider request JSON invalid or oversized", + StatusCode::INTERNAL_SERVER_ERROR, + format!("provider request rewrite task failed:{error}"), ) } } @@ -264,10 +271,10 @@ async fn proxy_codex_provider_request( } let upstream_authorization = match format!("Bearer {}", state.upstream_bearer_token).parse() { Ok(value) => value, - Err(_) => { + Err(error) => { return proxy_error( StatusCode::INTERNAL_SERVER_ERROR, - "provider proxy credential invalid", + format!("provider proxy credential invalid:{error}"), ) } }; @@ -281,7 +288,12 @@ async fn proxy_codex_provider_request( .await { Ok(response) => response, - Err(_) => return proxy_error(StatusCode::BAD_GATEWAY, "provider upstream unavailable"), + Err(error) => { + return proxy_error( + StatusCode::BAD_GATEWAY, + format!("provider upstream unavailable:{error}"), + ) + } }; let status = upstream.status(); let upstream_headers = upstream.headers().clone(); @@ -310,7 +322,12 @@ async fn proxy_codex_provider_request( } response .body(Body::from_stream(stream)) - .unwrap_or_else(|_| proxy_error(StatusCode::BAD_GATEWAY, "provider response invalid")) + .unwrap_or_else(|error| { + proxy_error( + StatusCode::BAD_GATEWAY, + format!("provider response invalid:{error}"), + ) + }) } #[cfg(test)] diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 43624394a..42ec51ead 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -114,6 +114,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 素材直传、参考图直传、资源编辑上传/轮询、模型目录初始化和 Direct MCP stdio 的 `send`/读写/序列化失败,不得用 `map_err(|_| 固定句)` 丢掉 `reqwest`、serde 或 OS/IPC 正文;固定句只能作为事实前缀,底层原因必须接在后面。 - 插件 stdout RPC 读取线程不得吞掉 `fill_buf`、UTF-8 或大小上限错误后伪装成“插件进程已退出”;读线程通过挂起 RPC 的错误通道传播真实原因。 - Codex CLI/Agent Runner 的 stderr、子任务 join、请求序列化/响应解析和本地资源/字体读取错误保留底层 `std::io`、serde 与 UTF-8 原因;路径安全校验仍单独使用稳定的安全拒绝码。 +- Codex Provider proxy 的请求体读取/JSON 改写、上游请求、响应构造和 SSE 传输错误也必须把 serde、join、reqwest 或流错误写入返回正文,不能只回 `provider upstream unavailable`。 - 浏览器启动/DevTools 握手与 Codex model-catalog 子进程失败保留 stderr、退出状态、解析错误和阶段;稳定机器码仍用于分类,但不能单独成为用户可见正文。 - Node/npm 环境探测和 Web scaffold 构建持续排空并保存有界 stdout/stderr 尾部,失败返回退出状态、超时阶段和脱敏正文;环境预检回执同时下发 `code` 与 `diagnostic`。 - HTTP 409 只有明确包含泥点不足事实时才映射为 `paidCreditsInsufficient`;Claude Code 的普通 409 冲突保留为 `upstreamFailed`。 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index ffde9ee82..daa45726f 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -17,6 +17,7 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 素材/参考图/资源编辑上传、资源编辑轮询、模型目录初始化和 Direct MCP stdio 的网络、JSON、写入、读写失败保留底层错误正文;不能由 `map_err(|_| 固定句)` 把它们压成无因的连接或失败提示。 - 插件 stdout RPC 的 `fill_buf`、UTF-8 和响应大小错误通过挂起 RPC 传播,不能在读取线程里吞掉后统一说“插件进程已退出”。 - Codex CLI/Agent Runner 及本地素材/字体读取保留 stderr、join、序列化/解析、UTF-8 和 OS 错误正文;路径边界拒绝仍保留稳定安全码。 +- Codex Provider proxy 的请求体改写、上游发送、响应构造和 SSE 错误保留底层正文,避免代理层再次把具体网络/解析故障压成单一 unavailable。 - Transport / Stream / IPC / host process / memory exhaustion 的回归测试与错误事件证据。 ## 不做 From 28ce0c0b239e7feea8cdaafae9aa871eb2a6ef2c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:52:06 +0800 Subject: [PATCH 036/132] =?UTF-8?q?=E8=A1=A5=E5=85=A8=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留浏览器发现配置 DevTools 和清理原因 通过浏览器健康定向测试 --- .../src-tauri/src/browser/process.rs | 38 ++++++++++++++----- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs index ac46bbbda..66862b26e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs @@ -198,6 +198,23 @@ impl BrowserLaunchFailure { } } + fn setup_with_detail(code: &'static str, detail: impl Into) -> Self { + let detail = detail.into(); + Self { + code, + stage: BrowserLaunchStage::Setup, + recoverable: false, + subprocess_exited: true, + cleanup_confirmed: true, + detail: Some( + crate::sanitize_diagnostic_message(&detail, None) + .chars() + .take(1200) + .collect(), + ), + } + } + fn from_launch_error( error: OwnedBrowserLaunchError, subprocess_exited: bool, @@ -666,10 +683,12 @@ async fn launch_browser_attempt( executable: &std::path::Path, proxy_bypass_list: &str, ) -> Result { - let temporary = create_browser_process_temp_dir() - .map_err(|_| BrowserLaunchFailure::setup("browser-temp-unavailable"))?; - let config = browser_config(executable, &temporary, proxy_bypass_list) - .map_err(|_| BrowserLaunchFailure::setup("browser-config-invalid"))?; + let temporary = create_browser_process_temp_dir().map_err(|error| { + BrowserLaunchFailure::setup_with_detail("browser-temp-unavailable", error) + })?; + let config = browser_config(executable, &temporary, proxy_bypass_list).map_err(|error| { + BrowserLaunchFailure::setup_with_detail("browser-config-invalid", error) + })?; launch_owned_browser(config, executable, temporary).await } @@ -685,15 +704,16 @@ async fn launch_browser_with_recovery( /// 仅验证浏览器启动和真实 CDP,不加载项目、不生成试玩凭证。 /// 每次独立 profile;既不串行化其它工具,也不继承 Codex 的临时 HOME。 pub(crate) async fn check_browser_health() -> Result { - let discovered = discover_chrome_or_edge().map_err(|_| "browser-not-found")?; + let discovered = + discover_chrome_or_edge().map_err(|error| format!("browser-not-found:{error}"))?; let owned = launch_browser_with_recovery(&discovered.executable_path, "<-loopback").await?; let version = tokio::time::timeout(Duration::from_secs(5), owned.browser().version()).await; - if owned.shutdown().await.is_err() { - return Err("browser-cleanup-failed".into()); + if let Err(error) = owned.shutdown().await { + return Err(format!("browser-cleanup-failed:{error}")); } let version = version - .map_err(|_| "browser-cdp-timeout")? - .map_err(|_| "browser-cdp-failed")?; + .map_err(|_| "browser-cdp-timeout:等待 DevTools 版本响应超过 5000 ms".to_string())? + .map_err(|error| format!("browser-cdp-failed:{error}"))?; if !safe_version_label(&version.product) || !safe_version_label(&version.protocol_version) { return Err("browser-version-invalid".into()); } diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 42ec51ead..e853f1829 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -115,6 +115,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 插件 stdout RPC 读取线程不得吞掉 `fill_buf`、UTF-8 或大小上限错误后伪装成“插件进程已退出”;读线程通过挂起 RPC 的错误通道传播真实原因。 - Codex CLI/Agent Runner 的 stderr、子任务 join、请求序列化/响应解析和本地资源/字体读取错误保留底层 `std::io`、serde 与 UTF-8 原因;路径安全校验仍单独使用稳定的安全拒绝码。 - Codex Provider proxy 的请求体读取/JSON 改写、上游请求、响应构造和 SSE 传输错误也必须把 serde、join、reqwest 或流错误写入返回正文,不能只回 `provider upstream unavailable`。 +- 浏览器健康检查的发现、临时目录、配置、DevTools 版本请求和清理失败保留底层错误/超时阶段;稳定浏览器码用于分类,但不能覆盖诊断正文。 - 浏览器启动/DevTools 握手与 Codex model-catalog 子进程失败保留 stderr、退出状态、解析错误和阶段;稳定机器码仍用于分类,但不能单独成为用户可见正文。 - Node/npm 环境探测和 Web scaffold 构建持续排空并保存有界 stdout/stderr 尾部,失败返回退出状态、超时阶段和脱敏正文;环境预检回执同时下发 `code` 与 `diagnostic`。 - HTTP 409 只有明确包含泥点不足事实时才映射为 `paidCreditsInsufficient`;Claude Code 的普通 409 冲突保留为 `upstreamFailed`。 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index daa45726f..f4fffb4cd 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -18,6 +18,7 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 插件 stdout RPC 的 `fill_buf`、UTF-8 和响应大小错误通过挂起 RPC 传播,不能在读取线程里吞掉后统一说“插件进程已退出”。 - Codex CLI/Agent Runner 及本地素材/字体读取保留 stderr、join、序列化/解析、UTF-8 和 OS 错误正文;路径边界拒绝仍保留稳定安全码。 - Codex Provider proxy 的请求体改写、上游发送、响应构造和 SSE 错误保留底层正文,避免代理层再次把具体网络/解析故障压成单一 unavailable。 +- 浏览器健康检查不再把发现、配置、DevTools 版本和清理失败只返回机器码;阶段码保留,同时带底层错误或明确的超时预算。 - Transport / Stream / IPC / host process / memory exhaustion 的回归测试与错误事件证据。 ## 不做 From 8ea1f018fe8ff823aa734b658a0c74f0d99d4e5c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:54:29 +0800 Subject: [PATCH 037/132] =?UTF-8?q?=E4=BF=9D=E7=95=99=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=8C=96=E9=94=99=E8=AF=AF=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免对象错误退化为 object Object 补充前端错误出口回归测试 --- .../src/services/errorReporting.ts | 26 ++++++++++++++++--- .../tests/errorReporting.test.ts | 14 ++++++++++ ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/services/errorReporting.ts b/apps/ai-game-creator-shell/src/services/errorReporting.ts index 7e7368b66..37ac949be 100644 --- a/apps/ai-game-creator-shell/src/services/errorReporting.ts +++ b/apps/ai-game-creator-shell/src/services/errorReporting.ts @@ -76,12 +76,32 @@ export function normalizeDiagnosticText(value: string) { .replace(/\b[0-9a-f]{8,}\b/giu, ''); } +function clientErrorText(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + if (error && typeof error === 'object') { + const record = error as Record; + const fields = ['message', 'detail', 'reason', 'code'] + .map((field) => record[field]) + .filter((value): value is string => typeof value === 'string') + .map((value) => value.trim()) + .filter(Boolean); + if (fields.length > 0) return fields.join(';'); + try { + return JSON.stringify(error); + } catch { + return ''; + } + } + return String(error ?? ''); +} + /** 用户可见的普通客户端错误正文:保留状态码/原因,替换凭据、URL、路径和长标识。 */ export function visibleClientErrorMessage( error: unknown, fallback = '未提供具体错误正文', ) { - const raw = error instanceof Error ? error.message : String(error ?? ''); + const raw = clientErrorText(error); const safe = normalizeDiagnosticText(raw.replace(/\s+/gu, ' ').trim()).slice( 0, 400, @@ -104,10 +124,10 @@ export async function captureClientError( } else if (error instanceof Error) { errorValue = error; } else { - errorValue = new Error(String(error)); + errorValue = new Error(clientErrorText(error)); } const message = - normalizeDiagnosticText(errorValue.message || '').slice(0, 400) || + normalizeDiagnosticText(clientErrorText(errorValue) || '').slice(0, 400) || '未知客户端错误'; const stack = errorValue.stack ? normalizeDiagnosticText(errorValue.stack).slice(0, 8_000) diff --git a/apps/ai-game-creator-shell/tests/errorReporting.test.ts b/apps/ai-game-creator-shell/tests/errorReporting.test.ts index 707d99f9a..833f1cd34 100644 --- a/apps/ai-game-creator-shell/tests/errorReporting.test.ts +++ b/apps/ai-game-creator-shell/tests/errorReporting.test.ts @@ -83,6 +83,7 @@ import { resetClientErrorEventsForTests, submitErrorReportBatch, subscribeClientErrorEvents, + visibleClientErrorMessage, } from '../src/services/errorReporting'; import type { ClientAuthError } from '../src/services/generated/ClientAuthError'; import { resetTauriRuntime } from './tauriRuntimeFake'; @@ -94,6 +95,19 @@ describe('客户端错误报告池', () => { vi.clearAllMocks(); }); + it('结构化客户端错误保留 message、code 和 detail,不退化为 object Object', () => { + expect( + visibleClientErrorMessage({ + code: 'ERR_NETWORK', + message: '连接被对端重置', + detail: 'socket hang up', + }), + ).toBe('连接被对端重置;socket hang up;ERR_NETWORK'); + expect(visibleClientErrorMessage({ reason: 'ipc-disconnected' })).toBe( + 'ipc-disconnected', + ); + }); + it('按 fingerprint 合并重复错误并累计次数', async () => { const first = (await captureClientError(new Error('重复错误'), { source: 'test', diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index e853f1829..949963a59 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -116,6 +116,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - Codex CLI/Agent Runner 的 stderr、子任务 join、请求序列化/响应解析和本地资源/字体读取错误保留底层 `std::io`、serde 与 UTF-8 原因;路径安全校验仍单独使用稳定的安全拒绝码。 - Codex Provider proxy 的请求体读取/JSON 改写、上游请求、响应构造和 SSE 传输错误也必须把 serde、join、reqwest 或流错误写入返回正文,不能只回 `provider upstream unavailable`。 - 浏览器健康检查的发现、临时目录、配置、DevTools 版本请求和清理失败保留底层错误/超时阶段;稳定浏览器码用于分类,但不能覆盖诊断正文。 +- WebView 普通错误出口遇到 Tauri 的结构化对象时,优先展示 `message/detail/reason/code`,再有界序列化对象;不得把对象转成 `[object Object]` 后回落“未提供具体错误正文”。 - 浏览器启动/DevTools 握手与 Codex model-catalog 子进程失败保留 stderr、退出状态、解析错误和阶段;稳定机器码仍用于分类,但不能单独成为用户可见正文。 - Node/npm 环境探测和 Web scaffold 构建持续排空并保存有界 stdout/stderr 尾部,失败返回退出状态、超时阶段和脱敏正文;环境预检回执同时下发 `code` 与 `diagnostic`。 - HTTP 409 只有明确包含泥点不足事实时才映射为 `paidCreditsInsufficient`;Claude Code 的普通 409 冲突保留为 `upstreamFailed`。 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index f4fffb4cd..2e333217d 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -19,6 +19,7 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - Codex CLI/Agent Runner 及本地素材/字体读取保留 stderr、join、序列化/解析、UTF-8 和 OS 错误正文;路径边界拒绝仍保留稳定安全码。 - Codex Provider proxy 的请求体改写、上游发送、响应构造和 SSE 错误保留底层正文,避免代理层再次把具体网络/解析故障压成单一 unavailable。 - 浏览器健康检查不再把发现、配置、DevTools 版本和清理失败只返回机器码;阶段码保留,同时带底层错误或明确的超时预算。 +- WebView 结构化对象错误保留 `message/detail/reason/code` 或有界 JSON 正文,避免 `[object Object]` 触发普通错误兜底。 - Transport / Stream / IPC / host process / memory exhaustion 的回归测试与错误事件证据。 ## 不做 From 7e24d9511fc9d3f5df34c4a085e6aba2c2bd6e29 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:58:40 +0800 Subject: [PATCH 038/132] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=8C=96=E9=94=99=E8=AF=AF=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免资源和工作台错误退化为 object Object 统一 Direct UI 和资源面板的正文脱敏出口 通过错误报告和 Direct 展示回归测试 --- apps/ai-game-creator-shell/src/App.tsx | 2 +- .../features/app-shell/useRecentProjects.ts | 2 +- .../resourceCanvasAssetGenerationQueue.ts | 14 ++----------- .../resourceVersionReplacementModel.ts | 10 +++++----- .../src/services/platformSessionRetry.ts | 3 ++- .../useDirectProjectChatController.ts | 11 ++++++---- .../useDirectThreadChatSubscription.ts | 6 +++++- .../src/view/project-development/index.tsx | 5 ++--- .../projectAssetCommandErrorMessage.ts | 15 ++------------ .../useProjectResourceCanvasLayout.ts | 16 +++++---------- .../useProjectResourceCardPreviews.ts | 5 ++--- .../src/view/ui-editor/useUiEditorPage.ts | 20 +++++++++---------- 12 files changed, 43 insertions(+), 66 deletions(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 0b6661de5..20dec971a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -567,7 +567,7 @@ export function App({ if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) { return; } - const rawMessage = error instanceof Error ? error.message : String(error); + const rawMessage = visibleClientErrorMessage(error); const message = visibleClientErrorMessage(error); if (isRuntimeConfigMissingError(rawMessage)) { requestRuntimeConfigOpen(); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts index d2dfb6e6a..f27558fb8 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts @@ -78,7 +78,7 @@ async function inspectRecentWorkspace( ]); return { path: workspace, status, retryable: true }; } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = visibleClientErrorMessage(error); return { path: workspace, status: null, diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts index 6d9b66c0d..48c13571e 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts @@ -1,4 +1,4 @@ -import { normalizeDiagnosticText } from '../../services/errorReporting'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { applyLocalProjectAssetGenerationRecords, type LocalProjectAssetGenerationTaskRecord, @@ -90,17 +90,7 @@ export type ResourceCanvasAssetGenerationQueue = { }; function errorMessage(error: unknown) { - const raw = - typeof error === 'string' - ? error - : error instanceof Error - ? error.message - : String(error ?? ''); - const safe = normalizeDiagnosticText(raw.replace(/\s+/gu, ' ').trim()).slice( - 0, - 400, - ); - return safe || '生成素材失败'; + return visibleClientErrorMessage(error, '生成素材失败').slice(0, 400); } function safeGenerationRecord( diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts index fb39281c9..598a63944 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts @@ -1,5 +1,5 @@ import type { EditorAsset } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes'; -import { normalizeDiagnosticText } from '../../services/errorReporting'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import type { ProjectResource } from '../../view/project-development/resourceProjectionModel'; import { resourceCanvasAssetKind, @@ -257,10 +257,10 @@ export function resourceReplacementPickerAssets( * 不认识的错误原样透出:宁可显示后端原文,也不把原因吞掉换成"替换失败"这种无信息文案。 */ export function resourceVersionReplacementErrorMessage(error: unknown): string { - const message = normalizeDiagnosticText( - error instanceof Error ? error.message : String(error ?? '').trim(), - ).slice(0, 400); - if (!message) return '替换素材失败'; + const message = visibleClientErrorMessage(error, '替换素材失败').slice( + 0, + 400, + ); if (message.includes('project-revision-conflict')) { return '项目已被其它操作改动,请重试替换'; } diff --git a/apps/ai-game-creator-shell/src/services/platformSessionRetry.ts b/apps/ai-game-creator-shell/src/services/platformSessionRetry.ts index baf76765d..c2bb57489 100644 --- a/apps/ai-game-creator-shell/src/services/platformSessionRetry.ts +++ b/apps/ai-game-creator-shell/src/services/platformSessionRetry.ts @@ -1,3 +1,4 @@ +import { visibleClientErrorMessage } from './errorReporting'; import { currentPlatformSessionGeneration, requestPlatformSessionRefresh, @@ -11,7 +12,7 @@ import { * 续期后重试一次,网络抖动、5xx 和业务拒绝必须原样抛出。 */ export function isPlatformAuthenticationRequiredError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); + const message = visibleClientErrorMessage(error); return ( message.includes('authentication-required') || message.includes('codex-app-server-error:unauthorized') || diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index d97a6945a..8f341d7e2 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -16,7 +16,10 @@ import { hasMeaningfulDirectCodexContent, userItemFromContent, } from '../../../../features/project-workspace/resourceReferences'; -import { captureAgentRuntimeError } from '../../../../services/errorReporting'; +import { + captureAgentRuntimeError, + visibleClientErrorMessage, +} from '../../../../services/errorReporting'; import { createDirectProjectTurnId, DIRECT_CODEX_AGENT_ID, @@ -495,7 +498,7 @@ export function useDirectProjectChatController({ if (projectPathRef.current === nextProjectPath) { onRuntimeError( projectRuntimeVisibleError( - error instanceof Error ? error.message : String(error), + visibleClientErrorMessage(error), '陶泥儿智能创作', true, ), @@ -565,7 +568,7 @@ export function useDirectProjectChatController({ if (!systemNotice) { onRuntimeError( projectRuntimeVisibleError( - error instanceof Error ? error.message : String(error), + visibleClientErrorMessage(error), '陶泥儿智能创作', true, ), @@ -741,7 +744,7 @@ export function useDirectProjectChatController({ // 与加载成功路径同样按世代守卫:切项目后在飞的读取失败不能算到新项目头上。 if (isStaleLoad()) return; const detail = visibleDirectFailureDetail( - error instanceof Error ? error.message : String(error), + visibleClientErrorMessage(error), ); const message = detail ? `读取更早的对话历史失败:${detail}` diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts index 1385de7d4..4306544d4 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts @@ -7,6 +7,7 @@ import { } from 'react'; import { resolveTauriInvoke } from '../../../../app/tauri'; +import { visibleClientErrorMessage } from '../../../../services/errorReporting'; import { canSubscribeTauriEvents, subscribeTauriEvent, @@ -128,7 +129,10 @@ export function useDirectThreadChatSubscription({ ); } while (consumeAgain && !disposed); } catch (error) { - if (!disposed && String(error).includes('SUBSCRIPTION_EXPIRED')) { + if ( + !disposed && + visibleClientErrorMessage(error).includes('SUBSCRIPTION_EXPIRED') + ) { subscriptionId = null; try { await bootstrap(); diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 3f99aa3b6..29c33bf23 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -525,7 +525,7 @@ function projectDevelopmentErrorMessage( } function isPlatformAuthenticationRequired(error: unknown) { - const message = error instanceof Error ? error.message : String(error); + const message = visibleClientErrorMessage(error); return ( message.includes('authentication-required') || message.includes('登录已失效') @@ -6728,8 +6728,7 @@ export default function ProjectDevelopmentView({ } } catch (error) { if (!isCurrentRecoveryProject(actionProject)) return; - const rawMessage = - error instanceof Error ? error.message : String(error); + const rawMessage = visibleClientErrorMessage(error); const message = projectDevelopmentErrorMessage(error); let displayMessage = message; if (rawMessage.includes('service-identity-confirmation-required')) { diff --git a/apps/ai-game-creator-shell/src/view/project-development/projectAssetCommandErrorMessage.ts b/apps/ai-game-creator-shell/src/view/project-development/projectAssetCommandErrorMessage.ts index 2c48a18af..0119f4c3e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/projectAssetCommandErrorMessage.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/projectAssetCommandErrorMessage.ts @@ -8,24 +8,13 @@ * 口径与 `resourceVersionReplacementErrorMessage` 里同样的两条映射逐字一致, * 三条链路给用户的说法必须一样。 */ -import { normalizeDiagnosticText } from '../../services/errorReporting'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; export function projectAssetCommandErrorMessage( error: unknown, fallback: string, ): string { - let message = ''; - if (typeof error === 'string') { - message = error.trim(); - } else if (error instanceof Error) { - message = error.message.trim(); - } else if (error !== null && error !== undefined) { - message = String(error).trim(); - } - message = normalizeDiagnosticText(message).slice(0, 400); - if (!message) { - return fallback; - } + const message = visibleClientErrorMessage(error, fallback); if (message.includes('project-revision-conflict')) { return '项目已被其它操作改动,请刷新后重试'; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts index e028ca312..949847eb6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts @@ -10,7 +10,7 @@ import { isSafeProjectResourceCanvasCoordinate, isSafeProjectResourceCanvasLayoutRevision, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -import { normalizeDiagnosticText } from '../../services/errorReporting'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { applyResourceCanvasPositionWrites, createEmptyResourceCanvasLayout, @@ -26,16 +26,12 @@ import { normalizeResourceCanvasPosition } from './resourceCanvasSectionMapping' * * 这两个 `.catch` 此前写成 `() => { ... }` —— 没有 message 参数,失败原因被整个吞掉: * 用户只看到"布局保存失败",排障端也拿不到为什么(revision 越界?IPC 拒绝?权限?)。 - * 这里把 `error.message` 压成一行并复用既有的 `normalizeDiagnosticText`(路径 / token / - * URL / 长 id 一律打码),再截断,避免多行堆栈糊进日志;日志由客户端已安装的 + * 这里复用既有的 `visibleClientErrorMessage`(结构化对象也保留正文,路径 / token / URL / + * 长 id 一律打码),再截断,避免多行堆栈糊进日志;日志由客户端已安装的 * webview→application.log 桥带进诊断日志,遇到"某个窗口保存一直失败"能直接查。 */ function describeResourceCanvasLayoutFailure(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return normalizeDiagnosticText(message.replace(/\s+/gu, ' ').trim()).slice( - 0, - 400, - ); + return visibleClientErrorMessage(error).slice(0, 400); } type LayoutNotice = @@ -439,9 +435,7 @@ function reconcileLayout( * 做,磁盘上仍是旧栏目取值的坐标不会因为 `section` 字面量不同而漏出整理范围。 */ organizeSections: - | readonly ProjectResourceCanvasCategory[] - | null - | undefined = undefined, + readonly ProjectResourceCanvasCategory[] | null | undefined = undefined, ) { // `undefined` = 不是整理;`null` = 整理全部栏目;否则按集合判定。 const organizedSections = diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts index 6b255b682..6522d7581 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts @@ -8,7 +8,7 @@ import { } from 'react'; import type { ProjectResourceCanvasLayoutMode } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -import { normalizeDiagnosticText } from '../../services/errorReporting'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { cancelLocalProjectResourcePreviewScope, createProjectResourcePreviewRequestId, @@ -109,8 +109,7 @@ function previewReadErrorMessage( kind: ProjectResourceCardPreviewKind, error: unknown, ): { error: string; retryable: boolean } { - const rawMessage = error instanceof Error ? error.message : String(error); - const message = normalizeDiagnosticText(rawMessage).slice(0, 400); + const message = visibleClientErrorMessage(error).slice(0, 400); const kindLabel = resourceReadKindLabel(kind); if (message.includes('项目权限策略要求用户确认')) { return { diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index 88de5c4b1..f5b3a78d2 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -342,8 +342,7 @@ export function useUiEditorSession( }) .catch((cause: unknown) => { if (!cancelled) { - const message = - cause instanceof Error ? cause.message : String(cause); + const message = visibleClientErrorMessage(cause); setStatus(message); setLoadError(message); } @@ -606,7 +605,7 @@ export function useUiEditorSession( })); setStatus(`已加入 ${prepared.length} 项独立素材。`); } catch (cause) { - setStatus(cause instanceof Error ? cause.message : String(cause)); + setStatus(visibleClientErrorMessage(cause)); } } @@ -970,7 +969,7 @@ export function useUiEditorSession( reportWorkflowCompletion( 'structure-recognition', 'failure', - cause instanceof Error ? cause.message : String(cause), + visibleClientErrorMessage(cause), setRecognitionStatus, ); } finally { @@ -1187,7 +1186,7 @@ export function useUiEditorSession( reportWorkflowCompletion( 'asset-separation', 'failure', - cause instanceof Error ? cause.message : String(cause), + visibleClientErrorMessage(cause), setSeparationStatus, ); } finally { @@ -1231,7 +1230,7 @@ export function useUiEditorSession( reportWorkflowCompletion( 'asset-separation', 'failure', - cause instanceof Error ? cause.message : String(cause), + visibleClientErrorMessage(cause), setSeparationStatus, ); } @@ -1266,7 +1265,7 @@ export function useUiEditorSession( reportWorkflowCompletion( 'asset-separation', 'failure', - cause instanceof Error ? cause.message : String(cause), + visibleClientErrorMessage(cause), setSeparationStatus, ); } @@ -1298,7 +1297,7 @@ export function useUiEditorSession( reportWorkflowCompletion( 'asset-separation', 'failure', - cause instanceof Error ? cause.message : String(cause), + visibleClientErrorMessage(cause), setSeparationStatus, ); } @@ -1424,8 +1423,7 @@ export function useUiEditorSession( analytics?.record(saved.status === 'saved'); return { status: 'generated' as const, result: generated }; } catch (cause) { - const message = - cause instanceof Error ? cause.message : String(cause); + const message = visibleClientErrorMessage(cause); return { status: 'failed' as const, phase: 'generate' as const, @@ -1434,7 +1432,7 @@ export function useUiEditorSession( } }); } catch (cause) { - const message = cause instanceof Error ? cause.message : String(cause); + const message = visibleClientErrorMessage(cause); setSaveError(message); return { status: 'failed' as const, phase: 'save' as const, message }; } finally { From dbe578fc622697a7f27f6bcac6fff98e89dd1c89 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:00:35 +0800 Subject: [PATCH 039/132] =?UTF-8?q?=E4=BF=9D=E7=95=99=20Direct=20=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=99=A8=E7=BB=93=E6=9E=84=E5=8C=96=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免控制器对象错误退化为 object Object 通过 Direct 失败展示回归测试 --- .../chat/conversation/directTurnFailure.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) 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 9fb1ad3ca..a1a583bba 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 @@ -70,7 +70,27 @@ export function visibleDirectControllerError( error: unknown, subject: string, ): string { - const raw = error instanceof Error ? error.message : String(error ?? ''); + const raw = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : error && typeof error === 'object' + ? (() => { + const record = error as Record; + const fields = ['message', 'detail', 'reason', 'code'] + .map((field) => record[field]) + .filter((value): value is string => typeof value === 'string') + .map((value) => value.trim()) + .filter(Boolean); + if (fields.length > 0) return fields.join(';'); + try { + return JSON.stringify(error); + } catch { + return ''; + } + })() + : String(error ?? ''); const detail = visibleDirectFailureDetail(raw); return detail ? `${subject}:${detail}` : `${subject}:未提供具体错误正文`; } From f4414a5ac5255584bf50a83ba87b457a89fc9632 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:04:23 +0800 Subject: [PATCH 040/132] =?UTF-8?q?=E6=A0=87=E6=98=8E=E7=BC=BA=E5=A4=B1=20?= =?UTF-8?q?Direct=20=E4=BC=A0=E8=BE=93=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免无 detail 载荷伪装成通道完整原因 补充旧载荷正文缺失回归测试 --- .../chat/conversation/directTurnFailure.ts | 20 ++++++++++++++----- .../tests/directTurnFailure.test.ts | 9 +++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) 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 a1a583bba..4d65fe27a 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 @@ -103,6 +103,16 @@ function withVisibleFailureDetail( return safeDetail ? `${message}:${safeDetail}` : message; } +function withVisibleFailureDetailOrMissing( + message: string, + detail: string | null | undefined, +) { + const safeDetail = visibleDirectFailureDetail(detail); + return safeDetail + ? `${message}:${safeDetail}` + : `${message}:宿主未提供具体错误正文`; +} + function stripUpstreamPrefix( detail: string | null | undefined, statusCode: number, @@ -188,17 +198,17 @@ function directModelCallText( detail, ); case 'connectionFailed': - return withVisibleFailureDetail( + return withVisibleFailureDetailOrMissing( '执行通道连接失败,本轮未完成;请重试,若持续失败请检查项目诊断', detail, ); case 'transportBroken': - return withVisibleFailureDetail( + return withVisibleFailureDetailOrMissing( '执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断', detail, ); case 'streamUnavailable': - return withVisibleFailureDetail( + return withVisibleFailureDetailOrMissing( '流式响应协议不可用,本轮未完成;请检查项目诊断后重试', detail, ); @@ -225,12 +235,12 @@ function directModelCallText( detail, ); case 'emptyResponse': - return withVisibleFailureDetail( + return withVisibleFailureDetailOrMissing( '模型未返回内容,请重试;如持续失败请检查项目诊断', detail, ); case 'payloadInvalid': - return withVisibleFailureDetail( + return withVisibleFailureDetailOrMissing( '模型回执无法解析,请重试;如持续失败请检查项目诊断', detail, ); diff --git a/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts b/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts index 1533f6999..cc4a4a5b5 100644 --- a/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts @@ -76,6 +76,15 @@ describe('DirectProject 上游失败文案', () => { ); }); + it('旧载荷缺少传输正文时明确标记正文缺失,不伪装成完整原因', () => { + const text = directTurnFailureNoticeText({ + type: 'modelCallFailed', + kind: { type: 'transportBroken' }, + detail: '', + }); + expect(text).toContain('宿主未提供具体错误正文'); + }); + it('保留内存不足和 IPC 错误文本', () => { const text = directTurnFailureNoticeText({ type: 'transportClosed', From f2946aaeca16d533c4604e7b43002ea0df772057 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:08:01 +0800 Subject: [PATCH 041/132] =?UTF-8?q?=E4=BF=9D=E7=95=99=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=A1=A5=E5=92=8C=E9=AA=8C=E8=AF=81=E8=AF=81=E6=8D=AE=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留图片证据读取和哈希底层原因 补充 Direct 工具错误展示约定 --- .../src-tauri/src/agent/direct_tool_bridge.rs | 8 +++---- .../src-tauri/src/agent/direct_validation.rs | 23 ++++++++++--------- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index a3a32f57f..3133e14c3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1053,15 +1053,15 @@ fn bridge_resource_request_fingerprint(input: &DirectResourceGenerationInput) -> fn bridge_png_content(root: &Path, path: &Path) -> Result { let root = root .canonicalize() - .map_err(|_| "工具桥项目根无法安全解析".to_string())?; + .map_err(|error| format!("工具桥项目根无法安全解析:{error}"))?; let path = path .canonicalize() - .map_err(|_| "工具桥图片不存在".to_string())?; + .map_err(|error| format!("工具桥图片不存在或无法解析:{error}"))?; if !path.starts_with(&root) { return Err("工具桥图片越出当前项目边界".to_string()); } let metadata = - std::fs::symlink_metadata(&path).map_err(|_| "读取工具桥图片失败".to_string())?; + std::fs::symlink_metadata(&path).map_err(|error| format!("读取工具桥图片失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > DIRECT_TOOL_BRIDGE_MAX_IMAGE_BYTES @@ -1071,7 +1071,7 @@ fn bridge_png_content(root: &Path, path: &Path) -> Result { let (mut file, _) = open_project_snapshot_regular_file(&path, "工具桥图片")?; let mut bytes = Vec::new(); file.read_to_end(&mut bytes) - .map_err(|_| "读取工具桥图片失败".to_string())?; + .map_err(|error| format!("读取工具桥图片失败:{error}"))?; if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") { return Err("工具桥图片不是有效 PNG".to_string()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs index a42aed1b0..9fa876912 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs @@ -104,17 +104,17 @@ fn evidence_hashes(root: &Path, result: &Value) -> Result { return Err("validation-evidence: 证据不属于受控验证目录".into()); } let path = resolve_local_project_path(root, relative)?; - let meta = - std::fs::symlink_metadata(&path).map_err(|_| "validation-evidence: 证据文件丢失")?; + let meta = std::fs::symlink_metadata(&path) + .map_err(|error| format!("validation-evidence: 证据文件丢失:{error}"))?; if !meta.is_file() || meta.file_type().is_symlink() || meta.len() > 16 * 1024 * 1024 { return Err("validation-evidence: 证据类型或大小无效".into()); } let mut bytes = Vec::new(); std::fs::File::open(path) - .map_err(|_| "validation-evidence: 无法读取证据")? + .map_err(|error| format!("validation-evidence: 无法读取证据:{error}"))? .take(16 * 1024 * 1024 + 1) .read_to_end(&mut bytes) - .map_err(|_| "validation-evidence: 无法读取证据")?; + .map_err(|error| format!("validation-evidence: 无法读取证据:{error}"))?; if bytes.len() > 16 * 1024 * 1024 { return Err("validation-evidence: 证据文件在读取时超限".into()); } @@ -234,14 +234,15 @@ fn fingerprint(root: &Path, include_outputs: bool) -> Result { let mut bytes = 0u64; let mut visited = 0usize; while let Some(directory) = directories.pop() { - for entry in - std::fs::read_dir(&directory).map_err(|_| "validation-fingerprint: 读取项目失败")? + for entry in std::fs::read_dir(&directory) + .map_err(|error| format!("validation-fingerprint: 读取项目失败:{error}"))? { visited += 1; if visited > 20_000 { return Err("validation-fingerprint: 项目文件数超限".into()); } - let entry = entry.map_err(|_| "validation-fingerprint: 读取目录项失败")?; + let entry = entry + .map_err(|error| format!("validation-fingerprint: 读取目录项失败:{error}"))?; let name = entry.file_name().to_string_lossy().to_ascii_lowercase(); if matches!( name.as_str(), @@ -264,7 +265,7 @@ fn fingerprint(root: &Path, include_outputs: bool) -> Result { } let ty = entry .file_type() - .map_err(|_| "validation-fingerprint: 读取类型失败")?; + .map_err(|error| format!("validation-fingerprint: 读取类型失败:{error}"))?; let path = entry.path(); if !include_outputs && name == "dist" @@ -300,14 +301,14 @@ fn fingerprint(root: &Path, include_outputs: bool) -> Result { if name.starts_with("game-creator.config") { continue; } - let mut file = - std::fs::File::open(&path).map_err(|_| "validation-fingerprint: 打开输入失败")?; + let mut file = std::fs::File::open(&path) + .map_err(|error| format!("validation-fingerprint: 打开输入失败:{error}"))?; let mut digest = Sha256::new(); let mut buffer = [0u8; 64 * 1024]; loop { let read = file .read(&mut buffer) - .map_err(|_| "validation-fingerprint: 读取输入失败")?; + .map_err(|error| format!("validation-fingerprint: 读取输入失败:{error}"))?; if read == 0 { break; } diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 949963a59..e066ee91c 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -117,6 +117,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - Codex Provider proxy 的请求体读取/JSON 改写、上游请求、响应构造和 SSE 传输错误也必须把 serde、join、reqwest 或流错误写入返回正文,不能只回 `provider upstream unavailable`。 - 浏览器健康检查的发现、临时目录、配置、DevTools 版本请求和清理失败保留底层错误/超时阶段;稳定浏览器码用于分类,但不能覆盖诊断正文。 - WebView 普通错误出口遇到 Tauri 的结构化对象时,优先展示 `message/detail/reason/code`,再有界序列化对象;不得把对象转成 `[object Object]` 后回落“未提供具体错误正文”。 +- Direct 工具桥图片读取和真实试玩验证证据的 OS 读取/哈希错误保留底层原因;路径越界、私有路径和安全拒绝仍使用稳定安全码。 - 浏览器启动/DevTools 握手与 Codex model-catalog 子进程失败保留 stderr、退出状态、解析错误和阶段;稳定机器码仍用于分类,但不能单独成为用户可见正文。 - Node/npm 环境探测和 Web scaffold 构建持续排空并保存有界 stdout/stderr 尾部,失败返回退出状态、超时阶段和脱敏正文;环境预检回执同时下发 `code` 与 `diagnostic`。 - HTTP 409 只有明确包含泥点不足事实时才映射为 `paidCreditsInsufficient`;Claude Code 的普通 409 冲突保留为 `upstreamFailed`。 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index 2e333217d..df5ddfe51 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -20,6 +20,7 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - Codex Provider proxy 的请求体改写、上游发送、响应构造和 SSE 错误保留底层正文,避免代理层再次把具体网络/解析故障压成单一 unavailable。 - 浏览器健康检查不再把发现、配置、DevTools 版本和清理失败只返回机器码;阶段码保留,同时带底层错误或明确的超时预算。 - WebView 结构化对象错误保留 `message/detail/reason/code` 或有界 JSON 正文,避免 `[object Object]` 触发普通错误兜底。 +- Direct 工具桥图片与真实试玩证据读取/哈希错误保留 OS 正文,安全边界拒绝继续使用稳定码。 - Transport / Stream / IPC / host process / memory exhaustion 的回归测试与错误事件证据。 ## 不做 From 9d450eca7c963e6552c7a04b3bb23756f251c326 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:11:37 +0800 Subject: [PATCH 042/132] =?UTF-8?q?=E4=BF=9D=E7=95=99=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E8=AF=BB=E5=8F=96=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留文件打开读取和验证指纹 OS 正文 补充工具桥与证据错误收口约定 --- .../src/agent/direct_project_context.rs | 45 ++++++++++--------- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs index cf06f63b0..939dfb184 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs @@ -237,50 +237,53 @@ fn normalize_requests(root: &Path, arguments: &Value) -> Result Ok(files) } -fn bounded_bytes(root: &Path, raw: &str) -> Result, &'static str> { - let target = resolve_game_agent_read_path(root, raw).map_err(|_| "unsafe-project-path")?; +fn bounded_bytes(root: &Path, raw: &str) -> Result, String> { + let target = + resolve_game_agent_read_path(root, raw).map_err(|_| "unsafe-project-path".to_string())?; let path = if let Some(relative) = target.project_relative.as_deref() { if relative.is_empty() { - return Err("not-safe-regular-file"); + return Err("not-safe-regular-file".to_string()); } - reject_agent_runtime_private_control_path(relative).map_err(|_| "private-control-path")?; - reject_sensitive_project_file_read(relative).map_err(|_| "sensitive-path")?; + reject_agent_runtime_private_control_path(relative) + .map_err(|_| "private-control-path".to_string())?; + reject_sensitive_project_file_read(relative).map_err(|_| "sensitive-path".to_string())?; if should_skip_project_snapshot_path(relative) { - return Err("excluded-project-path"); + return Err("excluded-project-path".to_string()); } // 检查每段目录,避免父目录 junction/symlink 绕过叶子 O_NOFOLLOW。 let mut component = root.to_path_buf(); for segment in relative.split('/') { component.push(segment); - let metadata = std::fs::symlink_metadata(&component).map_err(|_| "file-not-found")?; + let metadata = std::fs::symlink_metadata(&component) + .map_err(|error| format!("file-not-found:{error}"))?; if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { - return Err("linked-path"); + return Err("linked-path".to_string()); } } component } else { if external_read_is_protected(&target.display) { - return Err("sensitive-path"); + return Err("sensitive-path".to_string()); } reject_external_read_links(&target.absolute)?; target.absolute }; let (file, metadata) = open_project_snapshot_regular_file(&path, "项目批量读取") - .map_err(|_| "not-safe-regular-file")?; + .map_err(|error| format!("not-safe-regular-file:{error}"))?; if metadata.len() > MAX_FILE_BYTES as u64 { - return Err("file-too-large"); + return Err("file-too-large".to_string()); } read_bounded(file) } -fn read_bounded(reader: impl Read) -> Result, &'static str> { +fn read_bounded(reader: impl Read) -> Result, String> { let mut bytes = Vec::new(); reader .take(MAX_FILE_BYTES as u64 + 1) .read_to_end(&mut bytes) - .map_err(|_| "file-read-failed")?; + .map_err(|error| format!("file-read-failed:{error}"))?; if bytes.len() > MAX_FILE_BYTES { - return Err("file-grew-over-limit"); + return Err("file-grew-over-limit".to_string()); } Ok(bytes) } @@ -320,7 +323,7 @@ struct ReadResult { } fn read_file(root: &Path, input: &FileRequest, item_budget: usize) -> ReadResult { - let error = |code| ReadResult { + let error = |code: String| ReadResult { value: json!({"path":input.path,"status":"error","code":code}), source_hash: None, }; @@ -331,16 +334,16 @@ fn read_file(root: &Path, input: &FileRequest, item_budget: usize) -> ReadResult let source_hash = sha256(&bytes); let text = match std::str::from_utf8(&bytes) { Ok(text) if !text.contains('\0') => text, - _ => return error("not-utf8-text"), + _ => return error("not-utf8-text".to_string()), }; let lines: Vec<_> = text.split_inclusive('\n').collect(); let safe_text = safe_source_text(root, text); let safe_lines: Vec<_> = safe_text.split_inclusive('\n').collect(); if safe_lines.len() != lines.len() { - return error("redaction-line-boundary-error"); + return error("redaction-line-boundary-error".to_string()); } if input.start_line > lines.len().saturating_add(1) { - return error("line-out-of-range"); + return error("line-out-of-range".to_string()); } let start = input.start_line - 1; let requested_count = input.max_lines.min(lines.len().saturating_sub(start)); @@ -361,7 +364,7 @@ fn read_file(root: &Path, input: &FileRequest, item_budget: usize) -> ReadResult value, source_hash: Some(source_hash), }, - None => error("line-exceeds-response-budget"), + None => error("line-exceeds-response-budget".to_string()), } } @@ -571,7 +574,7 @@ mod tests { let mut reader = std::io::Cursor::new(vec![b'a'; MAX_FILE_BYTES * 2]); assert_eq!( read_bounded(&mut reader).unwrap_err(), - "file-grew-over-limit" + "file-grew-over-limit".to_string() ); assert_eq!(reader.position(), MAX_FILE_BYTES as u64 + 1); } @@ -595,7 +598,7 @@ mod tests { ] { assert_eq!( bounded_bytes(&root, &path.to_string_lossy()), - Err("linked-path") + Err("linked-path".to_string()) ); } std::fs::write(base.join("ordinary.txt"), "ordinary").unwrap(); diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index e066ee91c..d30d636a6 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -118,6 +118,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 浏览器健康检查的发现、临时目录、配置、DevTools 版本请求和清理失败保留底层错误/超时阶段;稳定浏览器码用于分类,但不能覆盖诊断正文。 - WebView 普通错误出口遇到 Tauri 的结构化对象时,优先展示 `message/detail/reason/code`,再有界序列化对象;不得把对象转成 `[object Object]` 后回落“未提供具体错误正文”。 - Direct 工具桥图片读取和真实试玩验证证据的 OS 读取/哈希错误保留底层原因;路径越界、私有路径和安全拒绝仍使用稳定安全码。 +- `agc_read_project_context` 的安全拒绝继续只给稳定码,但受控文件打开/读取/复核的 OS 错误要附在错误码后,不能把 `file-read-failed` 当成完整原因。 - 浏览器启动/DevTools 握手与 Codex model-catalog 子进程失败保留 stderr、退出状态、解析错误和阶段;稳定机器码仍用于分类,但不能单独成为用户可见正文。 - Node/npm 环境探测和 Web scaffold 构建持续排空并保存有界 stdout/stderr 尾部,失败返回退出状态、超时阶段和脱敏正文;环境预检回执同时下发 `code` 与 `diagnostic`。 - HTTP 409 只有明确包含泥点不足事实时才映射为 `paidCreditsInsufficient`;Claude Code 的普通 409 冲突保留为 `upstreamFailed`。 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index df5ddfe51..2df3632d6 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -21,6 +21,7 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 浏览器健康检查不再把发现、配置、DevTools 版本和清理失败只返回机器码;阶段码保留,同时带底层错误或明确的超时预算。 - WebView 结构化对象错误保留 `message/detail/reason/code` 或有界 JSON 正文,避免 `[object Object]` 触发普通错误兜底。 - Direct 工具桥图片与真实试玩证据读取/哈希错误保留 OS 正文,安全边界拒绝继续使用稳定码。 +- `agc_read_project_context` 保留 `file-read-failed` 等稳定码,同时在能取得时附带文件打开/读取/复核的 OS 正文。 - Transport / Stream / IPC / host process / memory exhaustion 的回归测试与错误事件证据。 ## 不做 From 88507e08b057ac937fe143cf114faaf0afee6989 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:14:47 +0800 Subject: [PATCH 043/132] =?UTF-8?q?=E4=BF=9D=E7=95=99=E4=BA=A4=E4=BB=98?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E5=BA=95=E5=B1=82=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 传播交付 worker 产物读取和执行器 OS 原因 通过 Direct 执行定向测试 --- .../src-tauri/src/agent/direct_delivery.rs | 24 ++++++++++--------- .../src-tauri/src/agent/direct_execution.rs | 22 ++++++++--------- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 4 files changed, 26 insertions(+), 22 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs index a65bf0679..88ce1af67 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs @@ -58,11 +58,12 @@ pub(super) async fn update_plan( } *explanation = truncate_agent_runtime_text(explanation, 1200); } - let value = serde_json::to_value(plan).map_err(|_| "direct-plan-invalid")?; + let value = + serde_json::to_value(plan).map_err(|error| format!("direct-plan-invalid:{error}"))?; let session = Arc::clone(session); tokio::task::spawn_blocking(move || session.update_plan(value)) .await - .map_err(|_| "direct-plan-worker-exited")? + .map_err(|error| format!("direct-plan-worker-exited:{error}"))? } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -404,7 +405,7 @@ pub(super) async fn requires_new_web_contract( let kind = creation_type.map(str::to_string); tokio::task::spawn_blocking(move || initial_requirement_at(&root, kind.as_deref(), cli)) .await - .map_err(|_| "delivery-initial-check-exited")? + .map_err(|error| format!("delivery-initial-check-exited:{error}"))? } fn mark_initial_delivered(root: &Path, ledger: &ExecutionLedger) -> Result<(), String> { @@ -451,7 +452,7 @@ pub(super) async fn register_contract( let value = serde_json::to_value(contract).map_err(|_| "delivery-contract-invalid")?; let frozen = session.freeze_contract(value)?; Ok(json!({"status":"frozen","contract":frozen,"next":"只推进已登记范围;使用托管构建/验证和固定场景提供真实证据"})) - }).await.map_err(|_| "delivery-contract-worker-exited")? + }).await.map_err(|error| format!("delivery-contract-worker-exited:{error}"))? } fn artifact_hash(root: &Path, relative: &str) -> Result { @@ -467,7 +468,7 @@ fn artifact_hash(root: &Path, relative: &str) -> Result { loop { let count = file .read(&mut buffer) - .map_err(|_| "delivery-artifact-read")?; + .map_err(|error| format!("delivery-artifact-read:{error}"))?; if count == 0 { break; } @@ -650,7 +651,7 @@ pub(super) async fn status(root: &Path, session: &Arc) -> Resu let session = Arc::clone(session); tokio::task::spawn_blocking(move || { let ledger = session.snapshot()?; let assessment = assess(&root,&ledger)?; Ok(json!({"phase":ledger.phase,"contract":ledger.contract,"plan":ledger.plan,"assessment":assessment,"writeRecoveryRequired":ledger.last_failed_write_revision.is_some(),"usedPasses":ledger.used_passes,"maxRuns":ledger.max_runs,"usedExecutionMs":ledger.used_execution_ms,"maxExecutionMs":ledger.max_execution_ms,"inFlight":ledger.active.len(),"report":ledger.terminal_report})) - }).await.map_err(|_| "delivery-status-worker-exited")? + }).await.map_err(|error| format!("delivery-status-worker-exited:{error}"))? } pub(super) async fn try_seal(root: &Path, session: &Arc) -> Result { @@ -667,7 +668,7 @@ pub(super) async fn try_seal(root: &Path, session: &Arc) -> Re session.begin_sealing(ledger.revision) }) .await - .map_err(|_| "delivery-seal-worker-exited")? + .map_err(|error| format!("delivery-seal-worker-exited:{error}"))? } pub(super) async fn finish_sealing( @@ -693,7 +694,7 @@ pub(super) async fn finish_sealing( session.complete(report.clone())?; if mark_initial_delivered(&root,&ledger).is_err() { app_log!("首次交付标记未写入,后续保持更严格的新项目验收要求"); } Ok(Some(report)) - }).await.map_err(|_| "delivery-finalize-worker-exited")? + }).await.map_err(|error| format!("delivery-finalize-worker-exited:{error}"))? } /// 回合末的宿主复核:返回要交付的答复,或者一个"还没完,按这份证据继续修"的要求。 @@ -722,9 +723,10 @@ pub(super) async fn review_reply( let review_session = Arc::clone(session); let review = tokio::task::spawn_blocking(move || review_session.record_delivery_review()) .await - .map_err(|_| "delivery-review-worker-exited")??; + .map_err(|error| format!("delivery-review-worker-exited:{error}"))??; let assessment = status(root, session).await?; - let detail = serde_json::to_string(&assessment).map_err(|_| "delivery-review-invalid")?; + let detail = serde_json::to_string(&assessment) + .map_err(|error| format!("delivery-review-invalid:{error}"))?; if review >= ledger.max_runs { let missing = assessment .pointer("/assessment/checks") @@ -751,7 +753,7 @@ pub(super) async fn review_reply( let saved = report.clone(); tokio::task::spawn_blocking(move || session.interrupt(saved)) .await - .map_err(|_| "delivery-review-worker-exited")??; + .map_err(|error| format!("delivery-review-worker-exited:{error}"))??; return Ok(Some(report)); } Err(TurnError::ReviewRequired { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs index 573653eb4..b8c20cc82 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs @@ -128,11 +128,11 @@ struct CodexExecutorIdentity { fn executor_digest(path: &Path) -> Result { use std::io::Read; - let mut file = - File::open(path).map_err(|_| "direct-execution-executor: 无法读取已选择的执行器")?; + let mut file = File::open(path) + .map_err(|error| format!("direct-execution-executor: 无法读取已选择的执行器:{error}"))?; let before = file .metadata() - .map_err(|_| "direct-execution-executor: 执行器身份不可用")?; + .map_err(|error| format!("direct-execution-executor: 执行器身份不可用:{error}"))?; if !before.is_file() || before.len() > 512 * 1024 * 1024 { return Err("direct-execution-executor: 执行器类型或大小无效".into()); } @@ -142,7 +142,7 @@ fn executor_digest(path: &Path) -> Result { loop { let read = file .read(&mut buffer) - .map_err(|_| "direct-execution-executor: 执行器读取失败")?; + .map_err(|error| format!("direct-execution-executor: 执行器读取失败:{error}"))?; if read == 0 { break; } @@ -154,7 +154,7 @@ fn executor_digest(path: &Path) -> Result { } let after = file .metadata() - .map_err(|_| "direct-execution-executor: 执行器身份不可用")?; + .map_err(|error| format!("direct-execution-executor: 执行器身份不可用:{error}"))?; if bytes != before.len() || after.len() != before.len() || before.modified().ok() != after.modified().ok() @@ -446,7 +446,7 @@ pub(super) async fn begin( ) }) .await - .map_err(|_| "direct-execution-start: 宿主状态初始化退出")??; + .map_err(|error| format!("direct-execution-start: 宿主状态初始化退出:{error}"))??; let root = session.root.clone(); { let mut registry = sessions() @@ -515,7 +515,7 @@ pub(super) fn open_with_analytics_at( config.validate()?; let root = root .canonicalize() - .map_err(|_| "direct-execution-project: 项目不可用")?; + .map_err(|error| format!("direct-execution-project: 项目不可用:{error}"))?; if turn.is_empty() || turn.len() > 512 || request_hash.len() != 64 { return Err("direct-execution-identity: 回合身份无效".into()); } @@ -523,17 +523,17 @@ pub(super) fn open_with_analytics_at( return Err("direct-execution-host: 权威状态不得写入模型项目目录".into()); } crate::ensure_game_creator_private_directory_tree(host, "宿主执行状态目录") - .map_err(|_| "direct-execution-host: 无法准备私有执行状态目录")?; + .map_err(|error| format!("direct-execution-host: 无法准备私有执行状态目录:{error}"))?; let host = host .canonicalize() - .map_err(|_| "direct-execution-host: 状态目录不可用")?; + .map_err(|error| format!("direct-execution-host: 状态目录不可用:{error}"))?; if host.starts_with(&root) { return Err("direct-execution-host: 权威状态不得写入模型项目目录".into()); } let project_key = hash(root.to_string_lossy().as_bytes()); let directory = host.join(&project_key); crate::ensure_game_creator_private_directory_tree(&directory, "宿主项目执行状态") - .map_err(|_| "direct-execution-host: 无法准备私有项目执行状态")?; + .map_err(|error| format!("direct-execution-host: 无法准备私有项目执行状态:{error}"))?; let key = hash(turn.as_bytes()); let lock_path = directory.join(format!("{key}.lock")); if lock_path.exists() { @@ -546,7 +546,7 @@ pub(super) fn open_with_analytics_at( .create(true) .truncate(false) .open(&lock_path) - .map_err(|_| "direct-execution-owner: 无法打开执行归属锁")?; + .map_err(|error| format!("direct-execution-owner: 无法打开执行归属锁:{error}"))?; owner .try_lock() .map_err(|_| "direct-execution-active: 同一回合仍由其他执行器持有")?; diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index d30d636a6..ac6ac1aab 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -119,6 +119,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - WebView 普通错误出口遇到 Tauri 的结构化对象时,优先展示 `message/detail/reason/code`,再有界序列化对象;不得把对象转成 `[object Object]` 后回落“未提供具体错误正文”。 - Direct 工具桥图片读取和真实试玩验证证据的 OS 读取/哈希错误保留底层原因;路径越界、私有路径和安全拒绝仍使用稳定安全码。 - `agc_read_project_context` 的安全拒绝继续只给稳定码,但受控文件打开/读取/复核的 OS 错误要附在错误码后,不能把 `file-read-failed` 当成完整原因。 +- Direct 交付复核/执行账本的 worker join、产物读取、执行器摘要和宿主目录/锁 I/O 错误也保留底层原因;稳定 delivery/direct-execution 分类码只负责分流。 - 浏览器启动/DevTools 握手与 Codex model-catalog 子进程失败保留 stderr、退出状态、解析错误和阶段;稳定机器码仍用于分类,但不能单独成为用户可见正文。 - Node/npm 环境探测和 Web scaffold 构建持续排空并保存有界 stdout/stderr 尾部,失败返回退出状态、超时阶段和脱敏正文;环境预检回执同时下发 `code` 与 `diagnostic`。 - HTTP 409 只有明确包含泥点不足事实时才映射为 `paidCreditsInsufficient`;Claude Code 的普通 409 冲突保留为 `upstreamFailed`。 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index 2df3632d6..a8feaef46 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -22,6 +22,7 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - WebView 结构化对象错误保留 `message/detail/reason/code` 或有界 JSON 正文,避免 `[object Object]` 触发普通错误兜底。 - Direct 工具桥图片与真实试玩证据读取/哈希错误保留 OS 正文,安全边界拒绝继续使用稳定码。 - `agc_read_project_context` 保留 `file-read-failed` 等稳定码,同时在能取得时附带文件打开/读取/复核的 OS 正文。 +- Direct 交付复核和执行账本的 worker、产物读取、执行器摘要及宿主目录/锁失败保留底层原因,分类码不再单独承载正文。 - Transport / Stream / IPC / host process / memory exhaustion 的回归测试与错误事件证据。 ## 不做 From 3ecff3f524f1367506405847dca60ecda345a030 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:18:06 +0800 Subject: [PATCH 044/132] =?UTF-8?q?=E4=BF=9D=E7=95=99=E4=BA=A4=E4=BB=98?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E5=BA=8F=E5=88=97=E5=8C=96=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充交付账本和执行状态的 serde 原因 通过 Direct 执行回归测试 --- .../src-tauri/src/agent/direct_delivery.rs | 38 ++++++++++--------- .../src-tauri/src/agent/direct_execution.rs | 17 ++++++--- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs index 88ce1af67..da2c19017 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs @@ -301,13 +301,13 @@ struct InitialDelivery { fn initial_delivery_path(root: &Path) -> Result { let root = root .canonicalize() - .map_err(|_| "delivery-project-unavailable")?; + .map_err(|error| format!("delivery-project-unavailable:{error}"))?; let host = crate::game_creator_runtime_config_dir() .ok_or("delivery-host-unavailable: 缺少客户端私有目录")?; crate::ensure_game_creator_private_directory_tree(&host, "客户端交付目录")?; let host = host .canonicalize() - .map_err(|_| "delivery-host-unavailable")?; + .map_err(|error| format!("delivery-host-unavailable:{error}"))?; if host.starts_with(&root) { return Err("delivery-host-invalid: 权威交付状态不能位于项目内".into()); } @@ -322,14 +322,14 @@ fn initial_delivery_path(root: &Path) -> Result { fn read_initial_delivery(path: &Path, project_id: &str) -> Result, String> { if !path .try_exists() - .map_err(|_| "delivery-initial-state-unavailable")? + .map_err(|error| format!("delivery-initial-state-unavailable:{error}"))? { return Ok(None); } let value: InitialDelivery = serde_json::from_str( &crate::read_game_creator_private_file_to_string(path, "首次交付状态", 16 * 1024)?, ) - .map_err(|_| "delivery-initial-state-invalid")?; + .map_err(|error| format!("delivery-initial-state-invalid:{error}"))?; if value.schema_version != "agc-initial-web-delivery.v1" || value.project_id != project_id { return Err("delivery-initial-state-identity".into()); } @@ -358,7 +358,7 @@ fn initial_requirement_at( let receipt = resolve_local_project_path(root, ".agent/runtime/web-scaffold-preparation.json")?; if !receipt .try_exists() - .map_err(|_| "delivery-scaffold-receipt-unavailable")? + .map_err(|error| format!("delivery-scaffold-receipt-unavailable:{error}"))? { return Ok(false); } @@ -366,12 +366,12 @@ fn initial_requirement_at( let mut bytes = Vec::new(); file.take(16 * 1024 + 1) .read_to_end(&mut bytes) - .map_err(|_| "delivery-scaffold-receipt-unavailable")?; + .map_err(|error| format!("delivery-scaffold-receipt-unavailable:{error}"))?; if bytes.len() > 16 * 1024 { return Err("delivery-scaffold-receipt-invalid".into()); } - let receipt: Value = - serde_json::from_slice(&bytes).map_err(|_| "delivery-scaffold-receipt-invalid")?; + let receipt: Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("delivery-scaffold-receipt-invalid:{error}"))?; let expected: BTreeMap<_, _> = crate::project::trusted_web_scaffold_files() .into_iter() .map(|(path, content)| (path, format!("{:x}", Sha256::digest(content.as_bytes())))) @@ -379,7 +379,8 @@ fn initial_requirement_at( if receipt["schemaVersion"] != "agc-web-scaffold-preparation.v1" || receipt["projectId"] != project.project_id || receipt["templateHashes"] - != serde_json::to_value(expected).map_err(|_| "delivery-scaffold-template-invalid")? + != serde_json::to_value(expected) + .map_err(|error| format!("delivery-scaffold-template-invalid:{error}"))? { return Err("delivery-scaffold-receipt-invalid".into()); } @@ -390,7 +391,7 @@ fn initial_requirement_at( project_id: project.project_id, completed: false, }) - .map_err(|_| "delivery-initial-state-invalid")?, + .map_err(|error| format!("delivery-initial-state-invalid:{error}"))?, "首次交付状态", )?; Ok(true) @@ -423,7 +424,7 @@ fn mark_initial_delivered(root: &Path, ledger: &ExecutionLedger) -> Result<(), S project_id: ledger.project_id.clone(), completed: true, }) - .map_err(|_| "delivery-initial-state-invalid")?, + .map_err(|error| format!("delivery-initial-state-invalid:{error}"))?, "首次交付状态", ) } @@ -438,18 +439,18 @@ pub(super) async fn register_contract( let input = input.clone(); tokio::task::spawn_blocking(move || { let state = session.snapshot()?; - if root.canonicalize().map_err(|_| "delivery-project-unavailable")? != session.root { return Err("delivery-project-identity".into()); } + if root.canonicalize().map_err(|error| format!("delivery-project-unavailable:{error}"))? != session.root { return Err("delivery-project-identity".into()); } let new_web = state.contract.as_ref().and_then(|value| value["newWebGame"].as_bool()).unwrap_or(state.requires_contract); let mut contract = normalized_contract(&input, new_web)?; contract.initial_artifact_hashes = if let Some(existing) = &state.contract { - serde_json::from_value::(existing.clone()).map_err(|_| "delivery-frozen-contract-invalid")?.initial_artifact_hashes + serde_json::from_value::(existing.clone()).map_err(|error| format!("delivery-frozen-contract-invalid:{error}"))?.initial_artifact_hashes } else { contract.requirements.iter().filter_map(|criterion| match criterion { Criterion::Artifact {id,path} => Some((id.clone(),artifact_hash(&root,path).ok())), _ => None, }).collect() }; - let value = serde_json::to_value(contract).map_err(|_| "delivery-contract-invalid")?; + let value = serde_json::to_value(contract).map_err(|error| format!("delivery-contract-invalid:{error}"))?; let frozen = session.freeze_contract(value)?; Ok(json!({"status":"frozen","contract":frozen,"next":"只推进已登记范围;使用托管构建/验证和固定场景提供真实证据"})) }).await.map_err(|error| format!("delivery-contract-worker-exited:{error}"))? @@ -534,8 +535,8 @@ fn assess(root: &Path, ledger: &ExecutionLedger) -> Result { checks: vec![json!({"id":"contract","passed":false,"detail":"尚未登记本轮验收合同"})], }); }; - let contract: FrozenContract = - serde_json::from_value(value.clone()).map_err(|_| "delivery-frozen-contract-invalid")?; + let contract: FrozenContract = serde_json::from_value(value.clone()) + .map_err(|error| format!("delivery-frozen-contract-invalid:{error}"))?; if contract.schema_version != CONTRACT_SCHEMA { return Err("delivery-frozen-contract-invalid".into()); } @@ -688,7 +689,10 @@ pub(super) async fn finish_sealing( if !first.ready || !final_check.ready || first.runtime_fingerprint != final_check.runtime_fingerprint || first.checks != final_check.checks { session.reopen_for_repair()?; return Ok(None); } - let contract: FrozenContract = serde_json::from_value(ledger.contract.clone().ok_or("delivery-contract-missing")?).map_err(|_| "delivery-contract-invalid")?; + let contract: FrozenContract = serde_json::from_value( + ledger.contract.clone().ok_or("delivery-contract-missing")?, + ) + .map_err(|error| format!("delivery-contract-invalid:{error}"))?; let report = format!("本轮已完成宿主验收。\n\n范围:{}\n\n{}\n\n执行/返修批次:{}/{}。仅证明以上登记范围;固定场景不代表完整长关卡已通关。执行预算按宿主观察的占用计入;第三方并发调用可能按组占用上界计入。", truncate_agent_runtime_text(&contract.scope,1200), final_check.checks.iter().map(|check|format!("- 已通过:{}",check["label"].as_str().unwrap_or("验收项"))).collect::>().join("\n"), ledger.used_passes,ledger.max_runs); session.complete(report.clone())?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs index b8c20cc82..059daae23 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs @@ -559,11 +559,15 @@ pub(super) fn open_with_analytics_at( "宿主执行状态", MAX_STATE_BYTES as u64, ) - .map_err(|_| { - "direct-execution-private-state: 无法安全读取宿主状态,禁止重置预算" + .map_err(|error| { + format!( + "direct-execution-private-state: 无法安全读取宿主状态,禁止重置预算:{error}" + ) })?, ) - .map_err(|_| "direct-execution-corrupt: 宿主执行状态无效,禁止重置预算")?, + .map_err(|error| { + format!("direct-execution-corrupt: 宿主执行状态无效,禁止重置预算:{error}") + })?, ) } else { None @@ -758,7 +762,7 @@ impl ExecutionSession { } next.revision = data.ledger.revision.saturating_add(1); let bytes = serde_json::to_vec(&next) - .map_err(|_| "direct-execution-persistence: 状态序列化失败")?; + .map_err(|error| format!("direct-execution-persistence: 状态序列化失败:{error}"))?; if bytes.len() > MAX_STATE_BYTES { return Err("direct-execution-capacity: 宿主状态超过保留上限".into()); } @@ -829,7 +833,10 @@ impl ExecutionSession { pub(super) fn update_plan(&self, plan: Value) -> Result { self.tick()?; if !plan.is_object() - || serde_json::to_vec(&plan).map_err(|_| "计划格式无效")?.len() > 32 * 1024 + || serde_json::to_vec(&plan) + .map_err(|error| format!("计划格式无效:{error}"))? + .len() + > 32 * 1024 { return Err("direct-plan-invalid: 计划必须为有界对象".into()); } From 9eabdacdfbc92a789d7c1610c273727eb7066592 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:20:29 +0800 Subject: [PATCH 045/132] =?UTF-8?q?=E4=BF=9D=E7=95=99=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E5=A4=B1=E8=B4=A5=E7=BB=86=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充目录认证解析超时和清理错误正文 通过模型目录定向测试 --- .../agent/codex_app_server/model_catalog.rs | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs index a718f0d3d..8687ca23f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs @@ -35,7 +35,7 @@ pub(super) fn source_for_credential( return Ok(CatalogSource::Bundled); }; let auth: Value = serde_json::from_slice(auth_json) - .map_err(|_| "model-catalog-auth-invalid: 认证桥接内容无效")?; + .map_err(|error| format!("model-catalog-auth-invalid: 认证桥接内容无效:{error}"))?; // 与 SDK resolved_mode 一样,显式 auth_mode 优先;旧文件才根据 API Key 判定。 let api_key_mode = match auth.get("auth_mode").and_then(Value::as_str) { Some(mode) => mode == "apikey", @@ -176,7 +176,7 @@ async fn export_catalog( tokio::select! { biased; _ = cancelled(cancel) => Err("model-catalog-cancelled".into()), - result = tokio::time::timeout(CAPTURE_TIMEOUT, collected) => result.unwrap_or_else(|_| Err("model-catalog-timeout".into())), + result = tokio::time::timeout(CAPTURE_TIMEOUT, collected) => result.unwrap_or_else(|_| Err(format!("model-catalog-timeout: exceeded {} ms", CAPTURE_TIMEOUT.as_millis()))), } } else { Err("model-catalog-stdio-unavailable".into()) @@ -184,7 +184,7 @@ async fn export_catalog( let proof = tree .shutdown(&mut child) .await - .map_err(|_| "model-catalog-process-exit-unconfirmed")?; + .map_err(|error| format!("model-catalog-process-exit-unconfirmed: {error}"))?; if !proof.main_process_exited || !proof.observed_tree_empty { return Err("model-catalog-process-exit-unconfirmed".into()); } @@ -344,7 +344,7 @@ pub(super) async fn capture( if source == CatalogSource::Authenticated && cache_path .try_exists() - .map_err(|_| "model-catalog-source-unavailable")? + .map_err(|error| format!("model-catalog-source-unavailable: {error}"))? { return Err("model-catalog-source-not-fresh: 当前回合必须使用新隔离目录".into()); } @@ -369,9 +369,11 @@ pub(super) async fn capture( "模型目录来源", MAX_CATALOG_BYTES as u64, ) - .map_err(|_| "model-catalog-source-unconfirmed: OAuth 目录未取得本回合有效远端来源")?; - let cache: Value = - serde_json::from_str(&cache_text).map_err(|_| "model-catalog-source-invalid")?; + .map_err(|error| { + format!("model-catalog-source-unconfirmed: OAuth 目录未取得本回合有效远端来源:{error}") + })?; + let cache: Value = serde_json::from_str(&cache_text) + .map_err(|error| format!("model-catalog-source-invalid: {error}"))?; verify_authenticated_source(&cache, &original, &bundled, started_ms, now_ms())?; let evidence = json!({"cacheSha256":format!("{:x}", Sha256::digest(cache_text.as_bytes())), "fetchedAt":cache.get("fetched_at"),"etagSha256":cache.get("etag").and_then(Value::as_str).map(|etag|format!("{:x}",Sha256::digest(etag.as_bytes())))}); @@ -386,8 +388,10 @@ pub(super) async fn capture( { return Err("model-catalog-cancelled".into()); } - let original_bytes = serde_json::to_vec(&original).map_err(|_| "model-catalog-json-invalid")?; - let derived_bytes = serde_json::to_vec(&derived).map_err(|_| "model-catalog-json-invalid")?; + let original_bytes = serde_json::to_vec(&original) + .map_err(|error| format!("model-catalog-json-invalid: {error}"))?; + let derived_bytes = serde_json::to_vec(&derived) + .map_err(|error| format!("model-catalog-json-invalid: {error}"))?; if derived_bytes.len() > MAX_CATALOG_BYTES { return Err("model-catalog-output-limit".into()); } @@ -401,7 +405,8 @@ pub(super) async fn capture( "changedField":"apply_patch_tool_type","modelCount":derived["models"].as_array().unwrap().len(),"cache":cache_evidence}); crate::write_game_creator_private_file( &isolated_home.join("direct-model-catalog-receipt.json"), - &serde_json::to_vec(&receipt).map_err(|_| "model-catalog-receipt-invalid")?, + &serde_json::to_vec(&receipt) + .map_err(|error| format!("model-catalog-receipt-invalid: {error}"))?, "模型目录来源回执", ) .map_err(|error| { From 04cc60eb04565dcf6f7afc2a35d8072a1e092855 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:25:14 +0800 Subject: [PATCH 046/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E4=BC=A0=E6=92=AD=20?= =?UTF-8?q?Direct=20=E5=B7=A5=E5=85=B7=E4=BB=BB=E5=8A=A1=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留上下文验证补丁 worker 和 OAuth 解析原因 通过 Direct 验证定向测试 --- .../model_catalog/auth_handoff.rs | 17 ++++++++++------- .../src-tauri/src/agent/direct_patch.rs | 2 +- .../src/agent/direct_project_context.rs | 12 ++++++------ .../src-tauri/src/agent/direct_validation.rs | 4 ++-- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog/auth_handoff.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog/auth_handoff.rs index c73c92f18..3db0ac57b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog/auth_handoff.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog/auth_handoff.rs @@ -47,9 +47,9 @@ fn oauth_identity(auth: &Value) -> Result { .ok_or("model-catalog-auth-handoff-identity")?; let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD .decode(payload.trim_end_matches('=')) - .map_err(|_| "model-catalog-auth-handoff-identity")?; - let claims: Value = - serde_json::from_slice(&payload).map_err(|_| "model-catalog-auth-handoff-identity")?; + .map_err(|error| format!("model-catalog-auth-handoff-identity:{error}"))?; + let claims: Value = serde_json::from_slice(&payload) + .map_err(|error| format!("model-catalog-auth-handoff-identity:{error}"))?; let claims_auth = &claims["https://api.openai.com/auth"]; let token_account = auth .pointer("/tokens/account_id") @@ -87,12 +87,13 @@ fn oauth_bytes(bytes: &[u8]) -> Result<(OAuthIdentity, Vec), String> { if bytes.len() > MAX_AUTH_BYTES as usize { return Err("model-catalog-auth-handoff-size".into()); } - let mut auth: Value = - serde_json::from_slice(bytes).map_err(|_| "model-catalog-auth-handoff-invalid")?; + let mut auth: Value = serde_json::from_slice(bytes) + .map_err(|error| format!("model-catalog-auth-handoff-invalid:{error}"))?; let identity = oauth_identity(&auth)?; // 显式 OAuth 文件可能还残留一个 API Key;私有轮换缓存从不携带它。 auth["OPENAI_API_KEY"] = Value::Null; - let bytes = serde_json::to_vec(&auth).map_err(|_| "model-catalog-auth-handoff-invalid")?; + let bytes = serde_json::to_vec(&auth) + .map_err(|error| format!("model-catalog-auth-handoff-invalid:{error}"))?; Ok((identity, bytes)) } @@ -181,7 +182,9 @@ impl OAuthHandoff { MAX_AUTH_BYTES, ) }) - .map_err(|_| "model-catalog-auth-handoff-read: 无法确认私有 OAuth 轮换状态".to_string()) + .map_err(|error| { + format!("model-catalog-auth-handoff-read: 无法确认私有 OAuth 轮换状态:{error}") + }) .and_then(|text| oauth_bytes(text.as_bytes())) .and_then(|(identity, bytes)| { if identity != self.identity { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_patch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_patch.rs index ad4026ad4..2562f9b5e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_patch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_patch.rs @@ -380,7 +380,7 @@ pub(super) async fn apply(root: &Path, arguments: &Value) -> Result((root, session, result)) }) .await - .map_err(|_| "patch-worker-exited: 补丁事务任务退出,结果需要核对")??; + .map_err(|error| format!("patch-worker-exited: 补丁事务任务退出,结果需要核对:{error}"))??; if result["status"] == "completed" { let _ = direct_delivery::try_seal(&root, &session).await; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs index 939dfb184..d49b1963b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs @@ -417,7 +417,7 @@ where context_identity(&identity_root) }) .await - .map_err(|_| "项目上下文读取任务中断")??; + .map_err(|error| format!("项目上下文读取任务中断:{error}"))??; let item_budget = MAX_ITEM_BYTES.min(response_budget.saturating_sub(4096) / files.len()); let reader = Arc::new(reader); let root = root.to_path_buf(); @@ -428,7 +428,7 @@ where let path = request.path.clone(); let item = tokio::task::spawn_blocking(move || reader(&root, &request, item_budget)) .await - .map_err(|_| "文件批量读取任务中断".to_string())?; + .map_err(|error| format!("文件批量读取任务中断:{error}"))?; Ok::<_, String>((index, path, item)) } })) @@ -463,10 +463,10 @@ where .collect::>() }) .await - .map_err(|_| "批量读取复核任务中断")?; + .map_err(|error| format!("批量读取复核任务中断:{error}"))?; let after = tokio::task::spawn_blocking(move || context_identity(&root)) .await - .map_err(|_| "项目上下文复核任务中断")??; + .map_err(|error| format!("项目上下文复核任务中断:{error}"))??; let stale = before.project_id != after.project_id || before.revision != after.revision || before.turn_id != after.turn_id @@ -483,7 +483,7 @@ where "status":if stale {"stale"} else {"ready"},"activity":before.activity,"turnStatus":before.status, "capturedAt":unix_millis().min(u128::from(u64::MAX)) as u64,"atomicSnapshot":false,"files":files}); if serde_json::to_vec(&result) - .map_err(|_| "上下文序列化失败")? + .map_err(|error| format!("上下文序列化失败:{error}"))? .len() > response_budget { @@ -533,7 +533,7 @@ pub(super) async fn prefetch_turn_input( .collect::>() }) .await - .map_err(|_| "项目预取扫描任务中断")?; + .map_err(|error| format!("项目预取扫描任务中断:{error}"))?; if files.is_empty() { return Ok(None); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs index 9fa876912..ecfa809e0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs @@ -381,7 +381,7 @@ async fn start_for_current_turn( reserve(&root, session, &key, &fingerprint, &source, build) }) .await - .map_err(|_| "验证预约任务退出".to_string())? + .map_err(|error| format!("验证预约任务退出:{error}"))? } async fn finish_async( @@ -393,7 +393,7 @@ async fn finish_async( let root = root.to_path_buf(); tokio::task::spawn_blocking(move || finish(&root, &reservation, result, passed)) .await - .map_err(|_| "验证回执任务退出".to_string())? + .map_err(|error| format!("验证回执任务退出:{error}"))? } #[derive(Serialize)] From 6f7ccc2832fd70d941aecd3c7166c7419e47daf9 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:03:04 +0800 Subject: [PATCH 047/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E9=81=97=E7=95=99=E9=94=99=E8=AF=AF=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 外部 MCP HTTP 错误返回状态、错误码和中文正文 认证、素材上传和发行发布保留上游 401/403/5xx 详情 精确脱敏 Direct 错误并标记旧历史缺失正文 补充回归测试、诊断文档和真实客户端启动验证 --- .../src-tauri/src/account_api.rs | 30 ++-- .../src-tauri/src/agent/direct_tools_mcp.rs | 109 ++++++++++++-- .../src-tauri/src/auth_error.rs | 16 ++- .../src-tauri/src/auth_session.rs | 11 +- .../src/game_distribution_publish.rs | 18 ++- .../src-tauri/src/platform_asset_upload.rs | 91 +++++++++--- .../src/app/AuthenticatedClient.tsx | 12 +- .../src/features/agent-runtime/model.ts | 135 +++++------------- .../generated/AuthServiceUnavailable.ts | 4 +- .../chat/conversation/directThreadSanitize.ts | 9 +- .../conversation/directTurnEnqueueFailure.ts | 20 +-- .../chat/conversation/directTurnFailure.ts | 4 +- .../tests/agentRuntimeModel.test.ts | 24 ++++ .../tests/appSurface/chat-composer.suite.ts | 15 +- .../appSurface/project-conversation.suite.ts | 12 +- .../tests/clientAuthErrorCarrier.test.ts | 6 +- .../tests/directThreadSanitize.test.ts | 11 ++ ...GC命令错误结构化与错误报告口径-2026-10-01.md | 3 + ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 3 + 19 files changed, 355 insertions(+), 178 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/account_api.rs b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs index f9204a992..99d258005 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/account_api.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs @@ -121,6 +121,9 @@ fn safe_error_body_detail(body: &str) -> Option { .and_then(|value| value.get("error").or(Some(value))) .and_then(|value| serde_json::to_string(value).ok()) .or_else(|| (!body.trim().is_empty()).then(|| body.trim().to_string()))?; + if matches!(detail.trim(), "{}" | "null" | "\"\"") { + return None; + } let detail = crate::agent::redact_agent_runtime_error( Path::new("__agc_no_project_root__"), &detail, @@ -131,21 +134,24 @@ fn safe_error_body_detail(body: &str) -> Option { fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { crate::platform_maintenance::watch_platform_response(status.as_u16(), body); + let server_detail = error_message(body) + .or_else(|| error_code(body)) + .or_else(|| safe_error_body_detail(body)); if status == StatusCode::UNAUTHORIZED { - return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(); + return match server_detail { + Some(detail) => { + format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}") + } + None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(), + }; } if status == StatusCode::FORBIDDEN { return format!( "permission-denied: {}", - error_message(body) - .or_else(|| safe_error_body_detail(body)) - .unwrap_or_else(|| "当前账号无权执行此操作".to_string()) + server_detail.unwrap_or_else(|| "当前账号无权执行此操作".to_string()) ); } - let detail = error_message(body) - .or_else(|| error_code(body)) - .or_else(|| safe_error_body_detail(body)) - .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + let detail = server_detail.unwrap_or_else(|| format!("HTTP {}", status.as_u16())); format!("{fallback}:{detail}") } @@ -371,6 +377,14 @@ mod tests { map_http_error(StatusCode::UNAUTHORIZED, "{}", "读取失败"), "authentication-required: 陶泥儿登录态已过期,请重新登录后重试" ); + assert_eq!( + map_http_error( + StatusCode::UNAUTHORIZED, + r#"{"error":{"message":"access token expired"}}"#, + "读取失败", + ), + "authentication-required: 陶泥儿登录态已过期,请重新登录后重试:access token expired" + ); assert_eq!( map_http_error( StatusCode::FORBIDDEN, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index e83d173e4..623421277 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -1697,7 +1697,7 @@ where #[cfg(not(test))] async fn run_direct_tools_mcp_stdio() -> Result<(), String> { let root = validate_direct_tools_project_root( - &std::env::current_dir().map_err(|_| "读取 MCP 工作目录失败".to_string())?, + &std::env::current_dir().map_err(|error| format!("读取 MCP 工作目录失败:{error}"))?, )?; let (sender, receiver) = tokio::sync::mpsc::channel(DIRECT_TOOLS_MCP_MAX_IN_FLIGHT); std::thread::Builder::new() @@ -1723,27 +1723,60 @@ fn external_mcp_authorized(headers: &HeaderMap, token: &str) -> bool { .is_some_and(|value| value == token) } +fn external_mcp_http_error( + status: StatusCode, + code: &'static str, + message: &'static str, +) -> axum::response::Response { + ( + status, + Json(json!({ + "error": { + "code": code, + "message": message, + "status": status.as_u16(), + } + })), + ) + .into_response() +} + async fn handle_external_mcp_http_request( AxumState(state): AxumState, headers: HeaderMap, Json(request): Json, -) -> Result { +) -> Result { if !external_mcp_authorized(&headers, &state.token) { - return Err(StatusCode::UNAUTHORIZED); + return Err(external_mcp_http_error( + StatusCode::UNAUTHORIZED, + "mcp-unauthorized", + "客户端 MCP 鉴权失败:Bearer token 缺失、无效或已过期", + )); } let Some(session) = current_platform_session() else { - return Err(StatusCode::UNAUTHORIZED); + return Err(external_mcp_http_error( + StatusCode::UNAUTHORIZED, + "mcp-session-unavailable", + "客户端 MCP 鉴权失败:当前账号会话不存在或已失效", + )); }; if session.user_id != state.session_user_id || session.identity_generation != state.session_identity_generation { - return Err(StatusCode::UNAUTHORIZED); + return Err(external_mcp_http_error( + StatusCode::UNAUTHORIZED, + "mcp-session-changed", + "客户端 MCP 鉴权失败:账号会话已变化,请重新建立 MCP 连接", + )); } // HTTP 有独立入口容量;饱和时在任何工具副作用前拒绝,不与 STDIO 重复取许可。 - let _permit = state - .in_flight - .try_acquire() - .map_err(|_| StatusCode::TOO_MANY_REQUESTS)?; + let _permit = state.in_flight.try_acquire().map_err(|_| { + external_mcp_http_error( + StatusCode::TOO_MANY_REQUESTS, + "mcp-capacity-exhausted", + "客户端受控工具桥并发已满(上限 8),请稍后重试", + ) + })?; // JSON-RPC 通知(没有 `id`)没有响应体:MCP 客户端在 `initialize` 之后一定会发 // `notifications/initialized`,按错误回 400 会被判成握手失败,整个服务器就被标记 // 成 `failed`、一个工具都不广告(真实案例:2026-10-02 的 cc DirectProject 因此 @@ -1757,7 +1790,13 @@ async fn handle_external_mcp_http_request( handle_direct_tools_mcp_request(&state.root, request), ) .await - .ok_or(StatusCode::BAD_REQUEST)?; + .ok_or_else(|| { + external_mcp_http_error( + StatusCode::BAD_REQUEST, + "mcp-bridge-context-missing", + "客户端受控工具桥未绑定:当前请求不在受控工具执行上下文中", + ) + })?; Ok(Json(response).into_response()) } @@ -1912,7 +1951,17 @@ mod tests { Json(json!({"id":1,"method":"ping"})), ) .await; - assert_eq!(rejected.unwrap_err(), StatusCode::TOO_MANY_REQUESTS); + let rejected = rejected.unwrap_err(); + assert_eq!(rejected.status(), StatusCode::TOO_MANY_REQUESTS); + let rejected_body = axum::body::to_bytes(rejected.into_body(), usize::MAX) + .await + .unwrap(); + let rejected_body: Value = serde_json::from_slice(&rejected_body).unwrap(); + assert_eq!(rejected_body["error"]["code"], "mcp-capacity-exhausted"); + assert_eq!(rejected_body["error"]["status"], 429); + assert!(rejected_body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("并发已满"))); drop(occupied); let response = handle_external_mcp_http_request( AxumState(state.clone()), @@ -1932,6 +1981,44 @@ mod tests { ); } + #[tokio::test] + async fn external_http_mcp_auth_failure_keeps_status_and_actionable_body() { + let _session = crate::platform_session::install_test_platform_session( + "mcp-auth-user", + "mcp-auth-key", + "http://127.0.0.1:9", + ); + let session = current_platform_session().unwrap(); + let temporary = tempfile::tempdir().unwrap(); + let state = ExternalMcpHttpState { + bridge_url: "http://127.0.0.1:9".to_string(), + root: temporary.path().to_path_buf(), + token: "expected-token".to_string(), + session_user_id: session.user_id, + session_identity_generation: session.identity_generation, + in_flight: std::sync::Arc::new(tokio::sync::Semaphore::new( + DIRECT_TOOLS_MCP_MAX_IN_FLIGHT, + )), + }; + let rejected = handle_external_mcp_http_request( + AxumState(state), + HeaderMap::new(), + Json(json!({"id":1,"method":"ping"})), + ) + .await + .unwrap_err(); + assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED); + let body = axum::body::to_bytes(rejected.into_body(), usize::MAX) + .await + .unwrap(); + let body: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(body["error"]["code"], "mcp-unauthorized"); + assert_eq!(body["error"]["status"], 401); + assert!(body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("Bearer token"))); + } + #[tokio::test] async fn mcp_dispatch_runs_mixed_tools_concurrently_with_bounded_out_of_order_responses() { use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs index 8c62e8deb..6a34d07c8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs @@ -161,12 +161,14 @@ pub(crate) struct AuthNetworkFailure { pub(crate) reason: AuthNetworkReason, } -/// 登录服务 5xx 的状态码。 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] +/// 登录服务 5xx 的状态码与服务端原文。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct AuthServiceUnavailable { pub(crate) status: u16, + /// 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。 + pub(crate) server_message: Option, } /// 其它未识别拒绝的状态码与服务端原文。 @@ -268,7 +270,10 @@ mod tests { "authNetworkFailure", ), ( - ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 }), + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { + status: 503, + server_message: None, + }), "authServiceUnavailable", ), ( @@ -342,7 +347,10 @@ mod tests { #[test] fn payload_variants_keep_machine_facts_and_server_text() { let unavailable = serde_json::to_value(ClientAuthError::AuthServiceUnavailable( - AuthServiceUnavailable { status: 503 }, + AuthServiceUnavailable { + status: 503, + server_message: None, + }, )) .expect("serialize auth error"); assert_eq!(unavailable["type"], "authServiceUnavailable"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index 25ea5b770..8428740fe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -565,6 +565,7 @@ fn map_auth_failure(status: StatusCode, body: &str, route: AuthRoute) -> ClientA if status.is_server_error() { return ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: status_code, + server_message, }); } ClientAuthError::UnexpectedRejection(UnexpectedRejection { @@ -1317,7 +1318,10 @@ mod tests { assert!(!transient.is_authority_failure()); assert_eq!( transient, - ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 500 }) + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { + status: 500, + server_message: None, + }) ); } @@ -1424,7 +1428,10 @@ mod tests { fn unreadable_body_keeps_the_known_status() { assert_eq!( classify_unreadable_body(StatusCode::SERVICE_UNAVAILABLE, AuthRoute::PhoneLogin), - ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 }) + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { + status: 503, + server_message: None, + }) ); assert_eq!( classify_unreadable_body(StatusCode::UNAUTHORIZED, AuthRoute::Session), diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index 5a3c3a5f9..194fc73bd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -321,7 +321,8 @@ fn parse_error_payload(body: &str) -> (Option, Option) { body.trim(), 600, ); - (!detail.trim().is_empty()).then_some(detail) + (!detail.trim().is_empty() && !matches!(detail.trim(), "{}" | "null" | "\"\"")) + .then_some(detail) }); (code, message) } @@ -342,7 +343,12 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { crate::platform_maintenance::watch_platform_response(status.as_u16(), body); let (code, message) = parse_error_payload(body); if status == StatusCode::UNAUTHORIZED { - return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(); + return match message.or(code) { + Some(detail) => { + format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}") + } + None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(), + }; } if status == StatusCode::FORBIDDEN { return format!( @@ -1616,6 +1622,14 @@ mod tests { map_http_error(StatusCode::UNAUTHORIZED, "{}", "读取失败"), "authentication-required: 陶泥儿登录态已过期,请重新登录后重试" ); + assert_eq!( + map_http_error( + StatusCode::UNAUTHORIZED, + r#"{"error":{"message":"access token expired"}}"#, + "读取失败", + ), + "authentication-required: 陶泥儿登录态已过期,请重新登录后重试:access token expired" + ); assert_eq!( map_http_error( StatusCode::BAD_REQUEST, diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs index 0b4f53aa4..90406c6d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs @@ -163,42 +163,80 @@ fn response_data(payload: Value) -> Value { fn response_error(status: StatusCode, body: &str, action: &str) -> String { crate::platform_maintenance::watch_platform_response(status.as_u16(), body); - if status == StatusCode::UNAUTHORIZED { - return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(); - } - if status == StatusCode::FORBIDDEN { - return format!("permission-denied: {action}权限不足"); - } let parsed = serde_json::from_str::(body).ok(); let error = parsed.as_ref().and_then(|value| value.get("error")); - let detail = error - .and_then(|value| value.get("message")) - .or_else(|| parsed.as_ref().and_then(|value| value.get("message"))) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) - .or_else(|| { - error - .and_then(|value| value.get("code")) - .or_else(|| parsed.as_ref().and_then(|value| value.get("code"))) + let detail = ["message", "code", "detail", "additionalDetails"] + .into_iter() + .filter_map(|field| { + let value = error + .and_then(|value| value.get(field)) + .or_else(|| parsed.as_ref().and_then(|value| value.get(field))) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) - .map(ToString::to_string) + .map(ToString::to_string)?; + // 先把常见的 `token=... provider rejected` 形状切开,再交给统一脱敏器; + // 否则它会把整行当成长凭据,连后面的真实原因一起吞掉。 + Some(redact_upload_inline_secret_tail(value)) }) + .collect::>() + .join(";"); + let detail = (!detail.is_empty()) + .then_some(detail) .or_else(|| { let safe = crate::agent::redact_agent_runtime_error( std::path::Path::new("__agc_no_project_root__"), body.trim(), 600, ); - (!safe.is_empty()).then_some(safe) + (!safe.is_empty() && !matches!(safe.trim(), "{}" | "null" | "\"\"")).then_some(safe) }) - .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + .map(|detail| { + crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &detail, + 600, + ) + }); + if status == StatusCode::UNAUTHORIZED { + return match detail { + Some(detail) => { + format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}") + } + None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(), + }; + } + if status == StatusCode::FORBIDDEN { + return format!( + "permission-denied: {action}权限不足{}", + detail + .map(|detail| format!(":{detail}")) + .unwrap_or_default() + ); + } + let detail = detail.unwrap_or_else(|| format!("HTTP {}", status.as_u16())); format!("{action}失败:{detail}") } +fn redact_upload_inline_secret_tail(mut value: String) -> String { + for key in ["token=", "api_key=", "api-key=", "authorization="] { + let Some(start) = value.to_ascii_lowercase().find(key) else { + continue; + }; + let value_start = start + key.len(); + let value_end = value[value_start..] + .find(|character: char| { + character.is_whitespace() || matches!(character, ';' | ';' | ',' | ',') + }) + .map(|offset| value_start + offset) + .unwrap_or(value.len()); + if value_end > value_start { + value.replace_range(value_start..value_end, "[redacted-secret]"); + } + } + value +} + fn upload_route() -> &'static str { "/api/assets/direct-upload-tickets" } @@ -477,4 +515,17 @@ mod tests { assert!(!detail.contains("token=secret"), "{detail}"); assert!(detail.contains("provider rejected"), "{detail}"); } + + #[test] + fn response_error_keeps_auth_status_and_server_message() { + let detail = response_error( + StatusCode::UNAUTHORIZED, + r#"{"error":{"message":"asset token expired"}}"#, + "上传素材", + ); + assert_eq!( + detail, + "authentication-required: 陶泥儿登录态已过期,请重新登录后重试:asset token expired" + ); + } } diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 205b6cc2c..1720efe3a 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -385,7 +385,9 @@ export function AuthenticatedClient({ } case 'authServiceUnavailable': { const payload = failure as AuthServiceUnavailable; - const text = `登录服务暂时不可用(HTTP ${payload.status})`; + const text = payload.serverMessage + ? `登录服务暂时不可用(HTTP ${payload.status}):${payload.serverMessage}` + : `登录服务暂时不可用(HTTP ${payload.status})`; setAuthCheckError(text); setLoginStatus(text); throw error; @@ -660,7 +662,9 @@ export function AuthenticatedClient({ case 'authServiceUnavailable': { const payload = failure as AuthServiceUnavailable; setLoginStatus( - `发送验证码失败:登录服务暂时不可用(HTTP ${payload.status})`, + payload.serverMessage + ? `发送验证码失败:登录服务暂时不可用(HTTP ${payload.status}):${payload.serverMessage}` + : `发送验证码失败:登录服务暂时不可用(HTTP ${payload.status})`, ); throw error; } @@ -898,7 +902,9 @@ export function AuthenticatedClient({ case 'authServiceUnavailable': { const payload = failure as AuthServiceUnavailable; setLoginStatus( - `登录失败:登录服务暂时不可用(HTTP ${payload.status})`, + payload.serverMessage + ? `登录失败:登录服务暂时不可用(HTTP ${payload.status}):${payload.serverMessage}` + : `登录失败:登录服务暂时不可用(HTTP ${payload.status})`, ); throw error; } 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 2f3c92b21..d5cc25cc7 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 @@ -87,58 +87,6 @@ export function isMudPointInsufficientRuntimeError(message: string) { ); } -const DIRECT_FAILURE_SENSITIVE_ASSIGNMENT_PATTERN = - /(?:^|[^\w])(?:proxy-authorization|authorization|set-cookie|cookie|access[_ -]?token|accesstoken|refresh[_ -]?token|refreshtoken|oauth[_ -]?token|id[_ -]?token|auth[_ -]?token|authtoken|client[_ -]?secret|clientsecret|private[_ -]?key|privatekey|secret[_ -]?key|secretkey|x-api-key|x_api_key|api[_ -]?key|apikey|credentials?|password|token|secret|bearer)\s*["']?\s*[:=]>?\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|(?:Bearer\s+(?:\[[^\]]+\]|<[^>]+>|[^\s,;,;&}\]]+)|\[[^\]]+\]|<[^>]+>|[^\s,;,;&}\]]+))/gi; - -const DIRECT_FAILURE_BEARER_PATTERN = - /(?:^|[^\w])Bearer\s+(\[[^\]]+\]|<[^>]+>|[^\s,;,;&}\]]+)/gi; - -function isRedactedDirectFailureValue(value: string) { - const normalized = value.trim(); - return ( - normalized.length === 0 || - /^(?:Bearer\s+)?(?:\[redacted-secret\]|\[redacted-sensitive-field\]|\[已隐藏凭据\]|\[已隐藏敏感字段\]|\[已隐藏链接\]|\[已隐藏路径\]|\[已隐藏配置\]|\[已隐藏敏感信息\]||||\[redacted-config\]|\[redacted sensitive context\])$/i.test( - normalized, - ) - ); -} - -function containsUnredactedDirectFailureSecret(value: string) { - // Provider payloads are sometimes embedded as escaped JSON in a single - // diagnostic line (`{\"token\":\"...\"}`). Normalize only the quote - // escapes for the detector; the displayed value still goes through the - // backend's redaction and marker conversion unchanged. - const normalizedValue = value.replace(/\\(["'])/g, '$1'); - DIRECT_FAILURE_SENSITIVE_ASSIGNMENT_PATTERN.lastIndex = 0; - let match: RegExpExecArray | null; - while ( - (match = - DIRECT_FAILURE_SENSITIVE_ASSIGNMENT_PATTERN.exec(normalizedValue)) !== - null - ) { - const rawCandidate = (match[1] ?? '').trim(); - const candidate = - rawCandidate.length >= 2 && - ((rawCandidate.startsWith('"') && rawCandidate.endsWith('"')) || - (rawCandidate.startsWith("'") && rawCandidate.endsWith("'"))) - ? rawCandidate.slice(1, -1).trim() - : rawCandidate; - if (!isRedactedDirectFailureValue(candidate)) { - return true; - } - } - - DIRECT_FAILURE_BEARER_PATTERN.lastIndex = 0; - while ( - (match = DIRECT_FAILURE_BEARER_PATTERN.exec(normalizedValue)) !== null - ) { - if (!isRedactedDirectFailureValue(match[1] ?? '')) { - return true; - } - } - return false; -} - function redactDirectFailureMarkers(value: string) { return value .replace(//gi, '[已隐藏链接]') @@ -149,6 +97,15 @@ function redactDirectFailureMarkers(value: string) { .replace(/\[redacted sensitive context\]/gi, '[已隐藏敏感信息]'); } +/** + * 跨 IPC 的宿主正文统一走同一套精确脱敏:遇到凭据、链接或路径时只替换敏感值, + * 不再因为一条正文里同时出现敏感字段就把 HTTP 状态、错误码和可行动原因整段丢弃。 + */ +function visibleRuntimeDetail(value: string, maxChars: number) { + const safe = runtimeFailureDetail(value)?.replace(/^:/u, '').trim() ?? ''; + return safe ? safe.slice(0, maxChars) : ''; +} + /** * 宿主收口文案(`direct-codex-failure:v1|v2 …`)解析出的可展示部分。 * @@ -185,23 +142,19 @@ function directCodexDiagnosticFailureParts( if (!stage || !retryable || !rawSummary || !rawHint) { return null; } - const summary = redactDirectFailureMarkers(rawSummary) + const summary = ( + visibleRuntimeDetail(rawSummary, 320) || + redactDirectFailureMarkers(rawSummary) + ) .replace(/\s+/g, ' ') .trim() .slice(0, 320); - const hint = redactDirectFailureMarkers(rawHint) + const hint = ( + visibleRuntimeDetail(rawHint, 180) || redactDirectFailureMarkers(rawHint) + ) .replace(/\s+/g, ' ') .trim() .slice(0, 180); - const combined = `${summary} ${hint}`; - if ( - containsUnredactedDirectFailureSecret(combined) || - /https?:\/\/|(?:^|[\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\/i.test( - combined, - ) - ) { - return null; - } const stageLabel = { request: '智能创作请求失败', 'art-preparation': '平台资源准备失败', @@ -262,15 +215,9 @@ function directPlatformFailureDetail(message: string) { )) { return null; } - if ( - containsUnredactedDirectFailureSecret(trimmed) || - /https?:\/\/|(?:^|[\\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\/i.test( - trimmed, - ) - ) { - return null; - } - const safe = redactDirectFailureMarkers(trimmed) + const safe = ( + visibleRuntimeDetail(trimmed, 420) || redactDirectFailureMarkers(trimmed) + ) .replace(/(?:;|;|\s)operationId\s*=\s*[^;;\s]+/gi, '') .replace(/(?:;|;)\s*externalGenerationJobId\s*=\s*[^;;\s]+/gi, '') .replace(/\s+/g, ' ') @@ -290,19 +237,9 @@ function directCodexFailureDetail(message: string) { if (!detail) { return null; } - if ( - /(?:authorization|bearer|api[_ -]?key|token|secret|cookie|set-cookie)/i.test( - detail, - ) - ) { - return null; - } - const safe = detail - .replace(/https?:\/\/[^\s]+/gi, '[已隐藏链接]') - .replace( - /(?:[A-Z]:\\|\\\\|\/(?:Users|home|var|tmp|private)\/)[^\s]+/gi, - '[已隐藏路径]', - ) + const safe = ( + visibleRuntimeDetail(detail, 420) || redactDirectFailureMarkers(detail) + ) .replace(/\s+/g, ' ') .trim() .slice(0, 280); @@ -319,14 +256,12 @@ function directRuntimeFailureDetail(message: string) { )) { return null; } - if ( - /(?:authorization|bearer|api[_ -]?key|token|secret|cookie|set-cookie|https?:\/\/|(?:^|[\\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\)/i.test( - trimmed, - ) - ) { - return null; - } - const safe = trimmed.replace(/\s+/g, ' ').trim().slice(0, 320); + const safe = ( + visibleRuntimeDetail(trimmed, 420) || redactDirectFailureMarkers(trimmed) + ) + .replace(/\s+/g, ' ') + .trim() + .slice(0, 320); return safe || null; } @@ -370,7 +305,7 @@ export function projectRuntimeVisibleError( other: '智能创作执行失败,请查看运行详情后重试', }[codexAppServerKind]; if (detail) { - return `${subject} ${detail}${runtimeFailureDetail(message) ?? ''}`; + return `${subject} ${detail}${runtimeFailureDetail(message) ?? ':宿主未提供具体错误正文'}`; } } const directCodexAppServerKind = visibleMessage.match( @@ -389,11 +324,11 @@ export function projectRuntimeVisibleError( other: '未完成本次执行,请查看项目文件是否已修改后再重试', }[directCodexAppServerKind]; if (detail) { - return `${subject} ${detail}${runtimeFailureDetail(message) ?? ''}`; + return `${subject} ${detail}${runtimeFailureDetail(message) ?? ':宿主未提供具体错误正文'}`; } } if (visibleMessage.includes('codex-app-server-terminal-unknown:')) { - return `${subject}服务或网络在执行中断开,最终状态未知;请先检查项目文件是否已修改,再决定是否重试${runtimeFailureDetail(message) ?? ''}`; + return `${subject}服务或网络在执行中断开,最终状态未知;请先检查项目文件是否已修改,再决定是否重试${runtimeFailureDetail(message) ?? ':宿主未提供具体错误正文'}`; } const directCodexDetail = directCodexFailureDetail(visibleMessage); if (directCodexDetail) { @@ -415,7 +350,7 @@ export function projectRuntimeVisibleError( !retryAttemptText || !maxRetriesText ) { - return `${subject} 执行失败${runtimeFailureDetail(message) ?? ',请稍后重试'}`; + return `${subject} 执行失败${runtimeFailureDetail(message) ?? ':宿主未提供具体错误正文'}`; } const httpStatus = Number.parseInt(httpStatusText, 10); const retryAttempt = Number.parseInt(retryAttemptText, 10); @@ -554,7 +489,7 @@ export function projectRuntimeVisibleError( const safeDetail = runtimeFailureDetail(message); return safeDetail ? `${subject} 执行失败${safeDetail}` - : `${subject} 执行失败,请稍后重试`; + : `${subject} 执行失败:宿主未提供具体错误正文`; } /** 精确脱敏普通 Tauri/IPC/网络错误:保留错误码与正文,只替换敏感值。 */ @@ -583,12 +518,12 @@ function runtimeFailureDetail(value: string) { .replace(/\s+/gu, ' ') .trim() .slice(0, 420); - if (!safe || safe === trimmed) return safe ? `:${safe}` : ''; + if (!safe || safe === trimmed) return safe ? `:${safe}` : null; return `:${safe}`; } function runtimeFailureDetailSuffix(value: string) { - return runtimeFailureDetail(value); + return runtimeFailureDetail(value) ?? ':宿主未提供具体错误正文'; } export function taskRowsFromManifest( diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts b/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts index 4bd75d065..835422835 100644 --- a/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts +++ b/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts @@ -1,6 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * 登录服务 5xx 的状态码。 + * 登录服务 5xx 的状态码与服务端原文。 */ -export type AuthServiceUnavailable = { status: number, }; +export type AuthServiceUnavailable = { status: number, serverMessage: string | null, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadSanitize.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadSanitize.ts index 5a8780be6..c182e7b53 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadSanitize.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadSanitize.ts @@ -512,7 +512,14 @@ export function sanitizeDirectThreadText( ); const withoutAbsolutePaths = redactAbsolutePaths(withoutProjectRoot); const withoutSecrets = redactSecretTokens(withoutAbsolutePaths); - return sanitizeSensitiveLines(withoutSecrets); + const sanitized = sanitizeSensitiveLines(withoutSecrets); + // 旧版已经落盘的失败条目无法凭空恢复被删掉的原因;把它们明确标成“宿主未提供正文”, + // 避免历史记录继续伪装成当前仍在吞错的完整兜底句。新事件带 detail 时不命中这里。 + const legacyGenericFailure = + '陶泥儿智能创作 执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断'; + return sanitized.trim() === legacyGenericFailure + ? `${sanitized}:宿主未提供具体错误正文` + : sanitized; } /** 条目投影的脱敏:只改自由文本字段,身份与时间原样保留。 */ 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 b27087055..326d52340 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 @@ -77,10 +77,14 @@ export function enqueueFailureNotice(failure: EnqueueError): string | null { return `待发消息已达上限(最多 ${failure.limit} 条),请等前面几条发完再发送`; case 'projectRootUnusable': return '当前项目目录不存在或不是绝对路径'; - case 'permissionRejected': - return failure.policyDetail?.trim() || null; - case 'inputRejected': - return failure.detail?.trim() || null; + case 'permissionRejected': { + const detail = visibleDirectFailureDetail(failure.policyDetail); + return detail ? detail : '项目权限策略拒绝执行:宿主未提供具体错误正文'; + } + case 'inputRejected': { + const detail = visibleDirectFailureDetail(failure.detail); + return detail ? detail : '输入被宿主拒绝:宿主未提供具体错误正文'; + } case 'contentEmpty': return '聊天内容不能为空'; // 宿主 / 环境事实:必须走上报通道,文案由 `enqueueSystemFailureNoticeText` 给固定句。 @@ -109,13 +113,9 @@ export function enqueueSystemFailureNoticeText( ): string | null { switch (failure.type) { case 'environmentNotReady': - return visibleDirectFailureDetail(failure.detail) - ? `${DIRECT_TURN_SUBJECT}:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试:${visibleDirectFailureDetail(failure.detail)}` - : `${DIRECT_TURN_SUBJECT}:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试`; + return `${DIRECT_TURN_SUBJECT}:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试:${visibleDirectFailureDetail(failure.detail) ?? '宿主未提供具体错误正文'}`; case 'hostStateUnavailable': - return visibleDirectFailureDetail(failure.detail) - ? `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,这一轮没有开始;请稍后重试:${visibleDirectFailureDetail(failure.detail)}` - : `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,这一轮没有开始;请稍后重试`; + return `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,这一轮没有开始;请稍后重试:${visibleDirectFailureDetail(failure.detail) ?? '宿主未提供具体错误正文'}`; // 业务变体由 `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 4d65fe27a..23781c1de 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 @@ -100,7 +100,9 @@ function withVisibleFailureDetail( detail: string | null | undefined, ) { const safeDetail = visibleDirectFailureDetail(detail); - return safeDetail ? `${message}:${safeDetail}` : message; + return safeDetail + ? `${message}:${safeDetail}` + : `${message}:宿主未提供具体错误正文`; } function withVisibleFailureDetailOrMissing( diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index 97c924321..b419300ea 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -24,4 +24,28 @@ describe('Agent Runtime 错误正文', () => { expect(text).toContain('status=401'); expect(text).toContain('detail=invalid token'); }); + + it('正文带敏感字段时只替换敏感值,不丢掉上游状态和原因', () => { + const text = projectRuntimeVisibleError( + 'direct-codex-error: HTTP 502 upstream overloaded;authorization=Bearer provider-secret;https://provider.example/private;C:\\Users\\demo\\game', + '陶泥儿智能创作', + ); + + 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('已知分类但宿主没有正文时明确标记正文缺失', () => { + const text = projectRuntimeVisibleError( + 'kind=codex-app-server-unauthorized', + '陶泥儿智能创作', + ); + + expect(text).toContain('鉴权失败'); + expect(text).toContain('宿主未提供具体错误正文'); + }); }); diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 3d875e617..50fe4a63a 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -148,8 +148,7 @@ function submitComposerForm(composer: HTMLElement) { function directTurnInputText(args?: Record) { const content = ( args?.userItem as - | { content?: Array<{ type: string; text?: string }> } - | undefined + { content?: Array<{ type: string; text?: string }> } | undefined )?.content; return (content ?? []) .map((part) => (part.type === 'input_text' ? (part.text ?? '') : '')) @@ -546,13 +545,13 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect( within(conversation).getByText( - '陶泥儿智能创作:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试', + '陶泥儿智能创作:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试:Codex app-server 启动失败:找不到可执行文件', ), ).not.toBeNull(); }); - // 宿主原文(`detail` 里的可执行文件路径、机器字段)只留在诊断里,不进聊天。 - expect(conversation.textContent ?? '').not.toContain( - 'Codex app-server 启动失败', + // 宿主原文经过精确脱敏后保留在聊天里,便于用户直接定位启动失败原因。 + expect(conversation.textContent ?? '').toContain( + 'Codex app-server 启动失败:找不到可执行文件', ); expect(conversation.textContent ?? '').not.toContain( 'direct-codex-failure', @@ -789,7 +788,7 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect( within(surface).getAllByText( - '陶泥儿智能创作 模型上下文已超限,请缩小任务范围后重试', + '陶泥儿智能创作 模型上下文已超限,请缩小任务范围后重试:codex-app-server-error:context-window-exceeded', ).length, ).toBeGreaterThan(0); }); @@ -853,7 +852,7 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect( within(conversation).getAllByText( - '陶泥儿智能创作:当前环境未就绪,本轮未完成;请检查本机运行环境后重试', + '陶泥儿智能创作:当前环境未就绪,本轮未完成;请检查本机运行环境后重试:写入本项目对话历史失败:项目对话历史追加写失败', ), ).toHaveLength(1); }); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index e0d3ae2d2..e29322cfb 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -55,12 +55,12 @@ export function registerProjectConversationTests() { detail: ' 用户条目不合法 ', }), ).toBe('用户条目不合法'); - // 空原文按"没有提示"处理:要么给一条能显示的话,要么给 null。 + // 空原文也不能静默:明确标出宿主未提供正文,避免用户只看到统一兜底句。 expect( enqueueFailureNotice({ type: 'permissionRejected', policyDetail: ' ' }), - ).toBeNull(); + ).toBe('项目权限策略拒绝执行:宿主未提供具体错误正文'); - // 宿主 / 环境事实不在这里认领:它们必须走上报通道,聊天里只给固定句,不回落 `detail`。 + // 宿主 / 环境事实不在这里认领:它们必须走上报通道,聊天里保留精确脱敏 detail。 expect( enqueueFailureNotice({ type: 'environmentNotReady', @@ -79,14 +79,16 @@ export function registerProjectConversationTests() { detail: 'Codex app-server 启动失败:找不到可执行文件', }), ).toBe( - '陶泥儿智能创作:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试', + '陶泥儿智能创作:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试:Codex app-server 启动失败:找不到可执行文件', ); expect( enqueueSystemFailureNoticeText({ type: 'hostStateUnavailable', detail: '账本损坏:exitStatus=signal: 9 (SIGKILL)', }), - ).toBe('陶泥儿智能创作:宿主执行状态不可用,这一轮没有开始;请稍后重试'); + ).toBe( + '陶泥儿智能创作:宿主执行状态不可用,这一轮没有开始;请稍后重试:账本损坏:exitStatus=signal: 9 (SIGKILL)', + ); // JS 侧载体:结构化载荷装进 Error,`error` 与 `message`(序列化的机器事实)都带出去。 const wrapper = new EnqueueFailureWrapper({ diff --git a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts index d3a43aa58..fff8a2a9d 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts @@ -196,7 +196,11 @@ describe('变体分流', () => { variantMessage({ type: 'passwordLoginRejected', serverMessage: null }), ).toBe('登录输入被拒绝'); expect( - variantMessage({ type: 'authServiceUnavailable', status: 503 }), + variantMessage({ + type: 'authServiceUnavailable', + status: 503, + serverMessage: null, + }), ).toBe('HTTP 503'); }); diff --git a/apps/ai-game-creator-shell/tests/directThreadSanitize.test.ts b/apps/ai-game-creator-shell/tests/directThreadSanitize.test.ts index 434c7acb0..f7d5fe8b8 100644 --- a/apps/ai-game-creator-shell/tests/directThreadSanitize.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadSanitize.test.ts @@ -92,6 +92,17 @@ describe('directThreadSanitize', () => { ); }); + it('旧版通用中断条目明确标记宿主没有记录正文', () => { + expect( + sanitizeDirectThreadText( + ROOT, + '陶泥儿智能创作 执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断', + ), + ).toBe( + '陶泥儿智能创作 执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断:宿主未提供具体错误正文', + ); + }); + it('多行文本保留换行', () => { const value = '第一段\nkey=sk-abcdefghijklmnop\n第三段\n'; expect(sanitizeDirectThreadText(ROOT, value)).toBe( diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index ac6ac1aab..73bd925cc 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -128,6 +128,9 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - AGC 项目资源编辑、上传、预览、恢复队列和生成结果回读的面板错误统一经过 `normalizeDiagnosticText`,保留状态码与安全正文,替换凭据、URL、路径和长标识;资源业务提示不再直接展示 `Error.message`。 - 应用壳、运行配置、认证状态、插件启动、运行预览和策划会话的普通客户端错误统一经过 `visibleClientErrorMessage`;结构化认证拒绝仍按 typed reason 分流,非结构化错误保留安全正文后再进入状态提示或错误上报。 - 资产导入、邀请码、策划工作区、发布封面/截图和素材命令的用户可见错误也复用该出口,避免业务面板把原始 `Error.message` 重新带回界面。 +- 外部 MCP loopback 的 401/429/桥上下文缺失必须返回带正文的 JSON 错误体(同时保留 HTTP 状态);不能让 Claude Code 侧只看到空 body 的状态码。 +- 认证 5xx、账户/素材上传/发行发布的 401/403 保留服务端 `message`/`code` 后再做精确脱敏;空 `{}`、`null` 等无信息响应不算错误正文。 +- 旧版已落盘的通用“执行通道中断”文本无法恢复历史原因,读取时明确追加“宿主未提供具体错误正文”;新事件只在确实没有 detail 时使用同样标记。 ## 后果与边界 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index a8feaef46..4fbdfa09b 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -49,3 +49,6 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 真实 AGC dev smoke:客户端使用 `3080`、后端 `8084`、数据库 `3101`、后台 `3103` 启动;真实 CC 回合复现 `Reached maximum number of turns (8)` 被旧代码错误记为 `transport-closed`,修复后 Tauri 已热重编译重启。修复后的真实 Provider 回放未再次发送,避免无必要的付费请求。 - 真实 AGC 项目诊断发现真实请求 HTTP 400 曾被摘要为 `codex-app-server-error:other detail=fields=codexErrorInfo`,另有 401 仅显示 `codex-app-server-error:unauthorized`;该漏损已在 app-server 投影边界修复。真实 Provider、真实 IPC 断链和真实内存压力仍未在本轮主动制造。 - 继续扫描发现素材/参考图/资源编辑上传、模型目录初始化和 Direct MCP stdio 仍有固定 `map_err(|_| ...)` 丢正文,已改为保留底层网络、serde、写入和 OS 错误;平台素材上传定向 3 项、模型目录定向 4 项、Direct MCP 断输出定向 1 项通过。资源编辑全套并行测试本轮 54 项通过、11 项因共享 fixture/本地端口或账号环境超时失败,不能把该套结果记为全绿。 +- 本轮继续扫出三类未收敛口:外部 MCP HTTP 的 401/429/桥上下文缺失原先只有空状态码响应,现改为保留 HTTP 状态、稳定错误码和中文正文;认证 5xx 结构化载荷、账户/素材上传/发行发布的 401/403 现保留服务端 message/code(仍精确脱敏)。 +- 前端历史错误文本此前会把含有敏感字段的整条 Direct 诊断直接丢弃,现改为只替换凭据、链接和路径;旧版已经落盘且无法恢复正文的“执行通道中断”条目会明确追加“宿主未提供具体错误正文”,不再伪装成当前仍在吞错。 +- 新增/通过:外部 MCP HTTP 401/429 定向 2 项、前端 Direct/Runtime 错误正文 25 项、旧历史通用中断脱敏回归 1 项;认证 Rust 定向 19 项、素材上传响应错误定向 1 项通过。`appSurface` 全套仍有环境/fixture 相关失败,不能据此宣称全绿。 From 7bc724d8e219d6425dadba3fd7eb69c2e16dd654 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:20:30 +0800 Subject: [PATCH 048/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E5=A4=96=E5=9B=B4=20HTTP=20=E9=94=99=E8=AF=AF=E6=AD=A3?= =?UTF-8?q?=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一画板、模板、图片分离和素材下载的状态码与安全正文 保留资源编辑、生成恢复、项目快照和错误报告的上游详情 补充精确脱敏测试与错误处理文档 --- .../src-tauri/src/agent/direct_runtime/mod.rs | 7 +- .../src/agent/generation/canvas_generation.rs | 23 ++- .../src-tauri/src/assets.rs | 93 +++++++-- .../src-tauri/src/commands.rs | 9 +- .../src-tauri/src/error_report/submit.rs | 82 +++++--- .../src-tauri/src/platform_asset_upload.rs | 11 +- .../src-tauri/src/project/resource_editor.rs | 180 +++++++++--------- .../src/project_snapshot/transport.rs | 8 +- .../src-tauri/src/template_library.rs | 8 +- .../commands/separation/workflow/extract.rs | 10 +- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 12 files changed, 288 insertions(+), 145 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 53eff6757..ed89d3a1d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -2907,7 +2907,12 @@ async fn recover_direct_taonier_spritesheet_read_only_at( access.validate_frozen_session()?; let status = response.status(); if !status.is_success() { - return Err(format!("读取陶泥儿画布资源失败:HTTP {}", status.as_u16())); + let body = response.text().await.unwrap_or_default(); + return Err(crate::assets::external_asset_http_failure( + "读取陶泥儿画布资源", + status, + &body, + )); } let payload = response .json::() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 745cbd6c7..00d800c36 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -933,8 +933,8 @@ pub(crate) fn classify_external_generation_initial_response( )) } _ => Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成返回未识别的成功状态 HTTP {}", - status.as_u16() + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {}", + format_external_http_error("平台图片生成", status, &payload.to_string()) )), } } @@ -1361,9 +1361,11 @@ pub(crate) async fn submit_external_generation_request( ) })?; if response.status().is_server_error() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); return Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后收到 HTTP {},服务端是否已产生副作用未知", - response.status().as_u16() + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后收到 {},服务端是否已产生副作用未知", + format_external_http_error("平台图片生成", status, &body) )); } Ok(response) @@ -1394,8 +1396,8 @@ async fn resume_prepared_external_generation_at( let body = response.text().await.unwrap_or_default(); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); return Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor prepared 恢复提交返回 HTTP {};原生成账本已保留", - status.as_u16() + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor prepared 恢复提交返回 {};原生成账本已保留", + format_external_http_error("External Editor prepared 恢复提交", status, &body) )); } let submission_payload_result = response @@ -2137,9 +2139,12 @@ async fn upload_manifest_asset_remote_reference_at( .await .map_err(|error| format!("上传当前账号参考图失败:{error}"))?; if upload_response.status().as_u16() != ticket.success_action_status { - return Err(format!( - "上传当前账号参考图失败:HTTP {}", - upload_response.status().as_u16() + let status = upload_response.status(); + let body = upload_response.text().await.unwrap_or_default(); + return Err(crate::assets::external_asset_http_failure( + "上传当前账号参考图", + status, + &body, )); } access.validate_frozen_session()?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index f7881313c..9da8c8460 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -11,6 +11,60 @@ const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE: &str = const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE: &str = "private-external-editor-credential-persistence-failed"; +/// Extract a bounded, redacted upstream error without treating a status code as the whole reason. +fn external_asset_http_detail(body: &str) -> Option { + let parsed = serde_json::from_str::(body).ok(); + let error = parsed + .as_ref() + .and_then(|value| value.get("error")) + .or_else(|| parsed.as_ref()); + let detail = ["message", "detail", "code", "additionalDetails"] + .into_iter() + .filter_map(|field| { + error + .and_then(|value| value.get(field)) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) + .collect::>() + .join(";"); + let detail = (!detail.is_empty()).then_some(detail).or_else(|| { + let safe = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); + (!safe.trim().is_empty() && !matches!(safe.trim(), "{}" | "null" | "\"\"")).then_some(safe) + })?; + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 600, + ); + (!detail.trim().is_empty()).then_some(detail) +} + +pub(crate) fn external_asset_http_failure( + action: &str, + status: reqwest::StatusCode, + body: &str, +) -> String { + let detail = external_asset_http_detail(body) + .map(|detail| format!(":{detail}")) + .unwrap_or_default(); + match status { + reqwest::StatusCode::UNAUTHORIZED => { + format!("authentication-required: {action}失败(HTTP 401),请重新登录后重试{detail}") + } + reqwest::StatusCode::FORBIDDEN => { + format!("permission-denied: {action}被服务器拒绝(HTTP 403){detail}") + } + _ => format!("{action}失败:HTTP {}{detail}", status.as_u16()), + } +} + /// A task-scoped External Editor credential used only by the direct Codex /// runtime. It never changes the GUI account session and deliberately keeps /// the key private: callers may use it to make authenticated requests but may @@ -406,15 +460,11 @@ async fn create_private_external_editor_api_credentials_from_platform_session( if !status.is_success() { let body = response.text().await.unwrap_or_default(); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); - return Err(match status { - reqwest::StatusCode::UNAUTHORIZED => { - "authentication-required: 陶泥儿登录已失效,无法创建本机开发者 Key".to_string() - } - reqwest::StatusCode::FORBIDDEN => { - "permission-denied: 当前陶泥儿账号无权创建本机开发者 Key".to_string() - } - _ => format!("创建本机陶泥儿开发者 Key 失败:HTTP {}", status.as_u16()), - }); + return Err(external_asset_http_failure( + "创建本机陶泥儿开发者 Key", + status, + &body, + )); } let payload = response .json::() @@ -907,9 +957,10 @@ pub(crate) async fn sync_canvas_project_assets_at( if !project_status.is_success() { let body = project_response.text().await.unwrap_or_default(); crate::platform_maintenance::watch_platform_response(project_status.as_u16(), &body); - return Err(format!( - "读取画板项目失败:HTTP {}", - project_status.as_u16() + return Err(external_asset_http_failure( + "读取画板项目", + project_status, + &body, )); } let project_payload = project_response @@ -1364,7 +1415,8 @@ where return Err("画板资产下载地址发生重定向,已拒绝继续请求".to_string()); } if !status.is_success() { - return Err(format!("下载画板资产失败:HTTP {}", status.as_u16())); + let body = response.text().await.unwrap_or_default(); + return Err(external_asset_http_failure("下载画板资产", status, &body)); } if response .content_length() @@ -1436,7 +1488,7 @@ pub(crate) async fn resolve_external_asset_signed_url( if !status.is_success() { let body = response.text().await.unwrap_or_default(); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); - return Err(format!("换签画板资产失败:HTTP {}", status.as_u16())); + return Err(external_asset_http_failure("换签画板资产", status, &body)); } let payload = response .json::() @@ -2257,6 +2309,19 @@ mod tests { use shared_contracts::game_creation_app::GameCreationAppAssetCategory; use std::io::{Read, Write}; + #[test] + fn external_asset_http_failure_keeps_status_and_redacted_upstream_detail() { + let detail = external_asset_http_failure( + "读取画板项目", + reqwest::StatusCode::UNAUTHORIZED, + r#"{"error":{"code":"TOKEN_EXPIRED","message":"access token expired;authorization=Bearer secret"}}"#, + ); + assert!(detail.contains("HTTP 401"), "{detail}"); + assert!(detail.contains("TOKEN_EXPIRED"), "{detail}"); + assert!(detail.contains("access token expired"), "{detail}"); + assert!(!detail.contains("Bearer secret"), "{detail}"); + } + #[test] fn design_artifact_registration_reports_only_real_manifest_changes() { let temporary = tempfile::tempdir().expect("tempdir"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index ea9d974da..32f57ecfb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -3706,9 +3706,12 @@ async fn download_ui_editor_remote_asset(url: &str, max_bytes: u64) -> Result String { } fn response_error(status: StatusCode, body: &str) -> String { - if status == StatusCode::UNAUTHORIZED { - return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(); - } - if status == StatusCode::FORBIDDEN { - return "permission-denied: 当前账号没有提交错误报告的权限".to_string(); - } let detail = serde_json::from_str::(body) .ok() .and_then(|value| { let error = value.get("error").unwrap_or(&value); - error - .get("message") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) - .or_else(|| { - error - .get("code") - .and_then(Value::as_str) - .map(str::to_string) - }) - .or_else(|| serde_json::to_string(error).ok()) + Some( + ["message", "detail", "code", "additionalDetails"] + .into_iter() + .filter_map(|field| { + error + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) + .collect::>() + .join(";"), + ) }) + .filter(|value| !value.is_empty()) .or_else(|| { let detail = crate::agent::redact_agent_runtime_error( Path::new("__agc_no_project_root__"), body.trim(), 600, ); - (!detail.trim().is_empty()).then_some(detail) + (!detail.trim().is_empty() && !matches!(detail.trim(), "{}" | "null" | "\"\"")) + .then_some(detail) }) - .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); - format!("错误报告提交失败:{detail}") + .map(|detail| { + crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 600, + ) + }) + .filter(|value| !value.trim().is_empty()); + if status == StatusCode::UNAUTHORIZED { + return match detail { + Some(detail) => { + format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}") + } + None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(), + }; + } + if status == StatusCode::FORBIDDEN { + return format!( + "permission-denied: 当前账号没有提交错误报告的权限{}", + detail + .map(|detail| format!(":{detail}")) + .unwrap_or_default() + ); + } + format!( + "错误报告提交失败:HTTP {}{}", + status.as_u16(), + detail + .map(|detail| format!(":{detail}")) + .unwrap_or_default() + ) } fn response_data(body: &str) -> Result { @@ -234,4 +260,16 @@ mod tests { assert_eq!(value["batchId"], "batch-1"); assert!(!value.to_string().contains("token")); } + + #[test] + fn response_error_keeps_status_and_redacted_server_detail() { + let detail = response_error( + StatusCode::TOO_MANY_REQUESTS, + r#"{"error":{"code":"RATE_LIMITED","message":"too many requests;token=secret"}}"#, + ); + assert!(detail.contains("HTTP 429"), "{detail}"); + assert!(detail.contains("RATE_LIMITED"), "{detail}"); + assert!(detail.contains("too many requests"), "{detail}"); + assert!(!detail.contains("token=secret"), "{detail}"); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs index 90406c6d6..fc7e3988d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs @@ -376,10 +376,13 @@ async fn upload_object( .await .map_err(|error| format!("上传素材失败:无法访问素材存储:{error};请检查网络后重试"))?; validate_session(snapshot)?; - if response.status().as_u16() != ticket.success_action_status { - return Err(format!( - "上传素材到对象存储失败(HTTP {}),请重试", - response.status().as_u16() + let status = response.status(); + if status.as_u16() != ticket.success_action_status { + let body = response.text().await.unwrap_or_default(); + return Err(crate::assets::external_asset_http_failure( + "上传素材到对象存储", + status, + &body, )); } Ok(()) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 6e3c05987..e8b8e4ff8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -1768,32 +1768,31 @@ async fn request_resource_edit_upload_ticket( .await .map_err(|error| format!("result-unknown: 创建源资源上传凭证请求失败:{error}"))?; let mut post_response_session_error = access.validate_frozen_session().err(); - if response.status() == reqwest::StatusCode::UNAUTHORIZED { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authentication_error()); + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authentication_error); + return Err(append_editor_api_reason(base, response).await); } - if response.status() == reqwest::StatusCode::FORBIDDEN { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authorization_error()); + if status == reqwest::StatusCode::FORBIDDEN { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authorization_error); + return Err(append_editor_api_reason(base, response).await); } - if !response.status().is_success() { - if response.status().is_server_error() { - return Err(format!( + if !status.is_success() { + let base = if status.is_server_error() { + format!( "result-unknown: 创建源资源上传凭证返回 HTTP {}", - response.status().as_u16() - )); - } - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(format!( - "创建源资源上传凭证失败:HTTP {}", - response.status().as_u16() - )); + status.as_u16() + ) + } else if let Some(error) = post_response_session_error.as_ref() { + error.clone() + } else { + format!("创建源资源上传凭证失败:HTTP {}", status.as_u16()) + }; + return Err(append_editor_api_reason(base, response).await); } let payload = response .json::() @@ -1901,19 +1900,16 @@ async fn upload_resource_edit_source( .map_err(|error| format!("result-unknown: 上传源资源请求失败:{error}"))?; let post_response_session_error = access.validate_frozen_session().err(); if response.status().as_u16() != ticket.success_action_status { + let status = response.status(); if response.status().is_server_error() { - return Err(format!( - "result-unknown: 上传源资源返回 HTTP {}", - response.status().as_u16() - )); + let base = format!("result-unknown: 上传源资源返回 HTTP {}", status.as_u16()); + return Err(append_editor_api_reason(base, response).await); } if let Some(error) = post_response_session_error { - return Err(error); + return Err(append_editor_api_reason(error, response).await); } - return Err(format!( - "上传源资源失败:HTTP {}", - response.status().as_u16() - )); + let base = format!("上传源资源失败:HTTP {}", status.as_u16()); + return Err(append_editor_api_reason(base, response).await); } Ok(ResourceEditRemoteStage { value: (), @@ -1955,32 +1951,31 @@ async fn confirm_resource_edit_source( .await .map_err(|error| format!("result-unknown: 确认源资源上传请求失败:{error}"))?; let mut post_response_session_error = access.validate_frozen_session().err(); - if response.status() == reqwest::StatusCode::UNAUTHORIZED { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authentication_error()); + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authentication_error); + return Err(append_editor_api_reason(base, response).await); } - if response.status() == reqwest::StatusCode::FORBIDDEN { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authorization_error()); + if status == reqwest::StatusCode::FORBIDDEN { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authorization_error); + return Err(append_editor_api_reason(base, response).await); } - if !response.status().is_success() { - if response.status().is_server_error() { - return Err(format!( + if !status.is_success() { + let base = if status.is_server_error() { + format!( "result-unknown: 确认源资源上传返回 HTTP {}", - response.status().as_u16() - )); - } - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(format!( - "确认源资源上传失败:HTTP {}", - response.status().as_u16() - )); + status.as_u16() + ) + } else if let Some(error) = post_response_session_error.as_ref() { + error.clone() + } else { + format!("确认源资源上传失败:HTTP {}", status.as_u16()) + }; + return Err(append_editor_api_reason(base, response).await); } let payload = response .json::() @@ -2067,32 +2062,31 @@ async fn register_resource_edit_source_image( .await .map_err(|error| format!("result-unknown: 登记源图片项目资源请求失败:{error}"))?; let mut post_response_session_error = access.validate_frozen_session().err(); - if response.status() == reqwest::StatusCode::UNAUTHORIZED { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authentication_error()); + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authentication_error); + return Err(append_editor_api_reason(base, response).await); } - if response.status() == reqwest::StatusCode::FORBIDDEN { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authorization_error()); + if status == reqwest::StatusCode::FORBIDDEN { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authorization_error); + return Err(append_editor_api_reason(base, response).await); } - if !response.status().is_success() { - if response.status().is_server_error() { - return Err(format!( + if !status.is_success() { + let base = if status.is_server_error() { + format!( "result-unknown: 登记源图片项目资源返回 HTTP {}", - response.status().as_u16() - )); - } - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(format!( - "登记源图片项目资源失败:HTTP {}", - response.status().as_u16() - )); + status.as_u16() + ) + } else if let Some(error) = post_response_session_error.as_ref() { + error.clone() + } else { + format!("登记源图片项目资源失败:HTTP {}", status.as_u16()) + }; + return Err(append_editor_api_reason(base, response).await); } let payload = response .json::() @@ -2667,6 +2661,13 @@ async fn editor_api_rejection_reason(response: reqwest::Response) -> Option String { + match editor_api_rejection_reason(response).await { + Some(reason) => format!("{base}:{reason}"), + None => base, + } +} + /// 追加到用户可见原因里的外部字段上限:正文已经按 64KiB 收口,但单个字段仍可能把 /// 「不让整段正文进 UI」的初衷抵消掉,所以每个追加项单独截断。 const MAX_EDITOR_ERROR_DETAIL_CHARS: usize = 80; @@ -2788,16 +2789,16 @@ async fn submit_resource_edit_remote( let mut post_response_session_error = access.validate_frozen_session().err(); let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED { - if let Some(error) = post_response_session_error.as_ref() { - return Err(error.clone()); - } - return Err(editor_api_authentication_error()); + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authentication_error); + return Err(append_editor_api_reason(base, response).await); } if status == reqwest::StatusCode::FORBIDDEN { - if let Some(error) = post_response_session_error.as_ref() { - return Err(error.clone()); - } - return Err(editor_api_authorization_error()); + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authorization_error); + return Err(append_editor_api_reason(base, response).await); } if status == reqwest::StatusCode::BAD_REQUEST { ledger.terminal_failure_code = Some("remote-request-bad-request".to_string()); @@ -2934,10 +2935,13 @@ async fn wait_for_resource_edit_remote( continue; } if !response.status().is_success() { - return Err(ResourceEditError::Other(format!( + let base = format!( "result-unknown: 查询资源编辑任务返回 HTTP {}", response.status().as_u16() - ))); + ); + return Err(ResourceEditError::Other( + append_editor_api_reason(base, response).await, + )); } let payload = response .json::() diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs index 6ff4c226a..4b9a2901f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs @@ -323,7 +323,13 @@ async fn project_snapshot_response_error( let detail = response .text() .await - .map(|body| body.chars().take(200).collect::()) + .map(|body| { + crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &body, + 200, + ) + }) .unwrap_or_default(); let detail = detail.trim().to_string(); if detail.is_empty() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs index 65265250e..0c1e9a7af 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -572,7 +572,13 @@ async fn fetch_limited_bytes( .await .map_err(|error| format!("请求模板库失败:{error}"))?; if !response.status().is_success() { - return Err(format!("模板库返回 HTTP {}", response.status().as_u16())); + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(crate::assets::external_asset_http_failure( + "模板库请求", + status, + &body, + )); } if response .content_length() diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs index 9477e2a37..351df5525 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs @@ -118,11 +118,17 @@ async fn raw_extract_inner( format!("图片分离请求失败:{error}") })?; if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); app_log!( "ui_separation.error stage=image_edit reason=http_status status={}", - response.status() + status ); - return Err(format!("图片分离请求失败(HTTP {})", response.status())); + return Err(crate::assets::external_asset_http_failure( + "图片分离请求", + status, + &body, + )); } let payload = response.json::().await.map_err(|error| { app_log!("ui_separation.error stage=image_edit reason=parse_response error={error}"); diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 73bd925cc..63b172a29 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -131,6 +131,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 外部 MCP loopback 的 401/429/桥上下文缺失必须返回带正文的 JSON 错误体(同时保留 HTTP 状态);不能让 Claude Code 侧只看到空 body 的状态码。 - 认证 5xx、账户/素材上传/发行发布的 401/403 保留服务端 `message`/`code` 后再做精确脱敏;空 `{}`、`null` 等无信息响应不算错误正文。 - 旧版已落盘的通用“执行通道中断”文本无法恢复历史原因,读取时明确追加“宿主未提供具体错误正文”;新事件只在确实没有 detail 时使用同样标记。 +- 画板/模板/图片分离/参考图/External Editor/资源编辑/项目快照/错误报告等外围 HTTP 入口也遵循同一出口:读取有限响应正文,保留状态码、服务端 message/code 和操作上下文,凭据、URL、路径按统一规则脱敏;不能因为不是 Direct 回合主链就回到“HTTP N”兜底。 ## 后果与边界 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index 4fbdfa09b..da9e89b3f 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -52,3 +52,4 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 本轮继续扫出三类未收敛口:外部 MCP HTTP 的 401/429/桥上下文缺失原先只有空状态码响应,现改为保留 HTTP 状态、稳定错误码和中文正文;认证 5xx 结构化载荷、账户/素材上传/发行发布的 401/403 现保留服务端 message/code(仍精确脱敏)。 - 前端历史错误文本此前会把含有敏感字段的整条 Direct 诊断直接丢弃,现改为只替换凭据、链接和路径;旧版已经落盘且无法恢复正文的“执行通道中断”条目会明确追加“宿主未提供具体错误正文”,不再伪装成当前仍在吞错。 - 新增/通过:外部 MCP HTTP 401/429 定向 2 项、前端 Direct/Runtime 错误正文 25 项、旧历史通用中断脱敏回归 1 项;认证 Rust 定向 19 项、素材上传响应错误定向 1 项通过。`appSurface` 全套仍有环境/fixture 相关失败,不能据此宣称全绿。 +- 继续扫描又发现画板项目/资产下载、模板库、图片分离、参考图上传、External Editor 生成恢复、资源编辑上传/轮询、项目快照和错误报告提交仍有“HTTP 只有编号”或未脱敏正文路径;已统一补上状态码 + 有界正文 + 精确脱敏。资源编辑拒绝原因继续复用既有 `details.message` 解析,避免丢掉平台具体原因。 From e0af8b98f8311288a8880088b90a3a4b1fcef4f8 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:25:47 +0800 Subject: [PATCH 049/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=A1=A5=E4=B8=8E=E6=A8=A1=E6=9D=BF=E6=9D=83?= =?UTF-8?q?=E9=99=90=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留 loopback MCP HTTP 正文和模板权限网络解析详情 --- .../src-tauri/src/agent/direct_tools_mcp.rs | 11 ++++++---- .../src-tauri/src/template_library.rs | 21 ++++++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 623421277..ee30d612b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -923,10 +923,13 @@ async fn call_client_tool_bridge(tool: &str, arguments: &Value) -> Value { .send() .await .map_err(|error| format!("连接客户端受控工具桥失败:{error}"))?; - if !response.status().is_success() { - return Err(format!( - "客户端受控工具桥返回 HTTP {}", - response.status().as_u16() + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(crate::assets::external_asset_http_failure( + "客户端受控工具桥请求", + status, + &body, )); } if response diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs index 0c1e9a7af..aeb79a8ee 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -42,7 +42,7 @@ async fn template_library_access_for_session( .timeout(Duration::from_secs(15)) .redirect(reqwest::redirect::Policy::none()) .build() - .map_err(|_| "template-library-unavailable: 无法检查模板库权限".to_string())?; + .map_err(|error| format!("template-library-unavailable: 无法检查模板库权限:{error}"))?; let response = client .get(format!( "{}/api/runtime/frontend-config", @@ -51,12 +51,16 @@ async fn template_library_access_for_session( .bearer_auth(&session.access_token) .send() .await - .map_err(|_| "template-library-unavailable: 检查模板库权限失败,请重试".to_string())?; + .map_err(|error| { + format!("template-library-unavailable: 检查模板库权限失败,请重试:{error}") + })?; validate_platform_session_identity(&session.identity())?; - if !response.status().is_success() { + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); return Err(format!( - "template-library-unavailable: 检查模板库权限返回 HTTP {}", - response.status().as_u16() + "template-library-unavailable: {}", + crate::assets::external_asset_http_failure("检查模板库权限", status, &body,) )); } const MAX_BYTES: usize = 64 * 1024; @@ -69,8 +73,9 @@ async fn template_library_access_for_session( let mut bytes = Vec::new(); let mut stream = response.bytes_stream(); while let Some(chunk) = stream.next().await { - let chunk = - chunk.map_err(|_| "template-library-unavailable: 读取模板库权限失败".to_string())?; + let chunk = chunk.map_err(|error| { + format!("template-library-unavailable: 读取模板库权限失败:{error}") + })?; if bytes.len() + chunk.len() > MAX_BYTES { return Err("template-library-unavailable: 模板库权限响应无效".to_string()); } @@ -78,7 +83,7 @@ async fn template_library_access_for_session( } validate_platform_session_identity(&session.identity())?; let payload: serde_json::Value = serde_json::from_slice(&bytes) - .map_err(|_| "template-library-unavailable: 模板库权限响应无效".to_string())?; + .map_err(|error| format!("template-library-unavailable: 模板库权限响应无效:{error}"))?; Ok(payload .get("agcTemplateLibraryEnabled") .and_then(|value| value.as_bool()) From 8c4486f9f1b5816d0f9efe34cf6df38ddab53949 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:36:49 +0800 Subject: [PATCH 050/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E4=B8=8E=E8=AE=A2=E9=98=85=20IPC=20=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留模板权限失败正文并显示 Direct 订阅链 IPC 详情 补充前端回归测试与错误处理文档 --- .../template-library/useTemplateLibrary.ts | 24 +++++++++++++++---- .../useDirectProjectChatController.ts | 5 ++++ .../useDirectThreadChatSubscription.ts | 15 +++++++++--- .../tests/appSurface/chat-composer.suite.ts | 4 +++- .../tests/useTemplateLibrary.test.tsx | 2 ++ ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 7 files changed, 43 insertions(+), 9 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts index 47d817c42..6f174fa4f 100644 --- a/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts +++ b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts @@ -97,6 +97,19 @@ export function useTemplateLibrary({ setBusyKind(null); }, []); + const disableAccessWithError = useCallback((message: string) => { + scopeRef.current.generation += 1; + scopeRef.current.allowed = false; + setAccess({ userId: scopeRef.current.userId, enabled: false }); + setSnapshot(null); + setStatus('error'); + setError(message); + setNotice(''); + setFilters(EMPTY_TEMPLATE_LIBRARY_FILTERS); + setBusyTemplateId(null); + setBusyKind(null); + }, []); + const handleOperationError = useCallback( (nextError: unknown) => { const message = errorMessage(nextError); @@ -104,12 +117,13 @@ export function useTemplateLibrary({ message.includes('template-library-unavailable:') || message.includes('authentication-required:') ) { - revokeAccess(); + // 权限/会话失败仍然关闭入口,但不能把服务端 HTTP、错误码和正文一起清掉。 + disableAccessWithError(message); } else { setError(message); } }, - [revokeAccess], + [disableAccessWithError], ); const refresh = useCallback(async () => { @@ -133,8 +147,8 @@ export function useTemplateLibrary({ let allowed: boolean; try { allowed = await invoke('get_game_template_library_access'); - } catch { - if (isCurrent()) revokeAccess(); + } catch (nextError) { + if (isCurrent()) disableAccessWithError(errorMessage(nextError)); return; } if (!isCurrent()) return; @@ -161,7 +175,7 @@ export function useTemplateLibrary({ setStatus('error'); handleOperationError(nextError); } - }, [handleOperationError, revokeAccess]); + }, [disableAccessWithError, handleOperationError, revokeAccess]); useEffect(() => { revokeAccess(); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 8f341d7e2..0dfcbb040 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -197,6 +197,11 @@ export function useDirectProjectChatController({ const directEntries = directThread.entries; const currentTurnRunning = directThread.turnRunning; const currentTurnStartedAt = directThread.turnStartedAt; + useEffect(() => { + if (!directThread.error) return; + setStatusNotice(directThread.error); + onRuntimeError(directThread.error); + }, [directThread.error, onRuntimeError]); /** * 待发消息:**投影自事件流**,界面不持有第二份队列。入队、取消、放行都只让这条列表跟着变。 */ diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts index 4306544d4..02f898a09 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts @@ -47,6 +47,8 @@ export type DirectThreadChatSubscription = { completedTurnCount: number; /** 订阅回执锚点闸门:首屏历史读取靠它拿到 `lastCompletedItemId`。 */ anchorGateRef: MutableRefObject; + /** subscribe/consume/IPC 失败的安全正文;不能用空 catch 让聊天静默失联。 */ + error: string | null; /** 历史切片并入同一个 reducer:条目只有这一份事实源。 */ mergeHistoryItems: (items: readonly ThreadItem[]) => void; }; @@ -68,11 +70,13 @@ export function useDirectThreadChatSubscription({ const [state, setState] = useState(() => emptyDirectThreadChatState(), ); + const [error, setError] = useState(null); const anchorGateRef = useRef(null); // 项目切换时聊天容器整体换一份状态:上一个项目的条目不能落进新项目。 useEffect(() => { setState(emptyDirectThreadChatState()); + setError(null); }, [projectPath]); useEffect(() => { @@ -136,9 +140,11 @@ export function useDirectThreadChatSubscription({ subscriptionId = null; try { await bootstrap(); - } catch { - // 下一次通知再重试。 + } catch (recoveryError) { + if (!disposed) setError(visibleClientErrorMessage(recoveryError)); } + } else if (!disposed) { + setError(visibleClientErrorMessage(error)); } } finally { consuming = false; @@ -161,6 +167,7 @@ export function useDirectThreadChatSubscription({ return; } subscriptionId = result.subscriptionId; + setError(null); anchorGate.settle(result.lastCompletedItemId ?? null); setState((current) => resolveDirectThreadBootstrap(current, { @@ -191,7 +198,8 @@ export function useDirectThreadChatSubscription({ } cleanup = unlisten; await bootstrap(); - } catch { + } catch (setupError) { + if (!disposed) setError(visibleClientErrorMessage(setupError)); anchorGate.settle(null); } }; @@ -225,6 +233,7 @@ export function useDirectThreadChatSubscription({ turnStartedAt: state.turnStartedAt, completedTurnCount: state.completedTurnCount, anchorGateRef, + error, mergeHistoryItems, }; } diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 50fe4a63a..15386e65c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -520,7 +520,9 @@ export function registerChatComposerControlTests() { // 认不出的入队失败 / 非结构化错误仍走既有捕获链路:横幅给用户一句可读的话。 await waitFor(() => { expect( - within(surface).getByText('陶泥儿智能创作 执行失败,请稍后重试'), + within(surface).getByText( + '陶泥儿智能创作 执行失败:authentication-required', + ), ).not.toBeNull(); }); } finally { diff --git a/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx b/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx index f1a941123..96f60bf57 100644 --- a/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx +++ b/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx @@ -197,6 +197,7 @@ describe('模板库灰度会话', () => { }); expect(result.current.enabled).toBe(false); expect(result.current.snapshot).toBeNull(); + expect(result.current.error).toContain('模板库暂未开放'); expect(onProjectCreated).not.toHaveBeenCalled(); }); @@ -212,6 +213,7 @@ describe('模板库灰度会话', () => { }); expect(result.current.enabled).toBe(false); expect(result.current.templates).toEqual([]); + expect(result.current.error).toContain('offline'); }); it('退出登录开始时即使 userId 未变也清空权限并停止旧建项回调', async () => { diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 63b172a29..441a902f8 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -132,6 +132,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 认证 5xx、账户/素材上传/发行发布的 401/403 保留服务端 `message`/`code` 后再做精确脱敏;空 `{}`、`null` 等无信息响应不算错误正文。 - 旧版已落盘的通用“执行通道中断”文本无法恢复历史原因,读取时明确追加“宿主未提供具体错误正文”;新事件只在确实没有 detail 时使用同样标记。 - 画板/模板/图片分离/参考图/External Editor/资源编辑/项目快照/错误报告等外围 HTTP 入口也遵循同一出口:读取有限响应正文,保留状态码、服务端 message/code 和操作上下文,凭据、URL、路径按统一规则脱敏;不能因为不是 Direct 回合主链就回到“HTTP N”兜底。 +- 模板库权限探测和 Direct 线程订阅/consume/bootstrap 属于用户可见的前端状态边界:权限/IPC 失败可以关闭入口或结束订阅,但必须同时保留安全错误正文;禁止用空 catch、只 settle 锚点或清空状态来吞掉认证、网络和 IPC 原因。 ## 后果与边界 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index da9e89b3f..848ac4170 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -53,3 +53,4 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 前端历史错误文本此前会把含有敏感字段的整条 Direct 诊断直接丢弃,现改为只替换凭据、链接和路径;旧版已经落盘且无法恢复正文的“执行通道中断”条目会明确追加“宿主未提供具体错误正文”,不再伪装成当前仍在吞错。 - 新增/通过:外部 MCP HTTP 401/429 定向 2 项、前端 Direct/Runtime 错误正文 25 项、旧历史通用中断脱敏回归 1 项;认证 Rust 定向 19 项、素材上传响应错误定向 1 项通过。`appSurface` 全套仍有环境/fixture 相关失败,不能据此宣称全绿。 - 继续扫描又发现画板项目/资产下载、模板库、图片分离、参考图上传、External Editor 生成恢复、资源编辑上传/轮询、项目快照和错误报告提交仍有“HTTP 只有编号”或未脱敏正文路径;已统一补上状态码 + 有界正文 + 精确脱敏。资源编辑拒绝原因继续复用既有 `details.message` 解析,避免丢掉平台具体原因。 +- 前端继续发现两个静默出口:模板库权限检查失败会直接清空入口,Direct 线程订阅/consume/bootstrap 的 IPC 失败会只 settle anchor 而不显示正文;现分别保留权限检查错误,并把订阅链错误正文送入项目状态栏/运行错误出口。认证失败回归已验证显示 `authentication-required`,不再变成泛化“执行失败,请稍后重试”。 From 0a249dff3aa8f4d5e46a6f7f0cf47190bfe18686 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:09:20 +0800 Subject: [PATCH 051/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=20IPC=20=E9=94=99=E8=AF=AF=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留模板权限、Direct 订阅和剪贴板失败详情 补充前端回归测试与错误处理文档 --- .../ui-editor/components/UiEditorCopyPathButton.tsx | 11 +++++++++-- apps/ai-game-creator-shell/tests/uiEditorPage.test.ts | 6 +++++- ...�ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md | 1 + .../plans/【里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorCopyPathButton.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorCopyPathButton.tsx index b33d9e62a..0d6a467a4 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorCopyPathButton.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorCopyPathButton.tsx @@ -2,6 +2,8 @@ import { writeText } from '@tauri-apps/plugin-clipboard-manager'; import { Copy } from 'lucide-react'; import { useState } from 'react'; +import { visibleClientErrorMessage } from '../../../services/errorReporting'; + type CopyState = 'idle' | 'copied' | 'failed'; export function UiEditorCopyPathButton({ @@ -15,16 +17,21 @@ export function UiEditorCopyPathButton({ relativePath: string; value: CopyState; }>({ relativePath, value: 'idle' }); + const [copyError, setCopyError] = useState(''); const value = copyState.relativePath === relativePath ? copyState.value : 'idle'; async function copyPath() { setCopyState({ relativePath, value: 'idle' }); + setCopyError(''); try { await writeText(relativePath); setCopyState({ relativePath, value: 'copied' }); - } catch { + } catch (error) { setCopyState({ relativePath, value: 'failed' }); + setCopyError( + `复制失败:${visibleClientErrorMessage(error)};请手动复制路径。`, + ); } } @@ -32,7 +39,7 @@ export function UiEditorCopyPathButton({ <> {value === 'failed' ? (

- 复制失败,请手动复制路径。 + {copyError || '复制失败,请手动复制路径。'}

) : null} ) : null} + {loadError && notificationEvents.length === 0 ? ( + + ) : null} ({ vi.mock('../src/services/errorReporting', () => ({ getPendingClientErrorEvents: mockState.getPending, + visibleClientErrorMessage: (error: unknown) => + error instanceof Error ? error.message : String(error), subscribeClientErrorEvents: (listener: () => void) => { mockState.listeners.push(listener); return () => { @@ -95,4 +98,20 @@ describe('ErrorReportNotice', () => { expect(await screen.findByTestId('error-report-dialog')).toBeTruthy(); expect(dialogState.fallbackEvents).toEqual([event]); }); + + it('项目诊断快照读取失败时显示具体错误正文', async () => { + mockState.getPending.mockRejectedValue(new Error('IPC channel closed')); + vi.useFakeTimers(); + try { + render(); + await act(async () => { + await vi.runAllTimersAsync(); + }); + expect( + screen.getByText('项目诊断读取失败:IPC channel closed'), + ).toBeTruthy(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index d4cc45d42..24bcd8eec 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -137,6 +137,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - `agc_web_search` 的上游非 2xx 也属于用户可见的受控工具失败:MCP 结果必须保留 HTTP 状态与有界脱敏正文,不能只返回状态码。 - 跨项目活动回合快照读取失败即使保留旧快照,也必须把最近一次安全正文一并呈现;“读不到不等于没有在跑”不能变成“读不到所以没有原因”。 - 项目打开前的权限策略读取属于 IPC 边界,失败不得静默中止加载;必须保留安全正文并标明是策略读取失败。 +- 错误报告通知自身的快照读取失败也不能被通知组件的重试逻辑吞掉;重试耗尽后必须显示安全正文和再次读取入口。 ## 后果与边界 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index ce011c3b6..2791df445 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -58,3 +58,4 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 受控联网搜索的上游非 2xx 响应原来只返回 HTTP 状态;现保留有界、脱敏的服务端正文,错误码/限流原因不再丢失。 - 窗口标题栏的 Direct 活动回合快照读取失败原来只给“读取失败”标志并保留旧快照;现额外保留脱敏后的 IPC/读取正文,面板不会把真实原因吞掉。 - 项目打开时读取 `conversation.read` 权限策略的 IPC 失败原来静默 return;现把脱敏错误正文写入项目状态和聊天错误提示。 +- 错误报告通知读取本地待报告快照失败原来只重试后静默消失;现显示“项目诊断读取失败”及安全正文,并提供重试入口。 From 55d1e76c955e70e3d703812700fa44a466b38bda Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:35:08 +0800 Subject: [PATCH 056/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=E6=B6=A6=E8=89=B2=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留 Prompt Polish 异步异常的安全详情 补充共享 hook 回归测试和文档 --- .../src/features/project-workspace/usePromptPolish.ts | 10 ++++++---- .../tests/usePromptPolish.test.tsx | 8 ++++++-- ...€�ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md | 1 + .../plans/【里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/usePromptPolish.ts b/apps/ai-game-creator-shell/src/features/project-workspace/usePromptPolish.ts index e8506823b..63511fb74 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/usePromptPolish.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/usePromptPolish.ts @@ -1,5 +1,6 @@ import { useCallback, useRef, useState } from 'react'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { requestChatPromptPolish } from './chatPromptPolish'; /** @@ -143,11 +144,12 @@ export function usePromptPolish({ setNotice(normalized.notice ?? null); applyPrompt(normalized.text); return normalized.text; - } catch { - // 注入的 `requestPolish` / 规范化 / 回填都可能抛:兜成失败提示, - // 而不是把这个 promise 变成未处理拒绝、让用户看不到任何反馈。 + } catch (error) { + // 注入的 requestPolish / 规范化 / 回填 都可能抛:保留安全正文, + // 不让“可重试”固定句吞掉平台、IPC 或宿主错误。 if (requestIdRef.current === requestId) { - setError(options?.failureMessage ?? failureMessage); + const summary = options?.failureMessage ?? failureMessage; + setError(`${summary}:${visibleClientErrorMessage(error)}`); } return null; } finally { diff --git a/apps/ai-game-creator-shell/tests/usePromptPolish.test.tsx b/apps/ai-game-creator-shell/tests/usePromptPolish.test.tsx index e0c97e61f..50ef523bb 100644 --- a/apps/ai-game-creator-shell/tests/usePromptPolish.test.tsx +++ b/apps/ai-game-creator-shell/tests/usePromptPolish.test.tsx @@ -54,7 +54,9 @@ describe('usePromptPolish', () => { // 默认 `requestChatPromptPolish` 自己吞掉失败;注入实现可以拒绝,此时必须 // 变成用户可见的失败提示,而不是把 promise 变成未处理拒绝、界面上什么都没有。 expect(returned).toBeNull(); - expect(result.current.error).toBe('AI 润色失败,可重试'); + expect(result.current.error).toBe( + 'AI 润色失败,可重试:platform llm unavailable', + ); expect(result.current.polishing).toBe(false); expect(applyPrompt).not.toHaveBeenCalled(); }); @@ -72,7 +74,9 @@ describe('usePromptPolish', () => { await result.current.polish({ failureMessage: '润色失败,可直接发送' }); }); - expect(result.current.error).toBe('润色失败,可直接发送'); + expect(result.current.error).toBe( + '润色失败,可直接发送:composer write failed', + ); expect(result.current.polishing).toBe(false); }); diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 24bcd8eec..9d5823eed 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -138,6 +138,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 跨项目活动回合快照读取失败即使保留旧快照,也必须把最近一次安全正文一并呈现;“读不到不等于没有在跑”不能变成“读不到所以没有原因”。 - 项目打开前的权限策略读取属于 IPC 边界,失败不得静默中止加载;必须保留安全正文并标明是策略读取失败。 - 错误报告通知自身的快照读取失败也不能被通知组件的重试逻辑吞掉;重试耗尽后必须显示安全正文和再次读取入口。 +- 共享异步操作 hook(包括 Prompt Polish)捕获异常时,操作指引可以保留,但固定失败文案后必须附精确脱敏正文。 ## 后果与边界 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index 2791df445..3322c34b4 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -59,3 +59,4 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 窗口标题栏的 Direct 活动回合快照读取失败原来只给“读取失败”标志并保留旧快照;现额外保留脱敏后的 IPC/读取正文,面板不会把真实原因吞掉。 - 项目打开时读取 `conversation.read` 权限策略的 IPC 失败原来静默 return;现把脱敏错误正文写入项目状态和聊天错误提示。 - 错误报告通知读取本地待报告快照失败原来只重试后静默消失;现显示“项目诊断读取失败”及安全正文,并提供重试入口。 +- 共享 Prompt Polish hook 的注入请求/规范化/回填异常原来只显示固定失败提示;现保留精确脱敏后的异常正文,聊天输入和资源润色共用同一口径。 From c95d9849ee74c3a3310ac94339aecaf69f713f48 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:40:09 +0800 Subject: [PATCH 057/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E4=BB=BB=E5=8A=A1=E8=B4=A6=E6=9C=AC=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留历史生成任务读取的 IPC 与文件正文 补充资源画布回归测试和文档 --- .../src/view/project-development/index.tsx | 10 ++++++---- .../tests/appSurface/project-development.suite.ts | 4 +++- ...€�ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md | 1 + .../plans/【里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 29c33bf23..484daa99d 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -8995,10 +8995,12 @@ export default function ProjectDevelopmentView({ resourceGenerationDraftRef.current.clear(); resourceAssetGenerationDraftRef.current.clear(); void (async () => { - const reportUnavailable = () => { + const reportUnavailable = (error?: unknown) => { if (!cancelled) { setResourceWorkbenchNotice( - '生成任务列表读取失败,暂时无法恢复历史任务', + error + ? `生成任务列表读取失败:${projectDevelopmentErrorMessage(error)}` + : '生成任务列表读取失败,暂时无法恢复历史任务', ); } }; @@ -9013,8 +9015,8 @@ export default function ProjectDevelopmentView({ return; } records = response; - } catch { - reportUnavailable(); + } catch (error) { + reportUnavailable(error); return; } if (cancelled) { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index c83bafff6..625dc433b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -8133,7 +8133,9 @@ export function registerProjectAgentStatusTests() { expect(screen.getByRole('button', { name: '生成任务' })).not.toBeNull(); expect( - await screen.findByText('生成任务列表读取失败,暂时无法恢复历史任务'), + await screen.findByText( + '生成任务列表读取失败:unexpected invoke list_local_project_asset_generations', + ), ).not.toBeNull(); }, 20_000); diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 9d5823eed..88cc12551 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -139,6 +139,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 项目打开前的权限策略读取属于 IPC 边界,失败不得静默中止加载;必须保留安全正文并标明是策略读取失败。 - 错误报告通知自身的快照读取失败也不能被通知组件的重试逻辑吞掉;重试耗尽后必须显示安全正文和再次读取入口。 - 共享异步操作 hook(包括 Prompt Polish)捕获异常时,操作指引可以保留,但固定失败文案后必须附精确脱敏正文。 +- 资源画布历史任务账本读取属于用户可见的恢复路径:异常正文必须进入提示条;只有响应形状错误这类没有底层异常的情况才使用明确的契约提示。 ## 后果与边界 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index 3322c34b4..f5393c8c1 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -60,3 +60,4 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 项目打开时读取 `conversation.read` 权限策略的 IPC 失败原来静默 return;现把脱敏错误正文写入项目状态和聊天错误提示。 - 错误报告通知读取本地待报告快照失败原来只重试后静默消失;现显示“项目诊断读取失败”及安全正文,并提供重试入口。 - 共享 Prompt Polish hook 的注入请求/规范化/回填异常原来只显示固定失败提示;现保留精确脱敏后的异常正文,聊天输入和资源润色共用同一口径。 +- 资源画布历史生成任务账本读取失败原来统一显示“暂时无法恢复历史任务”;现在保留 Tauri/文件读取正文,非数组响应仍单独保留契约错误提示。 From e1b17c6f1b06ed5075b0f97bd646fe11b4356d05 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:49:31 +0800 Subject: [PATCH 058/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E6=B6=A6=E8=89=B2=E5=91=BD=E4=BB=A4=E5=BA=95=E5=B1=82=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 不再把 Tauri 润色异常吞成空回包 补充共享润色链路回归测试和文档 --- .../project-workspace/chatPromptPolish.ts | 19 ++++++++----------- .../tests/chatPromptPolish.test.tsx | 14 ++++++++++---- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts b/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts index 2f77b58a9..27b3af0a8 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts @@ -84,7 +84,8 @@ export function shouldRemindChatPromptPolish({ * 走平台 LLM 路由的短文本润色。 * 复用 `polish_local_project_prompt` 这条短文本生成通道:计费在平台 LLM 路由侧完成, * 这里不自建计费、不落盘。 - * 失败 / 超时 / 未配置模型统一返回 `null`,调用方保留原文并给出可重试提示。 + * 空回包 / 未配置模型返回 `null`;Tauri 调用抛出的平台、网络、IPC 正文继续上抛, + * 由共享 `usePromptPolish` 统一脱敏后展示。 */ export async function requestChatPromptPolish( prompt: string, @@ -96,14 +97,10 @@ export async function requestChatPromptPolish( return null; } const trimmedContext = context?.trim(); - try { - const result = await invoke('polish_local_project_prompt', { - prompt: trimmed, - context: trimmedContext ? trimmedContext : null, - }); - const polished = typeof result === 'string' ? result.trim() : ''; - return polished ? polished : null; - } catch { - return null; - } + const result = await invoke('polish_local_project_prompt', { + prompt: trimmed, + context: trimmedContext ? trimmedContext : null, + }); + const polished = typeof result === 'string' ? result.trim() : ''; + return polished ? polished : null; } diff --git a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx index c7b29ab60..bf2da0579 100644 --- a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx +++ b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx @@ -280,7 +280,7 @@ describe('requestChatPromptPolish', () => { }); }); - test('omits blank context and keeps failures recoverable', async () => { + test('omits blank context and preserves native failure details', async () => { const invoke = installPolishingInvoke('润色结果'); await requestChatPromptPolish('原始需求', ' '); expect(invoke).toHaveBeenCalledWith('polish_local_project_prompt', { @@ -292,7 +292,9 @@ describe('requestChatPromptPolish', () => { throw new Error('platform llm unavailable'); }); installTauriInvoke(failing); - await expect(requestChatPromptPolish('原始需求')).resolves.toBeNull(); + await expect(requestChatPromptPolish('原始需求')).rejects.toThrow( + 'platform llm unavailable', + ); await expect(requestChatPromptPolish(' ')).resolves.toBeNull(); }); @@ -344,7 +346,9 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { await user.click(screen.getByRole('button', { name: 'AI 润色' })); expect(await composerText()).toBe('原本的需求'); - expect(await screen.findByText('AI 润色失败,可重试')).not.toBeNull(); + expect( + await screen.findByText('AI 润色失败,可重试:platform llm timeout'), + ).not.toBeNull(); expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull(); }); @@ -425,7 +429,9 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { ); expect( - await screen.findByText('AI 润色失败,可重试或使用原文提交'), + await screen.findByText( + 'AI 润色失败,可重试或使用原文提交:platform llm timeout', + ), ).not.toBeNull(); expect(onSubmitDraft).not.toHaveBeenCalled(); expect(await composerText()).toBe(LONG_PROMPT); diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 88cc12551..19a0054ce 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -140,6 +140,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 错误报告通知自身的快照读取失败也不能被通知组件的重试逻辑吞掉;重试耗尽后必须显示安全正文和再次读取入口。 - 共享异步操作 hook(包括 Prompt Polish)捕获异常时,操作指引可以保留,但固定失败文案后必须附精确脱敏正文。 - 资源画布历史任务账本读取属于用户可见的恢复路径:异常正文必须进入提示条;只有响应形状错误这类没有底层异常的情况才使用明确的契约提示。 +- 异步业务 facade 不得把底层异常折成“空结果”来逃避错误处理;只有真实的空回包、未配置或浏览器桥不可用等无正文事实允许返回空值,Tauri/网络/IPC 异常必须上抛到统一脱敏出口。 ## 后果与边界 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index f5393c8c1..fe2c8ddb6 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -61,3 +61,4 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 错误报告通知读取本地待报告快照失败原来只重试后静默消失;现显示“项目诊断读取失败”及安全正文,并提供重试入口。 - 共享 Prompt Polish hook 的注入请求/规范化/回填异常原来只显示固定失败提示;现保留精确脱敏后的异常正文,聊天输入和资源润色共用同一口径。 - 资源画布历史生成任务账本读取失败原来统一显示“暂时无法恢复历史任务”;现在保留 Tauri/文件读取正文,非数组响应仍单独保留契约错误提示。 +- 发现默认 `requestChatPromptPolish` 还会把 Tauri 润色命令异常吞成 `null`,导致共享 hook 无法看到平台正文;现仅对空回包/桥不可用保留 `null`,原生异常继续上抛并统一脱敏。 From 8e9415c5d24123e4470fb191506d465f61e41ae7 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:53:21 +0800 Subject: [PATCH 059/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E5=9B=9E=E5=90=88=E4=BA=8B=E4=BB=B6=E6=A1=A5?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 显示 Direct 活动事件桥注册失败的 IPC 正文 补充活动项目面板回归测试和文档 --- .../features/agent-runtime/directActiveTurns.ts | 5 +++-- .../features/app-shell/ActiveProjectRunsPanel.tsx | 10 ++++++++++ .../tests/directActiveTurns.test.tsx | 14 ++++++++++++++ ...R】AGC命令错误结构化与错误报告口径-2026-10-01.md | 1 + .../【里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 5 files changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts index be98e4981..b21d4bbb3 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts @@ -157,8 +157,9 @@ export function useDirectActiveTurns({ return; } unsubscribe = release; - } catch { - // 事件桥不可用时仍做一次启动快照;不恢复定时轮询。 + } catch (error) { + // 事件桥不可用时仍做一次启动快照;同时保留 IPC 正文,不能让活动回合更新静默失联。 + if (!disposed) setSnapshotReadError(visibleClientErrorMessage(error)); } } if (!disposed) { diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx index d3cb512c0..9c7c4e0ee 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx @@ -156,6 +156,11 @@ export function ActiveProjectRunsPanel({ aria-hidden="true" /> + {readError ? ( + + {readError} + + ) : null} {open ? (
@@ -211,6 +216,11 @@ export function ActiveProjectRunsPanel({
+ {homeInputError ? ( +

+ {homeInputError} +

+ ) : null} {recoverableCreatedProjectPath && !creationBusy ? ( diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 4a6b38ff3..8fbb9cb76 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -142,6 +142,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 资源画布历史任务账本读取属于用户可见的恢复路径:异常正文必须进入提示条;只有响应形状错误这类没有底层异常的情况才使用明确的契约提示。 - 异步业务 facade 不得把底层异常折成“空结果”来逃避错误处理;只有真实的空回包、未配置或浏览器桥不可用等无正文事实允许返回空值,Tauri/网络/IPC 异常必须上抛到统一脱敏出口。 - Direct 活动回合事件桥注册失败即使仍有一次性快照,也必须标明事件更新通道已失联并展示安全正文,不能只显示旧快照而让用户误以为活动状态持续可靠。 +- 首页富文本剪贴板读取的“无内容”与“原生 IPC 失败”必须区分;后者展示安全正文,不能静默当作空剪贴板。 ## 后果与边界 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index b15f8c216..3bf31f5b4 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -63,3 +63,4 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 资源画布历史生成任务账本读取失败原来统一显示“暂时无法恢复历史任务”;现在保留 Tauri/文件读取正文,非数组响应仍单独保留契约错误提示。 - 发现默认 `requestChatPromptPolish` 还会把 Tauri 润色命令异常吞成 `null`,导致共享 hook 无法看到平台正文;现仅对空回包/桥不可用保留 `null`,原生异常继续上抛并统一脱敏。 - Direct 活动回合事件桥注册失败原来只退回启动快照,面板没有说明后续更新已失联;现保留事件桥 IPC 正文并在标题栏/活动项目面板显示。 +- 首页富文本输入的原生剪贴板图片/文本读取失败原来直接按空剪贴板处理;现通过输入区错误提示保留脱敏后的剪贴板 IPC 正文。 From c601b41c7ca1733cb47e058095605b8c696f4426 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:15:44 +0800 Subject: [PATCH 061/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E5=A4=8D=E5=88=B6=E4=B8=8E=E5=85=85=E5=80=BC?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留邀请码剪贴板和充值状态查询正文 补充账号错误处理文档 --- .../src/features/app-shell/useAccountReferral.ts | 3 ++- .../src/features/app-shell/useAccountWallet.ts | 5 +++-- .../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md | 1 + .../plans/【里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountReferral.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountReferral.ts index bf121ec03..9fbc2fe3b 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountReferral.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountReferral.ts @@ -104,8 +104,9 @@ export function useAccountReferral(currentUserId: string) { try { await writeText(value); applyState('copied'); - } catch { + } catch (error) { applyState('failed'); + setError(`复制失败:${visibleClientErrorMessage(error)};请手动复制。`); } } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts index 5c5017ba2..333973b55 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts @@ -368,17 +368,18 @@ export function useAccountWallet(currentUserId: string) { ? { ...current, isConfirming: false, confirmMessage } : current, ); - } catch { + } catch (error) { if ( rechargeLifecycleRef.current === rechargeLifecycle && currentUserIdRef.current === snapshotOwnerUserId ) { + const detail = visibleClientErrorMessage(error); setNativeRechargePayment((current) => current?.orderId === orderId ? { ...current, isConfirming: false, - confirmMessage: '暂时没能确认到账状态,请稍后再试。', + confirmMessage: `暂时没能确认到账状态:${detail};请稍后再试。`, } : current, ); diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 8fbb9cb76..186f49deb 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -143,6 +143,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 异步业务 facade 不得把底层异常折成“空结果”来逃避错误处理;只有真实的空回包、未配置或浏览器桥不可用等无正文事实允许返回空值,Tauri/网络/IPC 异常必须上抛到统一脱敏出口。 - Direct 活动回合事件桥注册失败即使仍有一次性快照,也必须标明事件更新通道已失联并展示安全正文,不能只显示旧快照而让用户误以为活动状态持续可靠。 - 首页富文本剪贴板读取的“无内容”与“原生 IPC 失败”必须区分;后者展示安全正文,不能静默当作空剪贴板。 +- 账号邀请码复制与充值状态确认的失败提示也必须附安全正文;操作指引不能替换底层剪贴板、网络或支付查询原因。 ## 后果与边界 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index 3bf31f5b4..47e7dc0e5 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -64,3 +64,4 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 发现默认 `requestChatPromptPolish` 还会把 Tauri 润色命令异常吞成 `null`,导致共享 hook 无法看到平台正文;现仅对空回包/桥不可用保留 `null`,原生异常继续上抛并统一脱敏。 - Direct 活动回合事件桥注册失败原来只退回启动快照,面板没有说明后续更新已失联;现保留事件桥 IPC 正文并在标题栏/活动项目面板显示。 - 首页富文本输入的原生剪贴板图片/文本读取失败原来直接按空剪贴板处理;现通过输入区错误提示保留脱敏后的剪贴板 IPC 正文。 +- 账号邀请码复制和微信充值状态确认仍有固定“复制失败/暂时没能确认到账”路径;现保留剪贴板/支付查询错误正文并继续给操作指引。 From 3daf0063c4e335c6a28588dfdae6a0271b2255da Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:21:13 +0800 Subject: [PATCH 062/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E9=9D=A2=E6=9D=BF=E9=94=99=E8=AF=AF=E6=AD=A3?= =?UTF-8?q?=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留截图上传、进度订阅和发布提交错误详情 补充发布错误处理文档 --- .../GameDistributionPublishPanel.tsx | 15 +++++++-------- ...】AGC命令错误结构化与错误报告口径-2026-10-01.md | 1 + .../【里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx index 67d9bfd0d..01c0df96e 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx @@ -629,10 +629,10 @@ export function GameDistributionPublishPanel({ ...item, status: 'failed', assetObjectId: null, - error: - uploadError instanceof Error - ? uploadError.message - : '截图上传失败,请删除后重试', + error: visibleClientErrorMessage( + uploadError, + '截图上传失败,请删除后重试', + ), } : item, ), @@ -703,8 +703,9 @@ export function GameDistributionPublishPanel({ : null, }); }); - } catch { + } catch (progressError) { // 订阅失败只影响阶段条:发布本身照常进行,按钮上仍显示「正在发布…」。 + setError(`发布进度订阅失败:${visibleClientErrorMessage(progressError)}`); } try { const next = await publishLocalProjectGame({ @@ -742,9 +743,7 @@ export function GameDistributionPublishPanel({ setResult(next); onPublished?.(next); } catch (nextError) { - setError( - nextError instanceof Error ? nextError.message : String(nextError), - ); + setError(visibleClientErrorMessage(nextError, '发布失败,请重试')); } finally { unsubscribe(); setBusy(false); diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 186f49deb..8d1637452 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -144,6 +144,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - Direct 活动回合事件桥注册失败即使仍有一次性快照,也必须标明事件更新通道已失联并展示安全正文,不能只显示旧快照而让用户误以为活动状态持续可靠。 - 首页富文本剪贴板读取的“无内容”与“原生 IPC 失败”必须区分;后者展示安全正文,不能静默当作空剪贴板。 - 账号邀请码复制与充值状态确认的失败提示也必须附安全正文;操作指引不能替换底层剪贴板、网络或支付查询原因。 +- 游戏发布面板的媒体上传、进度事件订阅和发布提交错误必须复用 `visibleClientErrorMessage`;不能因发布流程仍可继续或有进度兜底就丢掉 IPC/网络正文。 ## 后果与边界 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index 47e7dc0e5..f3ac1a626 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -65,3 +65,4 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - Direct 活动回合事件桥注册失败原来只退回启动快照,面板没有说明后续更新已失联;现保留事件桥 IPC 正文并在标题栏/活动项目面板显示。 - 首页富文本输入的原生剪贴板图片/文本读取失败原来直接按空剪贴板处理;现通过输入区错误提示保留脱敏后的剪贴板 IPC 正文。 - 账号邀请码复制和微信充值状态确认仍有固定“复制失败/暂时没能确认到账”路径;现保留剪贴板/支付查询错误正文并继续给操作指引。 +- 游戏发布面板的截图上传、发布进度订阅和最终发布异常也有固定文案/裸 `Error.message` 路径;现统一通过可见错误脱敏出口展示正文。 From 9b5e6a8f750fce7e1970210c71d4db1e9aafd8dd Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:29:43 +0800 Subject: [PATCH 063/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E6=B4=BB=E4=BD=93=E6=A0=B8=E9=AA=8C=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留预览状态 IPC 与陈旧记录收尾正文 补充预览核验测试和文档 --- .../src/features/app-shell/sessionPreview.ts | 16 +++++++++++++--- .../features/app-shell/useHomeProjectCreation.ts | 3 +++ .../tests/sessionPreview.test.ts | 1 + ...€‘AGC命令错误结构化与错误报告口径-2026-10-01.md | 1 + .../【里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 5 files changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/app-shell/sessionPreview.ts b/apps/ai-game-creator-shell/src/features/app-shell/sessionPreview.ts index bc77a6861..b2f5c3ca8 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/sessionPreview.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/sessionPreview.ts @@ -1,4 +1,5 @@ import type { GameCreationAppPreviewState } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; /** * 进入项目时的预览活体核验。 @@ -26,6 +27,8 @@ export type SessionPreviewResolution = { * 渲染运行画面。落盘那份由这次写入、退出收尾或下次进入时的核对负责收敛。 */ manifestPreviewPatch: GameCreationAppPreviewState | null; + /** 活体核验/陈旧记录收尾的安全正文;无错误时省略。 */ + error?: string; }; /** 预览命令的最小 invoke 形状(与 App 里 `window.__TAURI__.core.invoke` 一致)。 */ @@ -74,11 +77,15 @@ export async function resolveSessionPreviewOnProjectOpen({ 'get_local_game_preview_status', { projectPath: trimmedProjectPath }, ); - } catch { + } catch (error) { // 读不到 registry 不等于"确认没有在跑":`stop_local_game_preview` 会停掉 // 真在跑的活体预览,读失败(权限拒绝 / 瞬时 IPC 错误)时不能顺手当清理动作。 // 只按"不带预览进入"处理,让用户自己点「运行」。 - return { sessionPreview: null, manifestPreviewPatch: null }; + return { + sessionPreview: null, + manifestPreviewPatch: null, + error: `预览状态读取失败:${visibleClientErrorMessage(error)}`, + }; } const sessionPreview = livePreviewFromStatus(status); if (sessionPreview) { @@ -89,15 +96,18 @@ export async function resolveSessionPreviewOnProjectOpen({ return { sessionPreview: null, manifestPreviewPatch: null }; } + let cleanupError: string | undefined; try { await invoke('stop_local_game_preview', { projectPath: trimmedProjectPath, }); - } catch { + } catch (error) { // 对齐落盘记录失败不改判:本次会话照样不把陈旧记录当活体用。 + cleanupError = `陈旧预览记录收尾失败:${visibleClientErrorMessage(error)}`; } return { sessionPreview: null, manifestPreviewPatch: { status: 'stopped' }, + ...(cleanupError ? { error: cleanupError } : {}), }; } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 8848f6f06..c13b65598 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -324,6 +324,9 @@ export function useHomeProjectCreation({ : context, ); setActiveProjectPreview(session.sessionPreview); + if (session.error) { + setStatus(session.error); + } setAgentResults([]); setProjectPath(context.projectPath); setLauncherView('project-development'); diff --git a/apps/ai-game-creator-shell/tests/sessionPreview.test.ts b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts index 2443b834c..ac552bd06 100644 --- a/apps/ai-game-creator-shell/tests/sessionPreview.test.ts +++ b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts @@ -137,6 +137,7 @@ describe('进入项目时的预览活体核验', () => { expect(resolution).toEqual({ sessionPreview: null, manifestPreviewPatch: null, + error: '预览状态读取失败:需要在陶泥儿客户端内运行', }); // 关键:不许发 stop_local_game_preview —— 它按项目停的是活体预览, // 读失败时发出去会把真正在跑的预览一起停掉。 diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 8d1637452..e355ee12b 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -145,6 +145,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 首页富文本剪贴板读取的“无内容”与“原生 IPC 失败”必须区分;后者展示安全正文,不能静默当作空剪贴板。 - 账号邀请码复制与充值状态确认的失败提示也必须附安全正文;操作指引不能替换底层剪贴板、网络或支付查询原因。 - 游戏发布面板的媒体上传、进度事件订阅和发布提交错误必须复用 `visibleClientErrorMessage`;不能因发布流程仍可继续或有进度兜底就丢掉 IPC/网络正文。 +- 预览活体核验的“读失败不等于没有活体”安全判据必须保留,但读失败和陈旧记录收尾失败的正文仍要显示;安全不误停不能演变成静默。 ## 后果与边界 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index f3ac1a626..e93c90ac1 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -66,3 +66,4 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 首页富文本输入的原生剪贴板图片/文本读取失败原来直接按空剪贴板处理;现通过输入区错误提示保留脱敏后的剪贴板 IPC 正文。 - 账号邀请码复制和微信充值状态确认仍有固定“复制失败/暂时没能确认到账”路径;现保留剪贴板/支付查询错误正文并继续给操作指引。 - 游戏发布面板的截图上传、发布进度订阅和最终发布异常也有固定文案/裸 `Error.message` 路径;现统一通过可见错误脱敏出口展示正文。 +- 进入项目时预览活体核验的 IPC 失败原来只按“没有可复用预览”处理;现在保留安全正文并写入工作区状态,同时不误停未知活体。 From ea70e3fe7387945ba4949cc19ce2b11765ff0f34 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:35:24 +0800 Subject: [PATCH 064/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E6=B6=A6=E8=89=B2=E5=81=8F=E5=A5=BD=20IPC=20=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留提醒偏好读取和保存详情并显示回退状态 补充偏好错误处理文档 --- .../ResourceReferenceInput.tsx | 66 +++++++++++++++---- .../project-workspace/chatPromptPolish.ts | 8 +-- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 4 files changed, 56 insertions(+), 20 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 149e5f7f7..60d5483dd 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -41,7 +41,10 @@ import { import { createPortal } from 'react-dom'; import RichTextInput from '../../components/RichTextInput'; -import { captureClientError } from '../../services/errorReporting'; +import { + captureClientError, + visibleClientErrorMessage, +} from '../../services/errorReporting'; import type { UserContentPart } from '../../view/project-development/chat/generated/UserContentPart'; import { chatPromptDraftKey, @@ -655,6 +658,9 @@ function ResourceReferenceEditor({ // 聊天特有的「发送前提醒」:提醒偏好、本轮已确认草稿指纹与表单拦截。 const [reminderOpen, setReminderOpen] = useState(false); const [reminderPolishing, setReminderPolishing] = useState(false); + const [reminderPreferenceError, setReminderPreferenceError] = useState< + string | null + >(null); // 偏好正式数据在 Rust:初始值只是「未关闭」的占位,挂载后由快照与事件补齐。 const [reminderDisabled, setReminderDisabled] = useState(false); const acknowledgedDraftKeyRef = useRef(null); @@ -681,6 +687,7 @@ function ResourceReferenceEditor({ let unsubscribe: (() => void) | null = null; void subscribeChatPromptPolishReminderDisabled((disabled) => { if (disposed) return; + setReminderPreferenceError(null); reminderPreferenceGenerationRef.current += 1; setReminderDisabled(disabled); }) @@ -688,18 +695,41 @@ function ResourceReferenceEditor({ if (disposed) release(); else unsubscribe = release; }) - .catch(() => undefined); + .catch((error) => { + if (!disposed) { + setReminderPreferenceError( + `润色提醒偏好读取失败:${visibleClientErrorMessage(error)}`, + ); + void captureClientError(error, { + source: 'chat-prompt-polish', + action: 'read-reminder-disabled', + }); + } + }); // 快照读取与订阅并发,但只有「读取期间没有任何更新」时才允许写回。 const generationAtRead = reminderPreferenceGenerationRef.current; - void readChatPromptPolishReminderDisabled().then((disabled) => { - if ( - disposed || - reminderPreferenceGenerationRef.current !== generationAtRead - ) { - return; - } - setReminderDisabled(disabled); - }); + void readChatPromptPolishReminderDisabled() + .then((disabled) => { + if ( + disposed || + reminderPreferenceGenerationRef.current !== generationAtRead + ) { + return; + } + setReminderPreferenceError(null); + setReminderDisabled(disabled); + }) + .catch((error) => { + if (!disposed) { + setReminderPreferenceError( + `润色提醒偏好读取失败:${visibleClientErrorMessage(error)}`, + ); + void captureClientError(error, { + source: 'chat-prompt-polish', + action: 'read-reminder-disabled', + }); + } + }); return () => { disposed = true; unsubscribe?.(); @@ -805,9 +835,15 @@ function ResourceReferenceEditor({ reminderPreferenceGenerationRef.current += 1; setReminderDisabled(disabled); void writeChatPromptPolishReminderDisabled(disabled) - .then((persisted) => setReminderDisabled(persisted)) + .then((persisted) => { + setReminderPreferenceError(null); + setReminderDisabled(persisted); + }) .catch((error) => { setReminderDisabled(!disabled); + setReminderPreferenceError( + `润色提醒偏好保存失败:${visibleClientErrorMessage(error)}`, + ); captureClientError(error, { source: 'chat-prompt-polish', action: 'set-reminder-disabled', @@ -888,13 +924,15 @@ function ResourceReferenceEditor({ {/* 「与原文相同 / 已截断」这类提示也要可见:只报失败会让「润色没变化」看起来像按钮坏了。 */} {showPolishAction && !reminderOpen && - (polishing || polishError || polishNotice) ? ( + (polishing || polishError || polishNotice || reminderPreferenceError) ? ( - {polishing ? '润色中…' : (polishError ?? polishNotice)} + {polishing + ? '润色中…' + : (polishError ?? polishNotice ?? reminderPreferenceError)} ) : null} {providers.map((provider) => diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts b/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts index 27b3af0a8..8cb4529d0 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts @@ -22,12 +22,8 @@ export const CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH = 40; /** 读取 Rust 偏好投影;原生桥不可用时按未关闭处理。 */ export async function readChatPromptPolishReminderDisabled(): Promise { if (!resolveTauriInvoke()) return false; - try { - const snapshot = await readWorkspacePreferences(); - return snapshot.chatPromptPolishReminderDisabled; - } catch { - return false; - } + const snapshot = await readWorkspacePreferences(); + return snapshot.chatPromptPolishReminderDisabled; } /** 写入 Rust 偏好并返回落盘后的投影;原生桥不可用时只当成次生效。 */ diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index e355ee12b..d04e1b4a6 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -141,6 +141,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 共享异步操作 hook(包括 Prompt Polish)捕获异常时,操作指引可以保留,但固定失败文案后必须附精确脱敏正文。 - 资源画布历史任务账本读取属于用户可见的恢复路径:异常正文必须进入提示条;只有响应形状错误这类没有底层异常的情况才使用明确的契约提示。 - 异步业务 facade 不得把底层异常折成“空结果”来逃避错误处理;只有真实的空回包、未配置或浏览器桥不可用等无正文事实允许返回空值,Tauri/网络/IPC 异常必须上抛到统一脱敏出口。 +- 可选偏好可以安全回退默认值,但回退不能隐藏错误:偏好读写 IPC 失败必须同时保留安全正文、记录诊断并在当前输入上下文给出提示。 - Direct 活动回合事件桥注册失败即使仍有一次性快照,也必须标明事件更新通道已失联并展示安全正文,不能只显示旧快照而让用户误以为活动状态持续可靠。 - 首页富文本剪贴板读取的“无内容”与“原生 IPC 失败”必须区分;后者展示安全正文,不能静默当作空剪贴板。 - 账号邀请码复制与充值状态确认的失败提示也必须附安全正文;操作指引不能替换底层剪贴板、网络或支付查询原因。 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index e93c90ac1..89bf188a7 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -62,6 +62,7 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 共享 Prompt Polish hook 的注入请求/规范化/回填异常原来只显示固定失败提示;现保留精确脱敏后的异常正文,聊天输入和资源润色共用同一口径。 - 资源画布历史生成任务账本读取失败原来统一显示“暂时无法恢复历史任务”;现在保留 Tauri/文件读取正文,非数组响应仍单独保留契约错误提示。 - 发现默认 `requestChatPromptPolish` 还会把 Tauri 润色命令异常吞成 `null`,导致共享 hook 无法看到平台正文;现仅对空回包/桥不可用保留 `null`,原生异常继续上抛并统一脱敏。 +- 润色提醒偏好读取失败原来被当成“未关闭”默认值,写入失败只进错误上报池;现把读取/保存的 IPC 正文显示在输入区状态提示中,同时仍维持安全默认和回退行为。 - Direct 活动回合事件桥注册失败原来只退回启动快照,面板没有说明后续更新已失联;现保留事件桥 IPC 正文并在标题栏/活动项目面板显示。 - 首页富文本输入的原生剪贴板图片/文本读取失败原来直接按空剪贴板处理;现通过输入区错误提示保留脱敏后的剪贴板 IPC 正文。 - 账号邀请码复制和微信充值状态确认仍有固定“复制失败/暂时没能确认到账”路径;现保留剪贴板/支付查询错误正文并继续给操作指引。 From 2a160bca0211ef871e9444df1c2cff0d1338cbc3 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:42:33 +0800 Subject: [PATCH 065/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B=20?= =?UTF-8?q?Direct=20=E6=B8=85=E5=8D=95=E8=AF=BB=E5=8F=96=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留项目清单读取和事件订阅正文 补充 Direct 状态条回归文档 --- .../chat/DirectProjectChatView.tsx | 10 ++++++++-- .../controller/useDirectProjectManifest.ts | 19 +++++++++++++++---- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index a429ada6a..dd128bf37 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -106,8 +106,13 @@ export function DirectProjectChatView({ ensureConversationWriteAllowed, ref, }: DirectProjectChatViewProps) { - const { assets, projectId, refresh, versions } = - useDirectProjectManifest(projectPath); + const { + assets, + projectId, + error: manifestError, + refresh, + versions, + } = useDirectProjectManifest(projectPath); const composerHandleRef = useRef(null); const [runtimeNotice, setRuntimeNotice] = useState(''); const [settingsOpen, setSettingsOpen] = useState(false); @@ -181,6 +186,7 @@ export function DirectProjectChatView({ const statusText = runtimeNotice || statusNotice || + manifestError || (projectPath ? `已打开:${projectNameFromPath(projectPath)}` : '请选择工作区'); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectManifest.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectManifest.ts index 79ffbc562..734c1ac69 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectManifest.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectManifest.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { resolveTauriInvoke } from '../../../../app/tauri'; import { projectPathsMatchForInvalidation } from '../../../../features/project-summary/projectPath'; +import { visibleClientErrorMessage } from '../../../../services/errorReporting'; import { canSubscribeTauriEvents, subscribeTauriEvent, @@ -40,6 +41,7 @@ export function useDirectProjectManifest(projectPath: string | null) { const [manifest, setManifest] = useState( null, ); + const [error, setError] = useState(null); const projectPathRef = useRef(projectPath); projectPathRef.current = projectPath; const mountedRef = useRef(true); @@ -70,8 +72,12 @@ export function useDirectProjectManifest(projectPath: string | null) { return; } setManifest(nextManifest); - } catch { - // 清单读不到不能阻塞聊天:`@` 引用退化为空,回合仍以项目对话为准。 + setError(null); + } catch (cause) { + // 清单读不到不能阻塞聊天:`@` 引用退化为空,但必须把正文交给状态条。 + if (mountedRef.current && projectPathRef.current === nextProjectPath) { + setError(`项目清单读取失败:${visibleClientErrorMessage(cause)}`); + } } finally { inFlight.delete(nextProjectPath); } @@ -81,6 +87,7 @@ export function useDirectProjectManifest(projectPath: string | null) { }, []); useEffect(() => { + setError(null); if (!projectPath) { setManifest(null); return; @@ -115,8 +122,11 @@ export function useDirectProjectManifest(projectPath: string | null) { } unsubscribe = release; }) - .catch(() => { - // 拿不到订阅通道时清单只在项目打开和回合结束时刷新。 + .catch((cause) => { + // 拿不到订阅通道时清单只在项目打开和回合结束时刷新,但不能静默隐藏 IPC 正文。 + if (!disposed) { + setError(`项目清单事件订阅失败:${visibleClientErrorMessage(cause)}`); + } }); return () => { disposed = true; @@ -127,6 +137,7 @@ export function useDirectProjectManifest(projectPath: string | null) { return { assets: directProjectChatAssets(manifest), projectId: manifest?.projectId ?? null, + error, refresh, versions: manifest?.versions ?? NO_VERSIONS, }; diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index d04e1b4a6..7b6ce3388 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -142,6 +142,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 资源画布历史任务账本读取属于用户可见的恢复路径:异常正文必须进入提示条;只有响应形状错误这类没有底层异常的情况才使用明确的契约提示。 - 异步业务 facade 不得把底层异常折成“空结果”来逃避错误处理;只有真实的空回包、未配置或浏览器桥不可用等无正文事实允许返回空值,Tauri/网络/IPC 异常必须上抛到统一脱敏出口。 - 可选偏好可以安全回退默认值,但回退不能隐藏错误:偏好读写 IPC 失败必须同时保留安全正文、记录诊断并在当前输入上下文给出提示。 +- Direct 项目清单读取失败可以保留聊天可用性,但不得把素材/版本候选置空后静默;清单读取和事件订阅正文必须进入项目状态条。 - Direct 活动回合事件桥注册失败即使仍有一次性快照,也必须标明事件更新通道已失联并展示安全正文,不能只显示旧快照而让用户误以为活动状态持续可靠。 - 首页富文本剪贴板读取的“无内容”与“原生 IPC 失败”必须区分;后者展示安全正文,不能静默当作空剪贴板。 - 账号邀请码复制与充值状态确认的失败提示也必须附安全正文;操作指引不能替换底层剪贴板、网络或支付查询原因。 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index 89bf188a7..999dc6f6c 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -63,6 +63,7 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 资源画布历史生成任务账本读取失败原来统一显示“暂时无法恢复历史任务”;现在保留 Tauri/文件读取正文,非数组响应仍单独保留契约错误提示。 - 发现默认 `requestChatPromptPolish` 还会把 Tauri 润色命令异常吞成 `null`,导致共享 hook 无法看到平台正文;现仅对空回包/桥不可用保留 `null`,原生异常继续上抛并统一脱敏。 - 润色提醒偏好读取失败原来被当成“未关闭”默认值,写入失败只进错误上报池;现把读取/保存的 IPC 正文显示在输入区状态提示中,同时仍维持安全默认和回退行为。 +- Direct 项目清单首次读取和清单事件订阅失败原来只让 `@` 候选退化为空;现把脱敏后的读取/订阅正文送入项目聊天状态条。 - Direct 活动回合事件桥注册失败原来只退回启动快照,面板没有说明后续更新已失联;现保留事件桥 IPC 正文并在标题栏/活动项目面板显示。 - 首页富文本输入的原生剪贴板图片/文本读取失败原来直接按空剪贴板处理;现通过输入区错误提示保留脱敏后的剪贴板 IPC 正文。 - 账号邀请码复制和微信充值状态确认仍有固定“复制失败/暂时没能确认到账”路径;现保留剪贴板/支付查询错误正文并继续给操作指引。 From 2d6363470d9d7a803d87414b114dbac358d85fd7 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:15:37 +0800 Subject: [PATCH 066/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=AD=A3=E6=96=87=E4=B8=8E=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留非 2xx 响应正文读取失败和 CC stderr 排空错误 让执行许可、MCP、编辑器与 Cocos worker 的 JoinError 和 panic 进入统一错误正文 兼容旧历史通用中断文案的空白差异并补充回归测试与计划记录 --- .../src-tauri/src/agent/claude_code_cli.rs | 17 ++++++++-- .../src/agent/codex_app_server/execution.rs | 3 +- .../src-tauri/src/agent/direct_runtime/mod.rs | 5 ++- .../src-tauri/src/agent/direct_tool_bridge.rs | 26 ++++++++++----- .../src-tauri/src/agent/direct_tools_mcp.rs | 33 +++++++++++++++++-- .../src/agent/generation/canvas_generation.rs | 30 +++++++++++++---- .../src/agent/tool/editor_execute/error.rs | 5 +-- .../src-tauri/src/agent/tool/error.rs | 16 +++++---- .../src-tauri/src/assets.rs | 20 ++++++++--- .../src-tauri/src/commands.rs | 5 ++- .../src-tauri/src/config.rs | 5 ++- .../src-tauri/src/platform_asset_upload.rs | 5 ++- .../src-tauri/src/template_library.rs | 10 ++++-- .../commands/separation/workflow/extract.rs | 5 ++- .../chat/conversation/directThreadSanitize.ts | 6 +++- .../tests/directThreadSanitize.test.ts | 11 +++++++ ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 2 ++ 17 files changed, 164 insertions(+), 40 deletions(-) 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 01c63196c..d53d18a22 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 @@ -386,8 +386,21 @@ async fn run_sidecar_turn( kill_claude_code_process_tree(pid); let stderr_detail = match tokio::time::timeout(CLAUDE_CODE_STDERR_DRAIN_TIMEOUT, stderr_task).await { - Ok(Ok(Ok(bytes))) => String::from_utf8(bytes).ok(), - _ => None, + Ok(Ok(Ok(bytes))) => String::from_utf8(bytes) + .map_err(|error| { + format!("读取 Claude Agent SDK sidecar stderr 失败:{error}") + }) + .ok(), + Ok(Ok(Err(error))) => Some(format!( + "读取 Claude Agent SDK sidecar stderr 失败:{error}" + )), + Ok(Err(error)) => Some(format!( + "等待 Claude Agent SDK sidecar stderr 读取任务失败:{error}" + )), + Err(_) => Some(format!( + "读取 Claude Agent SDK sidecar stderr 超时({} 秒)", + CLAUDE_CODE_STDERR_DRAIN_TIMEOUT.as_secs() + )), } .map(|value| value.trim().chars().take(512).collect::()) .filter(|value| !value.is_empty()) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs index 656783c38..76c309ad8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs @@ -742,7 +742,8 @@ impl ExecutionAdapter { }) }) .await - .unwrap_or_else(|_| Err("执行许可任务中断".into())); + .map_err(|error| format!("执行许可任务中断:{error}")) + .and_then(|result| result); // 闸门拒绝(合同缺失 / 预算耗尽 / 阶段已收束 / 同一输入重复受理…)的真实原因只在 // `admitted` 里;它随后会被 move 掉,先取副本,供最后统一留痕。 let admit_failure = admitted.as_ref().err().cloned(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index ed89d3a1d..36cb6127a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -2907,7 +2907,10 @@ async fn recover_direct_taonier_spritesheet_read_only_at( access.validate_frozen_session()?; let status = response.status(); if !status.is_success() { - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); return Err(crate::assets::external_asset_http_failure( "读取陶泥儿画布资源", status, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 25ca700c3..7b342fc4a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -3438,9 +3438,11 @@ async fn bridge_update_plan( DirectExecutionGateRejection::SessionUnavailable { cause }, )) } - Err(_) => { + Err(error) => { return Err(UpdatePlanError::Gate( - DirectExecutionGateRejection::SessionTaskLost, + DirectExecutionGateRejection::SessionTaskLost { + cause: error.to_string(), + }, )) } }; @@ -3558,7 +3560,10 @@ async fn bridge_web_search_at( })?; if !response.status().is_success() { let status = response.status(); - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); let detail = crate::agent::redact_agent_runtime_error( Path::new("__agc_no_project_root__"), body.trim(), @@ -3697,7 +3702,10 @@ async fn bridge_editor_execute( )) } Ok(Err(error)) => Err(error), - Err(_) => Err(EditorExecuteError::ExecutionUnconfirmed { editor }), + Err(error) => Err(EditorExecuteError::ExecutionUnconfirmed { + editor, + cause: error.to_string(), + }), } } @@ -3931,9 +3939,9 @@ async fn bridge_cocos_call( ), error.to_string(), ), - Err(_) => ( + Err(error) => ( true, - "Cocos execute worker 退出,执行结果需要核对".to_string(), + format!("Cocos execute worker 退出:{error};执行结果需要核对"), ), Ok(Ok(_)) => unreachable!(), }; @@ -4004,14 +4012,16 @@ async fn handle_direct_tool_bridge( )), )); } - Err(_) => { + Err(error) => { return Json(compose_direct_tool_outcome( &state, request.tool.as_str(), &diagnostics_arguments, false, Err(ToolCallError::from( - &DirectExecutionGateRejection::PermitTaskLost, + &DirectExecutionGateRejection::PermitTaskLost { + cause: error.to_string(), + }, )), )); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index ee30d612b..347622806 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -925,7 +925,10 @@ async fn call_client_tool_bridge(tool: &str, arguments: &Value) -> Value { .map_err(|error| format!("连接客户端受控工具桥失败:{error}"))?; let status = response.status(); if !status.is_success() { - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); return Err(crate::assets::external_asset_http_failure( "客户端受控工具桥请求", status, @@ -1394,10 +1397,25 @@ async fn run_direct_tools_mcp_blocking_read( ) -> Value { match tokio::task::spawn_blocking(read).await { Ok(result) => result, - Err(_) => mcp_tool_result("MCP 本地资源读取任务未完成".to_string(), Vec::new(), true), + Err(error) => mcp_tool_result( + format!("MCP 本地资源读取任务未完成:{error}"), + Vec::new(), + true, + ), } } +fn direct_tools_mcp_panic_detail(payload: &(dyn std::any::Any + Send)) -> String { + let raw = if let Some(text) = payload.downcast_ref::<&str>() { + (*text).to_string() + } else if let Some(text) = payload.downcast_ref::() { + text.clone() + } else { + "未知 panic 负载".to_string() + }; + crate::agent::redact_agent_runtime_error(Path::new("__agc_no_project_root__"), &raw, 480) +} + async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option { let id = request.get("id").cloned(); let method = request.get("method").and_then(Value::as_str)?; @@ -1664,7 +1682,16 @@ where pending.push(async move { match std::panic::AssertUnwindSafe(future).catch_unwind().await { Ok(response) => response, - Err(_) => id.map(|id| mcp_error(id, -32603, "Tool execution interrupted")), + Err(payload) => id.map(|id| { + mcp_error( + id, + -32603, + &format!( + "Tool execution interrupted:{}", + direct_tools_mcp_panic_detail(payload.as_ref()) + ), + ) + }), } }); None diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 00d800c36..880230c77 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -1005,7 +1005,10 @@ pub(crate) async fn external_editor_json_request( })?; let status = response.status(); if !status.is_success() { - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); return Err(format_external_http_error(action, status, &body)); } @@ -1362,7 +1365,10 @@ pub(crate) async fn submit_external_generation_request( })?; if response.status().is_server_error() { let status = response.status(); - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); return Err(format!( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后收到 {},服务端是否已产生副作用未知", format_external_http_error("平台图片生成", status, &body) @@ -1393,7 +1399,10 @@ async fn resume_prepared_external_generation_at( let status = response.status(); if !status.is_success() { post_submit_session?; - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); return Err(format!( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor prepared 恢复提交返回 {};原生成账本已保留", @@ -1616,7 +1625,10 @@ pub(crate) async fn prepare_external_canvas_generation_context( return Err("绑定素材目录不存在,无法继续生成".to_string()); } if !status.is_success() { - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); return Err(format_external_http_error( "读取绑定素材目录", @@ -2140,7 +2152,10 @@ async fn upload_manifest_asset_remote_reference_at( .map_err(|error| format!("上传当前账号参考图失败:{error}"))?; if upload_response.status().as_u16() != ticket.success_action_status { let status = upload_response.status(); - let body = upload_response.text().await.unwrap_or_default(); + let body = upload_response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); return Err(crate::assets::external_asset_http_failure( "上传当前账号参考图", status, @@ -3530,7 +3545,10 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at if !status.is_success() { post_submit_session?; binding_access.validate_frozen_session()?; - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); binding_access.validate_frozen_session()?; if external_generation_submit_rejection_is_definitive(status) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/editor_execute/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/editor_execute/error.rs index 97f70b437..fc8267ff1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/editor_execute/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/editor_execute/error.rs @@ -72,6 +72,7 @@ pub(crate) enum EditorExecuteError { /// 执行任务异常退出,回执不可用:结果待核对,不要自动重放。 ExecutionUnconfirmed { editor: EditorKind, + cause: String, }, } @@ -108,8 +109,8 @@ impl ToolFailure for EditorExecuteError { Self::ExecutionNotCompleted { editor, report } => { format!("{} 执行未完成:{report}", editor.label()) } - Self::ExecutionUnconfirmed { editor } => format!( - "{} 执行任务异常,回执不可用;执行已发出,结果待核对,不要自动重放:\ + Self::ExecutionUnconfirmed { editor, cause } => format!( + "{} 执行任务异常,回执不可用:{cause};执行已发出,结果待核对,不要自动重放:\ {{\"status\":\"needs-reconciliation\",\"dispatched\":true,\"retryAllowed\":false}}", editor.label() ), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs index 1a45e4235..514ec8838 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs @@ -253,12 +253,12 @@ impl ToolFailure for UnknownClientToolRejection { pub(crate) enum DirectExecutionGateRejection { /// 执行许可(付费/写入/执行租约)取不到。 PermitUnavailable { cause: String }, - /// 取执行许可的阻塞任务没有返回。 - PermitTaskLost, + /// 取执行许可的阻塞任务没有返回;保留 join/panic 事实,避免退成无因固定句。 + PermitTaskLost { cause: String }, /// 回合执行会话 / 计划会话取不到。 SessionUnavailable { cause: String }, - /// 取执行会话的阻塞任务没有返回。 - SessionTaskLost, + /// 取执行会话的阻塞任务没有返回;保留 join/panic 事实,避免退成无因固定句。 + SessionTaskLost { cause: String }, /// 工具已返回,但执行回执没有可靠落盘。 ReceiptNotPersisted, } @@ -269,9 +269,13 @@ impl ToolFailure for DirectExecutionGateRejection { Self::PermitUnavailable { cause } => { format!("本轮执行许可不可用,未派发工具调用:{cause}") } - Self::PermitTaskLost => "宿主执行许可任务未返回,未派发工具调用。".to_string(), + Self::PermitTaskLost { cause } => { + format!("宿主执行许可任务未返回,未派发工具调用:{cause}") + } Self::SessionUnavailable { cause } => format!("本轮执行会话不可用:{cause}"), - Self::SessionTaskLost => "宿主执行会话任务未返回。".to_string(), + Self::SessionTaskLost { cause } => { + format!("宿主执行会话任务未返回:{cause}") + } Self::ReceiptNotPersisted => { "工具已返回,但宿主执行回执未可靠落盘;请查看交付状态并核对原操作,不自动重放" .to_string() diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 9da8c8460..1e8ec4712 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -458,7 +458,10 @@ async fn create_private_external_editor_api_credentials_from_platform_session( .map_err(|error| format!("创建本机陶泥儿开发者 Key 未取得确定响应;不会自动重试:{error}"))?; let status = response.status(); if !status.is_success() { - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); return Err(external_asset_http_failure( "创建本机陶泥儿开发者 Key", @@ -955,7 +958,10 @@ pub(crate) async fn sync_canvas_project_assets_at( .map_err(|error| format!("读取画板项目失败:{error}"))?; let project_status = project_response.status(); if !project_status.is_success() { - let body = project_response.text().await.unwrap_or_default(); + let body = project_response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(project_status.as_u16(), &body); return Err(external_asset_http_failure( "读取画板项目", @@ -1415,7 +1421,10 @@ where return Err("画板资产下载地址发生重定向,已拒绝继续请求".to_string()); } if !status.is_success() { - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); return Err(external_asset_http_failure("下载画板资产", status, &body)); } if response @@ -1486,7 +1495,10 @@ pub(crate) async fn resolve_external_asset_signed_url( .map_err(|error| format!("换签画板资产失败:{error}"))?; let status = response.status(); if !status.is_success() { - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); return Err(external_asset_http_failure("换签画板资产", status, &body)); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 32f57ecfb..324188536 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -3707,7 +3707,10 @@ async fn download_ui_editor_remote_asset(url: &str, max_bytes: u64) -> Result { ); }); + it('旧版通用中断条目换行或多空格时仍明确标记正文缺失', () => { + expect( + sanitizeDirectThreadText( + ROOT, + '陶泥儿智能创作 执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断', + ), + ).toBe( + '陶泥儿智能创作 执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断:宿主未提供具体错误正文', + ); + }); + it('多行文本保留换行', () => { const value = '第一段\nkey=sk-abcdefghijklmnop\n第三段\n'; expect(sanitizeDirectThreadText(ROOT, value)).toBe( diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index 999dc6f6c..fa66b9947 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -69,3 +69,5 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 账号邀请码复制和微信充值状态确认仍有固定“复制失败/暂时没能确认到账”路径;现保留剪贴板/支付查询错误正文并继续给操作指引。 - 游戏发布面板的截图上传、发布进度订阅和最终发布异常也有固定文案/裸 `Error.message` 路径;现统一通过可见错误脱敏出口展示正文。 - 进入项目时预览活体核验的 IPC 失败原来只按“没有可复用预览”处理;现在保留安全正文并写入工作区状态,同时不误停未知活体。 +- 继续真实客户端启动和旁路扫描又发现三处仍会损失事实:旧历史通用中断文案遇到换行/多空格时未命中“正文缺失”标记,非 2xx 响应正文读取失败被 `unwrap_or_default()` 抹掉,CC sidecar 的 stderr 排空超时/读取任务失败也被当成“没有 stderr”。现分别改为折叠空白识别、保留“响应正文读取失败:底层原因”和保留 stderr 排空阶段/底层原因;不改变状态码、重试和脱敏边界。 +- 第二轮 Agent/CC 旁路扫描继续发现执行许可/执行会话/MCP 本地读取/编辑器 worker/Cocos worker 的 JoinError,以及 MCP future panic,会被固定成“任务未返回/异常/需要核对”;现把 join/panic 正文并入既有 ToolFailure / MCP 错误载荷,保留 `needs-reconciliation` 的不可自动重放语义。Native 定向测试:Direct MCP 33 项、Direct Tool Bridge 41 项、CC 14 项均通过。 From f938ac6bf4c48210132432fd17d04171bc983963 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:23:46 +0800 Subject: [PATCH 067/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B=20?= =?UTF-8?q?MCP=20=E4=B8=8E=E4=BA=A4=E4=BB=98=E5=A4=8D=E6=A0=B8=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留 Direct Tools MCP 启动、Skill 资源和运行失败正文 保留交付封口复核底层原因并同步验证记录 --- .../src/agent/codex_app_server/execution.rs | 5 +-- .../src-tauri/src/agent/direct_tools_mcp.rs | 34 +++++++++++++------ ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs index 76c309ad8..e203810b6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs @@ -1152,8 +1152,9 @@ impl ExecutionAdapter { self.interrupt("收尾仍有未结算操作,交付尚未完成。").await; HostOutcome::Report(self.report()) } - Err(_) => { - self.interrupt("交付复核失败,已停止本轮并保留证据。").await; + Err(error) => { + let message = format!("交付复核失败:{error};已停止本轮并保留证据。"); + self.interrupt(&message).await; HostOutcome::Report(self.report()) } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 347622806..dde3bd93d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -80,11 +80,17 @@ pub(crate) fn run_direct_tools_mcp_if_requested(args: &[String]) -> Option .build() { Ok(runtime) => runtime, - Err(_) => return Some(1), + Err(error) => { + eprintln!("AGC Direct Tools MCP 运行时初始化失败:{error}"); + return Some(1); + } }; Some(match runtime.block_on(run_direct_tools_mcp_stdio()) { Ok(()) => 0, - Err(_) => 1, + Err(error) => { + eprintln!("AGC Direct Tools MCP 运行失败:{error}"); + 1 + } }) } @@ -1467,13 +1473,17 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option< .and_then(Value::as_str) .unwrap_or_default(); if uri == "agc://skills/index" { - let text = render_agc_skill_pack_index() - .map_err(|_| ()) - .unwrap_or_else(|_| "AGC Skill 索引暂不可用".to_string()); - return Some(mcp_success( - id, - json!({ "contents": [{ "uri": uri, "mimeType": "text/plain", "text": text }] }), - )); + return match render_agc_skill_pack_index() { + Ok(text) => Some(mcp_success( + id, + json!({ "contents": [{ "uri": uri, "mimeType": "text/plain", "text": text }] }), + )), + Err(error) => Some(mcp_error( + id, + -32603, + &format!("AGC Skill 索引读取失败:{error}"), + )), + }; } if let Some(resource) = uri.strip_prefix("agc://skills/") { return match read_agc_skill_resource(resource) { @@ -1484,7 +1494,11 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option< )) } Ok(_) => Some(mcp_error(id, -32000, "AGC Skill 资源超过响应大小上限")), - Err(_) => Some(mcp_error(id, -32602, "未知或未审核的 AGC Skill 资源")), + Err(error) => Some(mcp_error( + id, + -32603, + &format!("AGC Skill 资源读取失败:{error}"), + )), }; } if uri != "agc://conversation/codex-responses" { diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index fa66b9947..ba14a072d 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -71,3 +71,4 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 进入项目时预览活体核验的 IPC 失败原来只按“没有可复用预览”处理;现在保留安全正文并写入工作区状态,同时不误停未知活体。 - 继续真实客户端启动和旁路扫描又发现三处仍会损失事实:旧历史通用中断文案遇到换行/多空格时未命中“正文缺失”标记,非 2xx 响应正文读取失败被 `unwrap_or_default()` 抹掉,CC sidecar 的 stderr 排空超时/读取任务失败也被当成“没有 stderr”。现分别改为折叠空白识别、保留“响应正文读取失败:底层原因”和保留 stderr 排空阶段/底层原因;不改变状态码、重试和脱敏边界。 - 第二轮 Agent/CC 旁路扫描继续发现执行许可/执行会话/MCP 本地读取/编辑器 worker/Cocos worker 的 JoinError,以及 MCP future panic,会被固定成“任务未返回/异常/需要核对”;现把 join/panic 正文并入既有 ToolFailure / MCP 错误载荷,保留 `needs-reconciliation` 的不可自动重放语义。Native 定向测试:Direct MCP 33 项、Direct Tool Bridge 41 项、CC 14 项均通过。 +- 最新漏扫又补出 Direct Tools MCP 启动/运行退出码、Skill `resources/read` 索引与资源读取、交付 `finish_sealing` 错误三处固定句;现在分别写入 stderr、MCP 错误正文和交付复核底层原因,避免只剩退出码或“暂不可用/复核失败”。 From 196a5979ce61606741ddefd7b99d478f15ad505c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:31:41 +0800 Subject: [PATCH 068/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E4=B8=8E=E7=94=9F=E6=88=90=E9=98=9F=E5=88=97?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留预览复用 IPC 拒绝与重启回退的具体原因 保留素材生成事件订阅失败并补充预览与队列回归测试 --- apps/ai-game-creator-shell/src/App.tsx | 49 ++++++++++--------- .../resourceCanvasAssetGenerationQueue.ts | 23 +++++---- .../tests/previewActivation.test.tsx | 4 +- ...resourceCanvasAssetGenerationQueue.test.ts | 16 ++++++ ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 5 files changed, 59 insertions(+), 34 deletions(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index eafde83f5..266892262 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -1513,24 +1513,19 @@ export function App({ invoke: TauriInvoke, nextProjectPath: string, ): Promise { - try { - const status = await invoke( - 'activate_local_game_preview', - { projectPath: nextProjectPath }, - ); - if ( - status.status !== 'running' || - !status.url || - !status.port || - !status.root - ) { - return null; - } - return { url: status.url, port: status.port, root: status.root }; - } catch { - // 没在跑、不属于这个项目,或权限位要求确认:都不是错误,回落到重新启动预览。 + const status = await invoke( + 'activate_local_game_preview', + { projectPath: nextProjectPath }, + ); + if ( + status.status !== 'running' || + !status.url || + !status.port || + !status.root + ) { return null; } + return { url: status.url, port: status.port, root: status.root }; } async function executeRunLocal() { @@ -1548,11 +1543,14 @@ export function App({ return; } + let previewActivationError: string | null = null; try { - const activePreview = await activateRunningPreview( - invoke, - nextProjectPath, - ); + let activePreview: LocalPreviewResult | null = null; + try { + activePreview = await activateRunningPreview(invoke, nextProjectPath); + } catch (error) { + previewActivationError = visibleClientErrorMessage(error); + } if (activePreview) { updateClientPreview(activePreview); // 运行成功的反馈走工作台壳的 toast(对话区只保留对话内容),因此不再往聊天里 @@ -1571,11 +1569,18 @@ export function App({ * `announceToChat` 约束(它是旧的「聊天播报」开关)。当前唯一调用点由播放请求驱动、 * 恒为 true(见 `executeRunLocalRef.current(true)`)。 */ - onRunNotice?.({ message: '运行通过,已载入客户端运行视图' }); + onRunNotice?.({ + message: previewActivationError + ? `已有预览复用失败,已重新启动运行视图:${previewActivationError}` + : '运行通过,已载入客户端运行视图', + }); } catch (error) { + const startError = visibleClientErrorMessage(error); onRunNotice?.({ tone: 'error', - message: `运行游戏失败:${visibleClientErrorMessage(error)}`, + message: previewActivationError + ? `已有预览复用失败:${previewActivationError};重新启动运行也失败:${startError}` + : `运行游戏失败:${startError}`, }); } } diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts index 48c13571e..2840b589d 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts @@ -153,13 +153,13 @@ export function createResourceCanvasAssetGenerationQueue( function replaceFromRecord( task: ResourceCanvasAssetGenerationTask, record: LocalProjectAssetGenerationTaskRecord, + subscriptionWarning?: string | null, ) { - deps.replaceTask( - mergeLocalProjectAssetGenerationRecord( - task, - safeGenerationRecord(record), - ), - ); + const safeRecord = safeGenerationRecord(record); + if (subscriptionWarning) { + safeRecord.phaseDetail = `${subscriptionWarning};${safeRecord.phaseDetail}`; + } + deps.replaceTask(mergeLocalProjectAssetGenerationRecord(task, safeRecord)); } async function dispatch( @@ -167,6 +167,7 @@ export function createResourceCanvasAssetGenerationQueue( ): Promise { const projectPath = deps.projectPath(); let releaseTaskSubscription: (() => void) | null = null; + let subscriptionWarning: string | null = null; let taskEventPending = false; let resolveTaskEvent: (() => void) | null = null; const waitForTaskEvent = () => { @@ -190,8 +191,10 @@ export function createResourceCanvasAssetGenerationQueue( resolve?.(); }, ); - } catch { - // 事件桥不可用时仅回退到测试/浏览器预览的注入等待器。 + } catch (error) { + // 事件桥不可用时回退到轮询,但不能把 IPC 失败伪装成“没有事件”;把安全正文附到 + // 任务阶段,用户仍能看到本轮为何降级以及后续账本读取是否成功。 + subscriptionWarning = `任务事件订阅失败,已回退轮询:${errorMessage(error)}`; releaseTaskSubscription = null; } } @@ -239,7 +242,7 @@ export function createResourceCanvasAssetGenerationQueue( error: errorMessage(error), }; } - replaceFromRecord(task, started); + replaceFromRecord(task, started, subscriptionWarning); let missingRecordPolls = 0; let readFailed = false; let lastReadError = RESOURCE_CANVAS_ASSET_GENERATION_MALFORMED_READ_ERROR; @@ -266,7 +269,7 @@ export function createResourceCanvasAssetGenerationQueue( missingRecordPolls = 0; readFailed = false; const safeRecord = safeGenerationRecord(record); - replaceFromRecord(task, safeRecord); + replaceFromRecord(task, safeRecord, subscriptionWarning); if (isTerminalStatus(safeRecord.status)) { return { taskId: task.taskId, diff --git a/apps/ai-game-creator-shell/tests/previewActivation.test.tsx b/apps/ai-game-creator-shell/tests/previewActivation.test.tsx index 8816daaac..bfabc2cd0 100644 --- a/apps/ai-game-creator-shell/tests/previewActivation.test.tsx +++ b/apps/ai-game-creator-shell/tests/previewActivation.test.tsx @@ -154,7 +154,7 @@ describe('运行入口切到已经在跑的客户端预览', () => { ); }); - it('权限位要求确认时不把拒绝当错误,直接回落到重新启动预览', async () => { + it('预览复用 IPC 被拒绝时回落重启但保留具体正文', async () => { const { invokeCounts } = installTauri(() => { throw new Error('需要先确认预览权限'); }); @@ -168,7 +168,7 @@ describe('运行入口切到已经在跑的客户端预览', () => { ); await expectRunNoticeWithoutChatTip( onRunNotice, - '运行通过,已载入客户端运行视图', + '已有预览复用失败,已重新启动运行视图:需要先确认预览权限', ); }); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts index 3e2491016..0a7be8171 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts @@ -348,6 +348,22 @@ describe('本地排队驱动器', () => { expect(releaseCount).toBe(1); }); + test('事件桥 IPC 失败回退轮询时保留安全错误正文', async () => { + const harness = createHarness((pollCount, api) => { + if (pollCount === 1) api.advance('task-subscribe-failed', 'completed'); + }); + harness.deps.subscribe = async () => { + throw new Error('IPC EPIPE:事件订阅失败'); + }; + const queue = createResourceCanvasAssetGenerationQueue(harness.deps); + + await queue.submit(localTask('task-subscribe-failed', 10)); + + expect(harness.tasks()[0]?.phaseDetail).toContain('任务事件订阅失败'); + expect(harness.tasks()[0]?.phaseDetail).toContain('IPC EPIPE'); + expect(harness.settlements[0]?.status).toBe('completed'); + }); + test('第一条未完成时提交第二条:第二条仍是排队中且生成提交 IPC 只发了一次', async () => { let assertedMidFlight = false; const harness = createHarness((pollCount, api) => { diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index ba14a072d..90ab4729f 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -72,3 +72,4 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 继续真实客户端启动和旁路扫描又发现三处仍会损失事实:旧历史通用中断文案遇到换行/多空格时未命中“正文缺失”标记,非 2xx 响应正文读取失败被 `unwrap_or_default()` 抹掉,CC sidecar 的 stderr 排空超时/读取任务失败也被当成“没有 stderr”。现分别改为折叠空白识别、保留“响应正文读取失败:底层原因”和保留 stderr 排空阶段/底层原因;不改变状态码、重试和脱敏边界。 - 第二轮 Agent/CC 旁路扫描继续发现执行许可/执行会话/MCP 本地读取/编辑器 worker/Cocos worker 的 JoinError,以及 MCP future panic,会被固定成“任务未返回/异常/需要核对”;现把 join/panic 正文并入既有 ToolFailure / MCP 错误载荷,保留 `needs-reconciliation` 的不可自动重放语义。Native 定向测试:Direct MCP 33 项、Direct Tool Bridge 41 项、CC 14 项均通过。 - 最新漏扫又补出 Direct Tools MCP 启动/运行退出码、Skill `resources/read` 索引与资源读取、交付 `finish_sealing` 错误三处固定句;现在分别写入 stderr、MCP 错误正文和交付复核底层原因,避免只剩退出码或“暂不可用/复核失败”。 +- 前端继续实测发现预览活体复用的 IPC / 权限拒绝会被当成“没有活体”并静默重启,资源生成事件订阅失败也只回退轮询;现在两条回退仍保留,但分别把复用失败正文附到重启结果、把订阅失败正文附到任务阶段。预览与生成队列定向测试 25 项通过。 From e75f35b28497d0b467905b3729eeac44b44256bb Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:40:43 +0800 Subject: [PATCH 069/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E8=B5=84=E6=BA=90=E8=AF=BB=E5=8F=96=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留清单刷新、资源预览、UI 编辑器和依赖图读取失败原因 补充预览、生成队列和编辑器回归验证 --- .../useDirectProjectChatController.ts | 8 ++++-- .../src/view/project-development/index.tsx | 27 +++++++++++++++++-- .../useProjectResourceCardPreviews.ts | 4 ++- .../src/view/ui-editor/useUiEditorPage.ts | 7 ++++- .../tests/uiEditorPage.test.ts | 2 +- ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 2 ++ 6 files changed, 43 insertions(+), 7 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 0dfcbb040..d88da118b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -603,8 +603,12 @@ export function useDirectProjectChatController({ async function refreshDirectManifest(nextProjectPath: string) { try { await refreshManifest(nextProjectPath); - } catch { - // 回合结果仍是聊天的权威来源:清单缺失或过旧不能藏掉 Codex 回复。 + } catch (error) { + // 回合结果仍是聊天的权威来源,但清单刷新失败也不能静默:资源/版本面板可能因此 + // 过期,必须把 IPC/读取正文送到同一条运行错误出口。 + if (projectPathRef.current === nextProjectPath) { + onRuntimeError(`项目清单刷新失败:${visibleClientErrorMessage(error)}`); + } } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 484daa99d..03a7eb114 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -2924,12 +2924,14 @@ export default function ProjectDevelopmentView({ status: 'idle' | 'loading' | 'ready' | 'failed'; graph: ProjectResourceGraph; hasSnapshot: boolean; + error: string | null; }>({ projectScopeKey: '', scopeKey: '', status: 'idle', graph: EMPTY_PROJECT_RESOURCE_GRAPH, hasSnapshot: false, + error: null, }); useEffect(() => { @@ -2941,6 +2943,7 @@ export default function ProjectDevelopmentView({ status: 'idle', graph: EMPTY_PROJECT_RESOURCE_GRAPH, hasSnapshot: false, + error: null, }); return () => { cancelled = true; @@ -2954,6 +2957,7 @@ export default function ProjectDevelopmentView({ status: 'failed', graph: EMPTY_PROJECT_RESOURCE_GRAPH, hasSnapshot: false, + error: '需要在客户端内读取资源依赖图', }); return () => { cancelled = true; @@ -2969,6 +2973,7 @@ export default function ProjectDevelopmentView({ status: 'loading', graph: canReuseSnapshot ? current.graph : EMPTY_PROJECT_RESOURCE_GRAPH, hasSnapshot: canReuseSnapshot, + error: null, }; }); void invoke( @@ -2987,14 +2992,19 @@ export default function ProjectDevelopmentView({ status: 'ready', graph: normalizeProjectResourceGraph(readModel), hasSnapshot: true, + error: null, }); } }) - .catch(() => { + .catch((error) => { if (!cancelled) { setResourceGraphState((current) => current.scopeKey === resourceGraphScopeKey - ? { ...current, status: 'failed' } + ? { + ...current, + status: 'failed', + error: visibleClientErrorMessage(error, '读取资源依赖图失败'), + } : current, ); } @@ -3020,6 +3030,9 @@ export default function ProjectDevelopmentView({ const resourceGraphReady = resourceGraphAvailable; const resourceGraphFailed = resourceGraphScopeMatches && resourceGraphState.status === 'failed'; + const resourceGraphError = resourceGraphScopeMatches + ? resourceGraphState.error + : null; const resourceGraph = resourceGraphAvailable ? resourceGraphState.graph : EMPTY_PROJECT_RESOURCE_GRAPH; @@ -10912,6 +10925,16 @@ export default function ProjectDevelopmentView({ onPointerCancel={stopResourceCanvasPan} onLostPointerCapture={stopResourceCanvasPan} > + {sortMode === 'dependency' && + resourceGraphFailed && + resourceGraphError ? ( +

+ 资源依赖图读取失败:{resourceGraphError} +

+ ) : null} {sortMode === 'dependency' && dependencyRelationshipDescriptions.length > 0 ? (
{ fireEvent.click(await screen.findByRole('button', { name: '仍然保存' })); expect((await screen.findByRole('dialog')).textContent).toContain( - '保存失败,请稍后重试。', + '临时存储不可用', ); expect(screen.getByRole('button', { name: '重试保存' })).toBeTruthy(); expect(stateStore.save).toHaveBeenCalledTimes(1); diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index 90ab4729f..5f77e0821 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -73,3 +73,5 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 第二轮 Agent/CC 旁路扫描继续发现执行许可/执行会话/MCP 本地读取/编辑器 worker/Cocos worker 的 JoinError,以及 MCP future panic,会被固定成“任务未返回/异常/需要核对”;现把 join/panic 正文并入既有 ToolFailure / MCP 错误载荷,保留 `needs-reconciliation` 的不可自动重放语义。Native 定向测试:Direct MCP 33 项、Direct Tool Bridge 41 项、CC 14 项均通过。 - 最新漏扫又补出 Direct Tools MCP 启动/运行退出码、Skill `resources/read` 索引与资源读取、交付 `finish_sealing` 错误三处固定句;现在分别写入 stderr、MCP 错误正文和交付复核底层原因,避免只剩退出码或“暂不可用/复核失败”。 - 前端继续实测发现预览活体复用的 IPC / 权限拒绝会被当成“没有活体”并静默重启,资源生成事件订阅失败也只回退轮询;现在两条回退仍保留,但分别把复用失败正文附到重启结果、把订阅失败正文附到任务阶段。预览与生成队列定向测试 25 项通过。 +- 继续扫前端外围又发现 Direct 回合后的清单刷新、资源卡片预览未知错误、UI 编辑器单图预览读取失败仍会静默或使用无因固定句;现分别进入运行错误出口、保留脱敏后的预览错误正文并在编辑器状态提示中显示。 +- 资源依赖图读取失败原先只切到 fallback 布局状态,用户看不到 IPC/权限正文;现把安全错误保存到状态并在依赖画布内联显示。 From 7498c2e2bb3de5f0a66b4e8ce36aec383b4c70bb Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:45:24 +0800 Subject: [PATCH 070/132] =?UTF-8?q?=E6=98=8E=E7=A1=AE=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E6=AD=A3=E6=96=87=E7=BC=BA=E5=A4=B1=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 空错误正文不再退化为无因资源读取失败文案 --- .../view/project-development/useProjectResourceCardPreviews.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts index 0a6b0af25..b4fed7470 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts @@ -152,7 +152,7 @@ function previewReadErrorMessage( return { error: message ? `资源暂时无法读取:${message};请关闭详情并重试` - : '资源暂时无法读取,请关闭详情并重试', + : '资源暂时无法读取:未提供具体错误正文;请关闭详情并重试', retryable: true, }; } From f118e6b77ca201a3e947fafcb23c6f1a46e21fc2 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:53:08 +0800 Subject: [PATCH 071/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=B8=83=E5=B1=80=E9=94=99=E8=AF=AF=E6=AD=A3?= =?UTF-8?q?=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把布局读写失败的脱敏底层原因附到工作台提示 更新资源布局回归断言和错误展示记录 --- .../useProjectResourceCanvasLayout.ts | 25 ++++++++----------- .../appSurface/project-development.suite.ts | 2 +- .../useProjectResourceCanvasLayout.test.ts | 20 +++++++++++---- ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts index 949847eb6..e19141611 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts @@ -34,13 +34,7 @@ function describeResourceCanvasLayoutFailure(error: unknown) { return visibleClientErrorMessage(error).slice(0, 400); } -type LayoutNotice = - | '' - | '布局已保存' - | '布局读取失败,已使用当前会话布局' - | '布局保存失败,已保留当前会话布局' - | '布局保存失败,已恢复上次布局' - | '布局已在其他窗口更新'; +type LayoutNotice = string; type LayoutScope = { key: string; @@ -762,8 +756,8 @@ export function useProjectResourceCanvasLayout({ } setNotice( intent.kind === 'resources' - ? '布局保存失败,已保留当前会话布局' - : '布局保存失败,已恢复上次布局', + ? '布局保存失败:布局状态无效;已保留当前会话布局' + : '布局保存失败:布局状态无效;已恢复上次布局', ); void Promise.resolve().then(() => pumpWritesRef.current()); return; @@ -867,9 +861,8 @@ export function useProjectResourceCanvasLayout({ } // 原因先落一行脱敏日志:下面那条提示在部分分支里是刻意不显示的, // 若不记日志,这种"用户看不到、排障也看不到"的失败就完全消失。 - console.warn( - `[resource-canvas-layout] 布局保存失败:${describeResourceCanvasLayoutFailure(error)}`, - ); + const failureDetail = describeResourceCanvasLayoutFailure(error); + console.warn(`[resource-canvas-layout] 布局保存失败:${failureDetail}`); removeWriteIntent(intent); rebuildOptimisticLayout( currentScope.epoch, @@ -885,8 +878,8 @@ export function useProjectResourceCanvasLayout({ ) { setNotice( intent.kind === 'resources' - ? '布局保存失败,已保留当前会话布局' - : '布局保存失败,已恢复上次布局', + ? `布局保存失败:${failureDetail};已保留当前会话布局` + : `布局保存失败:${failureDetail};已恢复上次布局`, ); } }) @@ -1031,7 +1024,9 @@ export function useProjectResourceCanvasLayout({ epoch, automaticPositionPolicy(rederiveAutomaticPositions), ); - setNotice('布局读取失败,已使用当前会话布局'); + setNotice( + `布局读取失败:${describeResourceCanvasLayoutFailure(error)};已使用当前会话布局`, + ); pumpWritesRef.current(); }); return () => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 625dc433b..ef37a425c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -5870,7 +5870,7 @@ export function registerProjectWorkbenchFoundationTests() { ); expect( - await screen.findByText('布局保存失败,已保留当前会话布局'), + await screen.findByText(/布局保存失败:.*disk full.*已保留当前会话布局/), ).not.toBeNull(); await openResourceBookCategory('文档'); expect( diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index bbd90923d..b08b7687d 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -442,7 +442,9 @@ describe('useProjectResourceCanvasLayout', () => { ); await waitFor(() => - expect(result.current.notice).toBe('布局读取失败,已使用当前会话布局'), + expect(result.current.notice).toContain( + '布局读取失败:layout response revision or coordinates are invalid', + ), ); expect( invoke.mock.calls.filter( @@ -490,7 +492,10 @@ describe('useProjectResourceCanvasLayout', () => { act(() => result.current.commitPosition('resource-a', 'document', 100, 30)); await waitFor(() => - expect(result.current.notice).toBe('布局保存失败,已恢复上次布局'), + expect(result.current.notice).toContain('布局保存失败:'), + ); + expect(result.current.notice).toContain( + 'layout response revision or coordinates are invalid', ); expect(result.current.layout.positions[0]).toMatchObject({ x: 10, y: 20 }); expect(result.current.saving).toBe(false); @@ -1519,7 +1524,10 @@ describe('布局落盘 / 读盘失败的原因不再被吞掉', () => { act(() => result.current.commitPosition('resource-a', 'document', 100, 30)); await waitFor(() => - expect(result.current.notice).toBe('布局保存失败,已恢复上次布局'), + expect(result.current.notice).toContain('布局保存失败:'), + ); + expect(result.current.notice).toContain( + 'layout response revision or coordinates are invalid', ); // 用户可见文案保持原样(不把内部原因塞进面板),原因落到日志里可查。 @@ -1556,8 +1564,9 @@ describe('布局落盘 / 读盘失败的原因不再被吞掉', () => { }), ); await waitFor(() => - expect(result.current.notice).toBe('布局读取失败,已使用当前会话布局'), + expect(result.current.notice).toContain('布局读取失败:'), ); + expect(result.current.notice).toContain('读取布局失败'); const logged = warnMessages(warn).filter((message) => message.includes('[resource-canvas-layout] 布局读取失败'), @@ -2564,8 +2573,9 @@ describe('资源画布整理与批量坐标写入', () => { act(() => result.current.organizeNow(['document'])); await waitFor(() => - expect(result.current.notice).toBe('布局保存失败,已保留当前会话布局'), + expect(result.current.notice).toContain('布局保存失败:'), ); + expect(result.current.notice).toContain('layout write rejected'); expect( invoke.mock.calls.filter( ([command]) => diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index 5f77e0821..35d261e1b 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -75,3 +75,4 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 前端继续实测发现预览活体复用的 IPC / 权限拒绝会被当成“没有活体”并静默重启,资源生成事件订阅失败也只回退轮询;现在两条回退仍保留,但分别把复用失败正文附到重启结果、把订阅失败正文附到任务阶段。预览与生成队列定向测试 25 项通过。 - 继续扫前端外围又发现 Direct 回合后的清单刷新、资源卡片预览未知错误、UI 编辑器单图预览读取失败仍会静默或使用无因固定句;现分别进入运行错误出口、保留脱敏后的预览错误正文并在编辑器状态提示中显示。 - 资源依赖图读取失败原先只切到 fallback 布局状态,用户看不到 IPC/权限正文;现把安全错误保存到状态并在依赖画布内联显示。 +- 资源画布布局读写失败原先只把原因写日志、界面仍显示固定“布局失败”;现把脱敏后的 revision/IPC/权限/磁盘正文附到读取与保存提示,原有回退布局行为不变。 From d7c1a1475d7f0c43f7a1586624e90b501899d41f Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:57:14 +0800 Subject: [PATCH 072/132] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E7=A9=BA=E6=AD=A3=E6=96=87=E5=9B=BA=E5=AE=9A=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为发布、扩展、更新和生成任务保留正文缺失标记 避免空错误被误显示为完整的通用失败原因 --- .../src/components/AppUpdateNotice.tsx | 2 +- .../game-distribution/GameDistributionPublishPanel.tsx | 9 +++++---- .../resourceCanvasAssetGenerationQueue.ts | 2 +- .../src/features/runtime-config/RuntimeConfigDialog.tsx | 3 ++- .../src/view/project-development/index.tsx | 8 ++++---- .../plans/【里程碑】AGC错误具体文本展示-2026-10-04.md | 1 + 6 files changed, 14 insertions(+), 11 deletions(-) diff --git a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx index 2d09967e3..b7fe11562 100644 --- a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx +++ b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx @@ -158,7 +158,7 @@ export function AppUpdateNotice() { ) : ( <> -

{downloadError || '下载更新失败,请稍后重试。'}

+

{downloadError || '下载更新失败:未提供具体错误正文。'}