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 d6e744329..c5191754e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -12,6 +12,8 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; mod codex_app_server; mod codex_cli; mod codex_provider_proxy; +mod design_runtime; +mod design_tools; mod direct_codex_attachments; mod direct_codex_audit; mod direct_project_history; @@ -19,8 +21,6 @@ mod direct_project_turn_history; mod direct_runtime; mod direct_tool_bridge; mod direct_tools_mcp; -mod design_runtime; -mod design_tools; mod generation; mod interaction; mod prompt; @@ -40,6 +40,7 @@ pub(crate) use codex_cli::{ game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity, }; pub(crate) use codex_provider_proxy::*; +pub(crate) use design_runtime::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_audit::*; pub(crate) use direct_project_history::*; @@ -47,7 +48,6 @@ pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tools_mcp::*; -pub(crate) use design_runtime::*; pub(crate) use generation::*; pub(crate) use interaction::*; pub(crate) use prompt::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 57538c101..2088c1c08 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -3078,8 +3078,8 @@ fn parse_game_creator_codex_app_server_text( response_id: Some(thread_id.to_string()), usage: None, tool_calls, - responses_output: Vec::new(), - }) + responses_output: Vec::new(), + }) } async fn read_game_creator_codex_app_server_stdout( 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 2241fc7ae..8af1c9d2d 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 @@ -593,8 +593,8 @@ fn parse_game_creator_codex_cli_response( response_id, usage, tool_calls, - responses_output: Vec::new(), - }) + responses_output: Vec::new(), + }) } async fn request_game_creator_agent_codex_cli_with_executable( 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 ebef9a3c7..a20b961df 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,5 @@ -use super::*; use super::design_tools::*; +use super::*; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::fs::File; @@ -12,10 +12,20 @@ use uuid::Uuid; const DESIGN_ACTIVE_LOCK: &str = ".agent/design-agent/active.lock"; #[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub(crate) enum DesignInput { - Message { text: String }, - Clarification { request_id: String, option_index: Option, text: Option }, + Message { + text: String, + }, + Clarification { + request_id: String, + option_index: Option, + text: Option, + }, Retry, } @@ -55,21 +65,40 @@ pub(crate) struct DesignEvent { fn design_view(session: &DesignSession, running: bool) -> DesignView { DesignView { session: DesignSessionSummary { - session_id: session.session_id.clone(), project_id: session.project_id.clone(), - current_phase: session.current_phase.clone(), approved_phases: session.approved_phases.clone(), + session_id: session.session_id.clone(), + project_id: session.project_id.clone(), + current_phase: session.current_phase.clone(), + approved_phases: session.approved_phases.clone(), pending_approval: session.pending_approval.clone(), pending_clarification: session.pending_clarification.clone(), - turn_index: session.turn_index, last_error: session.last_error.clone(), + turn_index: session.turn_index, + last_error: session.last_error.clone(), }, - messages: session.messages.clone(), running, - can_retry: !running && session.turn.as_ref().is_some_and(|turn| turn.pending) - && session.pending_approval.is_none() && session.pending_clarification.is_none(), + messages: session.messages.clone(), + running, + can_retry: !running + && session.turn.as_ref().is_some_and(|turn| turn.pending) + && session.pending_approval.is_none() + && session.pending_clarification.is_none(), } } -fn design_event(root: &Path, turn_id: &str, kind: &str, id: Option<&str>, text: Option, view: Option) -> DesignEvent { - DesignEvent { project_path: root.to_string_lossy().into_owned(), client_turn_id: turn_id.to_string(), - kind: kind.to_string(), message_id: id.map(str::to_string), text, view } +fn design_event( + root: &Path, + turn_id: &str, + kind: &str, + id: Option<&str>, + text: Option, + view: Option, +) -> DesignEvent { + DesignEvent { + project_path: root.to_string_lossy().into_owned(), + client_turn_id: turn_id.to_string(), + kind: kind.to_string(), + message_id: id.map(str::to_string), + text, + view, + } } fn design_project_id(root: &Path) -> Result { @@ -77,12 +106,18 @@ fn design_project_id(root: &Path) -> Result { Ok(read_existing_manifest_for_project(root)?.project_id) } -fn design_command_replayed(session: &DesignSession, id: &str, input: &Value) -> Result { +fn design_command_replayed( + session: &DesignSession, + id: &str, + input: &Value, +) -> Result { if id.trim().is_empty() || id.len() > 160 || id.chars().any(char::is_control) { return Err("回合身份不能为空或包含控制字符,且最多 160 字节".to_string()); } if let Some(previous) = session.commands.get(id) { - if previous != input { return Err("回合身份已用于另一条请求".to_string()); } + if previous != input { + return Err("回合身份已用于另一条请求".to_string()); + } return Ok(true); } Ok(false) @@ -90,36 +125,70 @@ fn design_command_replayed(session: &DesignSession, id: &str, input: &Value) -> fn append_design_user(session: &mut DesignSession, id: &str, text: String) { session.history.push(json!({"role":"user", "content":text})); - session.messages.push(DesignMessage { id: format!("{id}:user"), role: "user".into(), text }); + session.messages.push(DesignMessage { + id: format!("{id}:user"), + role: "user".into(), + text, + }); } fn begin_design_turn(session: &mut DesignSession, id: &str) { session.turn_index += 1; - session.turn = Some(DesignTurn { id: id.to_string(), pending: true, request_index: 0, attempt: 0 }); + session.turn = Some(DesignTurn { + id: id.to_string(), + pending: true, + request_index: 0, + attempt: 0, + }); session.last_error = None; session.updated_at = unix_timestamp(); } -fn prepare_design_input(session: &mut DesignSession, id: &str, input: DesignInput) -> Result { +fn prepare_design_input( + session: &mut DesignSession, + id: &str, + input: DesignInput, +) -> Result { let command = serde_json::to_value(&input).map_err(|e| e.to_string())?; - if design_command_replayed(session, id, &command)? { return Ok(false); } - if session.pending_approval.is_some() { return Err("请先处理当前阶段审批".into()); } + if design_command_replayed(session, id, &command)? { + return Ok(false); + } + if session.pending_approval.is_some() { + return Err("请先处理当前阶段审批".into()); + } if matches!(input, DesignInput::Retry) { - if session.pending_clarification.is_some() { return Err("请先回答当前澄清问题".into()); } - if !session.turn.as_ref().is_some_and(|turn| turn.pending) { return Err("当前没有需要恢复的回合".into()); } + if session.pending_clarification.is_some() { + return Err("请先回答当前澄清问题".into()); + } + if !session.turn.as_ref().is_some_and(|turn| turn.pending) { + return Err("当前没有需要恢复的回合".into()); + } session.last_error = None; } else { - if session.turn.as_ref().is_some_and(|turn| turn.pending) { return Err("上次回合尚未完成,请先恢复回合".into()); } + if session.turn.as_ref().is_some_and(|turn| turn.pending) { + return Err("上次回合尚未完成,请先恢复回合".into()); + } let text = match input { DesignInput::Message { text } => { - if text.trim().is_empty() { return Err("请输入消息".into()); } + if text.trim().is_empty() { + return Err("请输入消息".into()); + } if let Some(question) = session.pending_clarification.take() { format!("对于问题“{}”,用户回答:{}", question.question, text) - } else { text } + } else { + text + } } - DesignInput::Clarification { request_id, option_index, text } => { - let question = session.pending_clarification.as_ref() - .filter(|q| q.request_id == request_id).ok_or("澄清请求已过期")?; + DesignInput::Clarification { + request_id, + option_index, + text, + } => { + let question = session + .pending_clarification + .as_ref() + .filter(|q| q.request_id == request_id) + .ok_or("澄清请求已过期")?; let mut answer = format!("对于问题“{}”,", question.question); if let Some(index) = option_index { let label = question.options.get(index).ok_or("所选选项不存在")?; @@ -127,7 +196,9 @@ fn prepare_design_input(session: &mut DesignSession, id: &str, input: DesignInpu } if let Some(text) = text.filter(|t| !t.trim().is_empty()) { answer.push_str(&format!("用户补充:{}", text)); - } else if option_index.is_none() { return Err("请选择选项或填写回答".into()); } + } else if option_index.is_none() { + return Err("请选择选项或填写回答".into()); + } session.pending_clarification = None; answer } @@ -140,13 +211,28 @@ fn prepare_design_input(session: &mut DesignSession, id: &str, input: DesignInpu Ok(true) } -fn prepare_design_decision(session: &mut DesignSession, id: &str, request_id: &str, approved: bool) -> Result { +fn prepare_design_decision( + session: &mut DesignSession, + id: &str, + request_id: &str, + approved: bool, +) -> Result { let command = json!({"type":"approval", "requestId":request_id,"approved":approved}); - if design_command_replayed(session, id, &command)? { return Ok(false); } + if design_command_replayed(session, id, &command)? { + return Ok(false); + } if approved { let phase = approve_design_phase(session, request_id)?; - let suffix = if phase == "consultant" { "" } else { "请开始该阶段工作。" }; - append_design_user(session, id, format!("用户已批准上一阶段,现在进入 {phase} 阶段。{suffix}")); + let suffix = if phase == "consultant" { + "" + } else { + "请开始该阶段工作。" + }; + append_design_user( + session, + id, + format!("用户已批准上一阶段,现在进入 {phase} 阶段。{suffix}"), + ); begin_design_turn(session, id); } else { reject_design_phase(session, request_id)?; @@ -156,7 +242,8 @@ fn prepare_design_decision(session: &mut DesignSession, id: &str, request_id: &s } fn checkpoint_design(root: &Path, session: &DesignSession) -> Result<(), String> { - let _write = acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "design.session")?; + let _write = + acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "design.session")?; write_design_session(root, session) } @@ -166,24 +253,44 @@ fn design_workflow_status(session: &DesignSession) -> Value { "pending_approval": session.pending_approval.as_ref().map(|request| &request.phase)}) } -fn execute_design_tool(root: &Path, resources: &DesignResources, session: &mut DesignSession, call: &platform_llm::LlmToolCall) -> Result { - let args: Value = serde_json::from_str(&call.arguments).map_err(|error| format!("工具参数不是有效 JSON:{error}"))?; +fn execute_design_tool( + root: &Path, + resources: &DesignResources, + session: &mut DesignSession, + call: &platform_llm::LlmToolCall, +) -> Result { + let args: Value = serde_json::from_str(&call.arguments) + .map_err(|error| format!("工具参数不是有效 JSON:{error}"))?; match call.name.as_str() { "get_workflow_status" => Ok(design_workflow_status(session)), "list_resources" => resources.list().map(Value::String), - "read_resource" => resources.read(args.get("resource_id").and_then(Value::as_str).ok_or("缺少 resource_id")?).map(Value::String), + "read_resource" => resources + .read( + args.get("resource_id") + .and_then(Value::as_str) + .ok_or("缺少 resource_id")?, + ) + .map(Value::String), "submit_phase_for_approval" => { let request = submit_design_phase_for_approval(root, session)?; Ok(json!({"status":"waiting_for_approval", "phase":request.phase})) } "ask_clarification" => { - let question = args.get("question").and_then(Value::as_str).filter(|q| !q.trim().is_empty()).ok_or("缺少 question")?; + let question = args + .get("question") + .and_then(Value::as_str) + .filter(|q| !q.trim().is_empty()) + .ok_or("缺少 question")?; let options = match args.get("options") { None => Vec::new(), - Some(value) => serde_json::from_value::>(value.clone()).map_err(|_| "options 必须是文本列表")?, + Some(value) => serde_json::from_value::>(value.clone()) + .map_err(|_| "options 必须是文本列表")?, }; session.pending_clarification = Some(DesignClarificationRequest { - request_id: Uuid::new_v4().to_string(), question: question.into(), options, created_at: unix_timestamp(), + request_id: Uuid::new_v4().to_string(), + question: question.into(), + options, + created_at: unix_timestamp(), }); Ok(json!({"status":"waiting_for_user", "question":question})) } @@ -193,29 +300,58 @@ fn execute_design_tool(root: &Path, resources: &DesignResources, session: &mut D fn design_tool_line(call: &platform_llm::LlmToolCall, error: Option<&str>) -> String { let label = match call.name.as_str() { - "list_dir" => "列出目录", "read_file" => "读取文件", "write_file" => "写入文件", - "patch_file" => "局部修改", "delete_path" => "删除", "search_text" => "搜索文本", - "list_resources" => "列出资源目录", "read_resource" => "读取资源", - "ask_clarification" => "等待你的回答", "submit_phase_for_approval" => "等待阶段审批", - "get_workflow_status" => "查询工作阶段", _ => &call.name, + "list_dir" => "列出目录", + "read_file" => "读取文件", + "write_file" => "写入文件", + "patch_file" => "局部修改", + "delete_path" => "删除", + "search_text" => "搜索文本", + "list_resources" => "列出资源目录", + "read_resource" => "读取资源", + "ask_clarification" => "等待你的回答", + "submit_phase_for_approval" => "等待阶段审批", + "get_workflow_status" => "查询工作阶段", + _ => &call.name, }; let args: Value = serde_json::from_str(&call.arguments).unwrap_or(Value::Null); - let path = args.get("path").or_else(|| args.get("resource_id")).and_then(Value::as_str); + let path = args + .get("path") + .or_else(|| args.get("resource_id")) + .and_then(Value::as_str); let line = match (path, error) { (_, Some(error)) => format!("{label}失败:{error}"), (Some(path), None) => format!("{label}:{path}"), _ => label.to_string(), }; - line.replace(['\r','\n'], " ").chars().take(220).collect() + line.replace(['\r', '\n'], " ").chars().take(220).collect() } -fn record_design_tool_result(session: &mut DesignSession, call: &platform_llm::LlmToolCall, result: Value, line: String) { - let output = match result { Value::String(text) => text, value => value.to_string() }; - session.history.push(json!({"type":"function_call_output", "call_id":call.id,"output":output})); - session.messages.push(DesignMessage { id: format!("{}:tool", call.id), role: "tool".into(), text: line }); +fn record_design_tool_result( + session: &mut DesignSession, + call: &platform_llm::LlmToolCall, + result: Value, + line: String, +) { + let output = match result { + Value::String(text) => text, + value => value.to_string(), + }; + session + .history + .push(json!({"type":"function_call_output", "call_id":call.id,"output":output})); + session.messages.push(DesignMessage { + id: format!("{}:tool", call.id), + role: "tool".into(), + text: line, + }); } -fn process_design_batch(root: &Path, resources: &DesignResources, session: &mut DesignSession, emit: &mut (impl FnMut(DesignEvent) + Send)) -> Result<(), String> { +fn process_design_batch( + root: &Path, + resources: &DesignResources, + session: &mut DesignSession, + emit: &mut (impl FnMut(DesignEvent) + Send), +) -> Result<(), String> { while let Some(batch) = &session.pending_batch { if batch.cursor == batch.calls.len() { session.pending_batch = None; @@ -231,12 +367,21 @@ fn process_design_batch(root: &Path, resources: &DesignResources, session: &mut let result = if uncertain { Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string()) } else { - let _write = acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "design.tool")?; + let _write = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "design.tool", + )?; execute_design_tool(root, resources, session, &call) }; - let error = result.as_ref().err().map(|e| redact_agent_runtime_error(root, e, 1800)); + let error = result + .as_ref() + .err() + .map(|e| redact_agent_runtime_error(root, e, 1800)); let line = design_tool_line(&call, error.as_deref()); - let result = match result { Ok(value) => value, Err(_) => json!({"error":error}) }; + let result = match result { + Ok(value) => value, + Err(_) => json!({"error":error}), + }; record_design_tool_result(session, &call, result, line.clone()); let waiting = session.pending_approval.is_some() || session.pending_clarification.is_some(); let batch = session.pending_batch.as_mut().unwrap(); @@ -245,28 +390,67 @@ fn process_design_batch(root: &Path, resources: &DesignResources, session: &mut if waiting || uncertain { let remaining = batch.calls[batch.cursor..].to_vec(); for skipped in remaining { - let reason = if waiting { "正在等待用户,本次调用未执行" } else { "前一调用结果不确定,本次调用未执行" }; - record_design_tool_result(session, &skipped, json!({"status":"not_executed","reason":reason}), format!("未执行 {}:{reason}", skipped.name)); + let reason = if waiting { + "正在等待用户,本次调用未执行" + } else { + "前一调用结果不确定,本次调用未执行" + }; + record_design_tool_result( + session, + &skipped, + json!({"status":"not_executed","reason":reason}), + format!("未执行 {}:{reason}", skipped.name), + ); } session.pending_batch = None; - if waiting { session.turn.as_mut().unwrap().pending = false; } + if waiting { + session.turn.as_mut().unwrap().pending = false; + } } session.updated_at = unix_timestamp(); checkpoint_design(root, session)?; let id = &session.turn.as_ref().unwrap().id; - emit(design_event(root, id, "tool", Some(&format!("{}:tool",call.id)), Some(line), None)); - emit(design_event(root, id, "state", None, None, Some(design_view(session, !waiting)))); + emit(design_event( + root, + id, + "tool", + Some(&format!("{}:tool", call.id)), + Some(line), + None, + )); + emit(design_event( + root, + id, + "state", + None, + None, + Some(design_view(session, !waiting)), + )); } Ok(()) } -fn build_design_request(session: &DesignSession, resources: &DesignResources, llm: &GameCreatorLlmConfig) -> Result { - let messages = vec![platform_llm::LlmMessage::system(resources.system_prompt()), platform_llm::LlmMessage::system(resources.phase_context(session))]; - let mut input = messages.iter().map(|m| json!({"role":"system","content":m.content})).collect::>(); +fn build_design_request( + session: &DesignSession, + resources: &DesignResources, + llm: &GameCreatorLlmConfig, +) -> Result { + let messages = vec![ + platform_llm::LlmMessage::system(resources.system_prompt()), + platform_llm::LlmMessage::system(resources.phase_context(session)), + ]; + let mut input = messages + .iter() + .map(|m| json!({"role":"system","content":m.content})) + .collect::>(); input.extend(session.history.clone()); - let request = LlmRunRequest::new(messages).with_openai_responses().with_responses_input(input) - .with_model(llm.model.clone()).with_request_timeout_ms(llm.request_timeout_ms) - .with_function_tools(resources.function_tools()).with_tool_choice(platform_llm::LlmToolChoice::Auto) + let request = LlmRunRequest::new(messages) + .with_openai_responses() + .with_responses_input(input) + .with_model(llm.model.clone()) + .with_request_timeout_ms(llm.request_timeout_ms) + .with_function_tools(resources.function_tools()) + .with_tool_choice(platform_llm::LlmToolChoice::Auto) .with_web_search(false); apply_game_creator_llm_reasoning_effort(request, llm) } @@ -277,20 +461,29 @@ fn design_debug(root: &Path, kind: &str, data: Value) { static QUEUE: OnceLock> = OnceLock::new(); let sender = QUEUE.get_or_init(|| { let (sender, receiver) = std::sync::mpsc::sync_channel::(16); - let _ = std::thread::Builder::new().name("design-debug".into()).spawn(move || { - for (path, data) in receiver { - if let Ok(bytes) = serde_json::to_vec(&data) { - let _ = write_game_creator_private_file(&path, &bytes, "策划调试资料"); + let _ = std::thread::Builder::new() + .name("design-debug".into()) + .spawn(move || { + for (path, data) in receiver { + if let Ok(bytes) = serde_json::to_vec(&data) { + let _ = write_game_creator_private_file(&path, &bytes, "策划调试资料"); + } } - } - }); + }); sender }); - let path = root.join(".debug/design-agent").join(format!("{}-{kind}.json", Uuid::new_v4())); + let path = root + .join(".debug/design-agent") + .join(format!("{}-{kind}.json", Uuid::new_v4())); let _ = sender.try_send((path, data)); } -async fn request_design_provider(root: &Path, session: &mut DesignSession, resources: &DesignResources, emit: &mut (impl FnMut(DesignEvent) + Send)) -> Result { +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; @@ -310,27 +503,81 @@ async fn request_design_provider(root: &Path, session: &mut DesignSession, resou for attempt in 0..=max_retries { session.turn.as_mut().unwrap().attempt = attempt; checkpoint_design(root, session)?; - design_debug(root, "request", json!({"turnId":turn_id,"requestIndex":session.turn.as_ref().unwrap().request_index,"attempt":attempt,"input":session.history,"model":llm.model})); - emit(design_event(root, &turn_id, "tool", None, Some(if attempt == 0 { "正在请求 Provider…".into() } else { format!("Provider 重试 {attempt}/{max_retries}…") }), None)); + design_debug( + root, + "request", + json!({"turnId":turn_id,"requestIndex":session.turn.as_ref().unwrap().request_index,"attempt":attempt,"input":session.history,"model":llm.model}), + ); + 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)); + emit(design_event( + root, + &turn_id, + "text", + Some(&message_id), + Some(String::new()), + None, + )); let result = if llm.stream { - client.stream_run(request.clone(), |delta| { - emit(design_event(root, &turn_id, "text", Some(&message_id), Some(delta.accumulated_text.clone()), None)); - }).await - } else { client.run(request.clone()).await }; + client + .stream_run(request.clone(), |delta| { + emit(design_event( + root, + &turn_id, + "text", + Some(&message_id), + Some(delta.accumulated_text.clone()), + None, + )); + }) + .await + } else { + client.run(request.clone()).await + }; match result { Ok(response) => { - design_debug(root, "response", json!({"turnId":turn_id,"responseId":response.response_id,"output":response.responses_output,"text":response.text})); + design_debug( + root, + "response", + json!({"turnId":turn_id,"responseId":response.response_id,"output":response.responses_output,"text":response.text}), + ); return Ok(response); } Err(error) => { - let detail = redact_agent_runtime_error(root, &game_creator_agent_llm_error_public_summary(&error), 1800); - design_debug(root, "error", json!({"turnId":turn_id,"attempt":attempt,"error":detail})); - if attempt == max_retries || game_creator_agent_runtime_transient_provider_error_kind(&error, false).is_none() { + let detail = redact_agent_runtime_error( + root, + &game_creator_agent_llm_error_public_summary(&error), + 1800, + ); + design_debug( + root, + "error", + json!({"turnId":turn_id,"attempt":attempt,"error":detail}), + ); + if attempt == max_retries + || game_creator_agent_runtime_transient_provider_error_kind(&error, false) + .is_none() + { return Err(detail); } - tokio::time::sleep(Duration::from_millis(game_creator_agent_runtime_transient_retry_backoff_ms(llm.retry_backoff_ms, attempt + 1))).await; + tokio::time::sleep(Duration::from_millis( + game_creator_agent_runtime_transient_retry_backoff_ms( + llm.retry_backoff_ms, + attempt + 1, + ), + )) + .await; } } } @@ -391,42 +638,91 @@ async fn request_scripted_design_provider( unreachable!() } -fn accept_design_response(session: &mut DesignSession, response: platform_llm::LlmRunResponse) -> Result<(), String> { +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() { - session.messages.push(DesignMessage { id: format!("{}:response:{}",turn.id,turn.request_index), role: "assistant".into(), text: response.text.clone() }); + session.messages.push(DesignMessage { + id: format!("{}:response:{}", turn.id, turn.request_index), + role: "assistant".into(), + text: response.text.clone(), + }); } if response.responses_output.is_empty() { - if !response.tool_calls.is_empty() { return Err("Provider 返回工具调用但未提供完整 Responses output".into()); } - session.history.push(json!({"role":"assistant","content":response.text})); - } else { session.history.extend(response.responses_output); } + if !response.tool_calls.is_empty() { + return Err("Provider 返回工具调用但未提供完整 Responses output".into()); + } + session + .history + .push(json!({"role":"assistant","content":response.text})); + } else { + session.history.extend(response.responses_output); + } turn.request_index += 1; turn.attempt = 0; turn.pending = !response.tool_calls.is_empty(); if !response.tool_calls.is_empty() { - session.pending_batch = Some(DesignToolBatch { calls: response.tool_calls, cursor: 0, executing: false }); + session.pending_batch = Some(DesignToolBatch { + calls: response.tool_calls, + cursor: 0, + executing: false, + }); } session.updated_at = unix_timestamp(); Ok(()) } -async fn run_design_loop(root: &Path, resources: &DesignResources, session: &mut DesignSession, emit: &mut (impl FnMut(DesignEvent) + Send)) -> Result<(), String> { +async fn run_design_loop( + root: &Path, + resources: &DesignResources, + session: &mut DesignSession, + emit: &mut (impl FnMut(DesignEvent) + Send), +) -> Result<(), String> { while session.turn.as_ref().is_some_and(|turn| turn.pending) { process_design_batch(root, resources, session, emit)?; - if session.pending_approval.is_some() || session.pending_clarification.is_some() { break; } + if session.pending_approval.is_some() || session.pending_clarification.is_some() { + break; + } let response = request_design_provider(root, session, resources, emit).await?; accept_design_response(session, response)?; checkpoint_design(root, session)?; let turn = session.turn.as_ref().unwrap(); - emit(design_event(root, &turn.id, "state", None, None, Some(design_view(session, turn.pending)))); + emit(design_event( + root, + &turn.id, + "state", + None, + None, + Some(design_view(session, turn.pending)), + )); } Ok(()) } -async fn finish_design_command(root: &Path, resources: &DesignResources, mut session: DesignSession, active: File, run: bool, mut emit: impl FnMut(DesignEvent) + Send) -> Result { +async fn finish_design_command( + root: &Path, + resources: &DesignResources, + mut session: DesignSession, + active: File, + run: bool, + mut emit: impl FnMut(DesignEvent) + Send, +) -> Result { checkpoint_design(root, &session)?; - let turn_id = session.turn.as_ref().map(|turn| turn.id.clone()).unwrap_or_default(); - emit(design_event(root, &turn_id, "state", None, None, Some(design_view(&session, run)))); + let turn_id = session + .turn + .as_ref() + .map(|turn| turn.id.clone()) + .unwrap_or_default(); + emit(design_event( + root, + &turn_id, + "state", + None, + None, + Some(design_view(&session, run)), + )); if run { if let Err(error) = run_design_loop(root, resources, &mut session, &mut emit).await { // 从最后一个持久检查点恢复,防止写后未记结果被误认为已完成。 @@ -437,65 +733,125 @@ async fn finish_design_command(root: &Path, resources: &DesignResources, mut ses } let view = design_view(&session, false); drop(active); - emit(design_event(root, &turn_id, "state", None, None, Some(view.clone()))); + emit(design_event( + root, + &turn_id, + "state", + None, + None, + Some(view.clone()), + )); Ok(view) } -pub(crate) async fn continue_design_agent_at(root: &Path, resources: &DesignResources, id: &str, input: DesignInput, emit: impl FnMut(DesignEvent) + Send) -> Result { +pub(crate) async fn continue_design_agent_at( + root: &Path, + resources: &DesignResources, + id: &str, + input: DesignInput, + emit: impl FnMut(DesignEvent) + Send, +) -> Result { let project_id = design_project_id(root)?; ensure_design_workspace(root)?; - let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?.ok_or("策划 Agent 当前正在工作")?; + let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)? + .ok_or("策划 Agent 当前正在工作")?; let mut session = match read_design_session(root)? { Some(session) => session, None => { - if read_planning_session_v2(root)?.is_some() { return Err("此项目包含旧策划会话,请查看原有记录或在新项目开始五阶段策划".into()); } + if read_planning_session_v2(root)?.is_some() { + return Err("此项目包含旧策划会话,请查看原有记录或在新项目开始五阶段策划".into()); + } new_design_session(&project_id) } }; - if session.project_id != project_id { return Err("策划会话与当前项目不匹配".into()); } + if session.project_id != project_id { + return Err("策划会话与当前项目不匹配".into()); + } let run = prepare_design_input(&mut session, id, input)?; finish_design_command(root, resources, session, active, run, emit).await } -pub(crate) async fn decide_design_phase_at(root: &Path, resources: &DesignResources, id: &str, request_id: &str, approved: bool, emit: impl FnMut(DesignEvent) + Send) -> Result { +pub(crate) async fn decide_design_phase_at( + root: &Path, + resources: &DesignResources, + id: &str, + request_id: &str, + approved: bool, + emit: impl FnMut(DesignEvent) + Send, +) -> Result { let project_id = design_project_id(root)?; ensure_design_workspace(root)?; - let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?.ok_or("策划 Agent 当前正在工作")?; + let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)? + .ok_or("策划 Agent 当前正在工作")?; let mut session = read_design_session(root)?.ok_or("策划会话不存在")?; - if session.project_id != project_id { return Err("策划会话与当前项目不匹配".into()); } + if session.project_id != project_id { + return Err("策划会话与当前项目不匹配".into()); + } let run = prepare_design_decision(&mut session, id, request_id, approved)?; finish_design_command(root, resources, session, active, run, emit).await } #[tauri::command] -pub(crate) fn hydrate_design_agent_session(project_path: String) -> Result, String> { +pub(crate) fn hydrate_design_agent_session( + project_path: String, +) -> Result, String> { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; let project_id = design_project_id(root)?; - let Some(session) = read_design_session(root)? else { return Ok(None); }; - if session.project_id != project_id { return Err("策划会话与当前项目不匹配".into()); } + let Some(session) = read_design_session(root)? else { + return Ok(None); + }; + if session.project_id != project_id { + return Err("策划会话与当前项目不匹配".into()); + } let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?; Ok(Some(design_view(&session, active.is_none()))) } #[tauri::command] -pub(crate) async fn continue_design_agent_session(app: tauri::AppHandle, project_path: String, client_turn_id: String, input: DesignInput) -> Result { +pub(crate) async fn continue_design_agent_session( + app: tauri::AppHandle, + project_path: String, + client_turn_id: String, + input: DesignInput, +) -> Result { let root = PathBuf::from(project_path.trim()); enforce_project_permission_policy(&root, "conversation.write")?; let resources = DesignResources::new(resolve_design_resources_root(&app)?)?; - continue_design_agent_at(&root, &resources, &client_turn_id, input, |event| { let _ = app.emit("design-agent-update", event); }).await + continue_design_agent_at(&root, &resources, &client_turn_id, input, |event| { + let _ = app.emit("design-agent-update", event); + }) + .await } #[tauri::command] -pub(crate) async fn decide_design_phase(app: tauri::AppHandle, project_path: String, client_turn_id: String, request_id: String, approved: bool) -> Result { +pub(crate) async fn decide_design_phase( + app: tauri::AppHandle, + project_path: String, + client_turn_id: String, + request_id: String, + approved: bool, +) -> Result { let root = PathBuf::from(project_path.trim()); enforce_project_permission_policy(&root, "conversation.write")?; let resources = DesignResources::new(resolve_design_resources_root(&app)?)?; - decide_design_phase_at(&root, &resources, &client_turn_id, &request_id, approved, |event| { let _ = app.emit("design-agent-update", event); }).await + decide_design_phase_at( + &root, + &resources, + &client_turn_id, + &request_id, + approved, + |event| { + let _ = app.emit("design-agent-update", event); + }, + ) + .await } #[tauri::command] -pub(crate) fn list_design_workspace(project_path: String) -> Result, String> { +pub(crate) fn list_design_workspace( + project_path: String, +) -> Result, String> { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "file.list")?; design_project_id(root)?; @@ -503,7 +859,10 @@ pub(crate) fn list_design_workspace(project_path: String) -> Result Result { +pub(crate) fn read_design_workspace_file( + project_path: String, + path: String, +) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "file.read")?; design_project_id(root)?; @@ -606,7 +965,10 @@ mod tests { }); process_design_batch(root, &pack(), &mut session, &mut |_| {}).expect("batch"); assert_eq!( - session.pending_approval.as_ref().map(|request| request.phase.as_str()), + session + .pending_approval + .as_ref() + .map(|request| request.phase.as_str()), Some("concept") ); assert!(session.pending_batch.is_none()); @@ -692,11 +1054,16 @@ mod tests { } } - fn phase_write_and_submit(prefix: &str, files: &[(&str, &str)]) -> platform_llm::LlmRunResponse { + 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)) + .map(|(index, (path, content))| { + write_call(&format!("{prefix}-w{index}"), path, content) + }) .collect::>(); calls.push(submit_call(&format!("{prefix}-submit"))); fake_response(prefix, "", calls) @@ -768,7 +1135,9 @@ mod tests { 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")); + assert!(listed + .iter() + .any(|entry| entry.path == "project/00_concept/design.md")); let mut request = request_id(&view); for (turn, expected) in [ @@ -785,7 +1154,10 @@ mod tests { 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("顾问阶段待命"))); + assert!(view + .messages + .iter() + .any(|message| message.text.contains("顾问阶段待命"))); } else { assert!(view.session.pending_approval.is_some()); request = request_id(&view); @@ -827,12 +1199,16 @@ mod tests { 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()), + 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"); + 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()); @@ -845,7 +1221,10 @@ mod tests { 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")))); + assert!(restored + .history + .iter() + .any(|item| item.get("role") == Some(&json!("user")))); } #[tokio::test(flavor = "current_thread")] @@ -888,11 +1267,18 @@ mod tests { .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!(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); @@ -900,7 +1286,10 @@ mod tests { .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 + .messages + .iter() + .any(|message| message.text.contains("重试后继续"))); assert!(next.session.last_error.is_none()); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs index 14704faf6..edfcb5d7d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs @@ -92,10 +92,7 @@ impl DesignResources { items.sort_by(|left, right| left.id.cmp(&right.id)); lines.push(format!("\n{category}")); for item in items { - lines.push(format!( - "- {}|{}:{}", - item.id, item.title, item.summary - )); + lines.push(format!("- {}|{}:{}", item.id, item.title, item.summary)); } } Ok(lines.join("\n")) @@ -228,7 +225,11 @@ pub(crate) fn execute_design_file_tool( let name = workspace_display_path(&relative, &entry.file_name().to_string_lossy()); rows.push(format!( "{} {name}", - if child.is_dir() { "[目录]" } else { "[文件]" } + if child.is_dir() { + "[目录]" + } else { + "[文件]" + } )); } Ok(Value::String(if rows.is_empty() { @@ -276,10 +277,17 @@ pub(crate) fn execute_design_file_tool( } let (display, path) = resolve_design_workspace_path(root, &relative)?; if !path.is_file() { - return Ok(Value::String(format!("局部修改失败:文件不存在:{display}"))); + return Ok(Value::String(format!( + "局部修改失败:文件不存在:{display}" + ))); } - let content = fs::read_to_string(&path).map_err(|error| format!("读取失败:{error}"))?; - let newline = if content.contains("\r\n") { "\r\n" } else { "\n" }; + let content = + fs::read_to_string(&path).map_err(|error| format!("读取失败:{error}"))?; + let newline = if content.contains("\r\n") { + "\r\n" + } else { + "\n" + }; let old = old.replace("\r\n", "\n").replace('\n', newline); let new = new.replace("\r\n", "\n").replace('\n', newline); let count = content.matches(&old).count(); @@ -453,7 +461,8 @@ fn load_design_tools(root: &Path) -> Result, } fn read_pack_text(root: &Path, relative: &str) -> Result { - fs::read_to_string(root.join(relative)).map_err(|error| format!("读取 {relative} 失败:{error}")) + fs::read_to_string(root.join(relative)) + .map_err(|error| format!("读取 {relative} 失败:{error}")) } fn optional_tool_path(args: &Value) -> Result { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs index abf9e3e27..8cd93a2c5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs @@ -119,8 +119,8 @@ fn persist_tool_plan_handoff_repair_chain( response_id: None, usage: None, tool_calls: Vec::new(), - responses_output: Vec::new(), - }; + responses_output: Vec::new(), + }; tool_plan_handoff::write_at( root, &base_identity, @@ -1120,8 +1120,8 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo response_id: None, usage: None, tool_calls: Vec::new(), - responses_output: Vec::new(), - }; + responses_output: Vec::new(), + }; tool_plan_handoff::write_at( root, &base_identity, @@ -1440,8 +1440,8 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem response_id: None, usage: None, tool_calls: Vec::new(), - responses_output: Vec::new(), - }; + responses_output: Vec::new(), + }; tool_plan_handoff::write_at( root, &base_identity, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/design_session.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/design_session.rs index b7140a978..79d8e919e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/design_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/design_session.rs @@ -1,10 +1,10 @@ use super::*; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use uuid::Uuid; -use serde_json::Value; pub(crate) const DESIGN_SESSION_SCHEMA_VERSION: &str = "design-agent-session.v1"; pub(crate) const DESIGN_SESSION_ENGINE: &str = "design-agent"; @@ -181,8 +181,14 @@ pub(crate) fn validate_design_session(session: &DesignSession) -> Result<(), Str pub(crate) fn read_design_session(root: &Path) -> Result, String> { let Some(session) = read_agent_runtime_json_sidecar_with_max_bytes::( - root, DESIGN_SESSION_PATH, "策划 Agent 会话", DESIGN_SESSION_MAX_BYTES, - )? else { return Ok(None); }; + root, + DESIGN_SESSION_PATH, + "策划 Agent 会话", + DESIGN_SESSION_MAX_BYTES, + )? + else { + return Ok(None); + }; validate_design_session(&session)?; Ok(Some(session)) } @@ -190,7 +196,11 @@ pub(crate) fn read_design_session(root: &Path) -> Result, pub(crate) fn write_design_session(root: &Path, session: &DesignSession) -> Result<(), String> { validate_design_session(session)?; write_agent_runtime_json_sidecar_with_max_bytes( - root, DESIGN_SESSION_PATH, "策划 Agent 会话", session, DESIGN_SESSION_MAX_BYTES, + root, + DESIGN_SESSION_PATH, + "策划 Agent 会话", + session, + DESIGN_SESSION_MAX_BYTES, ) } @@ -199,7 +209,9 @@ pub(crate) fn ensure_design_session( project_id: &str, ) -> Result { if let Some(session) = read_design_session(root)? { - if session.project_id != project_id { return Err("策划会话与当前项目不匹配".to_string()); } + if session.project_id != project_id { + return Err("策划会话与当前项目不匹配".to_string()); + } return Ok(session); } let session = new_design_session(project_id.to_string()); @@ -244,7 +256,9 @@ pub(crate) fn submit_design_phase_for_approval( if session.current_phase == "consultant" { return Err("顾问态不提交阶段审批".to_string()); } - if let Some(request) = &session.pending_approval { return Ok(request.clone()); } + if let Some(request) = &session.pending_approval { + return Ok(request.clone()); + } let missing = check_design_phase_artifacts(root, session, &session.current_phase)?; if !missing.is_empty() { return Err(format!("缺少必需产物:{}", missing.join("、"))); @@ -274,8 +288,10 @@ pub(crate) fn approve_design_phase( } session.approved_phases.push(session.current_phase.clone()); let next = design_phase_index(&session.current_phase)? + 1; - session.current_phase = DESIGN_PHASES.get(next) - .ok_or_else(|| "顾问态没有下一阶段".to_string())?.to_string(); + session.current_phase = DESIGN_PHASES + .get(next) + .ok_or_else(|| "顾问态没有下一阶段".to_string())? + .to_string(); session.pending_clarification = None; session.updated_at = unix_timestamp(); Ok(session.current_phase.clone()) diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 582a24733..b0ce174bc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -2779,8 +2779,8 @@ fn durable_provider_handoff_prevents_shutdown_even_when_corrupt() { response_id: Some("provider-handoff-response".to_string()), usage: None, tool_calls: Vec::new(), - responses_output: Vec::new(), - }; + responses_output: Vec::new(), + }; let provider_request_id = format!("provider-request-{}", "f".repeat(64)); crate::provider_handoff::write_at( &root, diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index cd8dde108..ed4948a08 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -27,30 +27,35 @@ import { within, } from './harness'; -function emptyPlanningV2StartResult(projectId = 'local-project-draft') { +function homeDesignContinueView({ + prompt, + pendingClarification = null, +}: { + prompt: string; + pendingClarification?: { + requestId: string; + question: string; + options: string[]; + createdAt: number; + } | null; +}) { return { session: { - schemaVersion: 'planning-session.v2', - engine: 'planning-session-v2', - sessionId: 'home-planning-v2-session', - projectId, - mode: 'gdd', - status: 'planning', + sessionId: 'design-session-home', + projectId: 'local-project-draft', + currentPhase: 'concept', + approvedPhases: [], + pendingApproval: null, + pendingClarification, turnIndex: 1, - questionCount: 0, - questionLimit: 8, - revisionCount: 0, - currentArtifactVersion: null, - currentQuestion: null, - capabilities: { tools: [], skills: [] }, - processingSeconds: 0.5, - createdAtUtc: '2026-09-03T00:00:00Z', - updatedAtUtc: '2026-09-03T00:00:01Z', lastError: null, }, - result: null, - currentArtifact: null, - replayed: false, + messages: [ + { id: 'u1', role: 'user', text: prompt }, + { id: 'a1', role: 'assistant', text: '先确认核心循环。' }, + ], + running: false, + canRetry: false, }; } @@ -1784,7 +1789,7 @@ export function registerHomeProjectCreationTests() { ['做方案', false], ['做方案', true], ] as const)( - 'routes %s %s creation to Planning Session V2', + 'routes %s %s creation to the design agent', async (modeLabel, automatic) => { const projectPath = `/tmp/home-${modeLabel}-${automatic ? 'enter' : 'submit'}`; const manifest = createGameCreationAppManifest( @@ -1793,7 +1798,9 @@ export function registerHomeProjectCreationTests() { ); const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, - planningV2StartResult: emptyPlanningV2StartResult(), + designAgentContinueView: homeDesignContinueView({ + prompt: '整理一个可玩原型', + }), }); const invoke = vi.fn( async (command: string, args?: Record) => { @@ -1840,20 +1847,28 @@ export function registerHomeProjectCreationTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'start_planning_session_v2', + 'continue_design_agent_session', expect.objectContaining({ projectPath, - mode: 'gdd', + input: expect.objectContaining({ + type: 'message', + text: '整理一个可玩原型', + }), }), ); }); const startCall = invoke.mock.calls.find( - ([command]) => command === 'start_planning_session_v2', + ([command]) => command === 'continue_design_agent_session', ); expect(startCall?.[1]).not.toHaveProperty('attachments'); expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain( '本轮用户附件', ); + expect( + invoke.mock.calls.some( + ([command]) => command === 'start_planning_session_v2', + ), + ).toBe(false); expect(invoke).not.toHaveBeenCalledWith( 'chat_with_game_creator_direct_codex', expect.anything(), @@ -1882,9 +1897,9 @@ export function registerHomeProjectCreationTests() { ); const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, - planningV2StartResult: emptyPlanningV2StartResult( - 'home-planning-attachment', - ), + designAgentContinueView: homeDesignContinueView({ + prompt: '整理一份可玩原型', + }), }); const fileBytes = Array.from(new TextEncoder().encode('png')); const invoke = vi.fn( @@ -1937,10 +1952,10 @@ export function registerHomeProjectCreationTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'start_planning_session_v2', + 'continue_design_agent_session', expect.objectContaining({ projectPath, - mode: 'gdd', + input: expect.objectContaining({ type: 'message' }), }), ); }); @@ -1951,13 +1966,18 @@ export function registerHomeProjectCreationTests() { bytes: fileBytes, }); const startCall = invoke.mock.calls.find( - ([command]) => command === 'start_planning_session_v2', + ([command]) => command === 'continue_design_agent_session', ); expect(startCall?.[1]).not.toHaveProperty('attachments'); expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain('本轮用户附件'); expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain( 'assets/uploads/reference.png', ); + expect( + invoke.mock.calls.some( + ([command]) => command === 'start_planning_session_v2', + ), + ).toBe(false); expect(invoke).not.toHaveBeenCalledWith( 'chat_with_game_creator_direct_codex', expect.anything(), @@ -1980,53 +2000,15 @@ export function registerHomeProjectCreationTests() { ); const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, - expectedRunProfile: 'standard', - }); - supervisorHarness.setPlanningV2StartResult({ - session: { - schemaVersion: 'planning-session.v2', - engine: 'planning-session-v2', - sessionId: 'home-planning-v2-session', - projectId: 'local-project-draft', - mode: 'gdd', - status: 'awaiting_user', - turnIndex: 1, - questionCount: 1, - questionLimit: 8, - revisionCount: 0, - currentArtifactVersion: null, - currentQuestion: { - id: 'visual_direction', - header: '当前要决定:首版美术方向', + designAgentContinueView: homeDesignContinueView({ + prompt: '2D射击游戏', + pendingClarification: { + requestId: 'visual_direction', question: '首版角色规范图采用哪种美术方向?', - options: [ - { label: '像素', description: '低成本像素风。' }, - { label: '扁平', description: '清晰的扁平插画风。' }, - ], + options: ['像素', '扁平'], + createdAt: 1, }, - capabilities: { tools: [], skills: [] }, - processingSeconds: 1, - createdAtUtc: '2026-09-03T00:00:00Z', - updatedAtUtc: '2026-09-03T00:00:01Z', - lastError: null, - }, - result: { - schemaVersion: 'planning-turn-result.v2', - kind: 'question', - payload: { - question: { - id: 'visual_direction', - header: '当前要决定:首版美术方向', - question: '首版角色规范图采用哪种美术方向?', - options: [ - { label: '像素', description: '低成本像素风。' }, - { label: '扁平', description: '清晰的扁平插画风。' }, - ], - }, - }, - }, - currentArtifact: null, - replayed: false, + }), }); const invoke = vi.fn( async (command: string, args?: Record) => { @@ -2063,15 +2045,24 @@ export function registerHomeProjectCreationTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'start_planning_session_v2', - expect.objectContaining({ prompt: '2D射击游戏', mode: 'gdd' }), + 'continue_design_agent_session', + expect.objectContaining({ + projectPath, + input: expect.objectContaining({ + type: 'message', + text: '2D射击游戏', + }), + }), ); }); - - const strip = await screen.findByLabelText('立项策划运行状态'); expect( - within(strip).getByText('首版角色规范图采用哪种美术方向?'), - ).not.toBeNull(); + invoke.mock.calls.some( + ([command]) => command === 'start_planning_session_v2', + ), + ).toBe(false); + + await screen.findByText('首版角色规范图采用哪种美术方向?'); + expect(screen.getByRole('button', { name: '像素' })).not.toBeNull(); }); it('refreshes Direct Codex art commits while the turn is still running and after a later failure', async () => { const projectPath =