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 1a5a6e486..c81f84ec1 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 @@ -1,5 +1,6 @@ use super::design_tools::*; use super::*; +use futures::FutureExt; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::fs::File; @@ -11,6 +12,9 @@ use uuid::Uuid; const DESIGN_ACTIVE_LOCK: &str = ".agent/design-agent/active.lock"; +const DESIGN_PANIC_PUBLIC_ERROR: &str = + "策划运行发生内部错误,本轮已中断,可直接重试;若反复出现请反馈。"; + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde( tag = "type", @@ -421,6 +425,10 @@ fn execute_design_tool( ) -> Result { let args: Value = serde_json::from_str(&call.arguments) .map_err(|error| format!("工具参数不是有效 JSON:{error}"))?; + #[cfg(test)] + if call.name == "design_test__panic" { + panic!("注入的策划工具 panic"); + } match call.name.as_str() { "get_workflow_status" => Ok(design_workflow_status(session)), "list_resources" => resources.list().map(Value::String), @@ -971,7 +979,18 @@ async fn finish_design_command( Some(design_view(&session, run)), )); if run { - if let Err(error) = run_design_loop(root, resources, &mut session, &mut emit).await { + // panic 边界:运行期 panic 转成普通失败,交给既有错误分支恢复(重读检查点、 + // 写 last_error、发最终 view)。否则 unwind 会杀死 command task,IPC 永不 + // 返回(前端停在工作态),会话停在无错误的 pending,用户只能看到无声的重试。 + let outcome = + std::panic::AssertUnwindSafe(run_design_loop(root, resources, &mut session, &mut emit)) + .catch_unwind() + .await; + let result = match outcome { + Ok(result) => result, + Err(payload) => Err(design_panic_error(root, payload)), + }; + if let Err(error) = result { // 从最后一个持久检查点恢复,防止写后未记结果被误认为已完成。 session = read_design_session(root)?.ok_or("策划会话丢失")?; session.last_error = Some(redact_agent_runtime_error(root, &error, 1800)); @@ -991,6 +1010,17 @@ async fn finish_design_command( Ok(view) } +// panic 负载可能包含路径或内容片段,公开文案固定;细节只进私有 design_debug。 +fn design_panic_error(root: &Path, payload: Box) -> String { + let detail = payload + .downcast_ref::<&str>() + .map(|value| (*value).to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_else(|| "未知 panic 负载".to_string()); + design_debug(root, "panic", json!({"error": detail})); + DESIGN_PANIC_PUBLIC_ERROR.to_string() +} + pub(crate) async fn continue_design_agent_at( root: &Path, resources: &DesignResources, @@ -1632,6 +1662,66 @@ mod tests { .clone() } + #[tokio::test(flavor = "current_thread")] + async fn design_tool_panic_becomes_visible_retryable_error() { + let (_temp, root, resources) = init_design_project(); + let panic_call = platform_llm::LlmToolCall { + id: "call-panic".into(), + name: "design_test__panic".into(), + arguments: "{}".into(), + }; + let _fake = fake_provider::install( + vec![ + Ok(fake_response("panic-turn", "", vec![panic_call])), + Ok(fake_response("recovery", "已恢复", Vec::new())), + ], + 0, + ); + let view = continue_design_agent_at( + &root, + &resources, + "turn-panic", + DesignInput::Message { + text: "需求".into(), + }, + |_| {}, + ) + .await + .expect("panic 必须转成可恢复视图而不是向上传播"); + assert!(!view.running); + assert!(view.can_retry); + assert_eq!( + view.session.last_error.as_deref(), + Some(DESIGN_PANIC_PUBLIC_ERROR) + ); + let session = read_design_session(&root) + .expect("read session") + .expect("session exists"); + assert!( + !session.history.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some("call-panic") + }), + "panic 不得写半个工具输出" + ); + + let view = + continue_design_agent_at(&root, &resources, "turn-retry", DesignInput::Retry, |_| {}) + .await + .expect("panic 后可重试"); + assert!(!view.running); + assert!(!view.can_retry); + assert!(view.session.last_error.is_none()); + let session = read_design_session(&root) + .expect("read session") + .expect("session exists"); + assert!(session.turn.as_ref().is_some_and(|turn| !turn.pending)); + assert!(session.history.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some("call-panic") + })); + } + #[test] fn design_request_enables_reasoning_capture_only_for_design_runtime() { let session = new_design_session("project", "quality"); diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index dec89f82b..c505ceb32 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -4,6 +4,10 @@ `patch_file` 的所有 edits 均匹配同一份原文件,参数顺序不影响结果。完成唯一匹配与不重叠校验后,按原文起点升序拼接未修改片段与替换文本,最后一次性写入;任一校验失败时不写文件。回归用例覆盖乱序 edits、中文内容与替换长度增减,并核对完整落盘内容。此行为仅属于策划 Agent 文件工具。 +## 策划 Agent 运行 panic 边界 + +`finish_design_command` 的运行段包在 `catch_unwind` 边界内:任何运行期 panic 被转成一次普通失败,由既有错误分支从最后一个持久检查点恢复,向会话写入固定公开文案"策划运行发生内部错误,本轮已中断,可直接重试;若反复出现请反馈。",并以 `running=false、可重试、lastError 有值` 的视图正常返回。panic 负载只写入私有 design_debug,不进入用户可见消息;continue、recover_uncertain 与 decide 三个入口共享同一边界。边界不改变工具错误、Provider 瞬态重试和批次不确定恢复的既有语义。 + ## 资源画布交互与工作台状态同步 - 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。