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 e31e2587a..ebef9a3c7 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 @@ -291,6 +291,10 @@ fn design_debug(root: &Path, kind: &str, data: Value) { } async fn request_design_provider(root: &Path, session: &mut DesignSession, resources: &DesignResources, emit: &mut (impl FnMut(DesignEvent) + Send)) -> Result { + #[cfg(test)] + if fake_provider::is_active() { + return request_scripted_design_provider(root, session, emit).await; + } let config = load_game_creator_app_config()?; let mut llm = resolve_game_creator_llm_config_for_agent(&config, "design-agent"); // 此循环统一处理流中断与 HTTP 瞬态错误,避免与传输重试相乘。 @@ -333,6 +337,60 @@ async fn request_design_provider(root: &Path, session: &mut DesignSession, resou unreachable!() } +#[cfg(test)] +async fn request_scripted_design_provider( + root: &Path, + session: &mut DesignSession, + emit: &mut (impl FnMut(DesignEvent) + Send), +) -> Result { + let max_retries = fake_provider::max_retries(); + let turn = session.turn.as_ref().unwrap(); + let turn_id = turn.id.clone(); + let message_id = format!("{}:response:{}", turn.id, turn.request_index); + for attempt in 0..=max_retries { + session.turn.as_mut().unwrap().attempt = attempt; + checkpoint_design(root, session)?; + emit(design_event( + root, + &turn_id, + "tool", + None, + Some(if attempt == 0 { + "正在请求 Provider…".into() + } else { + format!("Provider 重试 {attempt}/{max_retries}…") + }), + None, + )); + emit(design_event( + root, + &turn_id, + "text", + Some(&message_id), + Some(String::new()), + None, + )); + match fake_provider::take() { + Some(Ok(response)) => return Ok(response), + Some(Err(error)) => { + let detail = redact_agent_runtime_error( + root, + &game_creator_agent_llm_error_public_summary(&error), + 1800, + ); + if attempt == max_retries + || game_creator_agent_runtime_transient_provider_error_kind(&error, false) + .is_none() + { + return Err(detail); + } + } + None => return Err("假 Provider 脚本耗尽".into()), + } + } + unreachable!() +} + fn accept_design_response(session: &mut DesignSession, response: platform_llm::LlmRunResponse) -> Result<(), String> { let turn = session.turn.as_mut().ok_or("缺少当前回合")?; if !response.text.is_empty() { @@ -452,6 +510,55 @@ pub(crate) fn read_design_workspace_file(project_path: String, path: String) -> read_design_workspace_file_at(root, &path) } +#[cfg(test)] +mod fake_provider { + use super::*; + use std::cell::{Cell, RefCell}; + use std::collections::VecDeque; + + // 队列按测试线程隔离,配合 current_thread runtime,避免并行测试互相抢脚本。 + thread_local! { + static ACTIVE: Cell = const { Cell::new(false) }; + static MAX_RETRIES: Cell = const { Cell::new(0) }; + static QUEUE: RefCell>> = + RefCell::new(VecDeque::new()); + } + + pub(super) struct Guard; + + impl Drop for Guard { + fn drop(&mut self) { + ACTIVE.with(|flag| flag.set(false)); + QUEUE.with(|queue| queue.borrow_mut().clear()); + MAX_RETRIES.with(|value| value.set(0)); + } + } + + pub(super) fn install( + items: Vec>, + max_retries: u32, + ) -> Guard { + ACTIVE.with(|flag| flag.set(true)); + MAX_RETRIES.with(|value| value.set(max_retries)); + QUEUE.with(|queue| { + *queue.borrow_mut() = items.into(); + }); + Guard + } + + pub(super) fn is_active() -> bool { + ACTIVE.with(Cell::get) + } + + pub(super) fn max_retries() -> u32 { + MAX_RETRIES.with(Cell::get) + } + + pub(super) fn take() -> Option> { + QUEUE.with(|queue| queue.borrow_mut().pop_front()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -531,4 +638,269 @@ mod tests { assert_eq!(session.current_phase, "concept"); assert!(session.pending_approval.is_none()); } + + fn fake_response( + id: &str, + text: &str, + calls: Vec, + ) -> platform_llm::LlmRunResponse { + let mut output = Vec::new(); + if !text.is_empty() { + output.push(json!({ + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": text}] + })); + } + for call in &calls { + output.push(json!({ + "type": "function_call", + "call_id": call.id, + "name": call.name, + "arguments": call.arguments + })); + } + platform_llm::LlmRunResponse { + provider: platform_llm::LlmProvider::OpenAiCompatible, + model: "fake-design".into(), + text: text.into(), + finish_reason: Some(if calls.is_empty() { + "stop".into() + } else { + "tool_calls".into() + }), + response_id: Some(id.into()), + usage: None, + tool_calls: calls, + responses_output: output, + } + } + + fn write_call(id: &str, path: &str, content: &str) -> platform_llm::LlmToolCall { + platform_llm::LlmToolCall { + id: id.into(), + name: "write_file".into(), + arguments: json!({"path": path, "content": content}).to_string(), + } + } + + fn submit_call(id: &str) -> platform_llm::LlmToolCall { + platform_llm::LlmToolCall { + id: id.into(), + name: "submit_phase_for_approval".into(), + arguments: "{}".into(), + } + } + + fn phase_write_and_submit(prefix: &str, files: &[(&str, &str)]) -> platform_llm::LlmRunResponse { + let mut calls = files + .iter() + .enumerate() + .map(|(index, (path, content))| write_call(&format!("{prefix}-w{index}"), path, content)) + .collect::>(); + calls.push(submit_call(&format!("{prefix}-submit"))); + fake_response(prefix, "", calls) + } + + fn init_design_project() -> (tempfile::TempDir, PathBuf, DesignResources) { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().to_path_buf(); + crate::project::init_local_game_project_at(&root, "design-fake", "策划假Provider") + .expect("init project"); + (temp, root, pack()) + } + + fn request_id(view: &DesignView) -> String { + view.session + .pending_approval + .as_ref() + .expect("pending approval") + .request_id + .clone() + } + + #[tokio::test(flavor = "current_thread")] + async fn fake_provider_walks_five_phases_and_enters_consultant() { + let (_temp, root, resources) = init_design_project(); + let _fake = fake_provider::install( + vec![ + Ok(phase_write_and_submit( + "concept", + &[ + ("project/00_concept/design.md", "概念"), + ("project/速览卡.md", "速览"), + ], + )), + Ok(phase_write_and_submit( + "top", + &[("project/01_top_design/design.md", "顶层")], + )), + Ok(phase_write_and_submit( + "arch", + &[("project/02_architecture/design.md", "架构")], + )), + Ok(phase_write_and_submit("systems", &[])), + Ok(phase_write_and_submit( + "tdd", + &[ + ("project/04_tdd/01_技术实现.md", "技术"), + ("project/04_tdd/02_美术圣经.md", "美术"), + ("project/04_tdd/03_数据与配表.md", "数据"), + ("project/04_tdd/总册.md", "总册"), + ], + )), + Ok(fake_response("consultant", "顾问阶段待命。", Vec::new())), + ], + 0, + ); + let view = continue_design_agent_at( + &root, + &resources, + "t-concept", + DesignInput::Message { + text: "做一个网页迷宫".into(), + }, + |_| {}, + ) + .await + .expect("concept"); + assert_eq!(view.session.current_phase, "concept"); + assert!(view.session.pending_approval.is_some()); + assert!(!view.running); + let listed = list_design_workspace_files(&root).expect("list"); + assert!(listed.iter().any(|entry| entry.path == "project/00_concept/design.md")); + + let mut request = request_id(&view); + for (turn, expected) in [ + ("t-top", "top_design"), + ("t-arch", "architecture"), + ("t-systems", "systems"), + ("t-tdd", "tdd"), + ("t-consultant", "consultant"), + ] { + let view = decide_design_phase_at(&root, &resources, turn, &request, true, |_| {}) + .await + .expect("approve"); + assert_eq!(view.session.current_phase, expected); + if expected == "consultant" { + assert!(view.session.pending_approval.is_none()); + assert!(view.session.approved_phases.ends_with(&["tdd".into()])); + assert!(view.messages.iter().any(|message| message.text.contains("顾问阶段待命"))); + } else { + assert!(view.session.pending_approval.is_some()); + request = request_id(&view); + } + } + let restored = read_design_session(&root).expect("read").expect("session"); + assert_eq!(restored.current_phase, "consultant"); + assert_eq!( + design_workflow_status(&restored)["current_phase"], + json!("consultant") + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn fake_provider_reject_does_not_wake_and_session_survives_restart() { + let (_temp, root, resources) = init_design_project(); + let _fake = fake_provider::install( + vec![Ok(phase_write_and_submit( + "concept", + &[ + ("project/00_concept/design.md", "概念"), + ("project/速览卡.md", "速览"), + ], + ))], + 0, + ); + let view = continue_design_agent_at( + &root, + &resources, + "t-submit", + DesignInput::Message { + text: "开工".into(), + }, + |_| {}, + ) + .await + .expect("submit"); + let request = request_id(&view); + let persisted = read_design_session(&root).expect("read").expect("session"); + assert_eq!(persisted.current_phase, "concept"); + assert_eq!( + persisted.pending_approval.as_ref().map(|item| item.request_id.as_str()), + Some(request.as_str()) + ); + let rejected = decide_design_phase_at(&root, &resources, "t-reject", &request, false, |_| {}) + .await + .expect("reject"); + assert_eq!(rejected.session.current_phase, "concept"); + assert!(rejected.session.pending_approval.is_none()); + assert!(rejected.session.approved_phases.is_empty()); + assert!(fake_provider::take().is_none()); + + let debug = root.join(".debug/design-agent"); + if debug.exists() { + fs::remove_dir_all(&debug).expect("remove debug"); + } + let restored = read_design_session(&root).expect("read").expect("session"); + assert_eq!(restored.current_phase, "concept"); + assert!(restored.pending_approval.is_none()); + assert!(restored.history.iter().any(|item| item.get("role") == Some(&json!("user")))); + } + + #[tokio::test(flavor = "current_thread")] + async fn fake_provider_survives_resource_and_tool_failures_then_retries_transient_error() { + let (_temp, root, resources) = init_design_project(); + let _fake = fake_provider::install( + vec![ + Ok(fake_response( + "fail-tools", + "", + vec![ + platform_llm::LlmToolCall { + id: "missing-resource".into(), + name: "read_resource".into(), + arguments: json!({"resource_id": "skills.missing"}).to_string(), + }, + write_call("escape", "../secret.md", "no"), + write_call("ok", "project/00_concept/design.md", "概念"), + write_call("card", "project/速览卡.md", "速览"), + submit_call("submit"), + ], + )), + Err(platform_llm::LlmError::Upstream { + status_code: 503, + message: "busy".into(), + }), + Ok(fake_response("recovered", "重试后继续。", Vec::new())), + ], + 1, + ); + let view = continue_design_agent_at( + &root, + &resources, + "t-fail", + DesignInput::Message { + text: "写概念".into(), + }, + |_| {}, + ) + .await + .expect("submit after failures"); + assert!(view.session.pending_approval.is_some()); + assert!(view.messages.iter().any(|message| message.text.contains("读取资源失败") + || message.text.contains("未知资源"))); + assert!(view.messages.iter().any(|message| message.text.contains("失败") + && message.text.contains("路径"))); + assert!(root.join(".workspace/project/00_concept/design.md").is_file()); + assert!(!root.join("secret.md").exists()); + + let request = request_id(&view); + let next = decide_design_phase_at(&root, &resources, "t-retry", &request, true, |_| {}) + .await + .expect("approve after transient retry"); + assert_eq!(next.session.current_phase, "top_design"); + assert!(next.messages.iter().any(|message| message.text.contains("重试后继续"))); + assert!(next.session.last_error.is_none()); + } } diff --git a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md index afbe3eb8e..e123de62b 100644 --- a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md +++ b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md @@ -297,7 +297,7 @@ UI 选项提交应携带请求身份和所选选项,Runtime 根据已保存的 5. 接入阶段提示、必读资源注入和 `get_workflow_status`。 6. 接入 `submit_phase_for_approval` 和 ✅/❌ 审批事件。 7. 复用 Game Agent 文件浏览实现,让用户查看 `.workspace` 文件。 -8. 用假 Provider 验证五阶段、恢复、审批拒绝、资源读取失败和工具失败。 +8. 用假 Provider 验证五阶段、恢复、审批拒绝、资源读取失败和工具失败。已由 `design_runtime` 脚本化假 Provider 定向测试覆盖。 9. 在生产入口切换到新设计 Agent。 10. 确认没有现役调用方后,再清理旧 Planning V2 入口和专用展示逻辑。