diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index 41f155eb7..6e225ac31 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -11,7 +11,7 @@ "autoCompactTokenLimit": 64000, "toolOutputTokenLimit": 12000, "requestTimeoutMs": 180000, - "maxRetries": 0, + "maxRetries": 2, "retryBackoffMs": 500 }, "agentLlm": {}, diff --git a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs index 2a3f86b18..98bf22ef3 100644 --- a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs @@ -120,7 +120,7 @@ export function deterministicLaneDefenseInitialHtml() { *{box-sizing:border-box}body{margin:0;min-height:100vh;background:#f4f8ee;color:#18351f;font:16px system-ui,sans-serif}main{width:min(960px,100%);margin:auto;padding:18px}h1{margin:0 0 4px;font-size:clamp(28px,7vw,46px)}p{margin:4px 0 14px}.toolbar,.plants{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0}button{min-height:44px;border:1px solid #315d35;background:#fff;color:#18351f;padding:9px 14px;font:inherit;font-weight:700;cursor:pointer}button:hover{background:#e6f3dc}.board{display:grid;gap:10px;background:#d9edc8;border:2px solid #315d35;padding:10px}.lane{display:grid;grid-template-columns:repeat(5,1fr);gap:6px}.cell{min-height:54px;background:#eef8e7}.status{font-weight:700;min-height:24px}#game{display:none;width:100%;height:auto;aspect-ratio:20/9;background:#18351f;border:2px solid #315d35}@media(max-width:520px){main{padding:12px}button{flex:1 1 44%}.cell{min-height:44px}} -
+
Garden defenders

灵露花园

GENARRATIVE_REAL_E2E_VISIBLE

Goal: defend the garden and win every wave.

diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/role_briefs.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/role_briefs.rs index c15f1bee5..2139ddfe1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/role_briefs.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/role_briefs.rs @@ -367,10 +367,26 @@ pub(crate) fn has_game_creator_agent_llm_override( config: &GameCreatorAppConfig, agent_id: &str, ) -> bool { - config - .agent_llm - .get(agent_id) - .is_some_and(|patch| !is_empty_game_creator_llm_patch(patch)) + config.agent_llm.get(agent_id).is_some_and(|patch| { + if is_empty_game_creator_llm_patch(patch) { + return false; + } + let only_canonical_reasoning_default = patch.api_key.is_none() + && patch.base_url.is_none() + && patch.model.is_none() + && patch.api_kind.is_none() + && patch.stream.is_none() + && patch.web_search_enabled.is_none() + && patch.context_window_tokens.is_none() + && patch.auto_compact_token_limit.is_none() + && patch.tool_output_token_limit.is_none() + && patch.request_timeout_ms.is_none() + && patch.max_retries.is_none() + && patch.retry_backoff_ms.is_none() + && patch.reasoning_effort.as_deref() + == game_creator_llm_agent_default_reasoning_effort(agent_id); + !only_canonical_reasoning_default + }) } pub(crate) async fn request_agent_role_brief_with_config( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index eaee84273..02afd4148 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -560,7 +560,32 @@ pub(in crate::agent) fn autonomous_manifest_dag_in_progress_at( root: &Path, ) -> Result { let manifest = read_manifest_for_project(root)?; - let seed_task_ids = new_game_creation_app_seed_tasks() + let source = read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .ok() + .filter(|runtime| { + runtime.state.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && agent_runtime_supervisor_source_is_trusted(&runtime.state.source) + }) + .map(|runtime| runtime.state.source) + .or_else(|| { + let path = game_creator_agent_runtime_task_path( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ); + read_all_game_creator_agent_runtime_tasks(&path) + .ok() + .map(latest_game_creator_agent_runtime_tasks) + .and_then(|records| { + records.into_iter().rev().find_map(|record| { + (record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && record.parent_run_id.is_none() + && agent_runtime_supervisor_source_is_trusted(&record.source)) + .then_some(record.source) + }) + }) + }) + .unwrap_or_else(|| AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE.to_string()); + let seed_task_ids = autonomous_manifest_seed_tasks_for_source(&source) .into_iter() .map(|task| task.id) .collect::>(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index bc244bcba..0084481cb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -126,6 +126,11 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( system_prompt.push_str( "\n\n当前 Run Profile 为 autonomous-game-build。不得调用 user.input_request,也不得为了等待确认而中断;对不改变核心目标的缺失细节,直接采用可逆、保守且可试玩的默认值。只使用当前 autoTools 推进项目内实现、委派和验证,不得请求 project.git_commit、command.exec、command.start、command.stdin、command.terminate 或其他仍需确认的动作。Project Supervisor 必须持续编排到最小可玩闭环通过 Runtime 完成门禁;专业 Agent 必须完成自己的合同并把结果交回父 Run。", ); + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + system_prompt.push_str( + "\n\nautonomous-game-build 的正式 manifest 任务图是唯一首轮专业执行链。不得在 manifest 之前另行创建 code-prototype、quality-review、art-director、design-foundation 或 art-asset-plan 的首批 agent.delegate;这些角色会由 Runtime 按 manifest 依赖顺序调度。没有待认领的显式返工合同时也不得额外委派。请直接推进/观察 manifest,Runtime 会在你尝试收束时调度 ready task,并在任务图完成前阻止最终交付。", + ); + } system_prompt.push_str(&format!( "\n\n自主构建专业 Agent 在首次项目修改前最多允许 {AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT} 轮 planning 探索。达到上限后,本响应必须直接调用 file.write、file.patch、file.delete、project.patchset、project.restore、canvas.asset_generate 等实际项目修改工具;若当前专业合同确实只要求只读验收,则必须调用 respond_to_user 交付结论。不得继续只调用 update_agent_plan、读取、搜索、状态查询或空验证。" )); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index fa86b1a46..791aca0bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -100,6 +100,16 @@ pub(crate) const AGENT_RUNTIME_ISOLATED_CHILD_SOURCE: &str = "agent-isolated-chi pub(crate) const AGENT_RUNTIME_ISOLATED_JOIN_SOURCE: &str = "agent-isolated-join"; pub(crate) const AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE: &str = "project-supervisor-gui"; pub(crate) const AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE: &str = "project-supervisor-cli"; +pub(crate) const AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE: &str = "project-supervisor-game-chat"; + +pub(crate) fn agent_runtime_supervisor_source_is_trusted(source: &str) -> bool { + matches!( + source.trim(), + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE + | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE + | AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + ) +} pub(super) const AGENT_RUNTIME_RUN_PROFILE_BINDING_SCHEMA_VERSION: &str = "game-creator-run-profile-binding.v1"; pub(super) const AGENT_RUNTIME_AUTONOMOUS_COMPLETION_CONTRACT_SCHEMA_VERSION: &str = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs index 2770eb4de..512271191 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs @@ -761,10 +761,7 @@ pub(crate) fn resolve_game_creator_agent_runtime_retry_configuration_at( if binding.parent_run_id.is_some() || binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || binding.root_run_id != task.run_id - || !matches!( - binding.source.as_str(), - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE - ) + || !agent_runtime_supervisor_source_is_trusted(&binding.source) { return Err("自主构建 Agent Runtime 重试绑定不是可信 Supervisor 根 Run".to_string()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index 7de274d2e..14ab4d3cf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -1460,6 +1460,7 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c let mut steered_during_actions = false; let mut provider_batch_superseded = false; + let mut preview_infrastructure_blocker = None; let mut parallel_batch_consumed_until = 0_usize; for (action_index, action) in plan .actions @@ -2502,6 +2503,11 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } + if let Some(failure_kind) = agent_runtime_preview_infrastructure_blocker(&observation) { + preview_infrastructure_blocker = Some(failure_kind); + provider_batch_superseded = true; + break; + } if resumed_provider_batch.is_some() && observation.status != "ok" { provider_batch_superseded = true; break; @@ -2672,6 +2678,17 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c return AgentBackgroundTaskOutcome::NeedsReconciliation; } } + if let Some(failure_kind) = preview_infrastructure_blocker { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!( + "preview-infrastructure-unavailable: 浏览器验证基础设施不可用({failure_kind}),已停止当前 run,避免在同一 revision 重复请求 Provider 和启动浏览器" + ), + ); + } if checkpoint == AgentRuntimeContextCheckpoint::Stalled { context_stalled = true; break 'agent_loop; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 5b3bc2f85..d50056898 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -119,10 +119,7 @@ pub(crate) fn start_game_creator_supervisor_background_task_for_session_at( source: &str, run_profile: &str, ) -> Result { - if !matches!( - source, - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE - ) { + if !agent_runtime_supervisor_source_is_trusted(source) { return Err("Project Supervisor 提交 source 不受信任".to_string()); } normalize_agent_runtime_run_profile(Some(run_profile))?; @@ -641,10 +638,7 @@ fn current_autonomous_game_build_root_task_at( && record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && record.parent_agent_id.is_none() && record.parent_run_id.is_none() - && matches!( - record.source.as_str(), - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE - ) + && agent_runtime_supervisor_source_is_trusted(&record.source) && seen_run_ids.insert(record.run_id.clone()) { root_run_ids.push(record.run_id.clone()); @@ -658,8 +652,25 @@ fn current_autonomous_game_build_root_task_at( .find(|record| record.run_id == *current_run_id)) } -fn autonomous_manifest_ready_task_ids(tasks: &[GameCreationAppTaskState]) -> Vec { - new_game_creation_app_seed_tasks() +pub(in crate::agent) fn autonomous_manifest_seed_tasks_for_source( + source: &str, +) -> Vec { + let seed_tasks = new_game_creation_app_seed_tasks(); + if source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE { + seed_tasks + .into_iter() + .take_while(|task| task.id != "publish-strategy") + .collect() + } else { + seed_tasks + } +} + +fn autonomous_manifest_ready_task_ids( + tasks: &[GameCreationAppTaskState], + source: &str, +) -> Vec { + autonomous_manifest_seed_tasks_for_source(source) .into_iter() .filter_map(|seed_task| { let task = tasks.iter().find(|task| task.id == seed_task.id)?; @@ -893,9 +904,17 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( .count(); let available = 3usize.saturating_sub(active_count); let seed_task_order = new_game_creation_app_seed_tasks(); + let allowed_seed_task_ids = + autonomous_manifest_seed_tasks_for_source(&parent_binding.source) + .into_iter() + .map(|task| task.id) + .collect::>(); let mut candidates = Vec::new(); for seed_task in &seed_task_order { + if !allowed_seed_task_ids.contains(&seed_task.id) { + continue; + } let Some(task) = manifest.tasks.iter().find(|task| task.id == seed_task.id) else { continue; }; @@ -917,7 +936,7 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( candidates.push((task.clone(), false)); } } - for task_id in autonomous_manifest_ready_task_ids(&manifest.tasks) + for task_id in autonomous_manifest_ready_task_ids(&manifest.tasks, &parent_binding.source) .into_iter() .take(limit.min(available)) { @@ -1183,9 +1202,11 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke }), )?; let completed = status == GameCreationAppTaskStatus::Completed; + let game_chat_single_round = + root_parent_binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE; let root = root.to_path_buf(); tauri::async_runtime::spawn(async move { - if completed { + if completed && !game_chat_single_round { if let Err(error) = schedule_autonomous_game_build_ready_tasks_at( &root, &parent_agent_id, @@ -1242,9 +1263,17 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt( } else { "" }; - return format!( + let code_visual_asset_requirement = if task.id == "code-prototype" + && editor_api_key_is_configured() + { + " External Editor API 已配置时,必须先调用 asset.list 确认 assets/art-spritesheet.png 已登记且 source.kind=canvas、资源有效;game/index.html 必须实际通过 HTML、CSS background 或 Canvas drawImage 引用 assets/art-spritesheet.png 作为游戏 UI 素材,不得只用 emoji、色块、CSS 绘图或占位文本冒充。" + } else { + "" + }; + let owner_prompt = format!( "{base}\n\n这是 autonomous-game-build 的正式 owner 写入任务。必须实际生成并写入非空正式产物:{paths};JSON 文件必须是可解析 JSON,code-prototype 的 game/index.html 不能沿用初始化占位。{publish_package_requirement}任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。完成修改后按当前 run 的验证门完成验证并直接交付结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" ); + return format!("{owner_prompt}{code_visual_asset_requirement}"); } format!( "{base}\n\n这是 autonomous-game-build 的只读协调任务,不要修改项目文件,也不要为了 manifest 内部回执路径写入 memory/、game/、assets/ 或 exports/。只读取当前项目事实,完成方向协调、审查或验收并直接交付结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index 584d8daa6..cfa5ff689 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -6,6 +6,7 @@ pub(crate) fn autonomous_game_build_root_run_active_at(root: &Path) -> bool { if read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID).is_ok_and( |runtime| { runtime.state.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && agent_runtime_supervisor_source_is_trusted(&runtime.state.source) && !matches!( runtime.state.phase.as_str(), "completed" | "failed" | "cancelled" | "budget-exhausted" @@ -22,10 +23,7 @@ pub(crate) fn autonomous_game_build_root_run_active_at(root: &Path) -> bool { tasks.into_iter().any(|task| { task.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && task.parent_run_id.is_none() - && matches!( - task.source.as_str(), - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE - ) + && agent_runtime_supervisor_source_is_trusted(&task.source) && matches!( task.status.as_str(), "pending" @@ -383,6 +381,42 @@ fn autonomous_manifest_owner_artifact_gaps_at( Ok(gaps) } +fn autonomous_code_prototype_art_asset_reference_gap_at( + root: &Path, + task_id: &str, +) -> Result, String> { + if task_id != "code-prototype" || !editor_api_key_is_configured() { + return Ok(None); + } + let manifest = read_manifest_for_project(root)?; + if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, "art-asset-plan") { + return Ok(Some(format!( + "assets/art-spritesheet.png(canvas-registration-invalid:{})", + sanitize_agent_runtime_text(&error, 240) + ))); + } + let Some((_, html)) = read_autonomous_evidence_file_at( + root, + AGENT_RUNTIME_GAME_INDEX_PATH, + "autonomous code-prototype 游戏入口", + AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES, + )? + else { + return Ok(Some( + "game/index.html(missing-art-spritesheet-reference)".to_string(), + )); + }; + if !html + .windows(b"assets/art-spritesheet.png".len()) + .any(|window| window == b"assets/art-spritesheet.png") + { + return Ok(Some( + "game/index.html(missing-art-spritesheet-reference)".to_string(), + )); + } + Ok(None) +} + pub(in crate::agent) fn game_creation_app_task_status_label( status: &GameCreationAppTaskStatus, ) -> String { @@ -397,7 +431,13 @@ fn autonomous_manifest_parent_completion_gaps_at( contract: &AgentRuntimeAutonomousCompletionContract, ) -> Result<(Vec, Vec), String> { let manifest = read_manifest_for_project(root)?; - let seed_tasks = new_game_creation_app_seed_tasks(); + let binding = read_game_creator_agent_runtime_run_profile_binding( + root, + &contract.agent_id, + &contract.run_id, + )? + .ok_or_else(|| "自主构建根 Supervisor Run 缺少 Run Profile 绑定".to_string())?; + let seed_tasks = crate::agent::autonomous_manifest_seed_tasks_for_source(&binding.source); let mut missing_tasks = Vec::new(); let mut missing_paths = Vec::new(); for seed_task in &seed_tasks { @@ -416,6 +456,13 @@ fn autonomous_manifest_parent_completion_gaps_at( contract.baseline_index_sha256.as_deref(), &contract.baseline_artifacts, )?); + if seed_task.id == "code-prototype" { + if let Some(gap) = + autonomous_code_prototype_art_asset_reference_gap_at(root, &seed_task.id)? + { + missing_paths.push(AutonomousManifestArtifactGap::new(gap)); + } + } } missing_paths.sort_by(|left, right| left.summary.cmp(&right.summary)); missing_paths.dedup_by(|left, right| left.summary == right.summary); @@ -464,6 +511,102 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( ), )); } + if state.agent_id == "preview-readiness" { + let revision = match read_game_creator_agent_runtime_project_revision(root) { + Ok(revision) => revision, + Err(error) => { + return Some(autonomous_completion_blocker( + "preview-readiness 无法读取当前项目 revision", + error, + )); + } + }; + let gate = match read_game_creator_agent_runtime_verification_gate( + root, + &state.agent_id, + &state.run_id, + ) { + Ok(gate) => gate, + Err(error) => { + return Some(autonomous_completion_blocker( + "preview-readiness 静态验证凭证不可用", + error, + )); + } + }; + if gate.last_verification_tool.as_deref() != Some("game.static_smoke") + || gate.last_verification_status.as_deref() + != Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED) + || gate.verified_revision != Some(revision.revision) + { + return Some(autonomous_completion_blocker( + "preview-readiness 尚未通过当前 revision 的 game.static_smoke", + format!( + "currentRevision={}, verifiedRevision={}", + revision.revision, + gate.verified_revision + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()) + ), + )); + } + } + if state.agent_id == "preview-playtest" { + let contract = match autonomous_playtest_completion_contract_for_state_at(root, state) { + Ok(Some(contract)) => contract, + Ok(None) => { + return Some(autonomous_completion_blocker( + "preview-playtest 缺少自主试玩完成合同", + "当前 child run 无法绑定父 Supervisor 的试玩场景与 revision。", + )); + } + Err(error) => { + return Some(autonomous_completion_blocker( + "preview-playtest 自主试玩完成合同不可用", + error, + )); + } + }; + let revision = match read_game_creator_agent_runtime_project_revision(root) { + Ok(revision) => revision, + Err(error) => { + return Some(autonomous_completion_blocker( + "preview-playtest 无法读取当前项目 revision", + error, + )); + } + }; + let receipt = match read_autonomous_playtest_receipt(root, &contract) { + Ok(Some(receipt)) => receipt, + Ok(None) => { + return Some(autonomous_completion_blocker( + "preview-playtest 尚未形成成功浏览器试玩回执", + "必须由 preview.validate 在当前 revision 生成 passed report 与桌面、移动截图。", + )); + } + Err(error) => { + return Some(autonomous_completion_blocker( + "preview-playtest 浏览器试玩回执不可用", + error, + )); + } + }; + if receipt.revision != revision.revision { + return Some(autonomous_completion_blocker( + "preview-playtest 浏览器试玩回执不属于当前 revision", + format!( + "receiptRevision={}, currentRevision={}", + receipt.revision, revision.revision + ), + )); + } + if let Err(error) = verify_autonomous_playtest_evidence_files_at(root, &receipt) { + return Some(autonomous_completion_blocker( + "preview-playtest 浏览器试玩证据复核未通过", + error, + )); + } + } let parent_contract = match read_autonomous_completion_contract( root, binding @@ -500,6 +643,24 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( )); } }; + match autonomous_code_prototype_art_asset_reference_gap_at(root, &state.agent_id) { + Ok(Some(gap)) => { + return Some(autonomous_completion_blocker( + "code-prototype 必须实际使用平台生成的美术资源", + format!( + "task={} missingPaths={},请先通过 asset.list 核对 Canvas 来源并在 game/index.html 中引用 assets/art-spritesheet.png", + state.agent_id, gap + ), + )); + } + Ok(None) => {} + Err(error) => { + return Some(autonomous_completion_blocker( + "code-prototype 美术资源引用无法安全核对", + error, + )); + } + } if gaps.is_empty() { return None; } @@ -758,6 +919,7 @@ pub(in crate::agent) fn validate_autonomous_completion_contract( || binding.parent_run_id.is_some() || binding.root_agent_id != contract.agent_id || binding.root_run_id != contract.run_id + || !agent_runtime_supervisor_source_is_trusted(&binding.source) { return Err("自主构建完成合同与根 Supervisor Run 不匹配".to_string()); } @@ -797,10 +959,7 @@ pub(in crate::agent) fn ensure_autonomous_completion_contract_for_task_at( return Ok(()); } if task.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || !matches!( - task.source.as_str(), - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE - ) + || !agent_runtime_supervisor_source_is_trusted(&task.source) { return Err("自主构建完成合同只允许可信根 Supervisor Run".to_string()); } @@ -1201,6 +1360,35 @@ pub(in crate::agent) fn autonomous_completion_contract_for_state_at( Ok(Some(contract)) } +pub(in crate::agent) fn autonomous_playtest_completion_contract_for_state_at( + root: &Path, + state: &AgentRuntimeState, +) -> Result, String> { + if let Some(contract) = autonomous_completion_contract_for_state_at(root, state)? { + return Ok(Some(contract)); + } + if state.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + return Ok(None); + } + let binding = + read_game_creator_agent_runtime_run_profile_binding(root, &state.agent_id, &state.run_id)? + .ok_or_else(|| "自主试玩 child Runtime 缺少 Run Profile 绑定".to_string())?; + if binding.parent_agent_id.as_deref() != Some(binding.root_agent_id.as_str()) + || binding.parent_run_id.as_deref() != Some(binding.root_run_id.as_str()) + { + return Err("自主试玩只接受根 Supervisor 的直接 manifest child".to_string()); + } + let contract = + read_autonomous_completion_contract(root, &binding.root_agent_id, &binding.root_run_id)? + .ok_or_else(|| "自主试玩 child Runtime 缺少根 Supervisor 完成合同".to_string())?; + if binding.parent_binding_fingerprint.as_deref() + != Some(contract.run_profile_binding_fingerprint.as_str()) + { + return Err("自主试玩 child Runtime 与根 Supervisor 完成合同绑定不匹配".to_string()); + } + Ok(Some(contract)) +} + pub(in crate::agent) fn autonomous_game_build_completion_blocker_at_locked( root: &Path, state: &AgentRuntimeState, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index d8a893254..9c000fef7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -12,6 +12,65 @@ fn autonomous_fixture( autonomous_fixture_with_setup(task, run_id, |_| {}) } +fn autonomous_fixture_with_source( + task: &str, + run_id: &str, + source: &str, +) -> ( + tempfile::TempDir, + PathBuf, + AgentRuntimeState, + AgentRuntimeAutonomousCompletionContract, +) { + let temporary = tempfile::tempdir().expect("create autonomous fixture root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "autonomous-project", task).expect("init project"); + let session_id = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve supervisor session"); + let record = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &session_id, + task, + run_id, + source, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue autonomous task"); + let state = agent_runtime_state_from_task_record(&record); + let contract = read_autonomous_completion_contract( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &record.run_id, + ) + .expect("read completion contract") + .expect("completion contract exists"); + prepare_completed_autonomous_manifest_fixture(&root); + (temporary, root, state, contract) +} + +#[test] +fn autonomous_supervisor_source_allowlist_includes_game_chat_only() { + assert!(agent_runtime_supervisor_source_is_trusted( + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE + )); + assert!(agent_runtime_supervisor_source_is_trusted( + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE + )); + assert!(agent_runtime_supervisor_source_is_trusted( + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + )); + assert!(!agent_runtime_supervisor_source_is_trusted( + "project-supervisor-forged" + )); +} + fn autonomous_fixture_with_setup( task: &str, run_id: &str, @@ -461,6 +520,96 @@ fn autonomous_preview_manifest_roles_keep_their_fixed_read_only_core() { assert!(publish_package.contains("所有 Markdown checklist 必须使用 [x] 或 [X]")); } +#[test] +fn autonomous_preview_manifest_tasks_require_current_revision_receipts_before_completion() { + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("做一个完整小游戏", "autonomous-preview-task-receipt-parent"); + for (task_id, expected_summary) in [ + ("preview-readiness", "尚未通过当前 revision"), + ("preview-playtest", "preview-playtest"), + ] { + update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Running) + .expect("mark preview manifest task running"); + let child = queue_autonomous_manifest_child_fixture(&root, &parent_state, task_id); + let blocker = autonomous_game_build_completion_blocker_at_locked( + &root, + &agent_runtime_state_from_task_record(&child), + ) + .expect("preview task without current receipt must be blocked"); + assert!(blocker.summary.contains(expected_summary)); + update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Pending) + .expect("reset preview manifest task"); + } +} + +#[test] +fn autonomous_preview_manifest_tasks_accept_bound_current_revision_receipts() { + let (_temporary, root, parent_state, _contract) = autonomous_fixture( + "做一个完整小游戏", + "autonomous-preview-readiness-receipt-parent", + ); + update_manifest_task_status_at( + &root, + "preview-readiness", + GameCreationAppTaskStatus::Running, + ) + .expect("mark preview readiness running"); + let readiness_child = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-readiness"); + let readiness_state = agent_runtime_state_from_task_record(&readiness_child); + advance_game_index_revision( + &root, + &parent_state, + "静态检查通过", + ); + mark_verification_passed(&root, &readiness_state, "game.static_smoke"); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &readiness_state).is_none()); + + let (_temporary, root, parent_state, contract) = autonomous_fixture( + "做一个完整小游戏", + "autonomous-preview-playtest-receipt-parent", + ); + update_manifest_task_status_at( + &root, + "preview-playtest", + GameCreationAppTaskStatus::Running, + ) + .expect("mark preview playtest running"); + let playtest_child = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-playtest"); + let playtest_state = agent_runtime_state_from_task_record(&playtest_child); + let revision = advance_game_index_revision( + &root, + &parent_state, + "浏览器试玩通过", + ); + let result = browser_result_fixture( + &root, + &parent_state, + revision, + BrowserPlaytestScenario::GenericV1, + ); + let action = AgentRuntimeToolAction { + tool: "preview.validate".to_string(), + reason: Some("验证当前 revision 的真实可玩闭环".to_string()), + input: serde_json::json!({}), + }; + let action_fingerprint = + agent_runtime_tool_action_fingerprint(&action, &playtest_state.current_task); + let action_id = + agent_runtime_tool_action_id(&playtest_state.run_id, 1, 0, 1, &action_fingerprint); + write_autonomous_playtest_receipt_at( + &root, + &contract, + &action_id, + &action_fingerprint, + revision, + &result, + ) + .expect("persist child-bound autonomous playtest receipt"); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &playtest_state).is_none()); +} + #[test] fn autonomous_completion_rejects_formal_artifact_unchanged_from_run_baseline() { let baseline_bytes = @@ -843,6 +992,80 @@ fn autonomous_parent_completion_lists_missing_seed_tasks_and_formal_artifacts() assert!(detail.contains("missingPaths=game/balance.json")); } +#[test] +fn game_chat_parent_completion_stops_after_preview_playtest_without_publish_tasks() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, state, contract) = autonomous_fixture_with_source( + "创建一轮植物塔防游戏", + "game-chat-single-round-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at( + &root, + "publish-strategy", + GameCreationAppTaskStatus::Pending, + ) + .expect("leave publish strategy pending"); + update_manifest_task_status_at(&root, "publish-package", GameCreationAppTaskStatus::Pending) + .expect("leave publish package pending"); + let revision = advance_game_index_revision( + &root, + &state, + "", + ); + mark_verification_passed(&root, &state, "game.static_smoke"); + let result = browser_result_fixture(&root, &state, revision, contract.playtest_scenario); + let action = AgentRuntimeToolAction { + tool: "preview.validate".to_string(), + reason: Some("game-chat single round preview".to_string()), + input: serde_json::json!({}), + }; + let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task); + let action_id = agent_runtime_tool_action_id(&state.run_id, 1, 0, 1, &action_fingerprint); + write_autonomous_playtest_receipt_at( + &root, + &contract, + &action_id, + &action_fingerprint, + revision, + &result, + ) + .expect("persist game-chat playtest receipt"); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &state).is_none()); +} + +#[test] +fn code_prototype_requires_registered_canvas_spritesheet_reference_when_editor_is_configured() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"game-chat-art-gate-key"}}"#.to_string(), + ); + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("创建一轮植物塔防游戏", "game-chat-code-art-gate-parent"); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("mark code prototype running"); + let code_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); + let code_state = agent_runtime_state_from_task_record(&code_record); + advance_game_index_revision( + &root, + &code_state, + "", + ); + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("missing spritesheet reference must block code prototype"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("assets/art-spritesheet.png"))); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); +} + #[test] fn autonomous_ready_child_missing_or_invalid_owner_artifact_is_blocked() { let (_temporary, root, parent_state, _contract) = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs index 490a355e0..6c162f963 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs @@ -523,8 +523,14 @@ pub(in crate::agent) fn read_game_creator_agent_runtime_context_bundle_with_supe { return Err("Agent Runtime context bundle 的停滞标记只能出现在上下文窗口边界".to_string()); } + let loop_limit = u32::try_from(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT).unwrap_or(1); + let completed_loop_remainder = bundle.next_loop_index % loop_limit; let max_completed_loops = - bundle.next_loop_index % u32::try_from(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT).unwrap_or(1); + if bundle.next_loop_index > 0 && completed_loop_remainder == 0 && !bundle.context_stalled { + loop_limit.saturating_sub(1) + } else { + completed_loop_remainder + }; if bundle.window_completed_loops > max_completed_loops { return Err(format!( "Agent Runtime context bundle 窗口轮次无效:windowCompletedLoops={} max={max_completed_loops}", @@ -809,3 +815,65 @@ pub(in crate::agent) fn persist_game_creator_agent_runtime_pause_boundary_contex context_tracker, ) } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn agent_runtime_context_bundle_restores_pre_checkpoint_window_boundary() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "genarrative-context-window-boundary-{}-{unique}", + std::process::id() + )); + init_local_game_project_at(&root, "project-1", "上下文窗口边界恢复项目") + .expect("project init"); + let mut runtime = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "验证窗口边界恢复", + "design-context-window-boundary-run", + "agent-background-task", + "窗口边界恢复测试", + vec!["恢复 checkpoint 前的窗口状态".to_string()], + ) + .expect("start window boundary runtime state"); + let mut tracker = AgentRuntimeContextWindowTracker::default(); + for next_loop_index in 1..AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { + assert_eq!( + tracker.complete_loop(next_loop_index), + AgentRuntimeContextCheckpoint::Continue + ); + } + assert_eq!(tracker.completed_loops, 5); + + let bundle = build_game_creator_agent_runtime_context_bundle( + &root, + &runtime, + &runtime.current_task, + &AgentRuntimeToolPlan::default(), + &[], + AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT, + &tracker, + ) + .expect("build pre-checkpoint boundary bundle"); + assert_eq!(bundle.next_loop_index, 6); + assert_eq!(bundle.context_window, 2); + assert_eq!(bundle.window_completed_loops, 5); + write_game_creator_agent_runtime_context_bundle(&root, &bundle) + .expect("write pre-checkpoint boundary bundle"); + + runtime.loop_iteration = 6; + let loaded = read_game_creator_agent_runtime_context_bundle(&root, &runtime) + .expect("pre-checkpoint boundary bundle must remain recoverable") + .expect("pre-checkpoint boundary bundle exists"); + assert_eq!(loaded.window_completed_loops, 5); + + fs::remove_dir_all(root).ok(); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs index c1ce7a907..dac96b4e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs @@ -111,10 +111,7 @@ pub(in crate::agent) fn validate_agent_runtime_run_profile_binding_record( } if binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && (binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || !matches!( - binding.source.as_str(), - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE - )) + || !agent_runtime_supervisor_source_is_trusted(&binding.source)) { return Err("自主构建 Run Profile 只允许可信 Supervisor 入口绑定".to_string()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs index d32bb1d3a..dde4e6405 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs @@ -1,5 +1,64 @@ use super::*; +const AGENT_RUNTIME_PREVIEW_INFRASTRUCTURE_ERROR_KIND: &str = "preview-infrastructure-unavailable"; + +fn agent_runtime_preview_infrastructure_failure_kind(error: &str) -> Option<&'static str> { + if error.contains("before websocket URL could be resolved") + || error.contains("启动浏览器失败") + || error.contains("启动浏览器超时") + { + Some("browser-launch-failed") + } else if error.contains("未发现可用的 Google Chrome") { + Some("browser-not-found") + } else if error.contains("创建浏览器临时目录失败") + || error.contains("创建浏览器临时 Profile 失败") + || error.contains("构建浏览器配置失败") + { + Some("browser-environment-invalid") + } else { + None + } +} + +fn agent_runtime_preview_infrastructure_observation( + root: &Path, + revision: u64, + failure_kind: &str, + error: &str, +) -> AgentRuntimeToolObservation { + let detail = serde_json::to_string(&serde_json::json!({ + "errorKind": AGENT_RUNTIME_PREVIEW_INFRASTRUCTURE_ERROR_KIND, + "failureKind": failure_kind, + "revision": revision, + "diagnostic": redact_agent_runtime_project_paths(root, error, 500), + })) + .ok(); + AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "blocked".to_string(), + summary: "浏览器验证基础设施不可用,当前任务已停止,未重复重试".to_string(), + detail, + } +} + +pub(in crate::agent) fn agent_runtime_preview_infrastructure_blocker( + observation: &AgentRuntimeToolObservation, +) -> Option { + if observation.tool != "preview.validate" || observation.status != "blocked" { + return None; + } + let detail = serde_json::from_str::(observation.detail.as_deref()?).ok()?; + (detail.get("errorKind").and_then(serde_json::Value::as_str) + == Some(AGENT_RUNTIME_PREVIEW_INFRASTRUCTURE_ERROR_KIND)) + .then(|| { + detail + .get("failureKind") + .and_then(serde_json::Value::as_str) + .unwrap_or("browser-infrastructure") + .to_string() + }) +} + pub(in crate::agent) fn observe_agent_runtime_preview_start( root: &Path, agent_id: &str, @@ -115,17 +174,18 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( }; } }; - let completion_contract = match autonomous_completion_contract_for_state_at(root, &runtime) { - Ok(contract) => contract, - Err(error) => { - return AgentRuntimeToolObservation { - tool: "preview.validate".to_string(), - status: "failed".to_string(), - summary: "自主构建完成合同不可用,未执行浏览器试玩".to_string(), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }; - } - }; + let completion_contract = + match autonomous_playtest_completion_contract_for_state_at(root, &runtime) { + Ok(contract) => contract, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "自主构建完成合同不可用,未执行浏览器试玩".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + }; if let (Some(contract), Some(requested)) = ( completion_contract.as_ref(), input.playtest_scenario.as_ref(), @@ -219,6 +279,14 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( let result = match validation { Ok(result) => result, Err(error) => { + if let Some(failure_kind) = agent_runtime_preview_infrastructure_failure_kind(&error) { + return agent_runtime_preview_infrastructure_observation( + root, + revision_before.revision, + failure_kind, + &error, + ); + } return AgentRuntimeToolObservation { tool: "preview.validate".to_string(), status: "failed".to_string(), @@ -399,3 +467,38 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( detail, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn browser_websocket_launch_exit_is_classified_as_infrastructure_failure() { + let error = "启动浏览器失败:Browser process exited with status ExitStatus(0) before websocket URL could be resolved, stderr=\"\""; + assert_eq!( + agent_runtime_preview_infrastructure_failure_kind(error), + Some("browser-launch-failed") + ); + let observation = agent_runtime_preview_infrastructure_observation( + Path::new("/project"), + 19, + "browser-launch-failed", + error, + ); + assert_eq!(observation.status, "blocked"); + assert_eq!( + agent_runtime_preview_infrastructure_blocker(&observation).as_deref(), + Some("browser-launch-failed") + ); + } + + #[test] + fn gameplay_validation_failure_is_not_an_infrastructure_failure() { + assert_eq!( + agent_runtime_preview_infrastructure_failure_kind( + "浏览器验证未通过,请根据诊断修复后重试" + ), + None + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index 9d60ee983..9f08cf712 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -309,6 +309,9 @@ pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) "llm.toolOutputTokenLimit={}", status.tool_output_token_limit ), + format!("llm.requestTimeoutMs={}", status.request_timeout_ms), + format!("llm.maxRetries={}", status.max_retries), + format!("llm.retryBackoffMs={}", status.retry_backoff_ms), ]; for agent in &status.agents { lines.push(format!( @@ -357,6 +360,18 @@ pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) "llm.agent.{}.toolOutputTokenLimit={}", agent.agent_id, agent.tool_output_token_limit )); + lines.push(format!( + "llm.agent.{}.requestTimeoutMs={}", + agent.agent_id, agent.request_timeout_ms + )); + lines.push(format!( + "llm.agent.{}.maxRetries={}", + agent.agent_id, agent.max_retries + )); + lines.push(format!( + "llm.agent.{}.retryBackoffMs={}", + agent.agent_id, agent.retry_backoff_ms + )); if let Some(error) = agent.error.as_deref() { lines.push(format!("llm.agent.{}.error={error}", agent.agent_id)); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs b/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs index 82ad1f376..0c1a01ee5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs @@ -22,8 +22,6 @@ const SUPERVISOR_COLLABORATION_POLICY_BINDING_LEGACY_CURRENT: &str = const SUPERVISOR_COLLABORATION_MAX_STATIC_DELEGATES: usize = 3; const SUPERVISOR_COLLABORATION_MAX_ISOLATED_CHILDREN: usize = 3; const SUPERVISOR_COLLABORATION_MAX_ISOLATED_GROUPS_BEFORE_CLAIM: usize = 16; -const AUTONOMOUS_GAME_BUILD_BASE_REQUIRED_STATIC_AGENT_IDS: [&str; 2] = - ["code-prototype", "quality-review"]; #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] @@ -67,93 +65,10 @@ impl Default for SupervisorCollaborationPolicy { } } -fn autonomous_game_build_has_canonical_art_asset(root: &Path) -> bool { - read_existing_manifest_for_project(root) - .ok() - .is_some_and(|manifest| { - manifest_has_required_visual_asset(root, &manifest, "art-asset-plan") - }) -} - -fn autonomous_game_build_has_canonical_art_spec(root: &Path) -> bool { - read_existing_manifest_for_project(root) - .ok() - .is_some_and(|manifest| manifest_has_required_visual_asset(root, &manifest, "art-director")) -} - -fn autonomous_game_build_has_canonical_ui_prototype(root: &Path) -> bool { - read_existing_manifest_for_project(root) - .ok() - .is_some_and(|manifest| { - manifest_has_required_visual_asset(root, &manifest, "design-foundation") - }) -} - fn autonomous_game_build_supervisor_collaboration_policy( - root: &Path, + _root: &Path, ) -> SupervisorCollaborationPolicy { - let mut required_static_agent_ids = AUTONOMOUS_GAME_BUILD_BASE_REQUIRED_STATIC_AGENT_IDS - .into_iter() - .map(str::to_string) - .collect::>(); - if editor_api_key_is_configured() { - if !autonomous_game_build_has_canonical_art_spec(root) { - required_static_agent_ids.push("art-director".to_string()); - } else if !autonomous_game_build_has_canonical_ui_prototype(root) { - required_static_agent_ids.push("design-foundation".to_string()); - } else if !autonomous_game_build_has_canonical_art_asset(root) { - required_static_agent_ids.push("art-asset-plan".to_string()); - } - } - SupervisorCollaborationPolicy { - required_initial_wave: SupervisorInitialCollaborationWave::Static, - min_static_delegates: required_static_agent_ids.len(), - required_static_agent_ids, - ..SupervisorCollaborationPolicy::default() - } -} - -fn apply_autonomous_game_build_required_static_agents( - root: &Path, - mut policy: SupervisorCollaborationPolicy, -) -> Result { - if editor_api_key_is_configured() { - if !autonomous_game_build_has_canonical_art_spec(root) - && !policy - .required_static_agent_ids - .iter() - .any(|existing| existing == "art-director") - { - policy - .required_static_agent_ids - .push("art-director".to_string()); - } else if autonomous_game_build_has_canonical_art_spec(root) - && !autonomous_game_build_has_canonical_ui_prototype(root) - && !policy - .required_static_agent_ids - .iter() - .any(|existing| existing == "design-foundation") - { - policy - .required_static_agent_ids - .push("design-foundation".to_string()); - } else if autonomous_game_build_has_canonical_art_spec(root) - && autonomous_game_build_has_canonical_ui_prototype(root) - && !autonomous_game_build_has_canonical_art_asset(root) - && !policy - .required_static_agent_ids - .iter() - .any(|existing| existing == "art-asset-plan") - { - policy - .required_static_agent_ids - .push("art-asset-plan".to_string()); - } - } - policy.min_static_delegates = policy - .min_static_delegates - .max(policy.required_static_agent_ids.len()); - normalize_supervisor_collaboration_policy(policy) + SupervisorCollaborationPolicy::default() } #[derive(Clone, Debug)] @@ -176,10 +91,7 @@ fn read_supervisor_collaboration_unbound_policy_for_run_at( let policy_path = root.join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH); match fs::symlink_metadata(&policy_path) { Ok(_) => { - let mut policy = read_supervisor_collaboration_policy_at(root)?; - if autonomous_supervisor { - policy = apply_autonomous_game_build_required_static_agents(root, policy)?; - } + let policy = read_supervisor_collaboration_policy_at(root)?; return Ok(SupervisorCollaborationUnboundPolicy { policy, source: "project-policy-unbound", 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 67a4f83f4..dbd8adef2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -489,6 +489,7 @@ pub(crate) fn start_game_creator_supervisor_runtime_task( task: String, run_id: String, run_profile: Option, + source: Option, ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; @@ -499,12 +500,20 @@ pub(crate) fn start_game_creator_supervisor_runtime_task( .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(AGENT_RUNTIME_RUN_PROFILE_STANDARD); + let source = source + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE); + if !agent_runtime_supervisor_source_is_trusted(source) { + return Err("Project Supervisor 提交 source 不受信任".to_string()); + } start_game_creator_supervisor_background_task_for_session_at( root, session_id.as_deref(), task.trim(), run_id.trim(), - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + source, run_profile, ) } @@ -663,11 +672,30 @@ pub(crate) fn steer_game_creator_agent_runtime_task( steer_id: String, instruction: String, run_profile: Option, + source: Option, ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; enforce_project_permission_policy(root, "conversation.write")?; enforce_project_permission_policy(root, "agent.run_status")?; + if let Some(source) = source + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if !agent_runtime_supervisor_source_is_trusted(source) { + return Err("Project Supervisor steer source 不受信任".to_string()); + } + let task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + agent_id.trim(), + run_id.trim(), + )? + .ok_or_else(|| "Agent Runtime steer 的 Run 不存在".to_string())?; + if task.source != source { + return Err("Agent Runtime steer source 与当前 Run 不一致".to_string()); + } + } let mut result = steer_game_creator_agent_runtime_task_for_profile_at( root, agent_id.trim(), 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 19fded1e3..1b6a1996d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -1,5 +1,37 @@ use super::*; +pub(crate) const GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS: [(&str, &str); 21] = [ + (GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "high"), + ("planner", "high"), + ("orchestrator", "medium"), + ("generator", "high"), + ("evaluator", "high"), + ("design-director", "medium"), + ("design-foundation", "high"), + ("balance-director", "medium"), + ("balance-seed", "medium"), + ("art-director", "high"), + ("art-asset-plan", "high"), + ("art-polish", "medium"), + ("audio-director", "low"), + ("audio-asset-plan", "medium"), + ("code-director", "medium"), + ("code-prototype", "high"), + ("quality-review", "high"), + ("preview-readiness", "low"), + ("preview-playtest", "low"), + ("publish-strategy", "low"), + ("publish-package", "medium"), +]; + +pub(crate) fn game_creator_llm_agent_default_reasoning_effort( + agent_id: &str, +) -> Option<&'static str> { + GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS + .iter() + .find_map(|(candidate, effort)| (*candidate == agent_id).then_some(*effort)) +} + pub(crate) fn build_game_creator_llm_client_from_llm_config( llm: &GameCreatorLlmConfig, config_path: &str, @@ -137,6 +169,9 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi context_window_tokens: DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS, auto_compact_token_limit: DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT, tool_output_token_limit: DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT, + request_timeout_ms: GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS, + max_retries: DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES, + retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS, error: Some(error), agents: Vec::new(), } @@ -248,6 +283,9 @@ pub(crate) fn check_game_creator_llm_config_values( context_window_tokens: config.context_window_tokens, auto_compact_token_limit: config.auto_compact_token_limit, tool_output_token_limit: config.tool_output_token_limit, + request_timeout_ms: config.request_timeout_ms, + max_retries: config.max_retries, + retry_backoff_ms: config.retry_backoff_ms, error, agents: Vec::new(), } @@ -287,6 +325,9 @@ pub(crate) fn check_game_creator_agent_llm_config_values( context_window_tokens: config.context_window_tokens, auto_compact_token_limit: config.auto_compact_token_limit, tool_output_token_limit: config.tool_output_token_limit, + request_timeout_ms: config.request_timeout_ms, + max_retries: config.max_retries, + retry_backoff_ms: config.retry_backoff_ms, error: status.error, } } @@ -1367,6 +1408,9 @@ pub(crate) fn resolve_game_creator_llm_config_for_agent( agent_id: &str, ) -> GameCreatorLlmConfig { let mut llm = config.llm.clone(); + if let Some(reasoning_effort) = game_creator_llm_agent_default_reasoning_effort(agent_id) { + llm.reasoning_effort = reasoning_effort.to_string(); + } if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { if let Some(patch) = config .agent_llm 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 8f72f2bcc..0e247a357 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -647,6 +647,9 @@ struct GameCreatorLlmConfigStatus { context_window_tokens: u64, auto_compact_token_limit: u64, tool_output_token_limit: u64, + request_timeout_ms: u64, + max_retries: u32, + retry_backoff_ms: u64, error: Option, agents: Vec, } @@ -667,6 +670,9 @@ struct GameCreatorAgentLlmConfigStatus { context_window_tokens: u64, auto_compact_token_limit: u64, tool_output_token_limit: u64, + request_timeout_ms: u64, + max_retries: u32, + retry_backoff_ms: u64, error: Option, } @@ -1969,6 +1975,52 @@ fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) { } } +#[derive(Debug, Eq, PartialEq)] +enum GameChatReleaseClientExitOutcome { + Shutdown, + Busy, + Failed(String), +} + +fn resolve_game_chat_release_client_exit(shutdown: F) -> GameChatReleaseClientExitOutcome +where + F: FnOnce() -> Result, +{ + match shutdown() { + Ok(true) => GameChatReleaseClientExitOutcome::Shutdown, + Ok(false) => GameChatReleaseClientExitOutcome::Busy, + Err(error) => GameChatReleaseClientExitOutcome::Failed(error), + } +} + +fn show_game_chat_release_client_exit_blocked(app: &tauri::AppHandle) { + app.dialog() + .message("当前仍有游戏创作任务或 Provider 请求在运行。为避免结果丢失,已阻止关闭;请先等待任务完成,或在任务页暂停/取消后再退出。") + .title("游戏创作任务仍在运行") + .show(|_| {}); +} + +#[cfg(test)] +mod game_chat_release_client_exit_tests { + use super::*; + + #[test] + fn client_exit_resolution_distinguishes_shutdown_busy_and_failure() { + assert_eq!( + resolve_game_chat_release_client_exit(|| Ok(true)), + GameChatReleaseClientExitOutcome::Shutdown + ); + assert_eq!( + resolve_game_chat_release_client_exit(|| Ok(false)), + GameChatReleaseClientExitOutcome::Busy + ); + assert_eq!( + resolve_game_chat_release_client_exit(|| Err("runner unavailable".to_string())), + GameChatReleaseClientExitOutcome::Failed("runner unavailable".to_string()) + ); + } +} + fn main() { let mut args = std::env::args().skip(1).collect::>(); #[cfg(target_os = "linux")] @@ -2351,17 +2403,28 @@ fn main() { let _ = append_bounded_diagnostic_line(path, "startup.run.begin"); } let shutdown_log = startup_log.clone(); - app.run(move |_, event| { + app.run(move |app_handle, event| { let game_chat_release = cfg!(all(not(debug_assertions), feature = "game-chat-release")); - if game_chat_release && should_shutdown_runner_on_tauri_event(true, &event) { + let game_chat_exit_requested = game_chat_release + && matches!( + &event, + tauri::RunEvent::WindowEvent { + event: tauri::WindowEvent::CloseRequested { .. }, + .. + } | tauri::RunEvent::ExitRequested { .. } + ); + if game_chat_exit_requested { if let Some(path) = shutdown_log.as_deref() { let _ = append_bounded_diagnostic_line( path, "startup.runner.shutdown-for-client-exit.begin", ); } - match shutdown_external_agent_runner_for_client_exit() { - Ok(()) => { + let outcome = resolve_game_chat_release_client_exit( + shutdown_external_agent_runner_for_client_exit, + ); + match &outcome { + GameChatReleaseClientExitOutcome::Shutdown => { if let Some(path) = shutdown_log.as_deref() { let _ = append_bounded_diagnostic_line( path, @@ -2369,7 +2432,15 @@ fn main() { ); } } - Err(error) => { + GameChatReleaseClientExitOutcome::Busy => { + if let Some(path) = shutdown_log.as_deref() { + let _ = append_bounded_diagnostic_line( + path, + "startup.runner.shutdown-for-client-exit.busy", + ); + } + } + GameChatReleaseClientExitOutcome::Failed(error) => { if let Some(path) = shutdown_log.as_deref() { let details = sanitize_diagnostic_message(&error, path.parent()); let _ = append_bounded_diagnostic_line( @@ -2382,6 +2453,17 @@ fn main() { eprintln!("game-chat 客户端退出协议关闭 Agent Runner 失败:{error}") } } + if outcome != GameChatReleaseClientExitOutcome::Shutdown { + match &event { + tauri::RunEvent::WindowEvent { + event: tauri::WindowEvent::CloseRequested { api, .. }, + .. + } => api.prevent_close(), + tauri::RunEvent::ExitRequested { api, .. } => api.prevent_exit(), + _ => {} + } + show_game_chat_release_client_exit_blocked(app_handle); + } } else if !game_chat_release { handle_game_creator_gui_run_event(&event); } 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 975968661..69c7da1c9 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 @@ -931,11 +931,11 @@ pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> { pub(super) fn shutdown_external_agent_runner_for_client_exit_at( config_dir: &Path, -) -> Result<(), String> { +) -> Result { let Some((endpoint_path, endpoint)) = read_external_agent_runner_endpoint_for_shutdown(config_dir)? else { - return Ok(()); + return Ok(true); }; let request_id = random_identifier(b"genarrative-agent-runner-client-exit-request-id")?; let result = match send_external_agent_runner_request_with_protocol_and_id( @@ -949,7 +949,7 @@ pub(super) fn shutdown_external_agent_runner_for_client_exit_at( Err(error) => { return match read_external_agent_runner_endpoint(&endpoint_path) { Ok(current) if current.boot_id == endpoint.boot_id => Err(error), - _ => Ok(()), + _ => Ok(true), }; } }; @@ -957,25 +957,32 @@ pub(super) fn shutdown_external_agent_runner_for_client_exit_at( .get("accepted") .and_then(Value::as_bool) .ok_or_else(|| "Agent Runner shutdown_for_client_exit 响应缺少 accepted".to_string())?; + let busy = result + .get("busy") + .and_then(Value::as_bool) + .ok_or_else(|| "Agent Runner shutdown_for_client_exit 响应缺少 busy".to_string())?; let will_shutdown = result .get("willShutdown") .and_then(Value::as_bool) .ok_or_else(|| "Agent Runner shutdown_for_client_exit 响应缺少 willShutdown".to_string())?; - if !accepted || !will_shutdown { - return Err("Agent Runner 拒绝按客户端退出协议关闭".to_string()); + match (accepted, busy, will_shutdown) { + (false, true, false) => return Ok(false), + (true, false, true) => {} + _ => return Err("Agent Runner shutdown_for_client_exit 响应状态不一致".to_string()), } wait_for_external_agent_runner_boot_exit( &endpoint_path, &endpoint, AGENT_RUNNER_CLIENT_EXIT_TIMEOUT, "Agent Runner 未在客户端退出期限内停止", - ) + )?; + Ok(true) } -pub(crate) fn shutdown_external_agent_runner_for_client_exit() -> Result<(), String> { +pub(crate) fn shutdown_external_agent_runner_for_client_exit() -> Result { let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); let Some(config_dir) = external_agent_runner_config_dir() else { - return Ok(()); + return Ok(true); }; shutdown_external_agent_runner_for_client_exit_at(&config_dir) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index f0be74db5..5bc8667ef 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -618,12 +618,53 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( ) } "runner.shutdown_for_client_exit" if cfg!(any(test, feature = "game-chat-release")) => { - state.draining.store(true, Ordering::Release); - state.shutdown_requested.store(true, Ordering::Release); - ExternalAgentRunnerResponse::success( - &request.request_id, - json!({ "accepted": true, "willShutdown": true }), - ) + if state.shutdown_requested.load(Ordering::Acquire) { + ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": true, "busy": false, "willShutdown": true }), + ) + } else if state + .draining + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + ExternalAgentRunnerResponse::failure( + &request.request_id, + "runner-draining", + "Agent Runner 已在排空", + ) + } else if state.active_connections.load(Ordering::Acquire) > 1 { + state.draining.store(false, Ordering::Release); + ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": false, "busy": true, "willShutdown": false }), + ) + } else { + match external_agent_runner_known_roots_are_idle(state) { + Ok(false) => { + state.draining.store(false, Ordering::Release); + ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": false, "busy": true, "willShutdown": false }), + ) + } + Ok(true) => { + state.shutdown_requested.store(true, Ordering::Release); + ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": true, "busy": false, "willShutdown": true }), + ) + } + Err(error) => { + state.draining.store(false, Ordering::Release); + ExternalAgentRunnerResponse::failure( + &request.request_id, + "runtime-state-unreadable", + redact_runner_secret(&error, &token), + ) + } + } + } } "runner.shutdown_if_idle" | "shutdown_if_idle" => { if request.params.root.is_some() { 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 be0931424..98edcf5f0 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 @@ -150,6 +150,91 @@ fn legacy_runner_force_migration_requires_authenticated_exact_ping_identity() { server.join().expect("join mismatched identity fixture"); } +#[test] +fn client_exit_client_returns_busy_without_waiting_and_accepts_idle_shutdown() { + let directory = unique_test_directory(); + let config_dir = private_runner_test_config_dir(&directory); + let endpoint_path = external_agent_runner_endpoint_path(&config_dir); + let token = "client-exit-response-token-client-exit-response-token"; + + let busy_listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) + .expect("bind busy client-exit fixture"); + let busy_endpoint = test_endpoint( + token, + "client-exit-busy-response-boot", + busy_listener + .local_addr() + .expect("busy fixture address") + .port(), + ); + write_external_agent_runner_endpoint_atomic(&endpoint_path, &busy_endpoint) + .expect("write busy client-exit endpoint"); + let busy_server = std::thread::spawn(move || { + let (mut stream, _) = busy_listener.accept().expect("accept busy client exit"); + let payload = read_external_agent_runner_frame(&mut stream).expect("read busy client exit"); + let request = serde_json::from_slice::(&payload) + .expect("parse busy client exit"); + assert_eq!(request.method, "runner.shutdown_for_client_exit"); + let response = ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": false, "busy": true, "willShutdown": false }), + ); + write_external_agent_runner_frame( + &mut stream, + &serde_json::to_vec(&response).expect("serialize busy client-exit response"), + ) + .expect("write busy client-exit response"); + }); + + let started = Instant::now(); + assert!( + !shutdown_external_agent_runner_for_client_exit_at(&config_dir) + .expect("busy client exit remains a successful refusal") + ); + assert!( + started.elapsed() < Duration::from_secs(2), + "busy client exit must not wait for Runner boot shutdown" + ); + busy_server.join().expect("join busy client-exit fixture"); + + let idle_listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) + .expect("bind idle client-exit fixture"); + let idle_endpoint = test_endpoint( + token, + "client-exit-idle-response-boot", + idle_listener + .local_addr() + .expect("idle fixture address") + .port(), + ); + write_external_agent_runner_endpoint_atomic(&endpoint_path, &idle_endpoint) + .expect("write idle client-exit endpoint"); + let idle_endpoint_path = endpoint_path.clone(); + let idle_server = std::thread::spawn(move || { + let (mut stream, _) = idle_listener.accept().expect("accept idle client exit"); + let payload = read_external_agent_runner_frame(&mut stream).expect("read idle client exit"); + let request = serde_json::from_slice::(&payload) + .expect("parse idle client exit"); + assert_eq!(request.method, "runner.shutdown_for_client_exit"); + let response = ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": true, "busy": false, "willShutdown": true }), + ); + write_external_agent_runner_frame( + &mut stream, + &serde_json::to_vec(&response).expect("serialize idle client-exit response"), + ) + .expect("write idle client-exit response"); + fs::remove_file(idle_endpoint_path).expect("remove idle endpoint after shutdown response"); + }); + + assert!( + shutdown_external_agent_runner_for_client_exit_at(&config_dir) + .expect("idle client exit must complete Runner shutdown") + ); + idle_server.join().expect("join idle client-exit fixture"); +} + #[test] fn endpoint_shape_accepts_legacy_missing_fingerprint_but_rejects_malformed_values() { let endpoint = test_endpoint( @@ -1248,7 +1333,7 @@ fn forced_shutdown_is_accepted_even_when_runtime_is_busy() { } #[test] -fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() { +fn shutdown_for_client_exit_rejects_busy_then_closes_idle_runner_idempotently() { let directory = unique_test_directory(); let root = directory.0.join("project"); let pending = root.join(".agent/runtime/pending-actions/code-prototype/run-client-exit.json"); @@ -1309,10 +1394,50 @@ fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() { assert!(!state.shutdown_requested.load(Ordering::Acquire)); assert!(!state.draining.load(Ordering::Acquire)); + let busy_response = handle_external_agent_runner_request( + ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "shutdown-client-exit-busy-1".to_string(), + token: token.to_string(), + method: "runner.shutdown_for_client_exit".to_string(), + params: ExternalAgentRunnerRequestParams::default(), + }, + &state, + ); + assert!(busy_response.ok); + assert_eq!( + busy_response + .result + .as_ref() + .and_then(|value| value["accepted"].as_bool()), + Some(false) + ); + assert_eq!( + busy_response + .result + .as_ref() + .and_then(|value| value["busy"].as_bool()), + Some(true) + ); + assert_eq!( + busy_response + .result + .as_ref() + .and_then(|value| value["willShutdown"].as_bool()), + Some(false) + ); + assert!(!state.shutdown_requested.load(Ordering::Acquire)); + assert!(!state.draining.load(Ordering::Acquire)); + assert_eq!( + fs::read(&pending).expect("read pending action"), + durable_bytes + ); + + fs::remove_file(&pending).expect("clear pending action before idle client exit"); let shutdown_response = handle_external_agent_runner_request( ExternalAgentRunnerRequest { protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, - request_id: "shutdown-client-exit-force-1".to_string(), + request_id: "shutdown-client-exit-idle-1".to_string(), token: token.to_string(), method: "runner.shutdown_for_client_exit".to_string(), params: ExternalAgentRunnerRequestParams::default(), @@ -1327,6 +1452,13 @@ fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() { .and_then(|value| value["accepted"].as_bool()), Some(true) ); + assert_eq!( + shutdown_response + .result + .as_ref() + .and_then(|value| value["busy"].as_bool()), + Some(false) + ); assert_eq!( shutdown_response .result @@ -1336,35 +1468,6 @@ fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() { ); assert!(state.shutdown_requested.load(Ordering::Acquire)); assert!(state.draining.load(Ordering::Acquire)); - assert_eq!( - fs::read(&pending).expect("read pending action"), - durable_bytes - ); - - let write_response = handle_external_agent_runner_request( - ExternalAgentRunnerRequest { - protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, - request_id: "shutdown-client-exit-write-after-drain".to_string(), - token: token.to_string(), - method: "runtime.continue_action".to_string(), - params: ExternalAgentRunnerRequestParams { - root: Some(root.to_string_lossy().into_owned()), - agent: Some("code-prototype".to_string()), - run_id: Some("run-client-exit".to_string()), - action_id: Some("action-client-exit".to_string()), - ..ExternalAgentRunnerRequestParams::default() - }, - }, - &state, - ); - assert!(!write_response.ok); - assert_eq!( - write_response - .error - .as_ref() - .map(|error| error.code.as_str()), - Some("runner-draining") - ); let repeated_response = handle_external_agent_runner_request( ExternalAgentRunnerRequest { @@ -1384,6 +1487,13 @@ fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() { .and_then(|value| value["accepted"].as_bool()), Some(true) ); + assert_eq!( + repeated_response + .result + .as_ref() + .and_then(|value| value["busy"].as_bool()), + Some(false) + ); assert_eq!( repeated_response .result @@ -1391,10 +1501,7 @@ fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() { .and_then(|value| value["willShutdown"].as_bool()), Some(true) ); - assert_eq!( - fs::read(&pending).expect("reread pending action"), - durable_bytes - ); + assert!(!pending.exists()); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs index fbdd800e6..bcf3f77b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs @@ -356,6 +356,7 @@ fn steer_and_wait_for_swarm_turn( steer_id.clone(), message.to_string(), Some(run_profile.to_string()), + None, )?; writeln!( output, diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs index 6d631df35..99e24580b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs @@ -355,6 +355,7 @@ pub(super) fn wait_for_swarm_turn( steer_id.clone(), message, Some(run_profile.to_string()), + None, )?; writeln!( output, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index d04162240..2ec2dcfc3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -281,7 +281,7 @@ fn project_supervisor_llm_config_prefers_specific_patch_and_falls_back_to_legacy assert_eq!(fallback.api_key, "legacy-chat-key"); assert_eq!(fallback.base_url, "https://legacy-chat.example.test/v1"); assert_eq!(fallback.model, "legacy-chat-model"); - assert_eq!(fallback.reasoning_effort, "medium"); + assert_eq!(fallback.reasoning_effort, "high"); assert!(fallback.stream); config.agent_llm.insert( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs index d4448c8d7..f9b5ad9d3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs @@ -1428,6 +1428,19 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio "总控首轮只读逃逸修复测试", ) .expect("project init"); + write_supervisor_collaboration_policy_at( + &root, + SupervisorCollaborationPolicy { + required_initial_wave: SupervisorInitialCollaborationWave::Static, + min_static_delegates: 2, + required_static_agent_ids: vec![ + "code-prototype".to_string(), + "quality-review".to_string(), + ], + ..SupervisorCollaborationPolicy::default() + }, + ) + .expect("write explicit collaboration repair policy"); let (sender, receiver) = mpsc::channel(); let delegate_function = native_runtime_function_name("agent.delegate").expect("delegate function"); @@ -1614,7 +1627,8 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio } #[test] -fn supervisor_autonomous_game_build_without_project_policy_requires_builder_and_reviewer() { +fn supervisor_autonomous_game_build_without_project_policy_uses_manifest_as_the_only_initial_wave() +{ let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -1657,13 +1671,10 @@ fn supervisor_autonomous_game_build_without_project_policy_requires_builder_and_ assert_eq!(resolution.project_policy_status, "absent"); assert_eq!( resolution.policy.required_initial_wave, - SupervisorInitialCollaborationWave::Static - ); - assert_eq!(resolution.policy.min_static_delegates, 2); - assert_eq!( - resolution.policy.required_static_agent_ids, - vec!["code-prototype".to_string(), "quality-review".to_string()] + SupervisorInitialCollaborationWave::Auto ); + assert_eq!(resolution.policy.min_static_delegates, 0); + assert!(resolution.policy.required_static_agent_ids.is_empty()); assert!(!root .join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH) .exists()); @@ -1673,8 +1684,7 @@ fn supervisor_autonomous_game_build_without_project_policy_requires_builder_and_ } #[test] -fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_in_dependency_order() -{ +fn supervisor_autonomous_game_build_with_editor_api_key_keeps_visual_agents_in_manifest_order() { let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -1717,18 +1727,10 @@ fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_i assert_eq!(resolution.project_policy_status, "absent"); assert_eq!( resolution.policy.required_initial_wave, - SupervisorInitialCollaborationWave::Static - ); - assert_eq!(resolution.policy.min_static_delegates, 3); - assert_eq!( - resolution - .policy - .required_static_agent_ids - .iter() - .map(String::as_str) - .collect::>(), - BTreeSet::from(["code-prototype", "quality-review", "art-director"]) + SupervisorInitialCollaborationWave::Auto ); + assert_eq!(resolution.policy.min_static_delegates, 0); + assert!(resolution.policy.required_static_agent_ids.is_empty()); assert!(!root .join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH) .exists()); @@ -1750,16 +1752,11 @@ fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_i design_run_id, ) .expect("resolve autonomous collaboration policy after art spec delivery"); - assert_eq!(design_resolution.policy.min_static_delegates, 3); - assert_eq!( - design_resolution - .policy - .required_static_agent_ids - .iter() - .map(String::as_str) - .collect::>(), - BTreeSet::from(["code-prototype", "quality-review", "design-foundation"]) - ); + assert_eq!(design_resolution.policy.min_static_delegates, 0); + assert!(design_resolution + .policy + .required_static_agent_ids + .is_empty()); register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); let art_run_id = "supervisor-autonomous-art-after-ui-run"; @@ -1778,16 +1775,8 @@ fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_i art_run_id, ) .expect("resolve autonomous collaboration policy after UI delivery"); - assert_eq!(art_resolution.policy.min_static_delegates, 3); - assert_eq!( - art_resolution - .policy - .required_static_agent_ids - .iter() - .map(String::as_str) - .collect::>(), - BTreeSet::from(["code-prototype", "quality-review", "art-asset-plan"]) - ); + assert_eq!(art_resolution.policy.min_static_delegates, 0); + assert!(art_resolution.policy.required_static_agent_ids.is_empty()); register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); let complete_run_id = "supervisor-autonomous-after-all-visual-assets-run"; @@ -1806,18 +1795,18 @@ fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_i complete_run_id, ) .expect("resolve autonomous collaboration policy after all visual deliveries"); - assert_eq!(complete_resolution.policy.min_static_delegates, 2); - assert_eq!( - complete_resolution.policy.required_static_agent_ids, - vec!["code-prototype".to_string(), "quality-review".to_string()] - ); + assert_eq!(complete_resolution.policy.min_static_delegates, 0); + assert!(complete_resolution + .policy + .required_static_agent_ids + .is_empty()); fs::remove_dir_all(root).ok(); fs::remove_dir_all(config_dir).ok(); } #[test] -fn supervisor_autonomous_game_build_augments_existing_project_policy_with_required_art_director() { +fn supervisor_autonomous_game_build_preserves_explicit_project_policy_without_hidden_agents() { let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -1839,8 +1828,16 @@ fn supervisor_autonomous_game_build_augments_existing_project_policy_with_requir "自主构建已有策略美术协作测试", ) .expect("project init"); - write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) - .expect("write default project collaboration policy"); + write_supervisor_collaboration_policy_at( + &root, + SupervisorCollaborationPolicy { + required_initial_wave: SupervisorInitialCollaborationWave::Static, + min_static_delegates: 1, + required_static_agent_ids: vec!["code-prototype".to_string()], + ..SupervisorCollaborationPolicy::default() + }, + ) + .expect("write explicit project collaboration policy"); let run_id = "supervisor-autonomous-existing-policy-art-run"; bind_game_creator_agent_runtime_run_profile_at( &root, @@ -1857,18 +1854,17 @@ fn supervisor_autonomous_game_build_augments_existing_project_policy_with_requir GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, ) - .expect("resolve augmented project collaboration policy"); + .expect("resolve explicit project collaboration policy"); assert_eq!(resolution.source, "project-policy-unbound"); assert_eq!(resolution.project_policy_status, "current"); + assert_eq!( + resolution.policy.required_initial_wave, + SupervisorInitialCollaborationWave::Static + ); assert_eq!(resolution.policy.min_static_delegates, 1); assert_eq!( - resolution - .policy - .required_static_agent_ids - .iter() - .map(String::as_str) - .collect::>(), - BTreeSet::from(["art-director"]) + resolution.policy.required_static_agent_ids, + vec!["code-prototype".to_string()] ); fs::remove_dir_all(root).ok(); @@ -1876,8 +1872,7 @@ fn supervisor_autonomous_game_build_augments_existing_project_policy_with_requir } #[test] -fn supervisor_autonomous_game_build_with_editor_api_key_and_all_visual_assets_skips_visual_delegate( -) { +fn supervisor_autonomous_game_build_visual_asset_state_does_not_add_hidden_delegates() { let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -1923,13 +1918,10 @@ fn supervisor_autonomous_game_build_with_editor_api_key_and_all_visual_assets_sk assert_eq!(resolution.project_policy_status, "absent"); assert_eq!( resolution.policy.required_initial_wave, - SupervisorInitialCollaborationWave::Static - ); - assert_eq!(resolution.policy.min_static_delegates, 2); - assert_eq!( - resolution.policy.required_static_agent_ids, - vec!["code-prototype".to_string(), "quality-review".to_string()] + SupervisorInitialCollaborationWave::Auto ); + assert_eq!(resolution.policy.min_static_delegates, 0); + assert!(resolution.policy.required_static_agent_ids.is_empty()); assert!(!root .join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH) .exists()); @@ -1952,19 +1944,22 @@ fn supervisor_autonomous_game_build_with_editor_api_key_and_all_visual_assets_sk corrupt_run_id, ) .expect("resolve autonomous collaboration policy with corrupt art asset"); - assert_eq!(corrupt_resolution.policy.min_static_delegates, 3); + assert_eq!( + corrupt_resolution.policy.required_initial_wave, + SupervisorInitialCollaborationWave::Auto + ); + assert_eq!(corrupt_resolution.policy.min_static_delegates, 0); assert!(corrupt_resolution .policy .required_static_agent_ids - .iter() - .any(|agent_id| agent_id == "art-asset-plan")); + .is_empty()); fs::remove_dir_all(root).ok(); fs::remove_dir_all(config_dir).ok(); } #[test] -fn supervisor_autonomous_legacy_visual_assets_do_not_skip_the_visual_delegate() { +fn supervisor_autonomous_legacy_visual_assets_do_not_add_hidden_delegate() { let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -2012,12 +2007,12 @@ fn supervisor_autonomous_legacy_visual_assets_do_not_skip_the_visual_delegate() run_id, ) .expect("resolve legacy visual collaboration policy"); - assert_eq!(resolution.policy.min_static_delegates, 3); - assert!(resolution - .policy - .required_static_agent_ids - .iter() - .any(|agent_id| agent_id == "art-director")); + assert_eq!( + resolution.policy.required_initial_wave, + SupervisorInitialCollaborationWave::Auto + ); + assert_eq!(resolution.policy.min_static_delegates, 0); + assert!(resolution.policy.required_static_agent_ids.is_empty()); fs::remove_dir_all(root).ok(); fs::remove_dir_all(config_dir).ok(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index a8c3ba393..d529116c8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -92,6 +92,7 @@ fn config_file_overrides_defaults_without_env() { assert_eq!(generator_llm.base_url, "https://generator.example.test/v1"); assert_eq!(generator_llm.model, "generator-model"); assert_eq!(generator_llm.api_kind, "openai_chat"); + assert_eq!(generator_llm.reasoning_effort, "high"); assert!(generator_llm.web_search_enabled); assert_eq!(config.editor_api.base_url, "http://127.0.0.1:8099"); assert_eq!(config.editor_api.api_key, "editor-key"); @@ -129,6 +130,199 @@ fn legacy_llm_config_deserialization_supplies_context_budget_defaults() { ); } +#[test] +fn canonical_agent_reasoning_effort_defaults_are_exhaustive_and_auditable() { + let expected = BTreeMap::from([ + ("project-supervisor", "high"), + ("planner", "high"), + ("orchestrator", "medium"), + ("generator", "high"), + ("evaluator", "high"), + ("design-director", "medium"), + ("design-foundation", "high"), + ("balance-director", "medium"), + ("balance-seed", "medium"), + ("art-director", "high"), + ("art-asset-plan", "high"), + ("art-polish", "medium"), + ("audio-director", "low"), + ("audio-asset-plan", "medium"), + ("code-director", "medium"), + ("code-prototype", "high"), + ("quality-review", "high"), + ("preview-readiness", "low"), + ("preview-playtest", "low"), + ("publish-strategy", "low"), + ("publish-package", "medium"), + ]); + let rust_defaults = GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS + .iter() + .copied() + .collect::>(); + assert_eq!(rust_defaults, expected); + assert_eq!( + GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS.len(), + rust_defaults.len(), + "规范 Agent 默认映射不能包含重复 ID" + ); + + let status_agent_ids = game_creator_llm_agent_status_definitions() + .into_iter() + .map(|definition| definition.agent_id) + .collect::>(); + assert_eq!( + status_agent_ids, + expected + .keys() + .map(|agent_id| (*agent_id).to_string()) + .collect::>(), + "新增规范 Agent 时必须先显式选择 reasoning effort,不能静默继承全局" + ); + for (agent_id, effort) in &expected { + assert_eq!( + game_creator_llm_agent_default_reasoning_effort(agent_id), + Some(*effort) + ); + parse_game_creator_llm_reasoning_effort(effort).expect("canonical reasoning effort"); + } + + let template = + serde_json::from_str::(DEFAULT_GAME_CREATOR_APP_CONFIG_JSON) + .expect("parse bundled runtime config template"); + assert_eq!( + template.llm.as_ref().and_then(|llm| llm.max_retries), + Some(DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES) + ); + assert!( + template.agent_llm.unwrap_or_default().is_empty(), + "bundled template must not persist canonical defaults as explicit overrides" + ); + + let ui_source = include_str!("../../../src/features/runtime-config/RuntimeConfigDialog.tsx"); + let ui_mapping = ui_source + .split("const runtimeAgentReasoningEffortDefaults = {") + .nth(1) + .and_then(|source| source.split("} as const satisfies").next()) + .expect("frontend Agent reasoning effort contract") + .lines() + .filter_map(|line| { + let line = line.trim().trim_end_matches(','); + let (agent_id, effort) = line.split_once(": ")?; + Some(( + agent_id.trim_matches(&['\'', '"'][..]).to_string(), + effort.trim_matches(&['\'', '"'][..]).to_string(), + )) + }) + .collect::>(); + assert_eq!( + ui_mapping, + expected + .iter() + .map(|(agent_id, effort)| ((*agent_id).to_string(), (*effort).to_string())) + .collect::>() + ); +} + +#[test] +fn canonical_reasoning_only_patch_does_not_activate_role_llm_override() { + let mut config = GameCreatorAppConfig::default(); + for (agent_id, effort) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS { + config.agent_llm.insert( + agent_id.to_string(), + GameCreatorLlmConfigFile { + reasoning_effort: Some(effort.to_string()), + ..GameCreatorLlmConfigFile::default() + }, + ); + assert!( + !has_game_creator_agent_llm_override(&config, agent_id), + "canonical reasoning-only default must not activate {agent_id} role LLM" + ); + } + + config.agent_llm.insert( + "design-director".to_string(), + GameCreatorLlmConfigFile { + reasoning_effort: Some("high".to_string()), + ..GameCreatorLlmConfigFile::default() + }, + ); + assert!(has_game_creator_agent_llm_override( + &config, + "design-director" + )); +} + +#[test] +fn empty_legacy_agent_llm_uses_agent_defaults_and_explicit_patch_wins() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("runtime config dir"); + fs::write( + root.join(GAME_CREATOR_CONFIG_FILE_NAME), + r#"{ + "llm": { + "apiKey": "global-key", + "baseUrl": "https://global.example.test/v1", + "model": "global-model", + "reasoningEffort": "default" + }, + "agentLlm": {} +} +"#, + ) + .expect("write legacy empty Agent config"); + let _guard = use_test_runtime_config_dir(root.clone()); + + let config = load_game_creator_app_config().expect("load legacy empty Agent config"); + assert!(config.agent_llm.is_empty()); + for (agent_id, effort) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS { + assert_eq!( + resolve_game_creator_llm_config_for_agent(&config, agent_id).reasoning_effort, + effort + ); + } + assert_eq!( + resolve_game_creator_llm_config_for_agent(&config, "non-canonical-agent").reasoning_effort, + "default" + ); + + let status = check_game_creator_llm_config_from_config(); + assert!(status.configured, "{:?}", status.error); + for (agent_id, effort) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS { + let agent = status + .agents + .iter() + .find(|agent| agent.agent_id == agent_id) + .expect("canonical Agent status"); + assert_eq!(agent.reasoning_effort, effort, "{agent_id}"); + } + + let mut overridden = config; + overridden.agent_llm.insert( + GAME_CREATOR_LEGACY_CHAT_AGENT_CONFIG_ID.to_string(), + GameCreatorLlmConfigFile { + reasoning_effort: Some("medium".to_string()), + ..GameCreatorLlmConfigFile::default() + }, + ); + for (agent_id, _) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS { + overridden.agent_llm.insert( + agent_id.to_string(), + GameCreatorLlmConfigFile { + reasoning_effort: Some("default".to_string()), + ..GameCreatorLlmConfigFile::default() + }, + ); + assert_eq!( + resolve_game_creator_llm_config_for_agent(&overridden, agent_id).reasoning_effort, + "default", + "显式 agentLlm.{agent_id} patch 必须覆盖规范默认值" + ); + } + + fs::remove_dir_all(root).ok(); +} + #[test] fn runtime_config_dir_supplies_app_config_file() { let root = unique_project_path(); @@ -576,6 +770,19 @@ fn llm_config_check_reports_per_agent_status_without_leaking_keys() { assert!(art.configured); assert_eq!(art.label, "美术组 / Asset"); assert_eq!(art.model.as_deref(), Some("art-model")); + assert_eq!(art.reasoning_effort, "high"); + let orchestrator = status + .agents + .iter() + .find(|agent| agent.agent_id == "orchestrator") + .expect("orchestrator status"); + assert_eq!(orchestrator.reasoning_effort, "medium"); + let preview = status + .agents + .iter() + .find(|agent| agent.agent_id == "preview-readiness") + .expect("preview status"); + assert_eq!(preview.reasoning_effort, "low"); let serialized = serde_json::to_string(&status).expect("status json"); assert!(!serialized.contains("planner-secret-key")); assert!(!serialized.contains("generator-secret-key")); @@ -639,6 +846,9 @@ fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() { context_window_tokens: 128_000, auto_compact_token_limit: 64_000, tool_output_token_limit: 12_000, + request_timeout_ms: 180_000, + max_retries: 2, + retry_backoff_ms: 500, error: Some("Generator:缺少 API Key".to_string()), agents: vec![GameCreatorAgentLlmConfigStatus { agent_id: "generator".to_string(), @@ -654,6 +864,9 @@ fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() { context_window_tokens: 96_000, auto_compact_token_limit: 48_000, tool_output_token_limit: 8_000, + request_timeout_ms: 90_000, + max_retries: 1, + retry_backoff_ms: 250, error: Some("LLM 未配置:请在 agentLlm.generator.apiKey 中设置 API Key".to_string()), }], }; @@ -666,6 +879,8 @@ fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() { assert!(lines.contains("llm.agent.generator.webSearchEnabled=false")); assert!(lines.contains("llm.reasoningEffort=high")); assert!(lines.contains("llm.agent.generator.reasoningEffort=medium")); + assert!(lines.contains("llm.maxRetries=2")); + assert!(lines.contains("llm.agent.generator.maxRetries=1")); assert!(lines.contains("llm.error=Generator:缺少 API Key")); assert!(!lines.contains("sk-")); assert!(!lines.contains("secret")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs index 51c511cde..cd234fa8e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs @@ -1017,7 +1017,7 @@ async fn background_agent_runtime_can_search_patch_and_read_in_sequence() { assert!(plan_request.contains("startLine")); assert!(plan_request.contains("expectedReplacements")); assert!(plan_request.contains("\"max_output_tokens\":4000")); - assert!(plan_request.contains("\"reasoning\":{\"effort\":\"high\"}")); + assert!(plan_request.contains("\"reasoning\":{\"effort\":\"medium\"}")); let verification_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("verification llm request"); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index d9c743258..f24759d9e 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -108,12 +108,14 @@ import { isRuntimeConfigMissingError, matchingAgentRuntimeForSteer, mergeAgentRuntimeStateIntoMap, + mergeGameChatRuntimeResponseMessagesIntoHistory, mergeProjectSupervisorConversation, mergeProjectSupervisorResponseStream, normalizeAgentRuntimeState, projectNameFromPath, projectProfessionalAgentLabel, projectSupervisorPendingRepairMatchesProfessional, + projectSupervisorResponseStreamIdentity, readProjectSupervisorActiveSessionId, sameAgentRuntimeRun, submitProjectSupervisorRuntimeTask, @@ -573,6 +575,7 @@ export function App({ const gameChatAutoPreviewAttemptedRef = useRef(new Set()); const gameChatObservedRunKeysRef = useRef(new Set()); const gameChatArchivedRunKeysRef = useRef(new Set()); + const gameChatCommittedResponseStreamKeysRef = useRef(new Set()); const initialSupervisorMessageLatchRef = useRef({ projectPath: initialProjectPath, prompt: initialSupervisorMessage.trim(), @@ -822,7 +825,41 @@ export function App({ runtime, ); const candidateStream = nextStream; - if ( + if (candidateStream?.status === 'ready' && gameChatOnly) { + const text = candidateStream.accumulatedText.trim(); + const responseKey = projectSupervisorResponseStreamIdentity( + candidateStream, + ); + const messageId = `runtime-response:${responseKey}`; + const alreadyCommitted = + gameChatCommittedResponseStreamKeysRef.current.has(responseKey) || + latestMessagesRef.current.some( + (message) => message.messageId === messageId, + ); + if (text && !alreadyCommitted) { + gameChatCommittedResponseStreamKeysRef.current.add(responseKey); + const nextMessage: ChatMessage = { + role: 'assistant', + text: candidateStream.accumulatedText, + messageId, + agentId: PROJECT_SUPERVISOR_AGENT_ID, + updatedAt: candidateStream.updatedAt, + runtimeOwned: true, + }; + setMessages((current) => { + if (current.some((message) => message.messageId === messageId)) { + latestMessagesRef.current = current; + return current; + } + const nextMessages = [...current, nextMessage]; + latestMessagesRef.current = nextMessages; + return nextMessages; + }); + } + // Ready text is now a normal chat message; transientReply must not show + // the same response a second time while polling/event delivery catches up. + nextStream = null; + } else if ( candidateStream && latestMessagesRef.current.some( (message) => @@ -833,6 +870,7 @@ export function App({ message.updatedAt >= candidateStream.startedAt, ) ) { + // Keep the existing supervisor-chat behavior for restored history. nextStream = null; } projectSupervisorResponseStreamRef.current = nextStream; @@ -846,6 +884,7 @@ export function App({ projectSupervisorRuntimeRef.current = null; projectSupervisorExpectedRunIdRef.current = null; projectSupervisorResponseStreamRef.current = null; + gameChatCommittedResponseStreamKeysRef.current.clear(); projectSupervisorRuntimeSyncingRef.current.clear(); setProjectSupervisorSessionId(null); setProjectSupervisorRuntime(null); @@ -2445,6 +2484,7 @@ export function App({ ); const transientResponse = projectSupervisorResponseStreamRef.current; if ( + !gameChatOnly && conversationContainsProjectSupervisorResponseStream( supervisorConversation.messages, transientResponse, @@ -2453,11 +2493,23 @@ export function App({ projectSupervisorResponseStreamRef.current = null; setProjectSupervisorResponseStream(null); } - savedConversationProjectPathRef.current = nextProjectPath; - savedConversationCountRef.current = conversationMessages.length; - latestMessagesRef.current = conversationMessages; setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); - setMessages(conversationMessages); + setMessages((current) => { + // React may apply this hydration update after a ready stream callback + // that was queued in the same turn. Read `current` inside the updater + // so a committed game-chat response cannot be overwritten by stale + // `latestMessagesRef` state captured before that callback ran. + const nextMessages = gameChatOnly + ? mergeGameChatRuntimeResponseMessagesIntoHistory( + conversationMessages, + current, + ) + : conversationMessages; + savedConversationProjectPathRef.current = nextProjectPath; + savedConversationCountRef.current = nextMessages.length; + latestMessagesRef.current = nextMessages; + return nextMessages; + }); const terminalRuntime = projectSupervisorRuntimeRef.current; if ( runId && @@ -2557,6 +2609,7 @@ export function App({ supervisorConversation?.messages ?? [], ); if ( + !gameChatOnly && conversationContainsProjectSupervisorResponseStream( supervisorConversation?.messages ?? [], runtimeResponseStream, @@ -2575,6 +2628,12 @@ export function App({ } setProjectSupervisorRuntimeError(runtimeError || resumeError); setMessages((current) => { + const nextConversationMessages = gameChatOnly + ? mergeGameChatRuntimeResponseMessagesIntoHistory( + conversationMessages, + current, + ) + : conversationMessages; const hasOnlyDefaultGreeting = current.length === 1 && current[0]?.role === 'assistant' && @@ -2594,8 +2653,8 @@ export function App({ } setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); savedConversationProjectPathRef.current = nextProjectPath; - savedConversationCountRef.current = conversationMessages.length; - latestMessagesRef.current = conversationMessages; + savedConversationCountRef.current = nextConversationMessages.length; + latestMessagesRef.current = nextConversationMessages; setWorkspaceStatus((workspaceStatus) => { if (mode === 'replace') { return `已读取项目对话历史:${conversationMessages.length} 条`; @@ -2604,7 +2663,7 @@ export function App({ ? `已打开:${nextProjectPath}` : workspaceStatus; }); - return conversationMessages; + return nextConversationMessages; }); } catch (error) { if ( @@ -5366,6 +5425,9 @@ export function App({ prompt, runtime: runtimeAtSubmission, runProfile: submissionRunProfile, + ...(gameChatOnly + ? { source: 'project-supervisor-game-chat' } + : {}), }); const runtimeResult = submission.runtimeResult; const acceptedRunId = submission.acceptedRunId.trim(); 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 09b2219f3..b0ef9627a 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 @@ -477,6 +477,22 @@ export function sameProjectSupervisorResponseStream( ); } +/** + * Durable identity for one final-reply response. The response text is + * intentionally excluded so two turns with identical wording remain + * distinct messages in game-chat. + */ +export function projectSupervisorResponseStreamIdentity( + stream: Pick< + AgentRuntimeResponseStream, + 'runId' | 'requestSlot' | 'responseRevision' + >, +) { + return [stream.runId, stream.requestSlot, stream.responseRevision].join( + '\u001f', + ); +} + export function mergeProjectSupervisorResponseStream( current: AgentRuntimeResponseStream | null, incoming: AgentRuntimeResponseStream | null | undefined, @@ -523,6 +539,44 @@ export function conversationContainsProjectSupervisorResponseStream( ); } +/** + * Keep final responses that were committed into the game-chat window while + * conversation history is being hydrated. Runtime responses have a durable + * local id; matching by text or timestamp would collapse two different runs + * that happen to produce the same wording. + */ +export function mergeGameChatRuntimeResponseMessagesIntoHistory( + historyMessages: ChatMessage[], + currentMessages: ChatMessage[], +) { + const historyMessageIds = new Set( + historyMessages + .map((message) => message.messageId?.trim()) + .filter((messageId): messageId is string => Boolean(messageId)), + ); + const addedMessageIds = new Set(); + const responsesToKeep = currentMessages.filter((message) => { + const messageId = message.messageId?.trim(); + if ( + !message.runtimeOwned || + message.role !== 'assistant' || + !messageId?.startsWith('runtime-response:') || + historyMessageIds.has(messageId) || + addedMessageIds.has(messageId) + ) { + return false; + } + addedMessageIds.add(messageId); + return true; + }); + if (responsesToKeep.length === 0) { + return historyMessages; + } + return [...historyMessages, ...responsesToKeep].sort( + (left, right) => (left.updatedAt ?? 0) - (right.updatedAt ?? 0), + ); +} + export function agentRuntimeWaitingOnFromPhase(phase: string) { switch (phase) { case 'planning': @@ -765,6 +819,7 @@ export async function submitProjectSupervisorRuntimeTask({ prompt, runtime, runProfile, + source, }: { invoke: TauriInvoke; projectPath: string; @@ -772,6 +827,7 @@ export async function submitProjectSupervisorRuntimeTask({ prompt: string; runtime: AgentRuntimeState | null; runProfile: 'standard' | 'autonomous-game-build'; + source?: string; }) { const steerRuntime = matchingAgentRuntimeForSteer( [runtime], @@ -790,6 +846,7 @@ export async function submitProjectSupervisorRuntimeTask({ steerId: createAgentChatRunId('project-supervisor-steer'), instruction: prompt, runProfile, + ...(source ? { source } : {}), }, ); return { @@ -807,6 +864,7 @@ export async function submitProjectSupervisorRuntimeTask({ task: prompt, runId: requestedRunId, runProfile, + ...(source ? { source } : {}), }, ); return { 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 2603b72de..3079ffa4d 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 @@ -26,6 +26,30 @@ import { type RuntimeMcpStructuredDraft, } from '../../app/types'; +const runtimeAgentReasoningEffortDefaults = { + 'project-supervisor': 'high', + planner: 'high', + orchestrator: 'medium', + generator: 'high', + evaluator: 'high', + 'design-director': 'medium', + 'design-foundation': 'high', + 'balance-director': 'medium', + 'balance-seed': 'medium', + 'art-director': 'high', + 'art-asset-plan': 'high', + 'art-polish': 'medium', + 'audio-director': 'low', + 'audio-asset-plan': 'medium', + 'code-director': 'medium', + 'code-prototype': 'high', + 'quality-review': 'high', + 'preview-readiness': 'low', + 'preview-playtest': 'low', + 'publish-strategy': 'low', + 'publish-package': 'medium', +} as const satisfies Record; + const defaultRuntimeConfigDraft: GameCreatorAppConfig = { llm: { apiKey: '', @@ -1068,6 +1092,10 @@ export function RuntimeConfigDialog({ {runtimeAgentLlmRows.map((agent) => { const agentLlm = runtimeConfigDraft.agentLlm?.[agent.id] ?? {}; + const defaultReasoningEffort = + runtimeAgentReasoningEffortDefaults[ + agent.id as keyof typeof runtimeAgentReasoningEffortDefaults + ]; return (