diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index 2149ad925..cbaaae623 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -283,7 +283,7 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ &action_fingerprint, pending_action, false, - || observe_agent_runtime_task_list(root), + || observe_agent_runtime_task_list(root, agent_id, run_id), ), "task.create" => observe_agent_runtime_task_create(root, agent_id, &action.input), "task.update" => observe_agent_runtime_task_update(root, agent_id, &action.input), @@ -414,7 +414,9 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ action_id, &action.input, ), - "agent.schedule_ready" => observe_agent_runtime_schedule_ready_tasks(root, &action.input), + "agent.schedule_ready" => { + observe_agent_runtime_schedule_ready_tasks(root, agent_id, run_id, &action.input) + } "agent.action_history" => observe_agent_runtime_project_snapshot_with_lock( root, agent_id, 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 02afd4148..a62eee9f5 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 @@ -584,7 +584,9 @@ pub(in crate::agent) fn autonomous_manifest_dag_in_progress_at( }) }) }) - .unwrap_or_else(|| AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE.to_string()); + .ok_or_else(|| { + "无法解析当前自主构建根 Run 的可信 source,拒绝按 GUI 完整 DAG 回退".to_string() + })?; let seed_task_ids = autonomous_manifest_seed_tasks_for_source(&source) .into_iter() .map(|task| task.id) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs index 19de323b5..a85194f36 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs @@ -115,7 +115,7 @@ pub(in crate::agent) fn execute_game_creator_agent_runtime_parallel_safe_read_at "git.inspect" => observe_agent_runtime_git_inspect(root, &action.input), "file.list" => observe_agent_runtime_file_list(root, &action.input), "file.read" => observe_agent_runtime_file(root, &action.input), - "task.list" => observe_agent_runtime_task_list(root), + "task.list" => observe_agent_runtime_task_list(root, agent_id, run_id), _ => AgentRuntimeToolObservation { tool: tool.to_string(), status: "rejected".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index 18fc74e29..804deadb6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -655,6 +655,20 @@ pub(in crate::agent) fn supervisor_collaboration_policy_completion_blocker_at_lo if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { return None; } + if read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id) + .ok() + .flatten() + .is_some_and(|binding| { + binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + && binding.root_agent_id == binding.agent_id + && binding.root_run_id == binding.run_id + }) + { + // game-chat 首版由 source-aware manifest scheduler 固定编排 + // code -> static smoke -> playtest,不再要求 Provider 建立额外委派波。 + return None; + } let policy = match resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id) { Ok(resolution) => resolution.policy, Err(error) => { 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 0084481cb..383a7c415 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 @@ -2,6 +2,43 @@ use super::*; const AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL: &str = "通用完成阻断规则:如果最新 observation 的 tool 为 runtime.autonomous_completion 且 status 为 blocked,本轮禁止直接调用 respond_to_user,也禁止在 legacy response 中填写最终回复;必须先读取该 observation.detail 的 nextRequiredAction,并据此调用合适的读取、修复和验证工具。只有完成要求的动作、取得后续可信 observation 且完成门禁不再阻断后,才能给最终回复;不得反复提交 final response,也不得按项目正文硬编码某一种 blocker 的处理方式。"; +const GAME_CHAT_CODE_PROTOTYPE_FAST_PATH_PROMPT: &str = "game-chat 首版使用五分钟快车道。当前任务的第一目标是在一次 Provider planning 内产出首个完整可玩版本:如果最新 observation 尚未显示 game/index.html 已由本 run 写入,本响应必须直接调用一次 file.write,把完整、自包含、可运行的 game/index.html 一次写完;禁止先调用读取、搜索、任务查询、委派、只更新计划或提交半成品。HTML 必须满足下方固定试玩合同,包含真实 Canvas 游戏循环、键盘与触控输入、开始、主要操作、重开、胜负状态和移动端布局;可以采用保守的原创玩法默认值。已登记的平台视觉规范图 ../assets/art-spec.png 是首版必需资源,必须在主要游戏画面中显著可见使用:至少把规范图实际绘制为主要背景,并从规范图中绘制玩家角色和目标实体。禁止仅放置隐藏 img、透明或屏外元素、微小水印、不可见预加载或只在源码中引用;也禁止用纯几何图形冒充平台图片使用。若规范图无法加载,游戏必须明确失败关闭,不能退回纯 Canvas 几何兜底。一次写入后不要继续扩写功能;Runtime 会在下一步自动执行静态自检并在通过后立即试玩。"; + +fn game_chat_fast_path_prompt_for_root_source( + agent_id: &str, + root_source: &str, +) -> Option<&'static str> { + (agent_id.trim() == "code-prototype" + && root_source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE) + .then_some(GAME_CHAT_CODE_PROTOTYPE_FAST_PATH_PROMPT) +} + +pub(in crate::agent) fn agent_runtime_root_source_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + let binding = read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? + .ok_or_else(|| "Agent Runtime 缺少 Run Profile 绑定,无法解析 root source".to_string())?; + if binding.root_agent_id == binding.agent_id && binding.root_run_id == binding.run_id { + return Ok(binding.source); + } + let root_binding = read_game_creator_agent_runtime_run_profile_binding( + root, + &binding.root_agent_id, + &binding.root_run_id, + )? + .ok_or_else(|| "Agent Runtime 缺少 root Run Profile 绑定".to_string())?; + if root_binding.agent_id != binding.root_agent_id + || root_binding.run_id != binding.root_run_id + || root_binding.root_agent_id != root_binding.agent_id + || root_binding.root_run_id != root_binding.run_id + { + return Err("Agent Runtime root Run Profile 绑定身份不一致".to_string()); + } + Ok(root_binding.source) +} + fn game_creator_agent_context_preload_notice(agent_id: &str) -> &'static str { if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { "下方已预加载有界仓库启动上下文、Supervisor 当前 Session、legacy 项目对话、项目记忆、黑板和资产摘要;源码正文仍只能通过已获准工具读取" @@ -142,6 +179,15 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( system_prompt.push_str(playtest_contract); system_prompt.push_str(" 只有当前 revision 通过 game.static_smoke,并由 preview.validate 对上述固定状态面和控件完成真实浏览器动作后,Runtime 才允许最终回复;不要伪造已通过 observation。"); } + if autonomous_game_build { + let root_source = agent_runtime_root_source_at(root, agent_id, run_id)?; + if let Some(fast_path_prompt) = + game_chat_fast_path_prompt_for_root_source(agent_id, &root_source) + { + system_prompt.push_str("\n\n"); + system_prompt.push_str(fast_path_prompt); + } + } let mut request = LlmRunRequest::new(vec![ LlmMessage::system(system_prompt), LlmMessage::user(prompt), @@ -349,10 +395,13 @@ pub(in crate::agent) fn build_game_creator_background_agent_context( #[cfg(test)] mod tests { use super::{ + agent_runtime_root_source_at, bind_game_creator_agent_runtime_run_profile_at, build_game_creator_agent_background_tool_plan_request, - game_creator_agent_context_preload_notice, init_local_game_project_at, - start_game_creator_agent_runtime_task_at, GameCreatorMcpCatalog, - AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, + game_chat_fast_path_prompt_for_root_source, game_creator_agent_context_preload_notice, + init_local_game_project_at, start_game_creator_agent_runtime_task_at, AgentRuntimeTaskLink, + GameCreatorMcpCatalog, AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, }; @@ -456,4 +505,87 @@ mod tests { assert!(protocol.contains("不得反复提交 final response")); assert!(protocol.contains("不得按项目正文硬编码")); } + + #[test] + fn game_chat_fast_path_prompt_forces_one_shot_playable_write_only_for_code_agent() { + let prompt = game_chat_fast_path_prompt_for_root_source( + "code-prototype", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ) + .expect("game-chat code fast path prompt"); + + assert!(prompt.contains("五分钟快车道")); + assert!(prompt.contains("一次 Provider planning")); + assert!(prompt.contains("直接调用一次 file.write")); + assert!(prompt.contains("禁止先调用读取、搜索、任务查询、委派")); + assert!(prompt.contains("../assets/art-spec.png")); + assert!(prompt.contains("平台视觉规范图")); + assert!(prompt.contains("主要背景")); + assert!(prompt.contains("玩家角色和目标实体")); + assert!(prompt.contains("禁止仅放置隐藏 img")); + assert!(prompt.contains("不能退回纯 Canvas 几何兜底")); + assert!(!prompt.contains("art-spritesheet.png")); + assert!(game_chat_fast_path_prompt_for_root_source( + "quality-review", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ) + .is_none()); + assert!(game_chat_fast_path_prompt_for_root_source( + "code-prototype", + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + ) + .is_none()); + assert!(game_chat_fast_path_prompt_for_root_source( + "code-prototype", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + ) + .is_none()); + assert!(game_chat_fast_path_prompt_for_root_source( + "code-prototype", + "agent-background-task", + ) + .is_none()); + } + + #[test] + fn root_source_resolver_uses_root_binding_for_game_chat_child() { + let temporary = tempfile::tempdir().expect("temporary project root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "root-source-project", "root source test") + .expect("project init"); + let parent = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "root-source-game-chat-run", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind game-chat root profile"); + let child_link = AgentRuntimeTaskLink { + parent_agent_id: Some(parent.agent_id.clone()), + parent_run_id: Some(parent.run_id.clone()), + delegation_id: Some("root-source-game-chat-child-delegation".to_string()), + }; + let child = bind_game_creator_agent_runtime_run_profile_at( + &root, + "code-prototype", + "root-source-game-chat-child", + "agent-ready-task-scheduler", + None, + Some(&child_link), + ) + .expect("bind game-chat child profile"); + + assert_eq!( + agent_runtime_root_source_at(&root, &parent.agent_id, &parent.run_id) + .expect("resolve root source"), + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + ); + assert_eq!( + agent_runtime_root_source_at(&root, &child.agent_id, &child.run_id) + .expect("resolve child root source"), + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + ); + } } 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 791aca0bb..bb36a99d4 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 @@ -206,10 +206,13 @@ pub(super) struct AgentRuntimeAutonomousPlaytestReceipt { mod entrypoints; mod finalization; +mod game_chat_fast_path; mod interaction; mod lifecycle_control; mod main_loop; #[cfg(test)] +mod main_loop_deadline_tests; +#[cfg(test)] mod main_loop_tests; mod pending_execution; mod pending_recovery; @@ -220,6 +223,7 @@ mod task_start; pub(in crate::agent) use entrypoints::*; pub(in crate::agent) use finalization::*; +pub(in crate::agent) use game_chat_fast_path::*; pub(in crate::agent) use interaction::*; pub(in crate::agent) use lifecycle_control::*; pub(in crate::agent) use main_loop::*; @@ -278,6 +282,7 @@ pub(crate) use provider_recovery::{ }; pub(crate) use recovery_scan::{ cleanup_game_creator_agent_runtime_completed_finalizations_at, + has_recoverable_game_creator_agent_background_tasks_at, resume_game_creator_agent_background_tasks_at, resume_game_creator_agent_pending_action_for_agent_at, wake_pending_game_creator_agent_background_tasks_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs new file mode 100644 index 000000000..8a8f51cdc --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs @@ -0,0 +1,801 @@ +//! A deterministic, dependency-free game-chat fallback. +//! +//! This module deliberately does not start the runtime or write project files. It only +//! renders a small, self-contained HTML document that the runtime can use when it needs to +//! make a first playable version available before the normal generation pass finishes. + +use super::*; + +pub(crate) const GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS: u64 = 240; +pub(crate) const GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS: u64 = 300; +pub(crate) const GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX: &str = + "game-chat-first-playable-hard-budget-exhausted"; + +const FALLBACK_THEME_MARKER: &str = "__GAME_CHAT_THEME__"; +const FALLBACK_PLATFORM_ART_MARKER: &str = "__GAME_CHAT_PLATFORM_ART__"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct GameChatFastPathBudget { + pub(crate) root_agent_id: String, + pub(crate) root_run_id: String, + pub(crate) baseline_revision: u64, + pub(crate) elapsed_seconds: u64, +} + +pub(crate) fn game_chat_fast_path_budget_at( + root: &Path, + agent_id: &str, + run_id: &str, + now: u64, +) -> Result, String> { + let binding = read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? + .ok_or_else(|| "game-chat 快车道缺少当前 Run Profile 绑定".to_string())?; + let root_binding = + if binding.root_agent_id == binding.agent_id && binding.root_run_id == binding.run_id { + binding + } else { + read_game_creator_agent_runtime_run_profile_binding( + root, + &binding.root_agent_id, + &binding.root_run_id, + )? + .ok_or_else(|| "game-chat 快车道缺少 root Run Profile 绑定".to_string())? + }; + if root_binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || root_binding.source != AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + { + return Ok(None); + } + if root_binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || root_binding.root_agent_id != root_binding.agent_id + || root_binding.root_run_id != root_binding.run_id + { + return Err("game-chat 快车道 root Run Profile 绑定身份不一致".to_string()); + } + let contract = + read_autonomous_completion_contract(root, &root_binding.agent_id, &root_binding.run_id)? + .ok_or_else(|| "game-chat 快车道缺少自主构建完成合同".to_string())?; + if contract.run_profile_binding_fingerprint != root_binding.binding_fingerprint { + return Err("game-chat 快车道完成合同与 root binding 不匹配".to_string()); + } + Ok(Some(GameChatFastPathBudget { + root_agent_id: root_binding.agent_id, + root_run_id: root_binding.run_id, + baseline_revision: contract.baseline_revision, + elapsed_seconds: now.saturating_sub(root_binding.bound_at), + })) +} + +pub(crate) fn game_chat_fast_path_provider_timeout( + budget: &GameChatFastPathBudget, +) -> Option { + GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS + .checked_sub(budget.elapsed_seconds) + .filter(|remaining| *remaining > 0) + .map(std::time::Duration::from_secs) +} + +fn game_chat_fast_path_action(tool: &str, input: serde_json::Value) -> AgentRuntimeToolPlan { + AgentRuntimeToolPlan { + thinking_summary: "game-chat 首版快车道正在按固定最短路径推进。".to_string(), + plan_update: None, + plan: Vec::new(), + actions: vec![AgentRuntimeToolAction { + tool: tool.to_string(), + reason: Some("在五分钟预算内形成并验证首个可玩版本".to_string()), + input, + }], + response: String::new(), + } +} + +fn game_chat_fast_path_fallback_write_plan_for_root( + root: &Path, + task: &str, +) -> Result { + if !game_chat_fast_path_has_platform_art_asset(root) { + return Err( + "game-chat 首版缺少已登记且可验证的平台视觉规范图,拒绝退回纯几何 Canvas".to_string(), + ); + } + Ok(game_chat_fast_path_action( + "file.write", + serde_json::json!({ + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + "content": render_game_chat_fast_path_html(task), + }), + )) +} + +fn game_chat_fast_path_has_platform_art_asset(root: &Path) -> bool { + let Ok(manifest) = read_manifest_for_project(root) else { + return false; + }; + validate_manifest_required_visual_asset(root, &manifest, "art-director").is_ok() +} + +fn game_chat_fast_path_has_visual_asset(root: &Path, task_id: &str) -> bool { + read_manifest_for_project(root).is_ok_and(|manifest| { + validate_manifest_required_visual_asset(root, &manifest, task_id).is_ok() + }) +} + +fn game_chat_fast_path_canvas_generation_failed(runtime: &AgentRuntimeState) -> bool { + runtime.observations.iter().rev().any(|observation| { + [ + "canvas.asset_generate:failed", + "canvas.asset_generate:blocked", + "canvas.asset_generate:rejected", + "canvas.asset_generate:needs-reconciliation", + ] + .iter() + .any(|prefix| observation.starts_with(prefix)) + }) +} + +fn game_chat_fast_path_root_task( + root: &Path, + budget: &GameChatFastPathBudget, +) -> Result { + let root_task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &budget.root_agent_id, + &budget.root_run_id, + )? + .ok_or_else(|| "game-chat 首版快车道缺少 root 任务记录".to_string())? + .task; + if root_task.trim().is_empty() { + return Err("game-chat 首版快车道 root 任务为空".to_string()); + } + Ok(root_task) +} + +fn game_chat_fast_path_canvas_asset_plan(task_id: &str, root_task: &str) -> AgentRuntimeToolPlan { + let theme = safe_theme_summary(root_task); + let (prompt, output_path, aspect_ratio, image_size, asset_kind, asset_label) = match task_id { + "art-director" => ( + format!( + "为原创小游戏“{theme}”生成统一视觉规范图:清晰展示玩家主体、目标物、场景地块、障碍、UI 图标、状态反馈、统一色板和材质规则;同一张图必须可直接作为首版主要背景、玩家和目标的可见绘制来源,不得使用现有知名游戏角色或标识。" + ), + AGENT_RUNTIME_ART_SPEC_PATH, + "1:1", + "1K", + "icon-spec", + "游戏统一视觉规范图", + ), + _ => unreachable!("only deterministic game-chat art tasks use this helper"), + }; + game_chat_fast_path_action( + "canvas.asset_generate", + serde_json::json!({ + "prompt": prompt, + "outputPath": output_path, + "aspectRatio": aspect_ratio, + "imageSize": image_size, + "assetKind": asset_kind, + "assetLabel": asset_label, + "replaceExisting": false, + }), + ) +} + +pub(crate) fn game_chat_fast_path_fallback_write_plan_for_budget_at( + root: &Path, + budget: &GameChatFastPathBudget, + _fallback_task: &str, +) -> Result { + let root_task = game_chat_fast_path_root_task(root, budget)?; + game_chat_fast_path_fallback_write_plan_for_root(root, &root_task) +} + +fn game_chat_fast_path_verified_delivery_plan( + runtime: &AgentRuntimeState, + response: &str, +) -> AgentRuntimeToolPlan { + AgentRuntimeToolPlan { + thinking_summary: "首版快车道已取得当前 revision 的验证证据。".to_string(), + plan_update: agent_runtime_verified_delivery_completion_plan_update(runtime), + plan: Vec::new(), + actions: Vec::new(), + response: response.to_string(), + } +} + +fn game_chat_fast_path_current_revision_is_verified( + root: &Path, + runtime: &AgentRuntimeState, +) -> Result { + let revision = read_game_creator_agent_runtime_project_revision(root)?; + let gate = read_game_creator_agent_runtime_verification_gate( + root, + &runtime.agent_id, + &runtime.run_id, + )?; + Ok(revision.revision > 0 + && gate.verified_revision == Some(revision.revision) + && gate.last_verification_tool.as_deref() == Some("game.static_smoke") + && gate.last_verification_status.as_deref() + == Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED)) +} + +fn game_chat_fast_path_current_revision_has_playtest_receipt( + root: &Path, + budget: &GameChatFastPathBudget, +) -> Result { + let contract = + read_autonomous_completion_contract(root, &budget.root_agent_id, &budget.root_run_id)? + .ok_or_else(|| "game-chat 首版快车道缺少 root 完成合同".to_string())?; + let revision = read_game_creator_agent_runtime_project_revision(root)?; + Ok(read_autonomous_playtest_receipt(root, &contract)? + .is_some_and(|receipt| receipt.revision == revision.revision)) +} + +pub(crate) fn game_chat_fast_path_plan_at( + root: &Path, + runtime: &AgentRuntimeState, + task: &str, + now: u64, +) -> Result, String> { + if runtime.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + return Ok(None); + } + let Some(budget) = + game_chat_fast_path_budget_at(root, &runtime.agent_id, &runtime.run_id, now)? + else { + return Ok(None); + }; + match runtime.agent_id.as_str() { + "art-director" => { + if game_chat_fast_path_has_visual_asset(root, "art-director") { + return Ok(Some(game_chat_fast_path_verified_delivery_plan( + runtime, + "统一视觉规范图已生成并登记。", + ))); + } + if !editor_api_key_is_configured() { + return Err( + "game-chat 首版必须配置 External Editor API Key 才能生成平台美术资源" + .to_string(), + ); + } + if game_chat_fast_path_canvas_generation_failed(runtime) { + return Err( + "game-chat 统一视觉规范图生成失败,拒绝跳过美术阶段或退回纯几何首版" + .to_string(), + ); + } + let root_task = game_chat_fast_path_root_task(root, &budget)?; + Ok(Some(game_chat_fast_path_canvas_asset_plan( + "art-director", + &root_task, + ))) + } + "preview-readiness" => { + if game_chat_fast_path_current_revision_is_verified(root, runtime)? { + Ok(Some(game_chat_fast_path_verified_delivery_plan( + runtime, + "首个可玩版本已通过静态自检。", + ))) + } else { + Ok(Some(game_chat_fast_path_action( + "command.run_limited", + serde_json::json!({ "commandId": "game.static_smoke" }), + ))) + } + } + "preview-playtest" => { + if game_chat_fast_path_current_revision_has_playtest_receipt(root, &budget)? { + Ok(Some(game_chat_fast_path_verified_delivery_plan( + runtime, + "首个可玩版本已通过桌面和移动端试玩。", + ))) + } else { + Ok(Some(game_chat_fast_path_action( + "preview.validate", + serde_json::json!({ + "viewports": ["desktop", "mobile"], + "expectedText": [], + "settleMs": 400, + "failOnConsoleError": true, + "playtestScenario": null, + }), + ))) + } + } + "code-prototype" => { + let revision = read_game_creator_agent_runtime_project_revision(root)?; + let gate = read_game_creator_agent_runtime_verification_gate( + root, + &runtime.agent_id, + &runtime.run_id, + )?; + let owns_current_mutation = gate.mutation_revision == Some(revision.revision); + let current_revision_verified = owns_current_mutation + && gate.verified_revision == Some(revision.revision) + && gate.last_verification_status.as_deref() + == Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED); + let current_revision_failed = owns_current_mutation + && gate.last_verification_status.as_deref() + == Some(AGENT_RUNTIME_VERIFICATION_STATUS_FAILED); + + if current_revision_verified { + return Ok(Some(game_chat_fast_path_verified_delivery_plan( + runtime, + "首个可玩版本代码已生成并通过静态自检。", + ))); + } + if owns_current_mutation && !current_revision_failed { + return Ok(Some(game_chat_fast_path_action( + "command.run_limited", + serde_json::json!({ "commandId": "game.static_smoke" }), + ))); + } + if current_revision_failed + || runtime.loop_iteration > 1 + || budget.elapsed_seconds >= GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS + { + return Ok(Some(game_chat_fast_path_fallback_write_plan_for_budget_at( + root, &budget, task, + )?)); + } + Ok(None) + } + _ => Ok(None), + } +} + +/// Render a safe title/theme summary from the user's request. +/// +/// The summary is escaped before it is inserted into HTML. It is only placed in a data +/// attribute and text nodes; it is never interpolated into JavaScript source. +pub(crate) fn render_game_chat_fast_path_html(prompt: &str) -> String { + let theme = html_escape(&safe_theme_summary(prompt)); + let platform_art = + r#"平台生成的统一视觉规范图"#; + FALLBACK_GAME_HTML + .replace(FALLBACK_THEME_MARKER, &theme) + .replace(FALLBACK_PLATFORM_ART_MARKER, platform_art) +} + +fn safe_theme_summary(prompt: &str) -> String { + let mut summary = String::new(); + let mut previous_was_space = false; + for character in prompt.trim().chars() { + if character.is_control() { + if !previous_was_space { + summary.push(' '); + previous_was_space = true; + } + continue; + } + if character.is_whitespace() { + if !previous_was_space { + summary.push(' '); + previous_was_space = true; + } + continue; + } + summary.push(character); + previous_was_space = false; + if summary.chars().count() >= 56 { + break; + } + } + let summary = summary.trim(); + if summary.is_empty() { + "轻量互动挑战".to_string() + } else { + summary.to_string() + } +} + +fn html_escape(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + _ => escaped.push(character), + } + } + escaped +} + +const FALLBACK_GAME_HTML: &str = r###" + + + + + Genarrative · __GAME_CHAT_THEME__ + + + +
+
+
+

首版可试玩 · __GAME_CHAT_THEME__

+

目标:收集能量并保持推进。胜利和失败都可以重开,当前版本不会自动结束。

+
+
得分 0准备就绪
+
+
+ __GAME_CHAT_PLATFORM_ART__ + +
点击开始,然后操作收集能量准备就绪
+ +
+ +
+ + + +"###; + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::{validate_game_html_smoke, validate_playable_game_html}; + + fn write_visual_png(path: &Path, alpha: u8) { + image::RgbaImage::from_pixel(4, 4, image::Rgba([80, 160, 220, alpha])) + .save(path) + .expect("write valid PNG fixture"); + } + + fn register_platform_art_spec(root: &Path) { + write_visual_png(&root.join(AGENT_RUNTIME_ART_SPEC_PATH), u8::MAX); + register_local_asset_at( + root, + AGENT_RUNTIME_ART_SPEC_PATH, + "icon-spec", + "image/png", + "canvas", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("platform-art-canvas".to_string()), + resource_id: Some("platform-art-spec-resource".to_string()), + asset_object_id: Some("platform-art-spec-object".to_string()), + task_id: Some("art-director".to_string()), + prompt: None, + model: None, + generation_route: Some("/api/external/v1/editor/images/generations".to_string()), + generation_kind: Some("spec".to_string()), + reference_resource_ids: Vec::new(), + }, + ) + .expect("register platform art spec fixture"); + } + + #[test] + fn budgets_leave_a_soft_and_hard_window() { + assert_eq!(GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS, 240); + assert_eq!(GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS, 300); + assert!( + GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS + < GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS + ); + } + + #[test] + fn fallback_html_satisfies_playable_contract() { + let html = render_game_chat_fast_path_html("星河收集挑战"); + validate_playable_game_html(&html, "game-chat fast path").expect("playable contract"); + validate_game_html_smoke(&html).expect("static smoke contract"); + for marker in [ + "requestAnimationFrame", + "playable-web-game-state.v1", + "data-playtest-id=\"start\"", + "data-playtest-id=\"primary-action\"", + "data-playtest-id=\"restart\"", + "pointerdown", + "keydown", + ] { + assert!(html.contains(marker), "missing fallback marker: {marker}"); + } + assert!(html.contains("src=\"../assets/art-spec.png\"")); + assert!(!html.contains("art-spritesheet.png")); + assert!(html.contains("context.drawImage(platformArt, 0, 0, canvas.width, canvas.height)")); + assert!(html.contains("playerX - 18")); + assert!(html.contains("targetX - 52")); + assert!(!html.contains("opacity: 0")); + assert!(!html.contains("width: 1px")); + } + + #[test] + fn fallback_html_requires_registered_platform_art_and_uses_it_prominently() { + let temporary = tempfile::tempdir().expect("temporary project"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-platform-art", "platform art") + .expect("initialize project"); + + assert!(game_chat_fast_path_fallback_write_plan_for_root( + &root, + "没有美术资源时必须失败关闭", + ) + .is_err()); + + register_platform_art_spec(&root); + + let with_asset = + game_chat_fast_path_fallback_write_plan_for_root(&root, "有美术资源时加载平台规范图") + .expect("render art-backed fallback"); + let with_html = with_asset.actions[0].input["content"] + .as_str() + .expect("fallback html with asset"); + assert!(with_html.contains("src=\"../assets/art-spec.png\"")); + assert!(!with_html.contains("art-spritesheet.png")); + assert!( + with_html.contains("context.drawImage(platformArt, 0, 0, canvas.width, canvas.height)") + ); + assert!(with_html.contains("playerX - 18")); + assert!(with_html.contains("targetX - 52")); + } + + #[test] + fn fallback_html_ignores_registered_art_when_file_is_missing() { + let temporary = tempfile::tempdir().expect("temporary project"); + let root = temporary.path().join("project"); + init_local_game_project_at( + &root, + "game-chat-platform-art-missing", + "platform art missing", + ) + .expect("initialize project"); + register_platform_art_spec(&root); + let asset_path = root.join(AGENT_RUNTIME_ART_SPEC_PATH); + fs::remove_file(asset_path).expect("remove platform art fixture"); + + assert!(game_chat_fast_path_fallback_write_plan_for_root( + &root, + "缺图时拒绝 Canvas fallback", + ) + .is_err()); + } + + #[test] + fn current_revision_is_verified_requires_static_smoke_tool() { + let temporary = tempfile::tempdir().expect("temporary project"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-verification", "verification") + .expect("initialize project"); + let runtime = default_game_creator_agent_runtime_state("preview-readiness", "verify-run"); + + let mut revision = + read_game_creator_agent_runtime_project_revision(&root).expect("read project revision"); + revision.revision = 1; + write_game_creator_agent_runtime_project_revision(&root, &revision) + .expect("write project revision"); + let mut gate = + default_agent_runtime_verification_gate(&root, &runtime.agent_id, &runtime.run_id) + .expect("default verification gate"); + gate.verified_revision = Some(1); + gate.last_verification_status = Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string()); + gate.last_verification_tool = Some("preview.validate".to_string()); + write_game_creator_agent_runtime_verification_gate(&root, &gate) + .expect("write verification gate"); + assert!( + !game_chat_fast_path_current_revision_is_verified(&root, &runtime) + .expect("check preview verification") + ); + + gate.last_verification_tool = Some("game.static_smoke".to_string()); + write_game_creator_agent_runtime_verification_gate(&root, &gate) + .expect("write static smoke gate"); + assert!( + game_chat_fast_path_current_revision_is_verified(&root, &runtime) + .expect("check static smoke verification") + ); + } + + #[test] + fn fallback_budget_uses_root_user_task_instead_of_child_manifest_prompt() { + let temporary = tempfile::tempdir().expect("temporary project"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-fallback-test", "fallback test") + .expect("initialize project"); + let run_id = "game-chat-fallback-root-task"; + let mut root_state = default_game_creator_agent_runtime_state( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ); + root_state.current_task = "制作星空飞船收集能量小游戏".to_string(); + append_game_creator_agent_runtime_task(&root, &root_state).expect("append root task"); + let budget = GameChatFastPathBudget { + root_agent_id: root_state.agent_id.clone(), + root_run_id: root_state.run_id.clone(), + baseline_revision: 0, + elapsed_seconds: 240, + }; + register_platform_art_spec(&root); + + let plan = game_chat_fast_path_fallback_write_plan_for_budget_at( + &root, + &budget, + "处理 manifest ready 任务:任务 ID:code-prototype;专业组:code", + ) + .expect("render fallback from root task"); + let content = plan.actions[0].input["content"] + .as_str() + .expect("fallback html content"); + assert!(content.contains("星空飞船收集能量小游戏")); + assert!(!content.contains("任务 ID:code-prototype")); + } + + #[test] + fn fallback_budget_fails_closed_without_root_task_journal() { + let temporary = tempfile::tempdir().expect("temporary project"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-fallback-missing", "fallback missing") + .expect("initialize project"); + let budget = GameChatFastPathBudget { + root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + root_run_id: "missing-root-run".to_string(), + baseline_revision: 0, + elapsed_seconds: 240, + }; + assert!(game_chat_fast_path_fallback_write_plan_for_budget_at( + &root, + &budget, + "任务 ID:code-prototype", + ) + .is_err()); + } + + #[test] + fn prompt_is_html_escaped_and_never_becomes_script() { + let html = render_game_chat_fast_path_html(" & \"主题\""); + assert!(!html.contains("") else { + return output; + }; + cursor = body_start + end_offset + "".len(); + } + output.push_str(&content[cursor..]); + output +} + +fn relative_visual_url_resolves_to_asset(value: &str, asset_path: &str) -> bool { + let value = value + .trim() + .trim_matches(|character| matches!(character, '\'' | '"')); + let path = value.split(['?', '#']).next().unwrap_or_default().trim(); + if path.is_empty() + || path.starts_with('/') + || path.contains(['\\', '%', ':']) + || asset_path.starts_with('/') + { + return false; + } + let mut components = vec!["game"]; + for component in path.split('/') { + match component { + "" | "." => {} + ".." => { + if components.pop().is_none() { + return false; + } + } + value => components.push(value), + } + } + components.join("/") == asset_path +} + +fn html_attribute_value<'a>(tag: &'a str, attribute: &str) -> Option<&'a str> { + let bytes = tag.as_bytes(); + let attribute_bytes = attribute.as_bytes(); + let mut cursor = 0; + while cursor + attribute_bytes.len() <= bytes.len() { + let offset = tag[cursor..].find(attribute)?; + let start = cursor + offset; + let end = start + attribute_bytes.len(); + let left_boundary = + start == 0 || matches!(bytes[start - 1], b'<' | b' ' | b'\t' | b'\r' | b'\n'); + let right_boundary = + end == bytes.len() || matches!(bytes[end], b'=' | b' ' | b'\t' | b'\r' | b'\n'); + if !left_boundary || !right_boundary { + cursor = end; + continue; + } + let mut value_start = end; + while value_start < bytes.len() && bytes[value_start].is_ascii_whitespace() { + value_start += 1; + } + if bytes.get(value_start) != Some(&b'=') { + cursor = end; + continue; + } + value_start += 1; + while value_start < bytes.len() && bytes[value_start].is_ascii_whitespace() { + value_start += 1; + } + let quote = bytes.get(value_start).copied(); + if matches!(quote, Some(b'\'' | b'"')) { + value_start += 1; + let value_end = bytes[value_start..] + .iter() + .position(|byte| Some(*byte) == quote) + .map(|offset| value_start + offset)?; + return Some(&tag[value_start..value_end]); + } + let value_end = bytes[value_start..] + .iter() + .position(|byte| byte.is_ascii_whitespace() || *byte == b'>') + .map(|offset| value_start + offset) + .unwrap_or(bytes.len()); + return (value_end > value_start).then(|| &tag[value_start..value_end]); + } + None +} + +fn css_numeric_property(value: &str, property: &str) -> Option { + let compact = value + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect::(); + let marker = format!("{property}:"); + let start = compact.find(&marker)? + marker.len(); + compact[start..] + .chars() + .take_while(|character| character.is_ascii_digit() || matches!(character, '.' | '-')) + .collect::() + .parse() + .ok() +} + +fn tag_dimension(tag: &str, attribute: &str, property: &str) -> Option { + html_attribute_value(tag, attribute) + .and_then(|value| value.trim_end_matches("px").parse().ok()) + .or_else(|| { + html_attribute_value(tag, "style") + .and_then(|style| css_numeric_property(style, property)) + }) +} + +fn tag_is_obviously_hidden_or_tiny(tag: &str, default_dimensions: (u32, u32)) -> bool { + let compact = tag + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect::(); + let hidden_attribute = tag + .split(|character: char| character.is_ascii_whitespace() || matches!(character, '<' | '>')) + .any(|token| token == "hidden" || token.starts_with("hidden=")); + if hidden_attribute + || compact.contains("display:none") + || compact.contains("visibility:hidden") + || compact.contains("left:-999") + || compact.contains("top:-999") + || compact.contains("translate(-999") + || css_numeric_property(tag, "opacity").is_some_and(|opacity| opacity < 0.25) + { + return true; + } + let width = tag_dimension(tag, "width", "width").unwrap_or(default_dimensions.0 as f64); + let height = tag_dimension(tag, "height", "height").unwrap_or(default_dimensions.1 as f64); + width < 24.0 || height < 24.0 +} + +fn css_contains_resolving_url(style: &str, asset_path: &str) -> bool { + let mut cursor = 0; + while let Some(offset) = style[cursor..].find("url(") { + let start = cursor + offset + "url(".len(); + let Some(end_offset) = style[start..].find(')') else { + return false; + }; + let end = start + end_offset; + if relative_visual_url_resolves_to_asset(&style[start..end], asset_path) { + return true; + } + cursor = end + 1; + } + false +} + +fn selector_is_bound_to_markup(selector: &str, markup: &str) -> bool { + selector.split(',').any(|selector| { + let selector = selector.trim(); + if let Some(class_name) = selector.strip_prefix('.') { + let class_name = class_name + .split(|character: char| { + !character.is_ascii_alphanumeric() && !matches!(character, '_' | '-') + }) + .next() + .unwrap_or_default(); + return !class_name.is_empty() + && markup.split('<').any(|tag| { + html_attribute_value(tag, "class").is_some_and(|classes| { + classes + .split_ascii_whitespace() + .any(|value| value == class_name) + }) + }); + } + if let Some(id) = selector.strip_prefix('#') { + let id = id + .split(|character: char| { + !character.is_ascii_alphanumeric() && !matches!(character, '_' | '-') + }) + .next() + .unwrap_or_default(); + return !id.is_empty() + && markup + .split('<') + .any(|tag| html_attribute_value(tag, "id") == Some(id)); + } + let tag_name = selector + .split(|character: char| !character.is_ascii_alphanumeric() && character != '-') + .next() + .unwrap_or_default(); + !tag_name.is_empty() + && markup + .split('<') + .any(|tag| tag.trim_start().starts_with(tag_name)) + }) +} + +fn selector_matches_tag(selector: &str, tag: &str) -> bool { + selector.split(',').any(|selector| { + let selector = selector.trim(); + if let Some(class_name) = selector.strip_prefix('.') { + let class_name = class_name + .split(|character: char| { + !character.is_ascii_alphanumeric() && !matches!(character, '_' | '-') + }) + .next() + .unwrap_or_default(); + return html_attribute_value(tag, "class").is_some_and(|classes| { + classes + .split_ascii_whitespace() + .any(|value| value == class_name) + }); + } + if let Some(id) = selector.strip_prefix('#') { + let id = id + .split(|character: char| { + !character.is_ascii_alphanumeric() && !matches!(character, '_' | '-') + }) + .next() + .unwrap_or_default(); + return html_attribute_value(tag, "id") == Some(id); + } + let name = selector + .split(|character: char| !character.is_ascii_alphanumeric() && character != '-') + .next() + .unwrap_or_default(); + !name.is_empty() + && tag + .trim_start_matches(['<', ' ', '\t', '\r', '\n']) + .starts_with(name) + }) +} + +fn tag_is_hidden_by_stylesheet(tag: &str, markup: &str, default_dimensions: (u32, u32)) -> bool { + markup.split("').map(|offset| offset + 1) else { + return false; + }; + let Some(body_end) = tail[body_start..].find("") else { + return false; + }; + tail[body_start..body_start + body_end] + .split('}') + .any(|rule| { + rule.rsplit_once('{') + .is_some_and(|(selector, declarations)| { + selector_matches_tag(selector, tag) + && tag_is_obviously_hidden_or_tiny( + &format!("
"), + default_dimensions, + ) + }) + }) + }) +} + +fn position_is_inside_javascript_string(content: &str, position: usize) -> bool { + let mut quote = None; + let mut escaped = false; + for byte in content.as_bytes().iter().copied().take(position) { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if let Some(active) = quote { + if byte == active { + quote = None; + } + } else if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + } + } + quote.is_some() +} + +fn matching_javascript_brace(content: &str, open: usize) -> Option { + let mut depth = 0usize; + let mut quote = None; + let mut escaped = false; + for (offset, byte) in content.as_bytes()[open..].iter().copied().enumerate() { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if let Some(active) = quote { + if byte == active { + quote = None; + } + } else if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + } else if byte == b'{' { + depth += 1; + } else if byte == b'}' { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some(open + offset); + } + } + } + None +} + +fn named_javascript_function_ranges(content: &str) -> Vec<(String, usize, usize)> { + let mut ranges = Vec::new(); + let mut cursor = 0; + while let Some(offset) = content[cursor..].find("function ") { + let definition_start = cursor + offset; + cursor = definition_start + "function ".len(); + if position_is_inside_javascript_string(content, definition_start) { + continue; + } + while content + .as_bytes() + .get(cursor) + .is_some_and(u8::is_ascii_whitespace) + { + cursor += 1; + } + let name_start = cursor; + while content + .as_bytes() + .get(cursor) + .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_' || *byte == b'$') + { + cursor += 1; + } + if cursor == name_start { + continue; + } + let name = content[name_start..cursor].to_string(); + let Some(open_offset) = content[cursor..].find('{') else { + break; + }; + let open = cursor + open_offset; + let Some(end) = matching_javascript_brace(content, open) else { + break; + }; + ranges.push((name, definition_start, end + 1)); + cursor = open + 1; + } + ranges +} + +fn javascript_named_function_is_reachable( + content: &str, + ranges: &[(String, usize, usize)], + function_index: usize, + visiting: &mut BTreeSet, +) -> bool { + if !visiting.insert(function_index) { + return false; + } + let (name, definition_start, definition_end) = &ranges[function_index]; + let invocation_markers = [ + format!("{name}("), + format!("requestanimationframe({name})"), + format!(",{name})"), + format!(", {name})"), + ]; + for marker in invocation_markers { + let mut cursor = 0; + while let Some(offset) = content[cursor..].find(&marker) { + let call = cursor + offset; + cursor = call + marker.len(); + if (*definition_start..*definition_end).contains(&call) + || position_is_inside_javascript_string(content, call) + { + continue; + } + let parent = ranges + .iter() + .enumerate() + .filter(|(_, (_, start, end))| (*start..*end).contains(&call)) + .min_by_key(|(_, (_, start, end))| end - start) + .map(|(index, _)| index); + if parent.is_none_or(|index| { + javascript_named_function_is_reachable(content, ranges, index, visiting) + }) { + visiting.remove(&function_index); + return true; + } + } + } + visiting.remove(&function_index); + false +} + +fn javascript_position_is_reachable( + content: &str, + ranges: &[(String, usize, usize)], + position: usize, +) -> bool { + let enclosing = ranges + .iter() + .enumerate() + .filter(|(_, (_, start, end))| (*start..*end).contains(&position)) + .min_by_key(|(_, (_, start, end))| end - start) + .map(|(index, _)| index); + enclosing.is_none_or(|index| { + javascript_named_function_is_reachable(content, ranges, index, &mut BTreeSet::new()) + }) +} + +fn identifier_before(content: &str, position: usize) -> Option { + let bytes = content.as_bytes(); + let mut end = position; + while end > 0 && bytes[end - 1].is_ascii_whitespace() { + end -= 1; + } + let mut start = end; + while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'_') { + start -= 1; + } + (start < end).then(|| content[start..end].to_string()) +} + +fn canvas_visual_identifiers(content: &str, markup: &str, asset_path: &str) -> BTreeSet { + let mut identifiers = BTreeSet::new(); + let mut cursor = 0; + while let Some(offset) = content[cursor..].find(".src") { + let dot = cursor + offset; + cursor = dot + 4; + if position_is_inside_javascript_string(content, dot) { + continue; + } + let Some(identifier) = identifier_before(content, dot) else { + continue; + }; + let mut value_start = cursor; + while content + .as_bytes() + .get(value_start) + .is_some_and(u8::is_ascii_whitespace) + { + value_start += 1; + } + if content.as_bytes().get(value_start) != Some(&b'=') { + continue; + } + value_start += 1; + while content + .as_bytes() + .get(value_start) + .is_some_and(u8::is_ascii_whitespace) + { + value_start += 1; + } + let Some(quote @ (b'\'' | b'"' | b'`')) = content.as_bytes().get(value_start).copied() + else { + continue; + }; + value_start += 1; + let Some(end_offset) = content.as_bytes()[value_start..] + .iter() + .position(|byte| *byte == quote) + else { + continue; + }; + if relative_visual_url_resolves_to_asset( + &content[value_start..value_start + end_offset], + asset_path, + ) { + identifiers.insert(identifier); + } + } + + for tag in markup.split('>').filter(|tag| tag.contains(asset_path)) { + let Some(id) = html_attribute_value(tag, "id") else { + continue; + }; + let source_matches = ["src", "href", "data", "poster"].iter().any(|attribute| { + html_attribute_value(tag, attribute) + .is_some_and(|url| relative_visual_url_resolves_to_asset(url, asset_path)) + }); + if !source_matches { + continue; + } + for marker in ["getelementbyid(", "queryselector("] { + let mut binding_cursor = 0; + while let Some(offset) = content[binding_cursor..].find(marker) { + let call = binding_cursor + offset; + binding_cursor = call + marker.len(); + if position_is_inside_javascript_string(content, call) { + continue; + } + let argument_tail = &content[binding_cursor..]; + let Some(argument_end) = argument_tail.find(')') else { + continue; + }; + let argument = argument_tail[..argument_end] + .trim() + .trim_matches(|character| matches!(character, '\'' | '"' | '#')); + if argument != id { + continue; + } + let Some(equals) = content[..call].rfind('=') else { + continue; + }; + if call.saturating_sub(equals) > 24 { + continue; + } + if let Some(identifier) = identifier_before(content, equals) { + identifiers.insert(identifier); + } + } + } + } + identifiers +} + +fn split_javascript_arguments(arguments: &str) -> Vec<&str> { + let mut result = Vec::new(); + let mut start = 0; + let mut depth: usize = 0; + let mut quote = None; + let mut escaped = false; + for (index, byte) in arguments.bytes().enumerate() { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if let Some(active) = quote { + if byte == active { + quote = None; + } + } else if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + } else if byte == b'(' { + depth += 1; + } else if byte == b')' { + depth = depth.saturating_sub(1); + } else if byte == b',' && depth == 0 { + result.push(arguments[start..index].trim()); + start = index + 1; + } + } + result.push(arguments[start..].trim()); + result +} + +fn javascript_call_arguments_end(content: &str, start: usize) -> Option { + let mut depth: usize = 0; + let mut quote = None; + let mut escaped = false; + for (offset, byte) in content.as_bytes()[start..].iter().copied().enumerate() { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if let Some(active) = quote { + if byte == active { + quote = None; + } + } else if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + } else if byte == b'(' { + depth += 1; + } else if byte == b')' { + if depth == 0 { + return Some(start + offset); + } + depth -= 1; + } + } + None +} + +fn draw_image_metrics(arguments: &[&str], asset_dimensions: (u32, u32)) -> (bool, bool, bool) { + let destination = match arguments.len() { + 3 => { + let significant = asset_dimensions.0 >= 32 && asset_dimensions.1 >= 32; + return (significant, significant, false); + } + 5 => arguments.get(3).zip(arguments.get(4)), + 9 => arguments.get(7).zip(arguments.get(8)), + _ => None, + }; + let Some((width, height)) = destination else { + return (false, false, false); + }; + let compact_width = width.split_ascii_whitespace().collect::(); + let compact_height = height.split_ascii_whitespace().collect::(); + let background = compact_width.ends_with(".width") + && compact_height.ends_with(".height") + && compact_width.trim_end_matches(".width") == compact_height.trim_end_matches(".height"); + let parse = |value: &str| value.trim().parse::().ok(); + let numeric = parse(width).zip(parse(height)); + let entity = numeric.is_some_and(|(width, height)| { + width.abs() >= 32.0 && height.abs() >= 32.0 && width.abs() * height.abs() >= 2048.0 + }); + let numeric_background = numeric.is_some_and(|(width, height)| { + width.abs() >= 320.0 && height.abs() >= 180.0 && width.abs() * height.abs() >= 100_000.0 + }); + let background = background || numeric_background; + (background || entity, background, entity) +} + +fn tag_visibly_uses_visual_asset( + tag: &str, + asset_path: &str, + asset_dimensions: (u32, u32), +) -> bool { + let tag = tag.to_ascii_lowercase(); + if !tag.contains(asset_path) || tag_is_obviously_hidden_or_tiny(&tag, asset_dimensions) { + return false; + } + let trimmed = tag.trim_start_matches(['<', ' ', '\t', '\r', '\n']); + let visual_element = ["img", "image", "object", "embed", "video", "input"] + .iter() + .any(|name| { + trimmed.starts_with(name) + && trimmed + .as_bytes() + .get(name.len()) + .is_some_and(|byte| byte.is_ascii_whitespace() || *byte == b'>') + }); + let direct_source = visual_element + && ["src", "href", "data", "poster"].iter().any(|attribute| { + html_attribute_value(&tag, attribute) + .is_some_and(|url| relative_visual_url_resolves_to_asset(url, asset_path)) + }); + direct_source + || html_attribute_value(&tag, "style") + .is_some_and(|style| css_contains_resolving_url(style, asset_path)) +} + +fn game_index_visibly_uses_visual_asset( + html: &[u8], + asset_path: &str, + asset_dimensions: (u32, u32), + require_canvas_composition: bool, +) -> bool { + let asset_path = asset_path.to_ascii_lowercase(); + let Ok(html) = std::str::from_utf8(html) else { + return false; + }; + let content = strip_art_reference_comments(html).to_ascii_lowercase(); + if !content.contains(&asset_path) { + return false; + } + let markup = strip_script_blocks(&content); + + let mut tag_cursor = 0; + while let Some(start_offset) = markup[tag_cursor..].find('<') { + let start = tag_cursor + start_offset; + let Some(end_offset) = markup[start..].find('>') else { + break; + }; + let end = start + end_offset + 1; + let tag = &markup[start..end]; + if !require_canvas_composition + && tag_visibly_uses_visual_asset(tag, &asset_path, asset_dimensions) + && !tag_is_hidden_by_stylesheet(tag, &markup, asset_dimensions) + { + return true; + } + tag_cursor = end; + } + + let mut style_cursor = 0; + while let Some(start_offset) = markup[style_cursor..].find("') else { + break; + }; + let body_start = start + open_end_offset + 1; + let Some(end_offset) = markup[body_start..].find("") else { + break; + }; + let body_end = body_start + end_offset; + let style = &markup[body_start..body_end]; + for rule in style.split('}') { + let Some((selector, declarations)) = rule.rsplit_once('{') else { + continue; + }; + if !require_canvas_composition + && css_contains_resolving_url(declarations, &asset_path) + && selector_is_bound_to_markup(selector, &markup) + && !tag_is_obviously_hidden_or_tiny( + &format!("
"), + (0, 0), + ) + { + return true; + } + } + style_cursor = body_end + "".len(); + } + + let active_canvas = markup.split('>').any(|tag| { + tag.trim_start_matches(['<', ' ', '\t', '\r', '\n']) + .starts_with("canvas") + && !tag_is_obviously_hidden_or_tiny(tag, (300, 150)) + && !tag_is_hidden_by_stylesheet(tag, &markup, (300, 150)) + }); + if !active_canvas { + return false; + } + let identifiers = canvas_visual_identifiers(&content, &markup, &asset_path); + if identifiers.is_empty() { + return false; + } + let mut significant_draws = 0usize; + let mut background_draws = 0usize; + let mut entity_draws = 0usize; + let function_ranges = named_javascript_function_ranges(&content); + let mut draw_cursor = 0; + while let Some(offset) = content[draw_cursor..].find("drawimage(") { + let call = draw_cursor + offset; + let arguments_start = call + "drawimage(".len(); + draw_cursor = arguments_start; + if position_is_inside_javascript_string(&content, call) + || !javascript_position_is_reachable(&content, &function_ranges, call) + { + continue; + } + let Some(arguments_end) = javascript_call_arguments_end(&content, arguments_start) else { + break; + }; + let arguments = split_javascript_arguments(&content[arguments_start..arguments_end]); + if arguments + .first() + .is_some_and(|identifier| identifiers.contains(*identifier)) + { + let (significant, background, entity) = + draw_image_metrics(&arguments, asset_dimensions); + significant_draws += usize::from(significant); + background_draws += usize::from(background); + entity_draws += usize::from(entity); + if !require_canvas_composition && significant { + return true; + } + } + draw_cursor = arguments_end + 1; + } + require_canvas_composition + && significant_draws >= 3 + && background_draws >= 1 + && entity_draws >= 2 +} + pub(in crate::agent) fn game_creation_app_task_status_label( status: &GameCreationAppTaskStatus, ) -> String { @@ -438,6 +1236,13 @@ fn autonomous_manifest_parent_completion_gaps_at( )? .ok_or_else(|| "自主构建根 Supervisor Run 缺少 Run Profile 绑定".to_string())?; let seed_tasks = crate::agent::autonomous_manifest_seed_tasks_for_source(&binding.source); + let required_visual_task_id = if binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE { + Some("art-director") + } else if editor_api_key_is_configured() { + Some("art-asset-plan") + } else { + None + }; let mut missing_tasks = Vec::new(); let mut missing_paths = Vec::new(); for seed_task in &seed_tasks { @@ -456,10 +1261,23 @@ 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)? + if required_visual_task_id == Some(seed_task.id.as_str()) { + if let Err(error) = + validate_manifest_required_visual_asset(root, &manifest, &seed_task.id) { + missing_paths.push(AutonomousManifestArtifactGap::new(format!( + "{}(canvas-registration-invalid:{})", + seed_task.id, + sanitize_agent_runtime_text(&error, 240) + ))); + } + } + if seed_task.id == "code-prototype" { + if let Some(gap) = autonomous_code_prototype_art_asset_reference_gap_at( + root, + &seed_task.id, + required_visual_task_id, + )? { missing_paths.push(AutonomousManifestArtifactGap::new(gap)); } } @@ -498,10 +1316,27 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( format!("missingTask={}", state.agent_id), )); }; + let root_source = match agent_runtime_root_source_at(root, &state.agent_id, &state.run_id) { + Ok(source) => source, + Err(error) => { + return Some(autonomous_completion_blocker( + "autonomous ready-task 无法解析 root source", + error, + )); + } + }; + // game-chat 可能在 UI 完成项目 hydration 前启动第一个 ready child,随后初始化写回会短暂把 + // code-prototype 恢复成 Pending。child binding、owner artifact 和验证门仍能确认当前 run, + // 因此仅允许这个首任务收束,再由 terminal projection 写入权威 Completed 状态。 + // GUI/CLI 以及后续 preview 任务继续严格要求 Running/Completed。 + let game_chat_hydration_pending = state.agent_id == "code-prototype" + && root_source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + && task.status == GameCreationAppTaskStatus::Pending; if !matches!( task.status, GameCreationAppTaskStatus::Running | GameCreationAppTaskStatus::Completed - ) { + ) && !game_chat_hydration_pending + { return Some(autonomous_completion_blocker( "autonomous ready-task manifest 状态不允许完成", format!( @@ -643,12 +1478,37 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( )); } }; - match autonomous_code_prototype_art_asset_reference_gap_at(root, &state.agent_id) { + let required_visual_task_id = if root_source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE { + Some("art-director") + } else if editor_api_key_is_configured() { + Some("art-asset-plan") + } else { + None + }; + if required_visual_task_id == Some(state.agent_id.as_str()) { + if let Err(error) = + validate_manifest_required_visual_asset(root, &manifest, &state.agent_id) + { + return Some(autonomous_completion_blocker( + "autonomous ready-task 缺少有效的 Canvas 美术资产登记", + format!( + "task={} validationError={}", + state.agent_id, + sanitize_agent_runtime_text(&error, 240) + ), + )); + } + } + match autonomous_code_prototype_art_asset_reference_gap_at( + root, + &state.agent_id, + required_visual_task_id, + ) { Ok(Some(gap)) => { return Some(autonomous_completion_blocker( "code-prototype 必须实际使用平台生成的美术资源", format!( - "task={} missingPaths={},请先通过 asset.list 核对 Canvas 来源并在 game/index.html 中引用 assets/art-spritesheet.png", + "task={} missingPaths={},请先通过 asset.list 核对 Canvas 来源并在 game/index.html 中可见使用对应平台美术资源", state.agent_id, gap ), )); 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 9c000fef7..ccdc990c2 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 @@ -71,6 +71,78 @@ fn autonomous_supervisor_source_allowlist_includes_game_chat_only() { )); } +#[test] +fn game_chat_manifest_seed_projection_is_a_serial_four_task_lane() { + let game_chat_tasks = + autonomous_manifest_seed_tasks_for_source(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE); + assert_eq!( + game_chat_tasks + .iter() + .map(|task| task.id.as_str()) + .collect::>(), + [ + "art-director", + "code-prototype", + "preview-readiness", + "preview-playtest", + ] + ); + assert_eq!(game_chat_tasks[0].dependencies, Vec::::new()); + assert_eq!( + game_chat_tasks[1].dependencies, + vec!["art-director".to_string()] + ); + assert_eq!( + game_chat_tasks[2].dependencies, + vec!["code-prototype".to_string()] + ); + assert_eq!( + game_chat_tasks[3].dependencies, + vec!["preview-readiness".to_string()] + ); + + let full_seed_tasks = new_game_creation_app_seed_tasks(); + for source in [ + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + ] { + assert_eq!( + autonomous_manifest_seed_tasks_for_source(source), + full_seed_tasks + ); + } + + let mut manifest_tasks = full_seed_tasks; + for task in &mut manifest_tasks { + if matches!( + task.id.as_str(), + "art-director" | "code-prototype" | "preview-readiness" | "preview-playtest" + ) { + task.status = GameCreationAppTaskStatus::Pending; + } + } + assert_eq!( + autonomous_manifest_ready_task_ids( + &manifest_tasks, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ), + vec!["art-director".to_string()] + ); + + manifest_tasks + .iter_mut() + .find(|task| task.id == "art-director") + .expect("art director task exists") + .status = GameCreationAppTaskStatus::Completed; + assert_eq!( + autonomous_manifest_ready_task_ids( + &manifest_tasks, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ), + vec!["code-prototype".to_string()] + ); +} + fn autonomous_fixture_with_setup( task: &str, run_id: &str, @@ -116,16 +188,13 @@ fn autonomous_fixture_with_setup( } fn register_autonomous_visual_fixture(root: &Path, local_path: &str, kind: &str) { - let bytes = if kind == "art-spritesheet" { - use image::ImageEncoder; - let mut bytes = Vec::new(); - image::codecs::png::PngEncoder::new(&mut bytes) - .write_image(&[12, 34, 56, 0], 1, 1, image::ColorType::Rgba8.into()) - .expect("encode transparent autonomous visual fixture"); - bytes - } else { - b"\x89PNG\r\n\x1a\nfixture".to_vec() - }; + use image::ImageEncoder; + let alpha = if kind == "art-spritesheet" { 0 } else { 255 }; + let pixels = [12, 34, 56, alpha].repeat(64 * 64); + let mut bytes = Vec::new(); + image::codecs::png::PngEncoder::new(&mut bytes) + .write_image(&pixels, 64, 64, image::ColorType::Rgba8.into()) + .expect("encode autonomous visual fixture"); fs::write(root.join(local_path), bytes).expect("write autonomous visual fixture"); let (generation_route, generation_kind, reference_resource_ids) = match kind { "icon-spec" => ( @@ -1008,10 +1077,19 @@ fn game_chat_parent_completion_stops_after_preview_playtest_without_publish_task .expect("leave publish strategy pending"); update_manifest_task_status_at(&root, "publish-package", GameCreationAppTaskStatus::Pending) .expect("leave publish package pending"); + for task in new_game_creation_app_seed_tasks() { + if !matches!( + task.id.as_str(), + "art-director" | "code-prototype" | "preview-readiness" | "preview-playtest" + ) { + update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Pending) + .unwrap_or_else(|error| panic!("leave non-fast-path task pending: {error}")); + } + } 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); @@ -1035,12 +1113,50 @@ fn game_chat_parent_completion_stops_after_preview_playtest_without_publish_task } #[test] -fn code_prototype_requires_registered_canvas_spritesheet_reference_when_editor_is_configured() { +fn game_chat_schedule_ready_tool_cannot_bypass_the_single_round_publish_boundary() { + let (_temporary, root, state, _contract) = autonomous_fixture_with_source( + "创建一轮植物塔防游戏", + "game-chat-schedule-ready-boundary", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + for task_id in ["publish-strategy", "publish-package"] { + update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Pending) + .expect("leave game-chat publish task pending"); + } + + let observation = observe_agent_runtime_schedule_ready_tasks( + &root, + &state.agent_id, + &state.run_id, + &serde_json::json!({ "limit": 16 }), + ); + + assert_eq!(observation.status, "ok"); + assert_eq!(observation.summary, "已调度 0 个 Ready 任务"); + let manifest = read_manifest_for_project(&root).expect("read game-chat manifest"); + for task_id in ["publish-strategy", "publish-package"] { + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == task_id) + .map(|task| &task.status), + Some(&GameCreationAppTaskStatus::Pending), + "game-chat must not schedule {task_id} after preview-playtest" + ); + } +} + +#[test] +fn game_chat_code_prototype_requires_registered_canvas_art_spec_visible_use() { 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"); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮植物塔防游戏", + "game-chat-code-art-gate-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) .expect("mark code prototype running"); let code_record = @@ -1052,7 +1168,140 @@ fn code_prototype_requires_registered_canvas_spritesheet_reference_when_editor_i "", ); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) - .expect("missing spritesheet reference must block code prototype"); + .expect("missing art-spec reference must block code prototype"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("assets/art-spec.png"))); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!( + autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some(), + "URL relative to game/index.html must resolve to the registered asset" + ); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("an unused art-spec string must not satisfy visible-use validation"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("missing-visible-art-spec-use"))); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + for hidden_html in [ + "", + "", + "", + ] { + advance_game_index_revision(&root, &code_state, hidden_html); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + } + + advance_game_index_revision( + &root, + &code_state, + "
", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); +} + +#[test] +fn cli_code_prototype_keeps_registered_canvas_spritesheet_gate_when_editor_is_configured() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"cli-art-gate-key"}}"#.to_string(), + ); + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("创建完整小游戏", "cli-code-art-gate-parent"); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("mark CLI 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("CLI must still require the art spritesheet"); assert!(blocker .detail .as_deref() @@ -1061,11 +1310,177 @@ fn code_prototype_requires_registered_canvas_spritesheet_reference_when_editor_i advance_game_index_revision( &root, &code_state, - "", + "", ); assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); } +#[test] +fn game_chat_requires_canvas_art_spec_even_without_editor_configuration() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮必须使用平台美术的小游戏", + "game-chat-unconfigured-art-gate-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + 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); + fs::remove_file(root.join("assets/art-spec.png")).expect("remove registered art-spec file"); + advance_game_index_revision( + &root, + &code_state, + "", + ); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("game-chat must fail closed without a valid Canvas art spec"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("canvas-registration-invalid"))); +} + +#[test] +fn game_chat_art_stage_fails_closed_without_editor_configuration_or_canvas_asset() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮必须先完成美术阶段的小游戏", + "game-chat-unconfigured-art-stage-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at(&root, "art-director", GameCreationAppTaskStatus::Running) + .expect("mark art director running"); + let art_record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "art-director"); + let art_state = agent_runtime_state_from_task_record(&art_record); + fs::remove_file(root.join("assets/art-spec.png")).expect("remove Canvas art spec file"); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &art_state) + .expect("game-chat art stage must fail closed without a valid Canvas asset"); + assert!(blocker.summary.contains("Canvas")); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("task=art-director"))); +} + +#[test] +fn game_chat_ready_child_can_converge_after_hydration_restores_manifest_to_pending() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮星空收集游戏", + "game-chat-ready-child-hydration-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Pending) + .expect("restore code prototype to pending as late hydration can do"); + let code_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); + let mut code_state = agent_runtime_state_from_task_record(&code_record); + advance_game_index_revision( + &root, + &code_state, + "", + ); + + assert!( + autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none(), + "the verified game-chat child owns the artifact and terminal projection will persist Completed" + ); + mark_verification_passed(&root, &code_state, "game.static_smoke"); + code_state.status = "completed".to_string(); + code_state.phase = "completed".to_string(); + assert!( + project_autonomous_manifest_ready_task_terminal_at(&root, &code_state) + .expect("project completed game-chat code child") + ); + let manifest = read_manifest_for_project(&root).expect("read projected game-chat manifest"); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .map(|task| &task.status), + Some(&GameCreationAppTaskStatus::Completed) + ); + let root_gate = read_game_creator_agent_runtime_verification_gate( + &root, + &parent_state.agent_id, + &parent_state.run_id, + ) + .expect("read projected root verification gate"); + assert_eq!(root_gate.verified_revision, Some(1)); + assert_eq!(root_gate.agent_id, parent_state.agent_id); + assert_eq!(root_gate.run_id, parent_state.run_id); + assert!(!root_gate.requires_verification); + assert_eq!(root_gate.mutation_revision, None); + assert_eq!( + root_gate.last_verification_status.as_deref(), + Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED) + ); + assert_eq!( + root_gate.last_verification_tool.as_deref(), + Some("game.static_smoke") + ); + for task_id in ["preview-readiness", "preview-playtest"] { + update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Completed) + .unwrap_or_else(|error| panic!("complete {task_id}: {error}")); + } + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &parent_state) + .expect("missing root playtest receipt must still block completion"); + assert!(blocker.summary.contains("交互试玩回执")); +} + +#[test] +fn game_chat_preview_child_still_rejects_pending_manifest_status() { + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮星空收集游戏", + "game-chat-preview-pending-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at( + &root, + "preview-readiness", + GameCreationAppTaskStatus::Pending, + ) + .expect("leave preview readiness pending"); + let preview_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-readiness"); + let preview_state = agent_runtime_state_from_task_record(&preview_record); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &preview_state) + .expect("preview child must keep the strict manifest status gate"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("status=pending"))); +} + +#[test] +fn gui_ready_child_still_rejects_pending_manifest_status() { + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("创建完整小游戏", "gui-ready-child-pending-manifest-parent"); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Pending) + .expect("mark GUI code prototype pending"); + 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("GUI child must keep the strict manifest status gate"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("status=pending"))); +} + #[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_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 24927fb05..12f7ff72f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -1,5 +1,118 @@ use super::*; +static AGENT_RUNTIME_EVENT_ID_SEQUENCE: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(1); + +fn new_game_creator_agent_runtime_event_id( + state: &AgentRuntimeState, + event_type: &str, + phase: &str, + action_id: Option<&str>, +) -> String { + if let Some(action_id) = action_id { + return format!( + "runtime-event-action-{}-{}-{}-{}", + state.run_id, event_type, phase, action_id + ); + } + let sequence = + AGENT_RUNTIME_EVENT_ID_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + format!( + "runtime-event-{}-{}-{}-{}", + std::process::id(), + unix_millis(), + sequence, + event_type + ) +} + +fn game_creator_agent_runtime_event_type_is_public(event_type: &str) -> bool { + matches!( + event_type, + "thinking_summary" + | "plan" + | "plan_update" + | "action" + | "observation" + | "turn.started" + | "turn.progress" + | "turn.completed" + | "turn.failed" + | "turn.budget_exhausted" + | "turn.cancelled" + | "response" + | "response.stale" + | "goal.paused" + | "goal.resumed" + | "agent.delegate.result" + | "agent.delegate.result_failed" + ) || event_type.starts_with("tool_confirmation.") + || event_type.starts_with("user_input.") +} + +fn game_creator_agent_runtime_public_event_text( + root: &Path, + event_type: &str, + summary: &str, +) -> Option { + let event_type = event_type.trim(); + if !game_creator_agent_runtime_event_type_is_public(event_type) { + return None; + } + let summary = redact_agent_runtime_error(root, summary.trim(), 240); + if summary.is_empty() { + return None; + } + let lower = summary.to_ascii_lowercase(); + if [ + "runtime.", + "agent.runtime.", + "provider.", + "provider_request.", + "parallel_read_batch.", + "provider_action_batch.", + "finalization.", + "context.", + "process_session.", + "steer.", + "autonomous_manifest.parent_wake", + "agent.delegate.parent_wake", + "command.exec:", + "command.exec:", + "command.output_read:", + "command.output_read:", + "agent.action_history:", + "agent.action_history:", + ] + .iter() + .any(|prefix| lower.starts_with(prefix)) + { + return None; + } + if [ + "sha256", + "fingerprint", + "authorization", + "bearer", + "api key", + "api_key", + "password", + "secret", + "cookie", + "token=", + "private process output", + " { Ok(None) @@ -2318,10 +2436,12 @@ pub(super) fn append_game_creator_agent_runtime_event_with_action( run_id: state.run_id.clone(), source: state.source.clone(), event_type: event_type.to_string(), + event_id: new_game_creator_agent_runtime_event_id(state, event_type, phase, action_id), action_id: action_id.map(ToString::to_string), status: status.to_string(), phase: phase.to_string(), summary: summary.to_string(), + public_text: game_creator_agent_runtime_public_event_text(root, event_type, summary), detail: detail .filter(|_| { !(event_type == "observation" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index f5aa2c596..cb3addee3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -228,6 +228,34 @@ pub(in crate::agent) fn render_static_delegate_task_contract( Ok(rendered) } +fn validate_publish_delegate_run_profile_at( + root: &Path, + agent_id: &str, + parent_run_id: &str, + target_agent_id: &str, +) -> Result<(), String> { + if !matches!(target_agent_id, "publish-strategy" | "publish-package") { + return Ok(()); + } + let current_binding = + read_game_creator_agent_runtime_run_profile_binding(root, agent_id, parent_run_id)? + .ok_or_else(|| { + "agent.delegate 缺少当前 Run Profile binding,已拒绝发布委派".to_string() + })?; + let root_binding = read_game_creator_agent_runtime_run_profile_binding( + root, + ¤t_binding.root_agent_id, + ¤t_binding.root_run_id, + )? + .ok_or_else(|| "agent.delegate 缺少 root Run Profile binding,已拒绝发布委派".to_string())?; + if root_binding.source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE { + return Err(format!( + "game-chat Run Profile 禁止委派 {target_agent_id},未创建 child runtime" + )); + } + Ok(()) +} + pub(crate) fn observe_agent_runtime_agent_delegate( root: &Path, agent_id: &str, @@ -359,6 +387,16 @@ pub(crate) fn observe_agent_runtime_agent_delegate( detail: None, }; } + if let Err(error) = + validate_publish_delegate_run_profile_at(root, agent_id, parent_run_id, &target_agent_id) + { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } let action_identity = action_id .filter(|value| !value.trim().is_empty()) .map(str::to_string) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs index 0f2815ff7..321e21aec 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs @@ -1347,12 +1347,31 @@ pub(in crate::agent) fn record_game_creator_agent_runtime_receipt_start_warning( pub(in crate::agent) fn observe_agent_runtime_schedule_ready_tasks( root: &Path, + agent_id: &str, + run_id: &str, input: &serde_json::Value, ) -> AgentRuntimeToolObservation { let limit = agent_runtime_tool_input_usize(input, &["limit", "maxTasks"]) .map(|value| value.clamp(1, 16)) .unwrap_or(16); - match schedule_game_creator_agent_ready_tasks_at(root, limit) { + let scheduled = + match read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id) { + Ok(Some(binding)) + if binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD => + { + schedule_autonomous_game_build_ready_tasks_at( + root, + &binding.root_agent_id, + &binding.root_run_id, + limit.min(3), + ) + } + Ok(_) => schedule_game_creator_agent_ready_tasks_at(root, limit), + Err(error) => Err(format!( + "agent.schedule_ready 无法核对当前 Run Profile 绑定:{error}" + )), + }; + match scheduled { Ok(results) => { let detail = results .iter() 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 dde4e6405..3fd2ab07a 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 @@ -203,8 +203,10 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( .as_ref() .map(|contract| contract.playtest_scenario.clone()) .or(input.playtest_scenario); - if completion_contract.is_some() { - if let Err(error) = remove_autonomous_playtest_receipt(root, agent_id, run_id) { + if let Some(contract) = completion_contract.as_ref() { + if let Err(error) = + remove_autonomous_playtest_receipt(root, &contract.agent_id, &contract.run_id) + { return AgentRuntimeToolObservation { tool: "preview.validate".to_string(), status: "failed".to_string(), @@ -246,10 +248,14 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( } }, }; + let (evidence_agent_id, evidence_run_id) = completion_contract + .as_ref() + .map(|contract| (contract.agent_id.as_str(), contract.run_id.as_str())) + .unwrap_or((agent_id, run_id)); let evidence_relative_root = format!( ".agent/runtime/browser-validations/{}/{}/{}", - agent_runtime_confirmation_path_component(agent_id, "agent"), - agent_runtime_confirmation_path_component(run_id, "run"), + agent_runtime_confirmation_path_component(evidence_agent_id, "agent"), + agent_runtime_confirmation_path_component(evidence_run_id, "run"), revision_before.revision, ); let evidence_root = match resolve_local_project_path(root, &evidence_relative_root) { @@ -339,7 +345,10 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( } } - if completion_contract.is_some() && !result.passed { + let contract_belongs_to_runtime = completion_contract.as_ref().is_some_and(|contract| { + contract.agent_id == runtime.agent_id && contract.run_id == runtime.run_id + }); + if contract_belongs_to_runtime && !result.passed { if let Err(error) = invalidate_agent_runtime_project_verification_after_preview_failure_at( root, agent_id, @@ -385,18 +394,20 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( }; } }; - if let Err(error) = clear_agent_runtime_failed_playtest_at( - root, - agent_id, - run_id, - revision_after.revision, - ) { - 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 contract_belongs_to_runtime { + if let Err(error) = clear_agent_runtime_failed_playtest_at( + root, + agent_id, + run_id, + revision_after.revision, + ) { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器验证已通过,但失败试玩凭证无法安全清除".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } } receipt } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs index bf93ebda3..ce731e574 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs @@ -2,15 +2,37 @@ use super::*; pub(in crate::agent) fn observe_agent_runtime_task_list( root: &Path, + agent_id: &str, + run_id: &str, ) -> AgentRuntimeToolObservation { - let result = read_manifest_for_project(root).map(|manifest| { - let ready_task_ids = ready_task_ids_for_tasks(&manifest.tasks); - let seed_task_ids = new_game_creation_app_seed_tasks() - .into_iter() - .map(|task| task.id) - .collect::>(); - let seed_tasks = manifest - .tasks + let result = (|| -> Result { + let game_chat_single_round = root_run_source_is_game_chat(root, agent_id, run_id)?; + let manifest = read_manifest_for_project(root)?; + let visible_tasks = if game_chat_single_round { + autonomous_manifest_seed_tasks_for_source(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE) + .into_iter() + .map(|mut projected| { + if let Some(persisted) = + manifest.tasks.iter().find(|task| task.id == projected.id) + { + projected.status = persisted.status.clone(); + } + projected + }) + .collect::>() + } else { + manifest.tasks.clone() + }; + let ready_task_ids = ready_task_ids_for_tasks(&visible_tasks); + let seed_task_ids = autonomous_manifest_seed_tasks_for_source(if game_chat_single_round { + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + } else { + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE + }) + .into_iter() + .map(|task| task.id) + .collect::>(); + let seed_tasks = visible_tasks .iter() .filter(|task| seed_task_ids.contains(&task.id)) .collect::>(); @@ -19,28 +41,23 @@ pub(in crate::agent) fn observe_agent_runtime_task_list( } else { ready_task_ids.join(", ") }; - let completed = manifest - .tasks + let completed = visible_tasks .iter() .filter(|task| task.status == GameCreationAppTaskStatus::Completed) .count(); - let running = manifest - .tasks + let running = visible_tasks .iter() .filter(|task| task.status == GameCreationAppTaskStatus::Running) .count(); - let pending = manifest - .tasks + let pending = visible_tasks .iter() .filter(|task| task.status == GameCreationAppTaskStatus::Pending) .count(); - let waiting = manifest - .tasks + let waiting = visible_tasks .iter() .filter(|task| task.status == GameCreationAppTaskStatus::WaitingForConfirmation) .count(); - let failed = manifest - .tasks + let failed = visible_tasks .iter() .filter(|task| task.status == GameCreationAppTaskStatus::Failed) .count(); @@ -72,10 +89,10 @@ pub(in crate::agent) fn observe_agent_runtime_task_list( ), format!( "taskCounts: completed={completed} running={running} pending={pending} waiting={waiting} failed={failed} total={}", - manifest.tasks.len() + visible_tasks.len() ), ]; - lines.extend(manifest.tasks.iter().map(|task| { + lines.extend(visible_tasks.iter().map(|task| { let dependencies = if task.dependencies.is_empty() { "-".to_string() } else { @@ -97,11 +114,144 @@ pub(in crate::agent) fn observe_agent_runtime_task_list( artifacts ) })); - lines.join("\n") - }); + Ok(lines.join("\n")) + })(); observation_from_text_result("task.list", result, "已读取 manifest 任务图") } +fn root_run_source_is_game_chat(root: &Path, agent_id: &str, run_id: &str) -> Result { + let binding = read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? + .ok_or_else(|| "task.list 缺少当前 Run Profile binding,无法确认运行来源".to_string())?; + let root_binding = if binding.root_agent_id == binding.agent_id + && binding.root_run_id == binding.run_id + { + binding + } else { + read_game_creator_agent_runtime_run_profile_binding( + root, + &binding.root_agent_id, + &binding.root_run_id, + )? + .ok_or_else(|| "task.list 缺少 root Run Profile binding,无法确认运行来源".to_string())? + }; + Ok(root_binding.source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn game_chat_task_list_hides_publish_tasks_and_counts() { + let temporary = tempfile::tempdir().expect("create task list project"); + let root = temporary.path(); + init_local_game_project_at(root, "game-chat-task-list", "game-chat task list") + .expect("initialize project"); + bind_game_creator_agent_runtime_run_profile_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "game-chat-task-list-run", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind game-chat run"); + for task_id in [ + "art-director", + "code-prototype", + "preview-readiness", + "preview-playtest", + ] { + update_manifest_task_status_at(root, task_id, GameCreationAppTaskStatus::Completed) + .expect("complete game-chat seed task"); + } + + let observation = observe_agent_runtime_task_list( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "game-chat-task-list-run", + ); + assert_eq!(observation.status, "ok"); + let detail = observation.detail.expect("task list detail"); + assert!(detail.contains("readyTaskIds: (none)"), "{detail}"); + assert!(!detail.contains("publish-strategy"), "{detail}"); + assert!(!detail.contains("publish-package"), "{detail}"); + assert!( + detail.contains( + "seedTaskCounts: completed=4 running=0 pending=0 waiting=0 failed=0 total=4" + ), + "{detail}" + ); + assert!( + detail + .contains("taskCounts: completed=4 running=0 pending=0 waiting=0 failed=0 total=4"), + "{detail}" + ); + + for task in new_game_creation_app_seed_tasks().into_iter().take(14) { + update_manifest_task_status_at(root, &task.id, GameCreationAppTaskStatus::Completed) + .expect("complete full pre-publish DAG for GUI comparison"); + } + + bind_game_creator_agent_runtime_run_profile_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "gui-task-list-run", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind GUI run"); + let gui_observation = observe_agent_runtime_task_list( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "gui-task-list-run", + ); + assert_eq!(gui_observation.status, "ok"); + let gui_detail = gui_observation.detail.expect("GUI task list detail"); + assert!( + gui_detail.contains("readyTaskIds: publish-strategy"), + "{gui_detail}" + ); + assert!( + gui_detail.contains( + "taskCounts: completed=14 running=0 pending=2 waiting=0 failed=0 total=16" + ), + "{gui_detail}" + ); + assert!(gui_detail.contains("publish-strategy"), "{gui_detail}"); + } + + #[test] + fn task_list_fails_closed_when_current_binding_parent_is_missing() { + let temporary = tempfile::tempdir().expect("create task list project"); + let root = temporary.path(); + init_local_game_project_at(root, "game-chat-task-list-missing-parent", "task list") + .expect("initialize project"); + let parent_run_id = "missing-game-chat-parent"; + let task_link = AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + ..Default::default() + }; + bind_game_creator_agent_runtime_run_profile_at( + root, + "code-prototype", + "game-chat-child-run", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + Some(&task_link), + ) + .expect("bind child run"); + + let observation = + observe_agent_runtime_task_list(root, "code-prototype", "game-chat-child-run"); + assert_eq!(observation.status, "failed"); + assert!(observation.detail.is_none()); + assert!(observation.summary.contains("binding"), "{observation:?}"); + } +} + pub(in crate::agent) fn observe_agent_runtime_task_create( root: &Path, agent_id: &str, 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 75cbb68f4..acee27f43 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -879,8 +879,11 @@ pub(crate) fn resume_game_creator_agent_runtime_tasks( ) -> Result, String> { 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 !has_recoverable_game_creator_agent_background_tasks_at(root)? { + return Ok(Vec::new()); + } + enforce_project_permission_policy(root, "conversation.write")?; enforce_project_auto_permission_policy(root, "agent.resume")?; resume_game_creator_agent_background_tasks_at(root) } @@ -1344,16 +1347,26 @@ pub(crate) fn append_local_conversation_message( agent_id: Option, session_id: Option, message: LocalConversationMessage, + message_id: Option, ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.write")?; let _lock = acquire_project_write_lock(root, "conversation.write")?; - append_local_conversation_message_for_session_at( - root, - agent_id.as_deref(), - session_id.as_deref(), - message, - ) + match message_id.as_deref() { + Some(message_id) => append_local_conversation_message_for_session_idempotent_at( + root, + agent_id.as_deref(), + session_id.as_deref(), + message, + message_id, + ), + None => append_local_conversation_message_for_session_at( + root, + agent_id.as_deref(), + session_id.as_deref(), + message, + ), + } } #[tauri::command] 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 1b6a1996d..7561cb15b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -675,6 +675,24 @@ pub(crate) fn initialize_windows_game_creator_file_owner_for_current_user( secure_windows_game_creator_path_for_current_user_with_owner_policy(path, false, true, true) } +#[cfg(windows)] +pub(crate) fn windows_private_dacl_security_information( + initialize_owner: bool, + owner_matches: bool, +) -> u32 { + const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001; + const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004; + const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000; + + DACL_SECURITY_INFORMATION + | PROTECTED_DACL_SECURITY_INFORMATION + | if initialize_owner && !owner_matches { + OWNER_SECURITY_INFORMATION + } else { + 0 + } +} + #[cfg(windows)] fn secure_windows_game_creator_path_for_current_user_with_owner_policy( path: &Path, @@ -795,7 +813,6 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( const SE_FILE_OBJECT: u32 = 1; const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001; const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004; - const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000; const SE_DACL_PROTECTED: u16 = 0x1000; const TOKEN_QUERY: u32 = 0x0000_0008; const TOKEN_USER_CLASS: u32 = 1; @@ -925,18 +942,13 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( )); } // SAFETY: path is NUL terminated and private_dacl was allocated by SetEntriesInAclW. + let should_initialize_owner = initialize_owner && !owner_matches; let set_status = unsafe { SetNamedSecurityInfoW( wide_path.as_mut_ptr(), SE_FILE_OBJECT, - DACL_SECURITY_INFORMATION - | PROTECTED_DACL_SECURITY_INFORMATION - | if initialize_owner { - OWNER_SECURITY_INFORMATION - } else { - 0 - }, - if initialize_owner { + windows_private_dacl_security_information(initialize_owner, owner_matches), + if should_initialize_owner { current_user_sid } else { std::ptr::null_mut() 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 fda932a89..61fd376e2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -468,6 +468,8 @@ struct AgentRuntimeEvent { #[serde(default)] event_type: String, #[serde(default)] + event_id: String, + #[serde(default)] action_id: Option, #[serde(default)] status: String, @@ -476,6 +478,8 @@ struct AgentRuntimeEvent { #[serde(default)] summary: String, #[serde(default)] + public_text: Option, + #[serde(default)] detail: Option, #[serde(default)] updated_at: u64, diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index d36f60193..056d25ce3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -84,6 +84,32 @@ impl PreviewRegistry { static GAME_CREATOR_PREVIEW_REGISTRY: OnceLock = OnceLock::new(); +const PREVIEW_REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(2); +const PREVIEW_REQUEST_MAX_HEADER_BYTES: usize = 32 * 1024; +const PREVIEW_REQUEST_MAX_HEADER_LINES: usize = 100; +const PREVIEW_RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_millis(250); +const PREVIEW_RESPONSE_DRAIN_MAX_BYTES: usize = 32 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PreviewListenerAcceptDisposition { + Sleep, + Retry, + Stop, +} + +pub(crate) fn classify_preview_listener_accept_error( + error: &std::io::Error, +) -> PreviewListenerAcceptDisposition { + match error.kind() { + std::io::ErrorKind::WouldBlock => PreviewListenerAcceptDisposition::Sleep, + std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::Interrupted + | std::io::ErrorKind::TimedOut => PreviewListenerAcceptDisposition::Retry, + _ => PreviewListenerAcceptDisposition::Stop, + } +} + pub(crate) fn game_creator_preview_registry() -> PreviewRegistry { GAME_CREATOR_PREVIEW_REGISTRY .get_or_init(PreviewRegistry::default) @@ -392,10 +418,18 @@ pub(crate) fn start_local_game_preview_for_project( } match listener.accept() { Ok((stream, _)) => handle_preview_stream(stream, &served_root), - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(25)); - } - Err(_) => break, + // Chromium can abandon a speculative loopback socket before accept() consumes + // it. Keep the listener alive for that connection; only an unrecoverable listener + // error should tear down the preview server. + Err(error) => match classify_preview_listener_accept_error(&error) { + PreviewListenerAcceptDisposition::Sleep => { + thread::sleep(Duration::from_millis(25)); + } + PreviewListenerAcceptDisposition::Retry => { + thread::sleep(Duration::from_millis(5)); + } + PreviewListenerAcceptDisposition::Stop => break, + }, } }); @@ -410,19 +444,109 @@ pub(crate) fn start_local_game_preview_for_project( } fn handle_preview_stream(mut stream: TcpStream, root: &Path) { - let mut request_line = String::new(); - { - let mut reader = BufReader::new(&mut stream); - if reader.read_line(&mut request_line).is_err() { - return; - } + // The listener is nonblocking so its accept loop can observe the stop channel. Windows may + // inherit that mode on accepted sockets; switch each connection back to blocking mode before + // waiting for Chromium's split request headers. + if stream.set_nonblocking(false).is_err() { + return; } + let request_line = match read_preview_request_line(&mut stream) { + Ok(Some(request_line)) => request_line, + Ok(None) | Err(_) => return, + }; let mut parts = request_line.split_whitespace(); let method = parts.next().unwrap_or_default(); let url_path = parts.next().unwrap_or("/"); let response = build_preview_response(root, method, url_path); - let _ = stream.write_all(&response); + if stream.write_all(&response).is_ok() { + let _ = stream.flush(); + // Explicitly half-close after the complete response, then consume the peer's remaining + // request bytes for a short bounded interval. This lets Windows complete a graceful + // FIN/ACK exchange instead of surfacing the close as WSAECONNABORTED to Chromium. + let _ = stream.shutdown(std::net::Shutdown::Write); + drain_preview_request_after_response(&mut stream); + } +} + +fn drain_preview_request_after_response(stream: &mut TcpStream) { + let _ = stream.set_read_timeout(Some(PREVIEW_RESPONSE_DRAIN_TIMEOUT)); + let mut buffer = [0u8; 4096]; + let mut drained_bytes = 0usize; + while drained_bytes < PREVIEW_RESPONSE_DRAIN_MAX_BYTES { + match stream.read(&mut buffer) { + Ok(0) => break, + Ok(bytes_read) => { + drained_bytes = drained_bytes.saturating_add(bytes_read); + } + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + break; + } + Err(_) => break, + } + } +} + +/// Read the request line and all headers before closing the connection. +/// +/// Chromium can deliver the request line and headers in separate packets. Dropping the +/// stream after only `read_line` leaves unread request bytes on Windows and may make the +/// close look like an abortive RST (`net::ERR_SOCKET_NOT_CONNECTED`). The bounded read keeps +/// slow or malformed clients from occupying a preview thread indefinitely. +fn read_preview_request_line(stream: &mut TcpStream) -> std::io::Result> { + stream.set_read_timeout(Some(PREVIEW_REQUEST_READ_TIMEOUT))?; + let mut reader = BufReader::new(stream); + let mut request_line = Vec::new(); + let mut total_bytes = 0usize; + + for line_index in 0..PREVIEW_REQUEST_MAX_HEADER_LINES { + let mut line = Vec::new(); + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + return Ok(None); + } + let newline_index = available.iter().position(|byte| *byte == b'\n'); + let bytes_to_consume = newline_index + .map(|index| index + 1) + .unwrap_or(available.len()); + if total_bytes.saturating_add(bytes_to_consume) > PREVIEW_REQUEST_MAX_HEADER_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "preview request headers exceed the size limit", + )); + } + line.extend_from_slice(&available[..bytes_to_consume]); + total_bytes += bytes_to_consume; + reader.consume(bytes_to_consume); + if newline_index.is_some() { + break; + } + } + let is_blank_line = line == b"\r\n" || line == b"\n"; + if line_index == 0 { + request_line = line; + } + if is_blank_line { + return String::from_utf8(request_line).map(Some).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "preview request line is not valid UTF-8", + ) + }); + } + } + + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "preview request headers exceed the line limit", + )) } pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str) -> Vec { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index ae54a9ba5..91fb6a9ff 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -258,11 +258,12 @@ pub(crate) fn validate_manifest_required_visual_asset( .and_then(|path| fs::read(path).ok()) .filter(|bytes| bytes.starts_with(b"\x89PNG\r\n\x1a\n")) .ok_or_else(|| format!("规范视觉资产不是有效登记的 PNG 文件:{expected_path}"))?; - if task_id == "art-asset-plan" - && !image::load_from_memory(&bytes) - .ok() - .is_some_and(|image| image.to_rgba8().pixels().any(|pixel| pixel[3] < u8::MAX)) - { + let decoded = image::load_from_memory(&bytes) + .map_err(|_| format!("规范视觉资产 PNG 无法完整解码:{expected_path}"))?; + if decoded.width() == 0 || decoded.height() == 0 { + return Err(format!("规范视觉资产 PNG 尺寸无效:{expected_path}")); + } + if task_id == "art-asset-plan" && !decoded.to_rgba8().pixels().any(|pixel| pixel[3] < u8::MAX) { return Err("首版美术素材图没有真实透明像素".to_string()); } let canvas_project_id = asset diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs index bb3167a00..541b08311 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs @@ -2060,6 +2060,136 @@ fn agent_native_delegate_contract_flows_through_parser_executor_and_delivery() { fs::remove_dir_all(root).ok(); } +#[test] +fn game_chat_autonomous_run_rejects_publish_delegates_before_child_creation() { + for target_agent_id in ["publish-strategy", "publish-package"] { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "game-chat 发布委派门禁") + .expect("project init"); + let parent_run_id = format!("game-chat-publish-deny-{target_agent_id}"); + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_run_id, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind game-chat autonomous profile"); + let action_id = format!("deny-{target_agent_id}"); + let observation = observe_agent_runtime_agent_delegate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_run_id, + Some(&action_id), + &serde_json::json!({ + "agentId": target_agent_id, + "task": "不应创建发布任务", + "acceptanceCriteria": ["必须先被 Runtime 拒绝"], + "expectedArtifacts": [], + "repairOfDelegationId": null, + "runId": null + }), + ); + assert_eq!(observation.status, "failed", "{observation:?}"); + assert!( + observation.summary.contains("game-chat") + || observation.summary.contains("publish") + || observation.summary.contains("发布"), + "{observation:?}" + ); + let delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_run_id, + target_agent_id, + &action_id, + ); + assert!( + read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read rejected delivery") + .is_none(), + "rejected publish delegate must not create delivery" + ); + assert!( + read_latest_game_creator_agent_runtime_task_by_delegation_id( + &root, + target_agent_id, + &delegation_id, + ) + .expect("read rejected child task") + .is_none(), + "rejected publish delegate must not create child runtime" + ); + fs::remove_dir_all(root).ok(); + } +} + +#[test] +fn gui_and_cli_autonomous_runs_still_allow_publish_delegates() { + for source in [ + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + ] { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "GUI CLI 发布委派允许") + .expect("project init"); + let parent_run_id = format!("publish-allow-{source}"); + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_run_id, + source, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind non-game-chat autonomous profile"); + start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "GUI CLI 发布委派父 Runtime", + &parent_run_id, + source, + "准备发布委派", + vec!["验证发布委派仍可创建子 Runtime".to_string()], + ) + .expect("start non-game-chat parent runtime"); + let target_agent_id = "publish-strategy"; + let action_id = format!("allow-{source}"); + let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id) + .expect("acquire publish target lane") + .expect("publish target lane available"); + let observation = observe_agent_runtime_agent_delegate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_run_id, + Some(&action_id), + &serde_json::json!({ + "agentId": target_agent_id, + "task": "允许 GUI CLI 发布策略委派", + "acceptanceCriteria": ["返回可核对回执"], + "expectedArtifacts": [], + "repairOfDelegationId": null, + "runId": null + }), + ); + assert_eq!(observation.status, "ok", "{observation:?}"); + let delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_run_id, + target_agent_id, + &action_id, + ); + assert!( + read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read allowed delivery") + .is_some(), + "non-game-chat publish delegate should create delivery" + ); + drop(target_lock); + fs::remove_dir_all(root).ok(); + } +} + #[test] fn visual_specialist_delegations_require_image_artifacts_but_read_only_work_allows_none() { let _config_guard = crate::tests::write_test_local_config( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs index f0580e9e2..a255f8748 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs @@ -90,6 +90,12 @@ fn project_supervisor_parent_wake_singleflight_coalesces_late_signal() { assert!(autonomous_manifest_parent_wake_error_is_transient( "项目正在被其他写操作占用:$PROJECT_ROOT/.agent/project.lock" )); + assert!(autonomous_manifest_parent_wake_error_is_transient( + "获取 Agent Runtime 系统文件锁失败:$PROJECT_ROOT/.agent/runtime/locks/balance-seed.lock: 另一个程序正在使用此文件。 (os error 32)" + )); + assert!(autonomous_manifest_parent_wake_error_is_transient( + "获取 Agent Runtime 系统文件锁失败:sharing violation" + )); assert!(!autonomous_manifest_parent_wake_error_is_transient( "manifest JSON 已损坏" )); 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 d529116c8..0276cb20d 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 @@ -1378,6 +1378,25 @@ pub(crate) fn set_windows_test_path_owner_to_distinct_token_owner(path: &Path) - changed } +#[cfg(windows)] +#[test] +fn windows_private_dacl_does_not_reassert_an_owner_that_already_matches() { + const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001; + const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004; + const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000; + + assert_eq!( + windows_private_dacl_security_information(true, true), + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION + ); + assert_eq!( + windows_private_dacl_security_information(true, false), + OWNER_SECURITY_INFORMATION + | DACL_SECURITY_INFORMATION + | PROTECTED_DACL_SECURITY_INFORMATION + ); +} + #[cfg(windows)] #[test] fn windows_appdata_validation_does_not_follow_directory_links() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 550350adc..344461628 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -331,6 +331,17 @@ fn canonical_visual_completion_requires_persisted_route_kind_and_current_spec_re .unwrap_or_else(|error| panic!("{task_id} provenance should pass: {error}")); } + let art_spec_path = root.join("assets/art-spec.png"); + let valid_art_spec = fs::read(&art_spec_path).expect("read valid art spec fixture"); + fs::write(&art_spec_path, &valid_art_spec[..valid_art_spec.len() / 2]) + .expect("write truncated art spec PNG"); + assert!( + validate_manifest_required_visual_asset(&root, &manifest, "art-director") + .expect_err("truncated PNG must fail complete decode") + .contains("无法完整解码") + ); + fs::write(&art_spec_path, valid_art_spec).expect("restore valid art spec PNG"); + let ui = manifest .assets .iter_mut() @@ -3190,6 +3201,77 @@ fn local_preview_server_serves_game_index() { fs::remove_dir_all(root).ok(); } +#[test] +fn local_preview_server_drains_split_browser_headers_before_response() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "鍍忕礌鍔ㄤ綔鍘熷瀷").expect("project init"); + + let (preview, stop) = start_local_game_preview_for_project(&root).expect("preview start"); + let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("read timeout"); + stream + .write_all(b"GET / HTTP/1.1\r\n") + .expect("request line"); + // Chromium may send the request line before the rest of its headers. Keep this split + // deliberate so the server must consume the complete header block before responding. + thread::sleep(Duration::from_millis(100)); + stream + .write_all( + b"Host: 127.0.0.1\r\nConnection: close\r\nUser-Agent: Mozilla/5.0\r\nAccept: text/html\r\n\r\n", + ) + .expect("browser headers"); + let mut response = Vec::new(); + stream.read_to_end(&mut response).expect("response"); + let header_end = response + .windows(b"\r\n\r\n".len()) + .position(|window| window == b"\r\n\r\n") + .expect("complete HTTP header block") + + b"\r\n\r\n".len(); + let headers = String::from_utf8(response[..header_end].to_vec()).expect("HTTP headers"); + assert!(headers.starts_with("HTTP/1.1 200 OK\r\n"), "{headers}"); + let content_length = headers + .lines() + .find_map(|line| line.strip_prefix("Content-Length: ")) + .and_then(|value| value.parse::().ok()) + .expect("valid content length"); + assert_eq!(response.len() - header_end, content_length); + assert!(headers.contains("Connection: close"), "{headers}"); + assert!(String::from_utf8_lossy(&response[header_end..]).contains("还没有生成游戏")); + + let _ = stop.send(()); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn preview_listener_retries_transient_accept_errors() { + assert_eq!( + classify_preview_listener_accept_error(&std::io::Error::from( + std::io::ErrorKind::WouldBlock, + )), + PreviewListenerAcceptDisposition::Sleep + ); + for kind in [ + std::io::ErrorKind::ConnectionAborted, + std::io::ErrorKind::ConnectionReset, + std::io::ErrorKind::Interrupted, + std::io::ErrorKind::TimedOut, + ] { + assert_eq!( + classify_preview_listener_accept_error(&std::io::Error::from(kind)), + PreviewListenerAcceptDisposition::Retry, + "transient accept error {kind:?} must keep preview server alive" + ); + } + assert_eq!( + classify_preview_listener_accept_error(&std::io::Error::from( + std::io::ErrorKind::InvalidData, + )), + PreviewListenerAcceptDisposition::Stop + ); +} + #[test] fn preview_content_type_covers_common_game_assets() { assert_eq!(content_type(Path::new("hero.webp")), "image/webp"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs index 3d1d27ab9..204b8d047 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs @@ -1852,6 +1852,147 @@ async fn background_agent_runtime_repairs_terminal_receipt_through_reconciliatio fs::remove_dir_all(root).ok(); } +#[test] +fn background_agent_runtime_resume_preflight_skips_policy_for_fresh_project() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "fresh project").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["conversation.write".to_string(), "agent.resume".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("require confirmation for write permissions"); + + let resumed = resume_game_creator_agent_runtime_tasks(root.to_string_lossy().into_owned()) + .expect("fresh project has no recoverable runtime work"); + assert!(resumed.is_empty()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn background_agent_runtime_resume_preflight_preserves_policy_for_terminal_recovery_artifact() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "terminal recovery artifact") + .expect("project init"); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: "design-director".to_string(), + session_id: "agent-session-design-director".to_string(), + run_id: "design-terminal-artifact-run".to_string(), + source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "terminal runtime with durable artifact".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "completed".to_string(), + terminal_detail: Some("completed".to_string()), + error: None, + updated_at: unix_timestamp(), + }, + ); + let artifact = root + .join(".agent/runtime/pending-actions/design-director/design-terminal-artifact-run.json"); + fs::create_dir_all(artifact.parent().expect("artifact parent")) + .expect("create artifact directory"); + fs::write(&artifact, b"{}\n").expect("write durable recovery artifact"); + + let error = resume_game_creator_agent_runtime_tasks(root.to_string_lossy().into_owned()) + .expect_err("durable recovery artifact still requires agent.resume approval"); + assert!(error.contains("agent.resume"), "{error}"); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn background_agent_runtime_resume_preflight_skips_policy_for_terminal_project() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "terminal project").expect("project init"); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: "design-director".to_string(), + session_id: "agent-session-design-director".to_string(), + run_id: "design-terminal-run".to_string(), + source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "completed runtime".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "completed".to_string(), + terminal_detail: Some("completed".to_string()), + error: None, + updated_at: unix_timestamp(), + }, + ); + + let resumed = resume_game_creator_agent_runtime_tasks(root.to_string_lossy().into_owned()) + .expect("terminal project has no recoverable runtime work"); + assert!(resumed.is_empty()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn background_agent_runtime_resume_preflight_preserves_policy_for_recoverable_task() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "recoverable project").expect("project init"); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: "design-director".to_string(), + session_id: "agent-session-design-director".to_string(), + run_id: "design-recoverable-run".to_string(), + source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "recoverable runtime".to_string(), + status: "pending".to_string(), + phase: "queued".to_string(), + current_action: "waiting for recovery".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }, + ); + + let error = resume_game_creator_agent_runtime_tasks(root.to_string_lossy().into_owned()) + .expect_err("recoverable runtime still requires agent.resume policy approval"); + assert!(error.contains("agent.resume"), "{error}"); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_resume_commands_distinguish_auto_and_confirmed_paths() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index 8fb6c488e..d718069e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -86,6 +86,100 @@ fn runtime_task_reader_rejects_unterminated_non_truncated_syntax_error() { fs::remove_dir_all(root).ok(); } +#[test] +fn runtime_events_expose_stable_ids_and_backend_owned_public_text_only() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("create runtime event fixture directory"); + let state = default_game_creator_agent_runtime_state("code-prototype", "public-event-run"); + + append_game_creator_agent_runtime_action_event( + &root, + &state, + "turn.progress", + "running", + "planning", + "正在生成首个可玩版本", + Some("taskSha256=private-hash"), + "public-event-progress-1", + ) + .expect("append public progress"); + append_game_creator_agent_runtime_action_event( + &root, + &state, + "observation", + "running", + "observation", + "runtime.plan_update:blocked · 内部计划门禁", + Some("fingerprint=private"), + "public-event-internal-1", + ) + .expect("append internal observation"); + append_game_creator_agent_runtime_action_event( + &root, + &state, + "turn.progress", + "running", + "planning", + "Bearer secret-token", + None, + "public-event-sensitive-1", + ) + .expect("append sensitive progress"); + append_game_creator_agent_runtime_action_event( + &root, + &state, + "action", + "running", + "action", + "调用工具 file.write", + Some("raw tool input must stay private"), + "public-event-action-1", + ) + .expect("append public action"); + append_game_creator_agent_runtime_action_event( + &root, + &state, + "action", + "running", + "action", + "调用工具 file.write", + Some("raw tool input must stay private"), + "public-event-action-1", + ) + .expect("repeat public action idempotently"); + + let events = read_recent_game_creator_agent_runtime_events( + &game_creator_agent_runtime_event_path(&root, "code-prototype"), + ) + .expect("read public runtime events"); + assert_eq!(events.len(), 4); + assert!(events.iter().all(|event| !event.event_id.trim().is_empty())); + let mut event_ids = events + .iter() + .map(|event| event.event_id.as_str()) + .collect::>(); + event_ids.sort_unstable(); + event_ids.dedup(); + assert_eq!(event_ids.len(), events.len()); + assert_eq!( + events[0].public_text.as_deref(), + Some("正在生成首个可玩版本") + ); + assert_eq!(events[1].public_text, None); + assert_eq!(events[2].public_text, None); + assert_eq!( + events[3].public_text.as_deref(), + Some("调用工具 file.write") + ); + assert!(!events[3] + .public_text + .as_deref() + .unwrap_or_default() + .contains("raw tool input must stay private")); + + fs::remove_dir_all(root).ok(); +} + #[test] fn runtime_task_reader_rejects_unknown_status_and_phase() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs index 9eb96c7d7..2f73eaf39 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs @@ -2616,6 +2616,7 @@ fn local_conversation_write_respects_project_policy() { content: "should fail".to_string(), agent_id: None, }, + None, ) .expect_err("conversation write denied"); assert!(error.contains("项目权限策略拒绝执行:conversation.write")); @@ -2623,6 +2624,33 @@ fn local_conversation_write_respects_project_policy() { fs::remove_dir_all(root).ok(); } +#[test] +fn local_conversation_command_message_id_is_idempotent() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "game-chat 输出消息").expect("project init"); + let message_id = "game-chat-output-code-prototype-run-1"; + let append = || { + append_local_conversation_message( + root.to_string_lossy().into_owned(), + None, + None, + LocalConversationMessage { + role: "assistant".to_string(), + content: "【程序 Agent】\n首个可玩版本代码已生成。".to_string(), + agent_id: None, + }, + Some(message_id.to_string()), + ) + }; + + append().expect("append game-chat output"); + let repeated = append().expect("repeat game-chat output idempotently"); + assert_eq!(repeated.messages.len(), 1); + assert_eq!(repeated.messages[0].message_id.as_deref(), Some(message_id)); + + fs::remove_dir_all(root).ok(); +} + #[test] fn local_conversation_read_respects_project_policy() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 561be140e..19c7c0049 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -230,6 +230,10 @@ import { buildGameChatProgressEvidence, collectGameChatResultImages, formatGameChatStageRecord, + gameChatFinalReplyMessages, + gameChatRuntimeEventMessages, + mergeGameChatFinalReplyMessagesIntoHistory, + mergeGameChatRuntimeEventMessagesIntoHistory, SupervisorChatOnlyView, } from './features/project-workspace/SupervisorChatOnlyView'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; @@ -263,6 +267,56 @@ type GameChatPreviewValidationCandidate = GameChatPlayableRevision & { playable: boolean; }; +const GAME_CHAT_STAGE_TASK_IDS = [ + 'art-director', + 'code-prototype', + 'preview-readiness', + 'preview-playtest', +] as const; + +function gameChatManifestHasTerminalStageTasks( + manifest: GameCreationAppManifest | null, +) { + if (!manifest) { + return false; + } + return GAME_CHAT_STAGE_TASK_IDS.every((taskId) => { + const status = manifest.tasks.find((task) => task.id === taskId)?.status; + return status === 'completed' || status === 'failed'; + }); +} + +function gameChatRuntimeHasTerminalOutcome(runtime: AgentRuntimeState) { + return ( + ['completed', 'failed', 'cancelled'].includes(runtime.status) || + ['completed', 'failed', 'cancelled'].includes(runtime.phase) + ); +} + +function mergeGameChatHydratedConversationMessages( + historyMessages: ChatMessage[], + currentMessages: ChatMessage[], +) { + const mergedMessages = mergeGameChatRuntimeEventMessagesIntoHistory( + mergeGameChatFinalReplyMessagesIntoHistory( + mergeGameChatRuntimeResponseMessagesIntoHistory( + historyMessages, + currentMessages, + ), + currentMessages, + ), + currentMessages, + ); + const historyMessageReferences = new Set(historyMessages); + const pendingMessages = mergedMessages + .filter((message) => !historyMessageReferences.has(message)) + .sort((left, right) => (left.updatedAt ?? 0) - (right.updatedAt ?? 0)); + // Conversation persistence uses a positional cursor. Keep durable history + // as one prefix so newly observed replies cannot sort ahead of that cursor + // and be skipped during hydration. + return [...historyMessages, ...pendingMessages]; +} + function gameChatPlayableRevisionIsAfterAuthorization( revision: GameChatPlayableRevision, authorization: GameChatAutoPreviewAuthorization, @@ -575,6 +629,9 @@ export function App({ const gameChatAutoPreviewAttemptedRef = useRef(new Set()); const gameChatObservedRunKeysRef = useRef(new Set()); const gameChatArchivedRunKeysRef = useRef(new Set()); + const gameChatPendingStageRuntimesRef = useRef( + new Map(), + ); const gameChatCommittedResponseStreamKeysRef = useRef(new Set()); const initialSupervisorMessageLatchRef = useRef({ projectPath: initialProjectPath, @@ -828,9 +885,8 @@ export function App({ const candidateStream = nextStream; if (candidateStream?.status === 'ready' && gameChatOnly) { const text = candidateStream.accumulatedText.trim(); - const responseKey = projectSupervisorResponseStreamIdentity( - candidateStream, - ); + const responseKey = + projectSupervisorResponseStreamIdentity(candidateStream); const messageId = `runtime-response:${responseKey}`; const alreadyCommitted = gameChatCommittedResponseStreamKeysRef.current.has(responseKey) || @@ -887,6 +943,7 @@ export function App({ projectSupervisorRuntimeRef.current = null; projectSupervisorExpectedRunIdRef.current = null; projectSupervisorResponseStreamRef.current = null; + gameChatPendingStageRuntimesRef.current.clear(); gameChatCommittedResponseStreamKeysRef.current.clear(); projectSupervisorRuntimeSyncingRef.current.clear(); setProjectSupervisorSessionId(null); @@ -933,43 +990,207 @@ export function App({ }); } + function flushPendingGameChatStageRecords() { + if (!gameChatOnly || !gameChatManifestHasTerminalStageTasks(manifest)) { + return; + } + for (const [ + archiveKey, + pendingRuntime, + ] of gameChatPendingStageRuntimesRef.current) { + if (gameChatArchivedRunKeysRef.current.has(archiveKey)) { + gameChatPendingStageRuntimesRef.current.delete(archiveKey); + continue; + } + const progress = buildGameChatProgressEvidence( + pendingRuntime, + agentRuntimeById, + manifest, + ); + if (!progress) { + continue; + } + const text = formatGameChatStageRecord( + pendingRuntime, + progress, + collectGameChatResultImages(manifest), + ); + gameChatArchivedRunKeysRef.current.add(archiveKey); + gameChatPendingStageRuntimesRef.current.delete(archiveKey); + setMessages((current) => { + if ( + current.some( + (message) => message.role === 'assistant' && message.text === text, + ) + ) { + return current; + } + // Conversation hydration can update the saved cursor in the same + // turn as this append. Clamp it to the pre-append list so the new + // stage record remains visible to the persistence effect. + const projectPath = archiveKey.split('\n', 1)[0]; + if (savedConversationProjectPathRef.current === projectPath) { + savedConversationCountRef.current = Math.min( + savedConversationCountRef.current, + current.length, + ); + } + return [...current, { role: 'assistant', text }]; + }); + } + } + + function appendGameChatRuntimeEventMessages( + nextProjectPath: string, + runtime: AgentRuntimeState, + ) { + const eventMessages = gameChatRuntimeEventMessages( + runtime, + agentRuntimeById, + ); + if (eventMessages.length === 0) { + return; + } + setMessages((current) => { + const currentMessageIds = new Set( + current + .map((message) => message.messageId?.trim()) + .filter((messageId): messageId is string => Boolean(messageId)), + ); + const missingMessages = eventMessages.filter( + (message) => + message.messageId && !currentMessageIds.has(message.messageId), + ); + if (missingMessages.length === 0) { + return current; + } + if (savedConversationProjectPathRef.current === nextProjectPath) { + savedConversationCountRef.current = Math.min( + savedConversationCountRef.current, + current.length, + ); + } + return [...current, ...missingMessages]; + }); + } + + const appendGameChatFinalReplyMessages = useCallback( + (nextProjectPath: string, runtimeResults: AgentRuntimeResult[]) => { + if (!gameChatOnly || runtimeResults.length === 0) { + return; + } + // A professional Runtime is only part of the active game-chat turn when + // it was delegated by the current Project Supervisor run. This prevents + // a stale child Runtime (or a different app mode) from leaking into the + // project transcript after a restart. + const supervisorRunId = + projectSupervisorRuntimeRef.current?.runId ?? + runtimeResults.find( + (result) => result.state.agentId === PROJECT_SUPERVISOR_AGENT_ID, + )?.state.runId; + if (!supervisorRunId) { + return; + } + const messages = runtimeResults.flatMap((result) => { + const runtime = agentRuntimeStateFromResult(result); + if ( + runtime.parentAgentId !== PROJECT_SUPERVISOR_AGENT_ID || + runtime.parentRunId !== supervisorRunId + ) { + return []; + } + return gameChatFinalReplyMessages([result.responseStream]); + }); + if (messages.length === 0) { + return; + } + setMessages((current) => { + const currentMessageIds = new Set( + current + .map((message) => message.messageId?.trim()) + .filter((messageId): messageId is string => Boolean(messageId)), + ); + const missingMessages = messages.filter( + (message) => + message.messageId && !currentMessageIds.has(message.messageId), + ); + if (missingMessages.length === 0) { + return current; + } + if (savedConversationProjectPathRef.current === nextProjectPath) { + savedConversationCountRef.current = Math.min( + savedConversationCountRef.current, + current.length, + ); + } + const orderedMissingMessages = [...missingMessages].sort( + (left, right) => (left.updatedAt ?? 0) - (right.updatedAt ?? 0), + ); + const nextMessages = [...current, ...orderedMissingMessages]; + latestMessagesRef.current = nextMessages; + return nextMessages; + }); + }, + [gameChatOnly], + ); + function appendGameChatStageRecord( nextProjectPath: string, runtime: AgentRuntimeState, ) { - if (!gameChatOnly || !isAgentRuntimeTerminalState(runtime)) { + if (!gameChatOnly || !gameChatRuntimeHasTerminalOutcome(runtime)) { return; } const archiveKey = `${nextProjectPath}\n${runtime.runId}`; - if ( - !gameChatObservedRunKeysRef.current.has(archiveKey) || - gameChatArchivedRunKeysRef.current.has(archiveKey) - ) { + // A restored terminal runtime may be the first runtime snapshot observed + // after the app opens. Do not require a prior non-terminal event: the + // durable runtime/manifest pair is sufficient evidence for the record. + if (gameChatArchivedRunKeysRef.current.has(archiveKey)) { return; } - const progress = buildGameChatProgressEvidence( - runtime, - agentRuntimeById, - manifest, - ); - if (!progress) { - return; - } - const text = formatGameChatStageRecord( - runtime, - progress, - collectGameChatResultImages(manifest), - ); - gameChatArchivedRunKeysRef.current.add(archiveKey); - setMessages((current) => - current.some( - (message) => message.role === 'assistant' && message.text === text, - ) - ? current - : [...current, { role: 'assistant', text }], - ); + gameChatPendingStageRuntimesRef.current.set(archiveKey, runtime); + flushPendingGameChatStageRecords(); } + useEffect(() => { + const nextProjectPath = localProject?.projectPath; + const runtime = projectSupervisorRuntime; + if (gameChatOnly && nextProjectPath && runtime) { + appendGameChatRuntimeEventMessages(nextProjectPath, runtime); + } + if ( + gameChatOnly && + nextProjectPath && + runtime && + gameChatRuntimeHasTerminalOutcome(runtime) + ) { + // Hydration can restore a terminal root run without delivering a live + // runtime-update event. Feed that snapshot through the same deferred + // archive path used by live terminal updates. A run that was already + // observed in a non-terminal state is archived by its terminal + // conversation refresh instead, avoiding a hydration race with that + // refresh's saved-message cursor. + const archiveKey = `${nextProjectPath}\n${runtime.runId}`; + const refreshKey = `${nextProjectPath}\n${runtime.sessionId}\n${runtime.runId}`; + if ( + !gameChatObservedRunKeysRef.current.has(archiveKey) && + !projectSupervisorRuntimeSyncingRef.current.has(refreshKey) + ) { + appendGameChatStageRecord(nextProjectPath, runtime); + } + } + flushPendingGameChatStageRecords(); + // The terminal Runtime can arrive before the durable manifest refresh. + // Retry when either projection changes, but archive each run only once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + agentRuntimeById, + gameChatOnly, + localProject?.projectPath, + manifest, + projectSupervisorRuntime, + ]); + useEscapeToClose(closeAgentConversation, selectedAgent !== null); useEscapeToClose(cancelUiCommandConfirmation, pendingUiConfirmation !== null); useEscapeToClose( @@ -1078,6 +1299,11 @@ export function App({ return; } const nextRuntime = agentRuntimeStateFromResult(payload.runtime); + if (gameChatOnly && payload.agentId !== PROJECT_SUPERVISOR_AGENT_ID) { + appendGameChatFinalReplyMessages(payload.projectPath, [ + payload.runtime, + ]); + } if (payload.agentId === PROJECT_SUPERVISOR_AGENT_ID) { const expectedSessionId = projectSupervisorSessionIdRef.current; const currentRuntime = projectSupervisorRuntimeRef.current; @@ -1159,6 +1385,8 @@ export function App({ cleanup?.(); }; }, [ + appendGameChatFinalReplyMessages, + gameChatOnly, updateProjectSupervisorResponseStream, updateProjectSupervisorRuntime, ]); @@ -1303,6 +1531,9 @@ export function App({ ) { return; } + if (gameChatOnly) { + appendGameChatFinalReplyMessages(nextProjectPath, runtimes); + } const nextRuntimes = runtimes.map((runtimeResult) => agentRuntimeStateFromResult(runtimeResult), ); @@ -1776,10 +2007,14 @@ export function App({ { projectPath: nextProjectPath, agentId: null, + ...(message.messageId ? { messageId: message.messageId } : {}), message: { role: message.role, content: message.text, agentId: null, + ...(typeof message.updatedAt === 'number' + ? { updatedAt: message.updatedAt } + : {}), }, }, ); @@ -2506,13 +2741,15 @@ export function App({ // so a committed game-chat response cannot be overwritten by stale // `latestMessagesRef` state captured before that callback ran. const nextMessages = gameChatOnly - ? mergeGameChatRuntimeResponseMessagesIntoHistory( + ? mergeGameChatHydratedConversationMessages( conversationMessages, current, ) : conversationMessages; savedConversationProjectPathRef.current = nextProjectPath; - savedConversationCountRef.current = nextMessages.length; + savedConversationCountRef.current = gameChatOnly + ? conversationMessages.length + : nextMessages.length; latestMessagesRef.current = nextMessages; return nextMessages; }); @@ -2635,7 +2872,7 @@ export function App({ setProjectSupervisorRuntimeError(runtimeError || resumeError); setMessages((current) => { const nextConversationMessages = gameChatOnly - ? mergeGameChatRuntimeResponseMessagesIntoHistory( + ? mergeGameChatHydratedConversationMessages( conversationMessages, current, ) @@ -2659,7 +2896,9 @@ export function App({ } setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); savedConversationProjectPathRef.current = nextProjectPath; - savedConversationCountRef.current = nextConversationMessages.length; + savedConversationCountRef.current = gameChatOnly + ? conversationMessages.length + : nextConversationMessages.length; latestMessagesRef.current = nextConversationMessages; setWorkspaceStatus((workspaceStatus) => { if (mode === 'replace') { @@ -9951,6 +10190,7 @@ export function App({ } agentRuntimeResumeProjectPathRef.current = nextProjectPath; for (const runtimeResult of resumedRuntimes) { + appendGameChatFinalReplyMessages(nextProjectPath, [runtimeResult]); rememberAgentRuntimeState( agentRuntimeStateFromResult(runtimeResult), ); @@ -9982,6 +10222,9 @@ export function App({ } agentRuntimeResumeProjectPathRef.current = nextProjectPath; for (const runtimeResult of resumedRuntimes) { + appendGameChatFinalReplyMessages(nextProjectPath, [ + runtimeResult, + ]); rememberAgentRuntimeState( agentRuntimeStateFromResult(runtimeResult), ); @@ -10022,6 +10265,7 @@ export function App({ } const nextRuntimes: AgentRuntimeState[] = []; for (const runtimeResult of runtimes) { + appendGameChatFinalReplyMessages(nextProjectPath, [runtimeResult]); nextRuntimes.push(agentRuntimeStateFromResult(runtimeResult)); } const supervisorRuntimeIndex = nextRuntimes.findIndex( diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 4f6cfa6cc..8cec58340 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -312,11 +312,14 @@ export interface AgentRuntimeEventRecord { source: string; runProfile?: 'standard' | 'autonomous-game-build'; runProfileBindingFingerprint?: string; + eventId?: string; + actionId?: string | null; eventType: string; status: string; phase: string; summary: string; detail: string | null; + publicText?: string | null; updatedAt: number; } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx index fb1421691..d3a67f345 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx @@ -14,6 +14,7 @@ import { useEffect, useMemo, useState } from 'react'; import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import type { AgentRuntimeEventRecord, + AgentRuntimeResponseStream, AgentRuntimeState, ChatMessage, LocalPreviewResult, @@ -54,6 +55,23 @@ export type GameChatRuntimeEvent = { event: AgentRuntimeEventRecord; }; +const GAME_CHAT_RUNTIME_EVENT_MESSAGE_PREFIX = 'game-chat-runtime-event:'; +const GAME_CHAT_FINAL_REPLY_MESSAGE_PREFIX = 'game-chat-final-reply:'; +const GAME_CHAT_FINAL_REPLY_AGENT_IDS = new Set([ + 'art-director', + 'code-prototype', + 'preview-readiness', + 'preview-playtest', +]); +const GAME_CHAT_INTERNAL_RUNTIME_EVENT_TYPES = new Set([ + 'tool.request', + 'tool.response', + 'tool.result', + 'agent.runtime.tool.request', + 'agent.runtime.tool.response', + 'agent.runtime.tool.result', +]); + export type GameChatProgressEvidence = { key: string; label: string; @@ -289,6 +307,14 @@ function latestEvidenceEvent( return events.find(({ event }) => predicate(event)) ?? null; } +export function formatGameChatRuntimeText(text: string) { + return text.replace(/第\s*\d+\s*轮/gu, '本轮'); +} + +export function formatGameChatRuntimeEvent(event: AgentRuntimeEventRecord) { + return formatGameChatRuntimeText(formatAgentRuntimeEvent(event)); +} + export function buildGameChatProgressEvidence( runtime: AgentRuntimeState | null, runtimeByAgentId: Record, @@ -297,7 +323,14 @@ export function buildGameChatProgressEvidence( if (!runtime?.runId) { return null; } - const tasks = manifest?.tasks ?? []; + const fastPathTaskIds = new Set([ + 'art-director', + 'code-prototype', + 'preview-readiness', + 'preview-playtest', + ]); + const tasks = + manifest?.tasks.filter((task) => fastPathTaskIds.has(task.id)) ?? []; const completedTasks = tasks.filter( (task) => task.status === 'completed', ).length; @@ -323,9 +356,6 @@ export function buildGameChatProgressEvidence( .map((professionalRuntime) => { const parts = [ projectProfessionalAgentLabel(professionalRuntime.agentId), - (professionalRuntime.loopIteration ?? 0) > 0 - ? `第 ${professionalRuntime.loopIteration} 轮` - : null, projectRuntimeVisibleCurrentWork(professionalRuntime), ].filter(Boolean); return compactProgressText(parts.join(' · '), 140); @@ -457,21 +487,24 @@ export function buildGameChatProgressEvidence( } return { runId: runtime.runId, - title: - (runtime.loopIteration ?? 0) > 0 - ? `Supervisor 进度播报 · 第 ${runtime.loopIteration} 轮` - : 'Supervisor 进度播报', + // loopIteration is the Runtime's private provider/tool loop, not a + // user-visible game generation round. A single game-chat turn can require + // several of these loops for delegation, repair and playtest. + title: '本轮生成进度', taskProgress: taskParts.join(' · ') || projectSupervisorChatRuntimeStatus(runtime), - currentWork: compactProgressText(projectRuntimeVisibleCurrentWork(runtime)), + currentWork: formatGameChatRuntimeText( + compactProgressText(projectRuntimeVisibleCurrentWork(runtime)), + ), activeAgents, evidence, }; } -export function collectGameChatRuntimeEvents( +function collectGameChatRuntimeEventsInternal( runtime: AgentRuntimeState | null, runtimeByAgentId: Record, + limit: number, ) { const sources = runtime ? [ @@ -492,6 +525,7 @@ export function collectGameChatRuntimeEvents( continue; } const key = [ + event.eventId, event.agentId, event.sessionId, event.runId, @@ -509,7 +543,173 @@ export function collectGameChatRuntimeEvents( } return Array.from(deduplicated.values()) .sort((left, right) => right.event.updatedAt - left.event.updatedAt) - .slice(0, 20); + .slice(0, limit); +} + +export function collectGameChatRuntimeEvents( + runtime: AgentRuntimeState | null, + runtimeByAgentId: Record, +) { + return collectGameChatRuntimeEventsInternal(runtime, runtimeByAgentId, 20); +} + +function gameChatRuntimeEventMessageText(item: GameChatRuntimeEvent) { + const event = item.event; + const eventType = + typeof event.eventType === 'string' + ? event.eventType.trim().toLowerCase() + : ''; + const rawPublicText = + typeof event.publicText === 'string' ? event.publicText.trim() : ''; + const publicText = formatGameChatRuntimeText( + compactProgressText(rawPublicText, 220), + ); + const eventId = typeof event.eventId === 'string' ? event.eventId.trim() : ''; + if ( + !eventId || + !publicText || + GAME_CHAT_INTERNAL_RUNTIME_EVENT_TYPES.has(eventType) + ) { + return null; + } + return `${item.agentLabel}:${publicText}`; +} + +export function gameChatRuntimeEventMessages( + runtime: AgentRuntimeState | null, + runtimeByAgentId: Record, +) { + return collectGameChatRuntimeEventsInternal( + runtime, + runtimeByAgentId, + Number.MAX_SAFE_INTEGER, + ) + .sort((left, right) => { + const updatedAtDelta = left.event.updatedAt - right.event.updatedAt; + return updatedAtDelta !== 0 + ? updatedAtDelta + : left.key.localeCompare(right.key); + }) + .flatMap((item) => { + const text = gameChatRuntimeEventMessageText(item); + const eventId = + typeof item.event.eventId === 'string' ? item.event.eventId.trim() : ''; + if (!text) { + return []; + } + return [ + { + role: 'assistant' as const, + text, + messageId: `${GAME_CHAT_RUNTIME_EVENT_MESSAGE_PREFIX}${eventId}`, + agentId: null, + updatedAt: item.event.updatedAt, + }, + ]; + }); +} + +/** + * Convert a professional Agent's durable final-reply stream into one normal + * project-chat message. Tool plans and in-flight streams intentionally stay + * out of the conversation: the chat is a user-facing transcript, not a + * Runtime protocol log. + */ +export function gameChatFinalReplyMessages( + streams: Array, +) { + return streams.flatMap((stream) => { + const accumulatedText = + typeof stream?.accumulatedText === 'string' + ? stream.accumulatedText.trim() + : ''; + if ( + !stream || + !GAME_CHAT_FINAL_REPLY_AGENT_IDS.has(stream.agentId) || + stream.requestKind !== 'final-reply' || + !['ready', 'committed'].includes(stream.status) || + !accumulatedText + ) { + return []; + } + const streamIdentity = [ + stream.sessionId, + stream.runId, + stream.requestSlot, + stream.responseRevision, + ].join('\u001f'); + return [ + { + role: 'assistant' as const, + text: `${projectProfessionalAgentLabel(stream.agentId)}:${accumulatedText}`, + messageId: `${GAME_CHAT_FINAL_REPLY_MESSAGE_PREFIX}${encodeURIComponent( + `${stream.agentId}\u001f${streamIdentity}`, + )}`, + agentId: stream.agentId, + updatedAt: stream.updatedAt, + }, + ]; + }); +} + +export function mergeGameChatFinalReplyMessagesIntoHistory( + 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 repliesToKeep = currentMessages.filter((message) => { + const messageId = message.messageId?.trim(); + if ( + !messageId?.startsWith(GAME_CHAT_FINAL_REPLY_MESSAGE_PREFIX) || + historyMessageIds.has(messageId) || + addedMessageIds.has(messageId) + ) { + return false; + } + addedMessageIds.add(messageId); + return true; + }); + if (repliesToKeep.length === 0) { + return historyMessages; + } + return [...historyMessages, ...repliesToKeep].sort( + (left, right) => (left.updatedAt ?? 0) - (right.updatedAt ?? 0), + ); +} + +export function mergeGameChatRuntimeEventMessagesIntoHistory( + 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 eventsToKeep = currentMessages.filter((message) => { + const messageId = message.messageId?.trim(); + if ( + !messageId?.startsWith(GAME_CHAT_RUNTIME_EVENT_MESSAGE_PREFIX) || + historyMessageIds.has(messageId) || + addedMessageIds.has(messageId) + ) { + return false; + } + addedMessageIds.add(messageId); + return true; + }); + if (eventsToKeep.length === 0) { + return historyMessages; + } + return [...historyMessages, ...eventsToKeep].sort( + (left, right) => (left.updatedAt ?? 0) - (right.updatedAt ?? 0), + ); } type SupervisorChatOnlyViewProps = { @@ -633,7 +833,10 @@ export function SupervisorChatOnlyView({ : runtimeEvents.slice(0, 4); const supervisorProgress = useMemo( () => - gameChatMode && projectReady + gameChatMode && + projectReady && + runtime && + !isAgentRuntimeTerminalState(runtime) ? buildGameChatProgressEvidence(runtime, runtimeByAgentId, manifest) : null, [gameChatMode, manifest, projectReady, runtime, runtimeByAgentId], @@ -781,7 +984,7 @@ export function SupervisorChatOnlyView({ {visibleRuntimeEvents.map((item) => ( {item.agentLabel} - {formatAgentRuntimeEvent(item.event)} + {formatGameChatRuntimeEvent(item.event)} ))}
diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 560753615..7555d2a6d 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -466,11 +466,18 @@ function createProjectSupervisorRuntimeHarness({ role: 'user' | 'assistant'; content: string; agentId: null; + updatedAt?: number; }; currentProjectMessages.push({ schemaVersion: 'game-creator-conversation.v1', ...message, - updatedAt: 1500 + ++messageSequence, + ...(args?.messageId + ? { messageId: String(args.messageId) } + : {}), + updatedAt: + typeof message.updatedAt === 'number' + ? message.updatedAt + : 1500 + ++messageSequence, }); return { path: `${projectPath}/.agent/conversations/project.jsonl`, diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 3c61e2209..7d70a4a2d 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -14,6 +14,11 @@ import { buildGameChatProgressEvidence, collectGameChatResultImages, collectGameChatRuntimeEvents, + formatGameChatStageRecord, + gameChatFinalReplyMessages, + gameChatRuntimeEventMessages, + isGameChatStageRecordMessage, + mergeGameChatFinalReplyMessagesIntoHistory, SupervisorChatOnlyView, } from '../../src/features/project-workspace/SupervisorChatOnlyView'; import { @@ -62,6 +67,8 @@ function gameChatRuntimeEvent({ summary, detail = null, updatedAt, + eventId = `${agentId}-${runId}-${updatedAt}-${eventType}`, + publicText = summary, }: { agentId?: string; taskId?: string; @@ -72,6 +79,8 @@ function gameChatRuntimeEvent({ phase?: string; summary: string; detail?: string | null; + eventId?: string; + publicText?: string | null; updatedAt: number; }): AgentRuntimeEventRecord { return { @@ -84,11 +93,13 @@ function gameChatRuntimeEvent({ agentId === 'project-supervisor' ? 'project-supervisor' : 'agent-delegate', + eventId, eventType, status, phase, summary, detail, + publicText, updatedAt, }; } @@ -121,6 +132,17 @@ function gameChatRuntimeState( }; } +const GAME_CHAT_STAGE_TASK_IDS = [ + 'art-director', + 'code-prototype', + 'preview-readiness', + 'preview-playtest', +] as const; + +function isGameChatStageTask(taskId: string) { + return (GAME_CHAT_STAGE_TASK_IDS as readonly string[]).includes(taskId); +} + function gameChatPreviewPlaytestRuntime({ parentRunId, revision, @@ -171,9 +193,11 @@ function gameChatPreviewPlaytestRuntime({ function renderGameChatStatus({ runtime, runtimeByAgentId = {}, + manifest = null, }: { runtime: AgentRuntimeState; runtimeByAgentId?: Record; + manifest?: ReturnType | null; }) { return render( React.createElement(SupervisorChatOnlyView, { @@ -203,6 +227,7 @@ function renderGameChatStatus({ workspaceStatus: '已打开', gameChatMode: true, runtimeByAgentId, + manifest, projectReady: true, }), ); @@ -2568,6 +2593,7 @@ export function registerProjectSupervisorSurfaceTests() { it('keeps the non-empty folder confirmation when game-chat initializes a picked project', async () => { const projectPath = '/tmp/game-chat-non-empty'; + const initialSupervisorMessage = '不要在确认前启动这一轮'; const harness = createProjectSupervisorRuntimeHarness({ projectPath }); const invoke = vi.fn( async (command: string, args?: Record) => { @@ -2607,6 +2633,7 @@ export function registerProjectSupervisorSurfaceTests() { React.createElement(App, { projectSupervisorOnly: true, gameChatOnly: true, + initialSupervisorMessage, }), ); @@ -2620,6 +2647,11 @@ export function registerProjectSupervisorSurfaceTests() { 'init_local_game_project', expect.anything(), ); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'start_game_creator_supervisor_runtime_task', + ), + ).toHaveLength(0); fireEvent.click(within(dialog).getByRole('button', { name: '继续新建' })); await waitFor(() => { @@ -2992,6 +3024,383 @@ export function registerProjectSupervisorSurfaceTests() { ]); }); + it('turns user-visible game-chat runtime outputs into chronological chat messages and filters protocol payloads', () => { + const runId = 'game-chat-runtime-message-run'; + const runtime = gameChatRuntimeState({ + runId, + recentEvents: [ + gameChatRuntimeEvent({ + runId, + eventType: 'turn.progress', + summary: 'Generated prototype progress', + detail: 'internal loop iteration 4', + updatedAt: 40, + }), + gameChatRuntimeEvent({ + runId, + eventType: 'tool.request', + summary: 'agent.runtime.tool.request', + detail: '{"tool":"agent.delegate","arguments":{"secret":"x"}}', + publicText: null, + updatedAt: 50, + }), + gameChatRuntimeEvent({ + runId, + eventType: 'action', + summary: 'call tool agent.delegate', + detail: 'code agent repair collision', + publicText: 'code agent repair collision', + updatedAt: 60, + }), + gameChatRuntimeEvent({ + runId, + eventType: 'observation', + summary: 'command.output_read:ok', + detail: '{"output":"private process output"}', + publicText: null, + updatedAt: 70, + }), + gameChatRuntimeEvent({ + runId, + eventType: 'observation', + summary: 'preview.validate:ok', + detail: '{"passed":true,"revision":3}', + updatedAt: 80, + }), + gameChatRuntimeEvent({ + runId, + eventType: 'turn.progress', + summary: 'legacy output without stable event identity', + eventId: '', + publicText: 'legacy output without stable event identity', + updatedAt: 90, + }), + ], + }); + + const messages = gameChatRuntimeEventMessages(runtime, {}); + + expect(messages.map((message) => message.updatedAt)).toEqual([40, 60, 80]); + expect(messages.map((message) => message.text)).toEqual([ + expect.stringContaining('Generated prototype progress'), + expect.stringContaining('code agent repair collision'), + expect.stringContaining('preview.validate:ok'), + ]); + expect(messages.every((message) => message.role === 'assistant')).toBe( + true, + ); + expect( + messages.every((message) => + message.messageId?.startsWith('game-chat-runtime-event:'), + ), + ).toBe(true); + expect(messages.map((message) => message.text).join('\n')).not.toContain( + 'private process output', + ); + expect(messages.map((message) => message.text).join('\n')).not.toContain( + 'agent.runtime.tool.request', + ); + }); + + it('turns only professional final-reply streams into labeled game-chat messages', () => { + const makeStream = ( + agentId: string, + status: 'ready' | 'committed', + accumulatedText: string, + responseRevision = 1, + ) => ({ + schemaVersion: 'game-creator-runtime-response-stream.v1', + agentId, + taskId: agentId, + sessionId: 'supervisor-session-active', + runId: 'game-chat-final-reply-run', + requestKind: 'final-reply', + requestSlot: `final-reply-loop-1-revision-${responseRevision}`, + appliedSteerCursor: 0, + responseRevision, + sequence: 2, + status, + accumulatedText, + finishReason: 'stop', + startedAt: 100, + updatedAt: 200 + responseRevision, + }); + const messages = gameChatFinalReplyMessages([ + makeStream('art-director', 'ready', '视觉方向已完成'), + makeStream('art-asset-plan', 'committed', '平台美术图集已生成并登记'), + makeStream('code-prototype', 'ready', '代码原型已完成'), + makeStream('preview-readiness', 'committed', '预览就绪检查已完成'), + makeStream('preview-playtest', 'ready', '试玩验证已完成'), + { + ...makeStream('code-prototype', 'ready', 'tool plan should be hidden'), + requestKind: 'tool-plan', + }, + ]); + expect(messages).toHaveLength(4); + expect(messages.map((message) => message.text)).toEqual([ + expect.stringContaining('视觉方向已完成'), + expect.stringContaining('代码原型已完成'), + expect.stringContaining('预览就绪检查已完成'), + expect.stringContaining('试玩验证已完成'), + ]); + expect(messages[0]?.messageId).toContain('art-director'); + expect(messages[0]?.messageId).toContain('game-chat-final-reply:'); + expect(messages.every((message) => message.agentId)).toBe(true); + const hydrated = mergeGameChatFinalReplyMessagesIntoHistory( + [messages[0]!], + messages, + ); + expect(hydrated.filter((message) => message.messageId === messages[0]?.messageId)).toHaveLength(1); + }); + + it('persists professional final-reply streams with stable ids and does not duplicate them after hydration', async () => { + const projectPath = '/tmp/game-chat-final-reply-hydration'; + const runId = 'game-chat-final-reply-hydration-run'; + const rootRuntime = gameChatRuntimeState({ + sessionId: 'supervisor-session-active', + runId, + status: 'running', + phase: 'execution', + updatedAt: 100, + }); + const makeRuntimeResult = ( + agentId: string, + status: 'ready' | 'committed', + text: string, + updatedAt: number, + ) => { + const state = gameChatRuntimeState({ + agentId, + taskId: agentId, + sessionId: 'supervisor-session-active', + runId: `${agentId}-${runId}`, + source: 'agent-delegate', + parentAgentId: 'project-supervisor', + parentRunId: runId, + status: 'completed', + phase: 'completed', + updatedAt, + }); + return { + state, + sessionPath: `${projectPath}/.agent/runtime/${agentId}.json`, + eventPath: `${projectPath}/.agent/runtime/${agentId}.jsonl`, + responseStream: { + schemaVersion: 'game-creator-runtime-response-stream.v1', + agentId, + taskId: agentId, + sessionId: 'supervisor-session-active', + runId: state.runId, + requestKind: 'final-reply', + requestSlot: 'final-reply-loop-1-revision-1', + appliedSteerCursor: 0, + responseRevision: 1, + sequence: 2, + status, + accumulatedText: text, + finishReason: 'stop', + startedAt: updatedAt - 20, + updatedAt, + }, + }; + }; + const professionalResults = [ + makeRuntimeResult('art-director', 'ready', '视觉方向已完成', 180), + makeRuntimeResult( + 'art-asset-plan', + 'committed', + '平台美术图集已生成并登记', + 190, + ), + makeRuntimeResult('code-prototype', 'ready', '代码原型已完成', 200), + makeRuntimeResult('preview-readiness', 'committed', '预览就绪已完成', 210), + makeRuntimeResult('preview-playtest', 'ready', '试玩验证已完成', 220), + ]; + const harness = createProjectSupervisorRuntimeHarness({ + projectPath, + initialRuntime: rootRuntime, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'game-chat-final-reply-hydration', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'read_game_creator_agent_runtimes') { + return professionalResults; + } + return harness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: harness.listen }, + }; + const renderRelease = () => + render( + React.createElement(App, { + initialProjectPath: projectPath, + projectSupervisorOnly: true, + gameChatOnly: true, + }), + ); + let rendered = renderRelease(); + await waitFor(() => { + expect(screen.getByText(/视觉方向已完成/)).not.toBeNull(); + expect(screen.queryByText(/平台美术图集已生成并登记/)).toBeNull(); + expect(screen.getByText(/代码原型已完成/)).not.toBeNull(); + expect(screen.getByText(/预览就绪已完成/)).not.toBeNull(); + expect(screen.getByText(/试玩验证已完成/)).not.toBeNull(); + }); + const finalReplyAppends = () => + invoke.mock.calls.filter( + ([command, args]) => + command === 'append_local_conversation_message' && + String(args?.messageId ?? '').startsWith('game-chat-final-reply:'), + ); + await waitFor(() => { + expect(finalReplyAppends()).toHaveLength(4); + }); + rendered.unmount(); + rendered = renderRelease(); + await waitFor(() => { + expect(screen.getByText(/代码原型已完成/)).not.toBeNull(); + }); + expect(finalReplyAppends()).toHaveLength(4); + rendered.unmount(); + }); + + it('persists game-chat runtime event messages with stable ids and does not duplicate them after hydration', async () => { + const projectPath = '/tmp/game-chat-runtime-message-hydration'; + const runId = 'game-chat-runtime-message-hydration-run'; + const events = [ + gameChatRuntimeEvent({ + sessionId: 'supervisor-session-active', + runId, + eventType: 'turn.progress', + summary: 'First visible runtime output', + updatedAt: 40, + }), + gameChatRuntimeEvent({ + sessionId: 'supervisor-session-active', + runId, + eventType: 'observation', + summary: 'Second visible runtime output', + updatedAt: 50, + }), + ]; + const manifest = createGameCreationAppManifest( + 'game-chat-runtime-message-hydration', + 'game-chat-runtime-message-hydration', + ); + const harness = createProjectSupervisorRuntimeHarness({ + projectPath, + initialRuntime: { + sessionId: 'supervisor-session-active', + runId, + status: 'running', + phase: 'execution', + recentEvents: events, + updatedAt: 60, + }, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: manifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'init_local_game_project') { + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + return harness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: harness.listen }, + }; + const renderApp = () => + render( + React.createElement(App, { + initialProjectPath: projectPath, + projectSupervisorOnly: true, + gameChatOnly: true, + }), + ); + let rendered = renderApp(); + const runtimeEventAppends = () => + invoke.mock.calls.filter( + ([command, args]) => + command === 'append_local_conversation_message' && + args?.agentId === null && + String( + args?.messageId ?? + '', + ).startsWith('game-chat-runtime-event:'), + ); + + await waitFor(() => { + expect(runtimeEventAppends()).toHaveLength(2); + }); + expect( + screen + .getAllByText(/First visible runtime output/) + .some((element) => element.tagName === 'P'), + ).toBe(true); + expect( + screen + .getAllByText(/Second visible runtime output/) + .some((element) => element.tagName === 'P'), + ).toBe(true); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'start_game_creator_supervisor_runtime_task', + ), + ).toHaveLength(0); + + rendered.unmount(); + rendered = renderApp(); + await waitFor(() => { + expect( + screen + .getAllByText(/First visible runtime output/) + .some((element) => element.tagName === 'P'), + ).toBe(true); + expect( + screen + .getAllByText(/Second visible runtime output/) + .some((element) => element.tagName === 'P'), + ).toBe(true); + }); + expect(runtimeEventAppends()).toHaveLength(2); + rendered.unmount(); + }); + it('keeps an unstructured image inspection neutral instead of presenting tool success as visual approval', () => { const runId = 'game-chat-unstructured-image-inspection'; const runtime = gameChatRuntimeState({ @@ -3408,7 +3817,54 @@ export function registerProjectSupervisorSurfaceTests() { ).toBe('true'); }); - it('updates one runtime-owned game-chat Supervisor progress broadcast in place without conversation writes', async () => { + it('hides internal loop iteration wording from game-chat latest status events', () => { + const runtime = gameChatRuntimeState({ + runId: 'game-chat-loop-wording-run', + recentEvents: [ + gameChatRuntimeEvent({ + runId: 'game-chat-loop-wording-run', + eventType: 'turn.progress', + summary: 'Agent 已形成第 8 轮有效进度', + detail: '生成 Agent 工具计划(第 9 轮)', + updatedAt: 9000, + }), + ], + }); + + renderGameChatStatus({ runtime }); + + const statusCard = screen.getByLabelText('最新状态'); + expect(document.body.textContent).not.toMatch(/第\s*\d+\s*轮/u); + expect(statusCard.textContent).toContain('Agent 已形成本轮有效进度'); + expect(statusCard.textContent).toContain('生成 Agent 工具计划(本轮)'); + }); + + it('counts only the four first-playable fast-path tasks in game-chat progress', () => { + const manifest = createGameCreationAppManifest( + 'game-chat-progress-total', + 'game-chat-progress-total', + ); + manifest.tasks = manifest.tasks.map((task) => + isGameChatStageTask(task.id) + ? { ...task, status: 'completed' as const } + : task, + ); + const runtime = gameChatRuntimeState({ + runId: 'game-chat-progress-total-run', + status: 'running', + phase: 'execution', + updatedAt: 9000, + }); + + renderGameChatStatus({ runtime, manifest }); + + const progress = screen.getByLabelText('Supervisor 进度播报'); + expect(progress.textContent).toContain('任务图 4/4'); + expect(progress.textContent).not.toContain('publish-strategy'); + expect(progress.textContent).not.toContain('publish-package'); + }); + + it('keeps one live progress card while persisting every public game-chat output as a message', async () => { const projectPath = '/tmp/game-chat-progress-broadcast'; const supervisorRunId = 'game-chat-progress-run'; const previewFailure = gameChatRuntimeEvent({ @@ -3578,19 +4034,18 @@ export function registerProjectSupervisorSurfaceTests() { expect(screen.getAllByLabelText('Supervisor 进度播报')).toHaveLength(1); expect(progress.getAttribute('data-runtime-owned')).toBe('true'); expect(progress.getAttribute('data-run-id')).toBe(supervisorRunId); - expect( - within(progress).getByText('Supervisor 进度播报 · 第 4 轮'), - ).not.toBeNull(); + expect(within(progress).getByText('本轮生成进度')).not.toBeNull(); + expect(within(progress).queryByText(/第 4 轮/u)).toBeNull(); expect( within(progress).getByText( - `任务图 3/${manifest.tasks.length} · 进行中 1 · 计划 1/3`, + '任务图 1/4 · 进行中 1 · 计划 1/3', ), ).not.toBeNull(); expect(within(progress).getByText('核对首版试玩诊断')).not.toBeNull(); expect(within(progress).getByText('活跃专业 Agent')).not.toBeNull(); expect( within(progress).getByText( - '程序原型 Agent · 第 2 轮 · 修复角色碰撞与重开逻辑', + '程序原型 Agent · 修复角色碰撞与重开逻辑', ), ).not.toBeNull(); expect(within(progress).getByText('试玩未通过')).not.toBeNull(); @@ -3599,11 +4054,19 @@ export function registerProjectSupervisorSurfaceTests() { 'revision 7 · 诊断 2 项 · 角色仍会穿过右侧墙体 · 失败后重开按钮没有响应', ), ).not.toBeNull(); - expect( + const runtimeEventAppends = () => invoke.mock.calls.filter( - ([command]) => command === 'append_local_conversation_message', - ), - ).toHaveLength(0); + ([command, args]) => + command === 'append_local_conversation_message' && + args?.agentId === null && + String( + args?.messageId ?? + '', + ).startsWith('game-chat-runtime-event:'), + ); + await waitFor(() => { + expect(runtimeEventAppends()).toHaveLength(1); + }); const delegateDecision = gameChatRuntimeEvent({ runId: supervisorRunId, @@ -3648,11 +4111,12 @@ export function registerProjectSupervisorSurfaceTests() { await waitFor(() => { expect( - within(progress).getByText('Supervisor 进度播报 · 第 5 轮'), + within(progress).getByText('本轮生成进度'), ).not.toBeNull(); + expect(within(progress).queryByText(/第 5 轮/u)).toBeNull(); expect( within(progress).getByText( - `任务图 3/${manifest.tasks.length} · 进行中 1 · 计划 2/3`, + '任务图 1/4 · 进行中 1 · 计划 2/3', ), ).not.toBeNull(); expect(within(progress).getByText('安排程序 Agent 返工')).not.toBeNull(); @@ -3665,11 +4129,9 @@ export function registerProjectSupervisorSurfaceTests() { }); expect(screen.getByLabelText('Supervisor 进度播报')).toBe(progress); expect(screen.getAllByLabelText('Supervisor 进度播报')).toHaveLength(1); - expect( - invoke.mock.calls.filter( - ([command]) => command === 'append_local_conversation_message', - ), - ).toHaveLength(0); + await waitFor(() => { + expect(runtimeEventAppends()).toHaveLength(2); + }); }); it('renders registered image outcomes in one runtime-owned game-chat Supervisor card', async () => { @@ -3883,8 +4345,10 @@ export function registerProjectSupervisorSurfaceTests() { 'game-chat-stage-record', 'game-chat-stage-record', ); - manifest.tasks = manifest.tasks.map((task, index) => - index < 3 ? { ...task, status: 'completed' as const } : task, + manifest.tasks = manifest.tasks.map((task) => + isGameChatStageTask(task.id) + ? { ...task, status: 'completed' as const } + : task, ); manifest.assets.push({ id: 'stage-art', @@ -4063,9 +4527,10 @@ export function registerProjectSupervisorSurfaceTests() { }); const stageRecord = await screen.findByText( - /【Supervisor 阶段记录】[\s\S]*第 6 轮 · 本轮已完成/, + /【Supervisor 阶段记录】[\s\S]*本轮生成进度 · 本轮已完成/, ); expect(stageRecord.className).toContain('game-chat-stage-record'); + expect(screen.queryByLabelText('Supervisor 进度播报')).toBeNull(); expect(stageRecord.textContent).toContain('试玩通过:revision 9'); expect(stageRecord.textContent).toContain( '返工决定:根据上一版试玩诊断安排程序 Agent 完成返工', @@ -4101,6 +4566,406 @@ export function registerProjectSupervisorSurfaceTests() { expect(screen.getByText(/试玩通过:revision 9/)).not.toBeNull(); }); + it('defers the terminal game-chat stage record until the refreshed manifest is terminal', async () => { + const projectPath = '/tmp/game-chat-stage-record-manifest-race'; + const harness = createProjectSupervisorRuntimeHarness({ projectPath }); + const pendingManifest = createGameCreationAppManifest( + 'game-chat-stage-record-manifest-race', + 'game-chat-stage-record-manifest-race', + ); + const terminalManifest = createGameCreationAppManifest( + 'game-chat-stage-record-manifest-race', + 'game-chat-stage-record-manifest-race', + ); + terminalManifest.tasks = terminalManifest.tasks.map((task) => + task.id === 'preview-playtest' + ? { ...task, status: 'failed' as const } + : [ + 'art-director', + 'code-prototype', + 'preview-readiness', + ].includes(task.id) + ? { ...task, status: 'completed' as const } + : task, + ); + let manifestReady = false; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'game-chat-stage-record-manifest-race', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'init_local_game_project') { + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest: pendingManifest, + }; + } + if (command === 'get_local_game_preview_status') { + return { + status: 'stopped', + url: null, + port: null, + root: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifestReady ? terminalManifest : pendingManifest; + } + return harness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: harness.listen }, + }; + render( + React.createElement(App, { + initialProjectPath: projectPath, + projectSupervisorOnly: true, + gameChatOnly: true, + }), + ); + + const composer = screen.getByRole('textbox') as HTMLTextAreaElement; + const form = composer.form; + if (!form) { + throw new Error('missing game-chat composer form'); + } + const stageRecordAppends = () => + invoke.mock.calls.filter( + ([command, args]) => + command === 'append_local_conversation_message' && + args?.agentId === null && + isGameChatStageRecordMessage( + String( + (args?.message as { content?: string } | undefined)?.content ?? + '', + ), + ), + ); + + await waitFor(() => { + expect(composer.disabled).toBe(false); + }); + fireEvent.change(composer, { target: { value: 'manifest race' } }); + fireEvent.submit(form); + await waitFor(() => { + expect( + invoke.mock.calls.some( + ([command]) => command === 'start_game_creator_supervisor_runtime_task', + ), + ).toBe(true); + }); + const startCall = invoke.mock.calls.find( + ([command]) => command === 'start_game_creator_supervisor_runtime_task', + ); + const runId = String(startCall?.[1]?.runId ?? ''); + expect(runId).not.toBe(''); + act(() => { + harness.emitRuntime( + harness.runtimeState({ + runId, + status: 'failed', + phase: 'failed', + currentAction: 'preview-playtest failed', + recentEvents: [ + gameChatRuntimeEvent({ + runId, + eventType: 'observation', + status: 'failed', + phase: 'tool-observation', + summary: 'preview.validate failed', + detail: JSON.stringify({ + diagnosticsCount: 1, + passed: false, + playtestPassed: false, + revision: 9, + }), + updatedAt: 9100, + }), + ], + updatedAt: 9200, + }), + ); + }); + + await waitFor(() => { + expect(stageRecordAppends()).toHaveLength(0); + }); + + manifestReady = true; + fireEvent.change(composer, { target: { value: '/tasks' } }); + fireEvent.submit(form); + await waitFor(() => { + expect( + invoke.mock.calls.some( + ([command, args]) => + command === 'get_local_game_manifest' && + args?.commandId === 'task.list', + ), + ).toBe(true); + }); + await waitFor(() => { + expect(stageRecordAppends()).toHaveLength(1); + }); + const stageRecord = String( + (stageRecordAppends()[0]?.[1] as { message?: { content?: string } }) + ?.message?.content ?? '', + ); + expect(stageRecord).toContain('3/4'); + }); + + it('archives a terminal game-chat run restored during initial hydration exactly once', async () => { + const projectPath = '/tmp/game-chat-stage-record-initial-terminal'; + const harness = createProjectSupervisorRuntimeHarness({ projectPath }); + const manifest = createGameCreationAppManifest( + 'game-chat-stage-record-initial-terminal', + 'game-chat-stage-record-initial-terminal', + ); + manifest.tasks = manifest.tasks.map((task) => + isGameChatStageTask(task.id) + ? { ...task, status: 'completed' as const } + : task, + ); + const runId = 'game-chat-stage-record-initial-terminal-run'; + const terminalRuntime = harness.runtimeState({ + runId, + status: 'completed', + phase: 'completed', + currentAction: 'preview complete', + recentEvents: [ + gameChatRuntimeEvent({ + runId, + eventType: 'observation', + status: 'completed', + phase: 'tool-observation', + summary: 'preview.validate:ok · 试玩验证已通过', + detail: JSON.stringify({ + diagnosticsCount: 0, + passed: true, + playtestPassed: true, + revision: 11, + }), + updatedAt: 9100, + }), + ], + updatedAt: 9200, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: manifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'init_local_game_project') { + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'read_game_creator_agent_runtime') { + return harness.runtimeResult(terminalRuntime); + } + return harness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: harness.listen }, + }; + render( + React.createElement(App, { + initialProjectPath: projectPath, + projectSupervisorOnly: true, + gameChatOnly: true, + }), + ); + + const stageRecordAppends = () => + invoke.mock.calls.filter( + ([command, args]) => + command === 'append_local_conversation_message' && + args?.agentId === null && + isGameChatStageRecordMessage( + String( + (args?.message as { content?: string } | undefined)?.content ?? + '', + ), + ), + ); + + await waitFor(() => { + expect(stageRecordAppends()).toHaveLength(1); + }); + expect(screen.getAllByText(/【Supervisor 阶段记录】/)).toHaveLength(1); + + // A later refresh/runtime snapshot for the same run must not append again. + fireEvent.change(screen.getByRole('textbox'), { + target: { value: '/tasks' }, + }); + fireEvent.submit((screen.getByRole('textbox') as HTMLTextAreaElement).form!); + await waitFor(() => { + expect( + invoke.mock.calls.some( + ([command, args]) => + command === 'get_local_game_manifest' && + args?.commandId === 'task.list', + ), + ).toBe(true); + }); + expect(stageRecordAppends()).toHaveLength(1); + }); + + it('does not duplicate a historical terminal game-chat stage record during restart hydration', async () => { + const projectPath = '/tmp/game-chat-stage-record-restart-hydration'; + const runId = 'game-chat-stage-record-restart-run'; + const terminalRuntime = gameChatRuntimeState({ + sessionId: 'supervisor-session-active', + runId, + status: 'completed', + phase: 'completed', + currentAction: 'preview complete', + updatedAt: 9200, + }); + const manifest = createGameCreationAppManifest( + 'game-chat-stage-record-restart-hydration', + 'game-chat-stage-record-restart-hydration', + ); + manifest.tasks = manifest.tasks.map((task) => + isGameChatStageTask(task.id) + ? { ...task, status: 'completed' as const } + : task, + ); + const progress = buildGameChatProgressEvidence( + terminalRuntime, + {}, + manifest, + ); + if (!progress) { + throw new Error('missing terminal game-chat progress fixture'); + } + const historicalStageRecord = formatGameChatStageRecord( + terminalRuntime, + progress, + collectGameChatResultImages(manifest), + ); + const harness = createProjectSupervisorRuntimeHarness({ + projectPath, + initialRuntime: terminalRuntime, + projectMessages: [ + { + role: 'assistant', + content: historicalStageRecord, + agentId: null, + messageId: 'historical-game-chat-stage-record', + updatedAt: 9201, + }, + ], + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: manifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'init_local_game_project') { + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'get_local_game_preview_status') { + return { + status: 'stopped', + url: null, + port: null, + root: null, + }; + } + return harness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: harness.listen }, + }; + render( + React.createElement(App, { + initialProjectPath: projectPath, + projectSupervisorOnly: true, + gameChatOnly: true, + }), + ); + + const stageRecordAppends = () => + invoke.mock.calls.filter( + ([command, args]) => + command === 'append_local_conversation_message' && + args?.agentId === null && + isGameChatStageRecordMessage( + String( + (args?.message as { content?: string } | undefined)?.content ?? + '', + ), + ), + ); + + await waitFor(() => { + expect(screen.getAllByText(/【Supervisor 阶段记录】/)).toHaveLength(1); + }); + expect(stageRecordAppends()).toHaveLength(0); + + // A terminal runtime event can race the hydration refresh. The historical + // record must remain the sole record and must not be appended again. + act(() => { + harness.emitRuntime( + harness.runtimeState({ + sessionId: terminalRuntime.sessionId, + runId, + status: 'completed', + phase: 'completed', + currentAction: 'preview complete', + updatedAt: 9300, + }), + ); + }); + await waitFor(() => { + expect(screen.getAllByText(/【Supervisor 阶段记录】/)).toHaveLength(1); + }); + expect(stageRecordAppends()).toHaveLength(0); + }); + it('starts and displays the first playable game-chat preview exactly once', async () => { const projectPath = '/tmp/game-chat-auto-preview'; const harness = createProjectSupervisorRuntimeHarness({ projectPath }); @@ -4259,6 +5124,144 @@ export function registerProjectSupervisorSurfaceTests() { ).toHaveLength(1); }); + it('restores an authorized playable game-chat preview after restart without starting it twice', async () => { + const projectPath = '/tmp/game-chat-auto-preview-restart'; + const runId = 'game-chat-auto-preview-restart-run'; + const revision = 7; + const harness = createProjectSupervisorRuntimeHarness({ + projectPath, + initialProjectRevision: revision, + initialRuntime: { + sessionId: 'supervisor-session-active', + runId, + status: 'completed', + phase: 'completed', + updatedAt: 9200, + }, + runtimeMapLoader: async () => [ + gameChatPreviewPlaytestRuntime({ + parentRunId: runId, + revision, + updatedAt: 9100, + }), + ], + }); + const manifest = createGameCreationAppManifest( + 'game-chat-auto-preview-restart', + 'game-chat-auto-preview-restart', + ); + manifest.tasks = manifest.tasks.map((task) => + task.id === 'code-prototype' + ? { ...task, status: 'completed' as const } + : task, + ); + let previewStarted = false; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: manifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'init_local_game_project') { + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'get_local_game_project_revision') { + return { revision }; + } + if (command === 'get_local_game_preview_status') { + return previewStarted + ? { + status: 'running', + url: 'http://127.0.0.1:4327', + port: 4327, + root: `${projectPath}/game`, + } + : { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'start_local_game_preview') { + expect(args).toEqual({ projectPath, expectedRevision: revision }); + previewStarted = true; + return { + url: 'http://127.0.0.1:4327', + port: 4327, + root: `${projectPath}/game`, + }; + } + return harness.invoke(command, args); + }, + ); + window.localStorage.setItem( + 'genarrative.game-chat.auto-preview-authorization.v2', + JSON.stringify({ + afterRevision: 0, + afterValidatedAt: 0, + authorizationId: 'restored-preview-authorization', + projectPath, + runId, + }), + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: harness.listen }, + }; + + const renderRelease = () => + render( + React.createElement(App, { + initialProjectPath: projectPath, + projectSupervisorOnly: true, + gameChatOnly: true, + }), + ); + let rendered = renderRelease(); + + await waitFor( + () => { + expect(invoke.mock.calls).toContainEqual( + expect.arrayContaining(['start_local_game_preview']), + ); + }, + { timeout: 5000 }, + ); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'start_local_game_preview', + ), + ).toHaveLength(1); + expect(screen.getByLabelText('游戏运行')).not.toBeNull(); + expect( + window.localStorage.getItem( + 'genarrative.game-chat.auto-preview-authorization.v2', + ), + ).toBeNull(); + + rendered.unmount(); + rendered = renderRelease(); + await waitFor(() => { + expect(screen.getByLabelText('游戏运行')).not.toBeNull(); + }); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'start_local_game_preview', + ), + ).toHaveLength(1); + rendered.unmount(); + }); + it('refreshes a running game-chat iframe after a later run completes without restarting the preview', async () => { const projectPath = '/tmp/game-chat-preview-revision'; const harness = createProjectSupervisorRuntimeHarness({ projectPath }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f75c2cda9..1b4d0dae0 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5501,8 +5501,8 @@ - 复用决策:窗口固定使用 `project-supervisor + autonomous-game-build`,复用 active Session、External Runner、持久 conversation、确认 / 追问链路和共享 `PreviewRegistry`;不新增玩法入口、后端 API、会话库、Runner 或预览服务。原 `supervisor-chat` 继续使用 `standard` profile 并保持纯聊天语义。 - Run 身份决策:External Runner 接受新任务后,命令响应中的 canonical `state` 允许暂时仍是上一轮 idle,而 `acceptedRunId` 才是新任务的权威身份。GUI 必须暂存该身份并开启轮询、放行对应 Runtime event,直到新 state 接管后再清除;自动预览授权同样绑定 `acceptedRunId`。忽略该字段会造成任务实际运行但界面永久显示“等待输入”。 - 状态决策:页面只聚合当前 Supervisor 父 run 及其直接委派专业 Agent 的事件,稳定去重后默认显示最新 4 条、可展开至 20 条;事件只作状态投影,不写入 conversation。无当前项目的有效 `running` PreviewRegistry 状态时只显示聊天;运行后桌面端显示“游戏 2 / 聊天 1”,移动端上下排列。iframe 只接受当前授权项目的 `http://127.0.0.1:*`,继续复用现有 CSP 和 sandbox 合同,远程 URL、`file://`、手填地址或陈旧 manifest 状态均失败关闭。 -- 进度证据决策:聊天消息流内增加单条 Runtime-owned “Supervisor 进度播报”,从当前 run 的 manifest 任务图、结构化计划、真实 loop、直接委派 Agent 和持久事件确定性派生,聚合迭代轮次、当前工作、活跃专业 Agent、试玩 / 静态测试、返工、代码修改和截图检查。该卡同一 run 原位更新,不调用模型、不写 conversation、不制造额外 assistant 记录;详情有界并移除绝对路径,不展示 Provider 元数据、指纹或原始内部正文。顶部原始事件列表继续保留以便核验。 -- 跨轮记录决策:只有当前 game-chat 窗口确实观察过活跃态的父 run,在其终态正式 conversation 刷新完成后才追加一次 `【Supervisor 阶段记录】` 项目 assistant 消息;内容只保留轮次、任务 / 计划完成度、最新测试、最近返工和成果图片路径。记录按“项目 + 父 run”内存幂等,继续经过 `conversation.write` 策略并写入项目 conversation,因此下一轮和重载后仍可见;已终态旧 run 在窗口启动时不回填,避免重复。该项目记录不进入 Supervisor Agent Session,不改变 Runtime 唯一 final assistant 合同。 +- 进度证据决策:聊天消息流内增加单条 Runtime-owned “Supervisor 进度播报”,从当前 run 的 manifest 任务图、结构化计划、真实 loop、直接委派 Agent 和持久事件确定性派生,聚合迭代轮次、当前工作、活跃专业 Agent、试玩 / 静态测试、返工、代码修改和截图检查。该卡同一 run 原位更新,不调用模型、不写 conversation、不制造额外 assistant 记录;详情有界并移除绝对路径,不展示 Provider 元数据、指纹或原始内部正文。顶部原始事件列表继续保留以便核验。2026-08-01 补充的逐条公开输出是独立的 `eventId + publicText` conversation 消息,不改变该进度卡自身不落盘的约束。 +- 跨轮记录决策:父 run 进入真实 `completed / failed / cancelled` 终态且 `code-prototype / preview-readiness / preview-playtest` 三个阶段全部终态后,追加一次 `【Supervisor 阶段记录】` 项目 assistant 消息;内容只保留本轮、任务 / 计划完成度、最新测试、最近返工和成果图片路径。Runtime 先终态而 manifest 尚未刷新时暂存候选,manifest 刷新后补写;页面启动时若首个可见快照已是终态,也必须补齐缺失记录,但 `idle` 不得被当作真实终态。记录按“项目 + 父 run”内存幂等,继续经过 `conversation.write` 策略并写入项目 conversation,因此下一轮和重载后仍可见。该项目记录不进入 Supervisor Agent Session,不改变 Runtime 唯一 final assistant 合同。 - 图片成果决策:manifest 中已登记的 PNG / JPEG / WebP 项目资源通过既有 `read_local_project_image_preview` 安全读取,并在聊天流中以单张 Runtime-owned “Supervisor 成果图片”卡原位展示,最多 4 张。只接受当前项目 `assets/` 已登记路径及返回身份完全一致的 data URL,不从自然语言或 Markdown 解析任意路径,不读取 `.agent` 验收截图,不写 conversation;切换项目、资源移除、读取失败或解码失败时立即移除图片或显示固定失败状态。缩略图点击进入独立模态查看器,支持 50%–400% 按钮 / 滚轮缩放、指针拖拽、复位和 Esc / 遮罩 / 按钮关闭,移动端全屏,不在聊天消息下方内联展开。 - 授权决策:用户成功提交本轮自主生成需求,即授予“当前项目 + 当前 Supervisor 父 run”一次性 `preview.start`;授权只把项目路径与 accepted parent runId 持久化到客户端本地状态,允许 App / WebView 重启恢复,不新增后端接口。首版产物完成后仍经现有权限、项目写锁、审计与 PreviewRegistry 链路启动;成功、显式 deny、非瞬时失败、父 run 在首版完成前终止或切换项目后消费或清除授权。首版完成投影与专业任务写入并发时,`preview.start` 可能暂时命中项目写锁;该错误不得提前标记“已尝试”或清空授权,应在释放锁后重试,最终仍只成功启动一次。项目或 Agent 策略的显式 deny 始终优先,不因页面授权而降级。 - 验证方式:定向覆盖 debug / release 入口分流、项目选择和切换隔离、父 run 事件聚合、首版只启动一次、deny 优先、预览停止后隐藏、loopback / sandbox 安全以及桌面 / 移动响应式布局。 @@ -5887,9 +5887,10 @@ ## 2026-07-31 game-chat 每条输出入聊天、试玩后收束与平台图集引用 -- 背景:game-chat 的 ready response 之前只作为 transient stream 展示,刷新或事件 / 轮询重放时可能丢失;自主构建完成后仍可能继续进入发布任务;配置 External Editor API 时,原型 HTML 也可能不实际使用平台生成的 Canvas 美术资源。 -- 决策:game-chat 为每条 Supervisor ready 输出分配由 `runId + requestSlot + responseRevision` 组成的稳定 `runtime-response:*` 消息 ID,并在事件、轮询、StrictMode 和 hydration 中按 ID 幂等固化到聊天框;普通 `supervisor-chat` 不改变。可信 source `project-supervisor-game-chat` 的 seed task 截断在 `preview-playtest`,试玩成功后完成门只验收保留任务、最新 revision、`game.static_smoke` 和 `preview.validate`,不再调度 `publish-strategy` / `publish-package`,GUI / CLI 仍执行完整 DAG。 -- 美术资源门禁:External Editor API 有效时,`code-prototype` 必须通过 `asset.list` 核对 Canvas 登记的 `assets/art-spritesheet.png`,并在 `game/index.html` 真实引用该文件;manifest、文件或 HTML 引用任一缺失均拒绝完成。确定性 Provider fixture 也必须带该引用,不能用占位内容绕过门禁。 +- 背景:game-chat 的 ready response 之前只作为 transient stream 展示,专业 Agent 的 `final-reply` 只进入各自私有 conversation,刷新或事件 / 轮询重放时项目聊天可能丢失这些输出;自主构建完成后仍可能继续进入发布任务;配置 External Editor API 时,原型 HTML 也可能不实际使用平台生成的 Canvas 美术资源。 +- 决策:game-chat 为 Supervisor ready 输出、四阶段专业 Agent `final-reply` 和每条后端批准公开的 Runtime 输出分配稳定消息 ID,并在事件、轮询、StrictMode 和 hydration 中按 ID 幂等固化到项目聊天。专业回复只接受 `art-director / code-prototype / preview-readiness / preview-playtest` 的 `requestKind=final-reply` 且 `status=ready|committed`;`art-asset-plan` 不属于五分钟首版阶段,其回复不进入 game-chat 项目聊天。tool-plan 与流式半成品不进入聊天。Runtime 事件由 Rust 在写事件时生成唯一 `eventId` 和可选 `publicText`,前端只消费这两个字段,不重新解释 `summary / detail`;无公开投影的 legacy、Provider、Runner、tool payload、路径、指纹、哈希和敏感字段不得写 conversation。`append_local_conversation_message` 通过顶层 `messageId` 使用后端幂等追加。普通 `supervisor-chat` 不改变。可信 source `project-supervisor-game-chat` 的 seed task 截断在 `preview-playtest`,试玩成功后完成门只验收保留任务、最新 revision、`game.static_smoke` 和 `preview.validate`,不再调度 `publish-strategy` / `publish-package`,GUI / CLI 仍执行完整 DAG。`agent.schedule_ready` 同样必须按当前父 Run 的持久 source/profile 走 source-aware scheduler,不得回退通用完整 DAG;`task.list` 必须隐藏两个发布节点及其 ready/count 投影,`agent.delegate` 必须根据 root binding 拒绝直接委派这两个节点,所有绑定读取错误均失败关闭。 +- 单轮语义:Runtime 的 `loopIteration` 是同一父 Run 内的 Provider / 工具循环,不是用户可见的游戏版本轮次;game-chat 的进度卡、当前工作和最新状态统一显示“本轮”,不显示根或专业 Agent 的内部“第 N 轮”,完整 GUI / CLI pre-publish 任务图仍可显示 `x/14`,首版快车道改为四阶段 `x/4`,终态后移除运行中进度卡。`preview-playtest` 与全部完成门满足且非验证屏障清零后,Runtime 以确定性最终回复完成结构化计划并立即结算父 Run,不再把“是否继续”交给下一次 Provider tool-plan。 +- 美术资源门禁:live10 实测透明 `icon-spritesheet` 的生成和后处理超过 `300` 秒,不能纳入 game-chat 五分钟首版。game-chat 改为由 `art-director` 通过一次平台 `images/generations` 生成并登记 `assets/art-spec.png`,`code-prototype` 必须把它显著用于用户可见的主要背景、玩家和目标;未配置 External Editor API、生成或登记失败、文件缺失、HTML 未引用或可见画面未使用均拒绝完成。普通 GUI / CLI autonomous 继续执行完整 DAG,并以正式透明 `assets/art-spritesheet.png` 及其真实引用作为原有美术硬门。 - 验证:`agentRuntimeModel.test.ts` 10 项通过;新增 Rust source allowlist、game-chat parent completion 与 Canvas spritesheet reference 合同测试通过;`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。两项既有 Windows `os error 32` 文件锁竞态仍单独记录,未归因于本次改动。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/development-workflow.md`。 @@ -5899,3 +5900,15 @@ - 决策:预览的业务失败与浏览器基础设施失败分流。基础设施失败按稳定 kind 持久化并立即失败结束当前 run;preview readiness/playtest 的 manifest 完成分别绑定当前 revision smoke 和根合同 browser receipt,read-only 文本交付不能绕过。 - 决策:game-chat 的 client-owned Runner 在活动任务期间禁止关闭客户端;关闭前复用既有 durable idle 真相源,避免另建 UI busy 状态。用户明确暂停/取消并达到 idle 后再退出,不能靠重启后自动重放未知 Provider 结果。 - 决策:规范 Agent reasoning 默认由角色职责分层,显式 per-Agent patch 优先;配置状态对外展示实际 timing/retry,避免全局文件、per-Agent resolver 与历史 run snapshot 混淆。 + +## 2026-08-01 game-chat 首版四阶段快车道与美术硬门 + +- 决策:game-chat 首版只投影 `art-director`、`code-prototype`、`preview-readiness`、`preview-playtest` 四个阶段,页面进度显示 `x/4`;四个专业 Agent 的安全 `final-reply` 均逐条进入项目聊天,`art-asset-plan` 不进入进度、阶段记录或 final-reply 投影。完整 GUI / CLI 任务图仍保留原有节点和执行语义,内部 Provider / child loop 不作为用户轮次。 +- 预算:父 Run 接受请求后以 `240` 秒作为首版软预算;父 Run、所有 child Run、等待、回收和确定性验收共享 `300` 秒累计硬上限。硬上限是从 root `bound_at` 计算的绝对 deadline,必须包住 Provider、图片生成、文件写入、静态检查、`preview.validate` 和 final-reply 的在途等待;超时先强制持久化 `failed` 终态,再清理 pending action、Provider batch、confirmation、recovery 和进程会话,不能留下 `needs-reconciliation` 悬空态。软预算后不再扩展 Provider 规划,只能运行受控 fallback、`game.static_smoke` 和 `preview.validate`;硬上限未形成当前 revision 的通过证据时必须失败关闭,单轮确定性收束也必须复核累计时间,不能在上限后补写 completed。 +- Provider:首版最多一次 Provider 规划 / 写入请求,禁止同一首版自动传输重试、第二次 tool-plan 或无限 repair。Provider 结束后由 Runtime 按当前 revision 依次执行确定性静态 smoke 与浏览器试玩。 +- 兜底:fallback HTML 必须自包含、无远程运行依赖,从 `ready` 开始并真实绘制 Canvas,持续更新 `playable-web-game-state.v1`,提供键盘 / 触控、start / primary-action / restart 和胜负状态;primary-action 后可保持 `playing`,restart 后可稳定恢复 `ready | playing`,不得开始前固定 `lost` 或用固定失败充当完成。fallback 只有在 `assets/art-spec.png` 已有效登记并真实存在时才能生成,而且必须把该平台图片显著绘制为主要背景、玩家和目标;不得以纯 Canvas 视觉绕过平台图片硬门。 +- 美术边界:live10 已证明正式透明 `icon-spritesheet` 的后处理无法稳定收进 `300` 秒。game-chat 首版必须配置可用的 External Editor API,由 `art-director` 发起一次平台 `images/generations` 并登记 `assets/art-spec.png`,再由 `code-prototype` 在用户可见画面中把该图片作为主要背景、玩家和目标真实加载和绘制。图片必须可完整解码,引用必须从 `game/index.html` 正确解析到登记路径;活动 Canvas 上同一资源至少包含一次背景级和两次实体级的可达 `drawImage`,隐藏 Canvas、诱饵路径和永不执行函数均不能过门。未配置 API,或生成、登记、文件、引用、可见使用任一缺失时都必须失败关闭,不得写 completed 或 `single_round_converged`。普通 GUI / CLI autonomous 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG,不采用 game-chat 的 `art-spec.png` 快车道。 +- Windows preview 稳定性:非阻塞 listener 接受连接后必须先把 accepted socket 恢复为阻塞模式,再有界读完拆分到达的请求头;完整响应 `flush + shutdown(Write)` 后执行短时有界 drain。Chromium speculative socket 导致的 `ConnectionAborted / ConnectionReset / Interrupted / TimedOut` 归为可继续监听的瞬时 accept 错误;单个中止连接不得令后续 `preview.validate` 复用或重建时连续得到 `net::ERR_SOCKET_NOT_CONNECTED`。 +- Runtime 恢复确认:GUI 自动扫描 `agent.resume` 前必须先用只读方式判断是否存在可恢复任务或 durable recovery artifact;全新项目与已完全终态、无任何恢复工作的项目直接返回空结果,不弹出“恢复未完成 Runtime 任务”;一旦存在 task、retry、handoff、finalization、pending action 或 reconciliation 等可恢复工作,仍必须经过原 `agent.resume` policy 门禁,不得通过吞掉 policy error 绕过确认。 +- 每条输出入聊天:事件文件中的原始 `summary / detail` 仍是私有 Runtime 证据,不可由前端直接持久化。Rust 只对白名单用户进度生成 `publicText`,同时为每次真实追加生成 `eventId`;action 重放沿用 action 身份,普通事件使用进程、毫秒与单调序列组成唯一身份。前端把 `eventId + publicText` 和四阶段专业 Agent 的 durable final reply 作为独立 assistant 消息,按顶层 `messageId` 幂等写入项目 conversation;重载恢复、轮询与实时事件并发不得重复或漏掉当前已观察输出。 +- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/development-workflow.md`。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 075c4c7c3..8f26bcc0c 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -67,7 +67,7 @@ npm run agc:build:game-chat-release 该命令启用 Rust `game-chat-release` feature,并配合编译期前端入口锁定生成独立 NSIS 包。当前专用 release 版本为 `0.1.1`;产物的 `productName` 为 `Genarrative Game Chat`,`identifier` 为 `world.genarrative.ai-game-creator.game-chat`,安装身份和 AppData 不与普通 AI 游戏创作客户端混用。独立包直接渲染本地 `GameChatReleaseApp`,绕过平台 `AuthenticatedClient`,进入本地项目工作台不依赖 `api-server`;普通 `npm run agc`、`npm run agc:dev`、debug game-chat 和 `npm run agc:build` 继续走既有认证入口与配置。 -game-chat 的用户可见 preview 必须由 Tauri 客户端 `PreviewRegistry` 持有。External Runner 和 Tauri 的 registry、server 句柄与 running 状态是进程内资源,不得互相推断或把 Runner 验证用 server 直接交给 iframe。当前 accepted Supervisor 父 run 下真实 `preview-playtest` scheduler child 首次给出结构化成功证据、且其 revision 精确等于项目当前 revision 后,客户端才消费一次性授权并启动一个 Tauri preview server,随后自动显示 iframe;`preview.start` 必须携带 `expectedRevision`,Tauri 在取得项目写锁后再次原子比对。same-run steer 授权必须记录授权前 revision / validation cursor 和唯一 generation ID,旧证据、旧 policy await 或旧 start 返回均不能清除新授权。同一 run 的更高 validated revision 只刷新原 iframe,不能重复 `preview.start` 或新增 server,相同 / 更低 revision 不刷新;同 revision 的最新失败证据必须关闭该 revision 的可玩判定。preview HTTP 的 HTML、脚本、样式、资源和错误响应都必须返回 `Cache-Control: no-store`。没有 Tauri 用户预览时,顶部状态必须显示“预览未启动”,不能只写“未启动”。 +game-chat 的用户可见 preview 必须由 Tauri 客户端 `PreviewRegistry` 持有。External Runner 和 Tauri 的 registry、server 句柄与 running 状态是进程内资源,不得互相推断或把 Runner 验证用 server 直接交给 iframe。当前 accepted Supervisor 父 run 下真实 `preview-playtest` scheduler child 首次给出结构化成功证据、且其 revision 精确等于项目当前 revision 后,客户端才消费一次性授权并启动一个 Tauri preview server,随后自动显示 iframe;`preview.start` 必须携带 `expectedRevision`,Tauri 在取得项目写锁后再次原子比对。same-run steer 授权必须记录授权前 revision / validation cursor 和唯一 generation ID,旧证据、旧 policy await 或旧 start 返回均不能清除新授权。同一 run 的更高 validated revision 只刷新原 iframe,不能重复 `preview.start` 或新增 server,相同 / 更低 revision 不刷新;同 revision 的最新失败证据必须关闭该 revision 的可玩判定。preview HTTP 的 HTML、脚本、样式、资源和错误响应都必须返回 `Cache-Control: no-store`。服务端必须在有界 read timeout、总 header 字节和 header 行数内读完请求头再响应,避免 Windows 因未读请求字节产生 abortive RST;`accept` 遇到 Chromium speculative socket 的 `ConnectionAborted / ConnectionReset / Interrupted / TimedOut` 时继续监听,不能让一个瞬时连接中止整个 preview server。没有 Tauri 用户预览时,顶部状态必须显示“预览未启动”,不能只写“未启动”。 `generic-v1` 的真实试玩必须从 `ready` 且正整数 level 开始;start 推进到 `playing` 后,必须先持续观察 2 秒并取得至少 8 个实际样本,期间保持 `playing`,以确认玩家获得正常操作机会;随后点击唯一可见、启用且真实可交互的 `data-playtest-id="primary-action"`,由该控件触发真实主要玩法操作,并以 sequence 相对点击前严格推进证明操作已被接受。操作被接受前进入 `won | lost` 代表玩家没有获得正常操作机会,必须失败;操作被接受后的单次 `lost` 是合法结局,但不能成为所有受控尝试的唯一结果。若主要操作后仍为 `playing`,则继续观察 3 秒并取得至少 12 个实际样本;`won` 可提前证明非失败推进。之后 restart 必须推进 sequence、恢复到 `ready | playing`,并持续观察 3 秒、取得至少 12 个实际样本;若首轮结果为 `lost`,重开稳定后必须再执行一次必要的 start、2 秒 / 8 样本操作机会和真实 primary-action,第二次必须进入或保持 `playing`(再观察 3 秒 / 12 样本且不得转为 `lost`)或进入 `won`。两次受控尝试都固定 `lost` 代表无法正常推进的恶性 bug,必须失败。各观察窗口内 sequence 不得回退,restart 窗口只能保持 `ready | playing`。样本数和观察时长必须同时满足,窗口末端必须强制再读取一次有效状态,不能靠前段样本数提前通过。selector、时长、样本门槛、终态边界、非失败推进、窗口末端覆盖、required assertions 和 sequence 规则都属于 scenario fingerprint。旧 fingerprint 回执在读取和 plan liveness 检查时按 stale missing 处理,让同一 run 可重新 `preview.validate`;身份、digest、路径或内容完整性篡改仍失败关闭,最终完成门仍须现场重算当前 fingerprint 并严格拒绝旧证据。 @@ -81,7 +81,11 @@ cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml real_ 修改 game-chat release flavor 后,至少执行壳配置门禁、AppSurface game-chat 定向测试、前端类型检查、AppData / 诊断日志 / release flavor 相关 Rust 定向测试、`npm run check:encoding` 和 `git diff --check`。打包 smoke 必须确认:安装信息和产物版本为 `0.1.1`;无参数启动直接进入且只能停留在 game-chat 页面;停止或断开 `api-server` 后本地工作台仍能打开;普通 dev / release 与 debug game-chat 仍走原认证入口;独立 AppData 生效。预览 smoke 应先让当前 run 成功验证 revision N,确认 Tauri registry 启动一个 server 且 iframe 自动出现;在 validate 后、start 取得锁前推进项目 revision,必须确认原子 `expectedRevision` 门禁拒绝启动且授权保留等待新证据;再验证 revision N+1,确认 server 进程和 loopback origin 不变、iframe 显示新版本且响应为 `no-store`。same-run steer 还要覆盖旧验证不消费新授权、旧异步 attempt 不清新 generation;Runner registry 单独 running、失败 / 相同 / 更低 revision 均不能触发用户预览或重复刷新,停止后顶部显示“预览未启动”。独立包退出时必须通过 `runner.shutdown_for_client_exit` 先进入 draining 再结束本 boot,保留 durable sidecar 供下次 reconciliation,不把中断任务写成 completed;Windows Runner 必须 `CREATE_SUSPENDED -> AssignProcessToJobObject -> ResumeThread`,分配或恢复失败时 kill + wait,客户端持有 kill-on-close Job 兜底,关闭主窗口后 Runner、MCP、command、ConPTY 及其后代都应消失。普通 dev / release 和 CLI 继续使用 `runner.shutdown_if_idle`。 -game-chat 迭代还必须确认三条行为:每条 Supervisor ready 输出都以 `runId + requestSlot + responseRevision` 组成的 durable message ID 固化在聊天框,事件 / 轮询 / hydration 重放不重复;可信 `project-supervisor-game-chat` 一轮完成 `preview-playtest` 后直接收束,不调度 `publish-strategy` / `publish-package`;配置 External Editor API 时,`code-prototype` 的 `game/index.html` 实际引用已由 Canvas 登记的 `assets/art-spritesheet.png`,缺少登记、文件或引用必须失败关闭。对应定向测试至少包括: +game-chat 迭代还必须确认以下行为:Supervisor ready 输出、`art-director / code-prototype / preview-readiness / preview-playtest` 四个专业 Agent 的 durable `final-reply`,以及 Rust 明确生成 `eventId + publicText` 的每条公开 Runtime 输出都作为独立 assistant 消息逐条固化在项目聊天;`art-asset-plan` 不进入进度、阶段记录或 final-reply 投影。消息通过顶层 `messageId` 幂等追加,事件 / 轮询 / hydration 重放不重复。前端禁止从原始 `summary / detail`、tool plan、Provider / Runner 元数据、命令输出或路径自行拼接持久消息;UI 把一个父 Run 统一显示为“本轮生成进度”,最新状态也不得暴露内部“第 N 轮”,完整 GUI / CLI 任务图可显示 `x/14`,首版快车道显示 `x/4`,终态不保留运行中进度卡;可信 `project-supervisor-game-chat` 一轮完成 `preview-playtest` 后直接收束,不请求下一次 Provider tool-plan;所有调度入口(包括 `agent.schedule_ready`、`task.list` 结果驱动的直接 `agent.delegate`)均不得暴露或启动 `publish-strategy` / `publish-package`。game-chat 必须配置可用的 External Editor API,由 `art-director` 通过一次平台 `images/generations` 生成并登记 `assets/art-spec.png`,`code-prototype` 必须把它显著用于用户可见的主要背景、玩家和目标;未配置 API 或缺少任一环节都必须失败关闭。普通 GUI / CLI autonomous 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG。对应定向测试至少包括: + +game-chat 首版快车道采用独立四阶段口径:只显示 `art-director`、`code-prototype`、`preview-readiness`、`preview-playtest` 的 `x/4`,不把完整 DAG 或内部 loop 计入分母。父 Run 与全部 child Run 共用 240 秒软预算和 300 秒累计硬上限;首版最多一次 Provider 规划 / 写入请求,软预算后只能运行确定性的本地 fallback、`game.static_smoke`、`preview.validate`,硬上限内未通过完成门必须失败,证据在上限后到齐也不得写 `single_round_converged`。live10 实测正式透明 `icon-spritesheet` 后处理超过 300 秒,因此 game-chat 改为一次平台 `images/generations` 生成并登记 `assets/art-spec.png`;fallback 只有该图片有效登记且真实存在时才允许生成,并必须把它显著绘制为主要背景、玩家和目标。未配置 External Editor API、图片生成失败、缺少 Canvas / manifest 登记、文件、HTML 引用或用户可见绘制时必须在 300 秒内失败关闭,绝不误报 completed。普通 GUI / CLI 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG。对应定向测试至少包括: + +game-chat GUI 恢复还要覆盖两类竞态:root Runtime 先终态、manifest 四阶段后终态时,必须等到四任务最终状态后仅持久化一条 `【Supervisor 阶段记录】`;页面初始 hydration 直接读到真实终态时也要补写缺失记录,但不得把 `idle` 当作完成。同时,GUI 启动的 `agent.resume` 自动扫描必须先做只读恢复工作预检:新项目或无 task / retry / handoff / finalization / pending / reconciliation 工作的已终态项目不弹确认,存在任何 durable recovery artifact 则仍必须命中 `agent.resume` policy。 ```bash npm run test -- apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts --run diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 21e1b67fc..e2a6f4f4b 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -31,10 +31,10 @@ - Windows AppData 安全迁移:首次创建客户端 AppData 时必须以进程 `TokenUser` SID 显式设置 owner,并写入当前用户私有 DACL,不能把可能为 Administrators 的 `TokenOwner` 当作用户身份。发现历史目录 owner 不属于当前 `TokenUser` 时,不在原目录上放宽权限,而是拒绝 reparse point / junction / symlink 后,将旧目录原子重命名到同级唯一 `.owner-mismatch-backup-*` 备份,再新建并验证当前用户 owner 与私有 DACL;迁移或备份失败必须失败关闭,不覆盖旧配置。 - Windows Runner 私有文件初始化:父 AppData 已归当前 `TokenUser` 后,新建 `agent-runner.lock`、endpoint 临时文件、project-owner 诊断临时文件与 real-E2E 私有文件的 owner 仍可能采用 token 默认 owner `Administrators`。固定 stale lock 只有在父目录已验证为当前用户 protected 私有 DACL、Windows 不共享独占句柄已取得、且句柄确认普通文件、非 reparse point、链接数为一时才允许修复;其它三类文件只允许在本进程 `create_new` 成功且仍持有同一独占句柄时初始化 `TokenUser` owner / DACL,再写入、原子安装并严格复核,初始化失败必须清理刚创建的文件。既有 durable endpoint / diagnostic 读取不得自动接管;活锁不得截断,只有 sharing / lock violation `32/33` 表示占用,access denied 等其它错误立即返回。父进程观察到 Runner 子进程退出后立即返回错误,不等待完整 30 秒 deadline。 - 启动诊断:独立 release 的 `startup.log` 和 `agent-runner.log` 只记录有界、脱敏的阶段与 stdout / stderr 摘要,凭据、AppData 路径和其它绝对路径不得原样落盘;单文件达到 256 KiB 后只轮转保留一份 `.previous.log`。`startup.log` 优先写独立 AppData,目录不可写时回退到系统 TEMP 下的 `Genarrative-Game-Chat-Diagnostics`;Tauri context、窗口 URL、AppData、Runner 或 `.setup()` / `.build()` 初始化失败时,Windows 必须显示可见错误对话框并给出诊断日志位置,不能只在无控制台 release 中静默退出。 -- 对话与事件:窗口固定使用 `project-supervisor + autonomous-game-build`,继续复用 active Session、External Runner、持久 conversation、流式回复、same-run steer、工具确认与用户追问。以 `/` 开头的输入必须继续走现有内置命令解析,例如 `/preview` 只能生成 `preview.start` 确认卡,不得作为自主构建任务投递给 Supervisor。界面聚合当前 Supervisor 父 run 及其直接委派专业 Agent 的最新事件,按时间倒序稳定去重并标注 Agent;默认显示 4 条,可展开至最新 20 条。这些原始事件只是 Runtime 状态投影,不写入 conversation,不伪装成用户或 assistant 消息。 +- 对话与事件:窗口固定使用 `project-supervisor + autonomous-game-build`,继续复用 active Session、External Runner、持久 conversation、流式回复、same-run steer、工具确认与用户追问。以 `/` 开头的输入必须继续走现有内置命令解析,例如 `/preview` 只能生成 `preview.start` 确认卡,不得作为自主构建任务投递给 Supervisor。界面聚合当前 Supervisor 父 run 及其直接委派专业 Agent 的最新原始事件,按时间倒序稳定去重并标注 Agent;默认显示 4 条,可展开至最新 20 条。原始 `summary / detail` 仍只作 Runtime 状态投影,不直接写入 conversation。需要进入聊天的事件必须由 Rust 同步生成唯一 `eventId` 与安全 `publicText`;前端只按这两个字段形成独立 assistant 消息,无 `eventId`、空 `publicText`、legacy 事件和内部 tool / Provider / Runner 协议一律忽略。 - Supervisor 进度播报:聊天消息流内保留且只保留一条当前 run 的 Runtime-owned 播报卡,由客户端从 manifest 任务图、Supervisor 结构化计划、`loopIteration`、当前动作、直接委派专业 Agent 及其持久事件确定性整理;显示当前轮次、任务 / 计划进度、活跃 Agent、最近试玩与静态检查、返工决定、代码修改和截图检查证据。同一 run 原位更新,切换 run 时替换,不调用额外模型、不追加持久 conversation,也不改变最终 assistant 回复的唯一性;任意详情必须有界且不展示绝对路径、Provider 元数据或内部指纹。 - Provider 故障展示:Provider retry 的“是否可重试”继续使用 `upstream-5xx` 等稳定类别判断,但 durable retry record 保留安全的精确 `upstream-` 身份。等待态必须从真实 record 显示 HTTP 状态、`nextAttempt/maxRetries` 与当前持久退避剩余秒数,例如“Provider 上游返回 HTTP 503,准备自动重试 1/3;预计 8 秒后重试”;不得以动画或前端自增计时伪造 attempt。重试耗尽的 Runtime 私有错误只保存 `kind/httpStatus/fingerprint/chars/retryAttempt/maxRetries/retryState`,前端和持久 conversation 仅在字段顺序、范围、状态一致且无尾随正文时派生“上游服务返回 HTTP 503;自动重试已耗尽(3/3)”;其它错误使用固定安全摘要。Provider 响应正文、URL/query、凭据、本地绝对路径、fingerprint、字符数和 `[redacted ...]` 占位符均不得进入用户可见消息。 -- 跨轮阶段记录:game-chat 确实观察过活跃态的父 run 进入 completed / failed / cancelled 等终态,且正式 Supervisor conversation 已刷新后,客户端把本轮轮次、任务 / 计划完成度、最新试玩 / 静态检查、最近返工决定和已登记成果图片路径整理成一条 `【Supervisor 阶段记录】` 项目 assistant 消息。每个“项目 + 父 run”最多追加一次,进入现有 `conversation.write` 权限与项目 conversation 持久化链路,下一轮及重载后继续保留;加载时已经终态但本窗口未观察其活跃过程的旧 run 不补写,防止每次启动重复归档。阶段记录不是 Supervisor Runtime 正式回复,不写入 Agent Session、不增加 final assistant 数量,也不逐条复制原始事件或内部正文。 +- 跨轮阶段记录:game-chat 父 run 进入真实 completed / failed / cancelled 终态后,客户端等待 `art-director / code-prototype / preview-readiness / preview-playtest` 四项快车道任务也全部投影到 completed / failed 终态,再把本轮、任务 / 计划完成度、最新试玩 / 静态检查、最近返工决定和已登记成果图片路径整理成一条 `【Supervisor 阶段记录】` 项目 assistant 消息。`art-asset-plan` 不属于五分钟首版阶段,不阻塞阶段记录。父 run 先终态而 manifest 仍在 hydration 时不得用陈旧 `0/4` 提前归档,要暂存终态 Runtime 并在 manifest 刷新后重试。页面初始 hydration 若直接读到缺少阶段记录的真实终态 run,也必须补写,但 `idle` 不是可归档终态。每个“项目 + 父 run”最多追加一次,进入现有 `conversation.write` 权限与项目 conversation 持久化链路,下一轮及重载后继续保留。阶段记录不是 Supervisor Runtime 正式回复,不写入 Agent Session、不增加 final assistant 数量,也不逐条复制原始事件或内部正文。 - 图片成果:当前 manifest 新增或恢复已登记的 PNG / JPEG / WebP 资源时,聊天消息流同步显示 Runtime-owned “Supervisor 成果图片”卡,最多展示最新 4 张并随 manifest 原位更新。图片必须通过现有 `read_local_project_image_preview` 读取,只允许当前授权项目中 `assets/` 下的已登记资源,继续执行 `file.read` auto 权限、真实格式、大小、尺寸、普通文件、祖先目录和项目根边界校验;前端只接受返回路径、媒体类型和 `data:` 前缀与请求完全一致的结果。缩略图点击后使用独立模态查看器,支持按钮与滚轮缩放、指针拖拽、双击 / 按钮复位、Esc / 按钮 / 遮罩关闭,移动端占满视口;不得在聊天卡下方追加展开区。图片卡不写入 conversation,不解析 assistant 文本中的任意 Markdown / 绝对路径,也不开放 `.agent` 验收截图读取。 - Run 接管:External Runner 模式下首次提交可能返回“旧 canonical state + 新 `acceptedRunId`”;页面必须以 `acceptedRunId` 作为本轮权威身份,在 state 尚未切换时显示“已投递,正在同步 Agent Runner”,并允许该 run 的 Tauri event 或轮询结果接管。不得把旧 idle state 当作本轮结果、过滤新 run 事件,自动预览授权也必须绑定 `acceptedRunId`。 - 运行容器:当前项目没有由 Tauri 客户端 `PreviewRegistry` 返回的有效 `running` 预览时,页面只渲染聊天,不显示游戏区域或占位文案,顶部运行状态必须明确显示“预览未启动”,不得再使用含义不明的“未启动”;预览运行后自动显示 iframe,桌面端按“游戏 2 / 聊天 1”分栏,移动端改为上下布局。预览停止、失败或切换项目后立即移除 iframe。运行容器继续只接受当前授权项目的 `http://127.0.0.1:*`,复用现有 CSP、iframe sandbox、autoplay、fullscreen 和 gamepad 约束;远程 URL、`file://`、手填地址或陈旧 manifest 状态均不得显示。 @@ -52,11 +52,21 @@ ## 2026-07-31 game-chat 输出、单轮预览与平台美术资源 -- 对话输出:game-chat 的 Supervisor `ready` response stream 以 `runId + requestSlot + responseRevision` 形成稳定 durable message ID,最终每条输出都追加到聊天框;事件、轮询、React StrictMode 重放和 hydration 只按该 ID 去重,不以正文或时间戳合并不同 Run 的相同回复。普通 `supervisor-chat` 保持原有 transient response 行为。 -- 单轮收束:game-chat source 只生成至 `preview-playtest` 的 manifest seed task,试玩完成后父 Run 直接进入完成门,不再调度 `publish-strategy` / `publish-package`;普通 GUI / CLI 仍执行完整发布 DAG。完成门仍要求当前 revision、`game.static_smoke` 和 `preview.validate` 结构化证据。 -- 平台美术资源:配置 External Editor API 时,`code-prototype` 必须通过 `asset.list` 核对 Canvas 登记的 `assets/art-spritesheet.png`,并在 `game/index.html` 实际引用该路径;缺少登记、文件或引用均 fail-closed。确定性 Provider fixture 同步输出图集引用,避免测试绕过该门禁。 +- 对话输出:game-chat 的 Supervisor `ready` response stream 继续以稳定身份显示;`art-director / code-prototype / preview-readiness / preview-playtest` 的 `requestKind=final-reply` 且 `status=ready|committed` 的非空安全回复也分别以 Agent、Session、run、request slot 和 response revision 形成 durable message ID,并带 Agent 标签逐条追加到项目聊天;`art-asset-plan` 的回复不进入 game-chat 项目聊天。每条 Rust `eventId + publicText` 公开输出同样形成独立 durable 消息。所有这些消息通过 `append_local_conversation_message` 的顶层 `messageId` 幂等写入,事件、轮询、React StrictMode 和 hydration 重放不重复;tool-plan、半成品 stream、原始事件 detail、命令正文、绝对路径、Provider / Runner 元数据、哈希和凭据不得进入聊天。普通 `supervisor-chat` 保持原有 transient response 行为。 +- 单轮收束:game-chat source 只生成至 `preview-playtest` 的 manifest seed task,试玩完成后父 Run 直接进入完成门,不再调度 `publish-strategy` / `publish-package`;`agent.schedule_ready` 必须按当前 Supervisor Run 的持久 source/profile 选择同一 source-aware scheduler,不能绕过该边界。`task.list` 对同一 root source 必须从任务行、readyTaskIds 和统计中排除两个发布节点,`agent.delegate` 也必须按 root binding 拒绝直接委派这两个节点,不能让 Provider 用“读取完整 DAG 后手工委派”恢复已裁掉的发布阶段。完成门满足且 collaboration、Provider batch、进程会话、视觉资源等非验证屏障全部清零后,Runtime 必须用确定性回复直接收束结构化计划并结束父 Run,不再请求下一次 Provider 工具计划。普通 GUI / CLI 仍执行完整发布 DAG。 +- 轮次展示:`loopIteration` 只是同一父 Run 内的 Provider / 工具规划循环,用于委派、回执、返工和验收,不是用户发起的游戏生成轮次。game-chat 的进度卡、当前工作和“最新状态”事件统一显示“本轮”,整个页面不向用户显示“第 N 轮”;完整 GUI / CLI pre-publish 任务图仍可显示 `x/14`,但首版快车道只按四阶段显示 `x/4`(详见 2026-08-01 小节),不得把两个发布节点计入任一分母。父 Run 终态后移除运行中进度卡,只保留终态阶段记录与预览。 +- 平台美术资源:live10 实测正式透明 `icon-spritesheet` 的生成与后处理超过 `300` 秒,因此 game-chat 五分钟首版不运行 `art-asset-plan` 图集链路。它必须配置可用的 External Editor API,由 `art-director` 通过一次平台 `images/generations` 生成并登记 `assets/art-spec.png`,再由 `code-prototype` 在 `game/index.html` 的用户可见画面中把该图片显著用于主要背景、玩家和目标;未配置 API,或缺少生成、登记、文件、引用、可见使用任一证据时均 fail-closed。普通 GUI / CLI autonomous 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG,不采用该快车道。 - 验证:前端运行时模型定向测试、Rust completion/source/asset 合同测试、`cargo fmt --check`、`npm run check:encoding` 与 `git diff --check` 必须全部执行;Windows 文件锁竞态只可作为既有测试失败单独记录,不得将其改写为本次改动的通过证据。 +## 2026-08-01 game-chat 首版四阶段快车道与美术硬门 + +- 首版任务边界:game-chat 首版只展示 `art-director`、`code-prototype`、`preview-readiness`、`preview-playtest` 四个阶段,进度统一显示为 `x/4`;`art-asset-plan` 不进入进度、阶段记录或 final-reply 投影。不把完整 GUI / CLI 任务图的其它节点投影到该页面,也不显示内部 Provider / child loop 轮次。 +- 时间预算:从 game-chat 父 Run 接受用户请求开始,首版可玩版本使用 `240` 秒软预算;父 Run 与其全部 child Run、等待和回收阶段共享从 root `bound_at` 计算的 `300` 秒绝对硬上限,不能把硬上限拆成每个 Agent 独立计时。整个 Runtime pass 必须受同一 `timeout_at` 约束,覆盖 Provider、图片生成、文件写入、静态检查、浏览器试玩和 final-reply 的在途等待;超时先强制持久化 `failed`,再清理恢复 sidecar 与进程会话。达到软预算后只允许进入确定性的本地兜底、静态 smoke 和浏览器试玩;达到硬上限仍未通过完成门必须失败关闭。即使完成证据恰好在上限后到齐,单轮确定性收束也必须再次检查累计预算并拒绝写入 `single_round_converged`,不得把超时伪装成 completed。 +- Provider 次数:首版快车道最多执行一次 Provider 首版规划 / 写入请求;后续不再请求第二次 Provider tool-plan、自动传输重试或无限 repair。Provider 成功返回后由 Runtime 依次执行确定性的 `game.static_smoke` 与 `preview.validate`,以当前 revision 和真实浏览器证据决定是否可交付。 +- 可玩兜底:软预算或首版 Provider 无法及时完成时,可以生成完整、自包含、无远程运行依赖的中文 HTML 模板。模板必须从 `ready` 开始,包含真实 Canvas 绘制、`requestAnimationFrame`、键盘 / 触控主要操作、唯一可见且启用的 start / primary-action / restart 控件,状态 JSON 持续推进,并能在 primary-action 后保持 `playing`、在 restart 后稳定回到 `ready | playing`;不得在开始前固定进入 `lost`,也不得通过固定失败冒充试玩通过。模板只有在 `assets/art-spec.png` 已经完成有效登记并真实存在时才允许生成,且必须把该平台图片显著绘制为主要背景、玩家和目标;未配置 External Editor API、图片生成失败或缺少有效登记时直接失败关闭,不得生成纯几何首版。 +- 平台图片:live10 已证明透明 `icon-spritesheet` 后处理无法稳定收进 `300` 秒。game-chat 由 `art-director` 在首版预算内发起一次平台 `images/generations`,生成并登记 `assets/art-spec.png`;图片必须完整解码,`game/index.html` 的引用必须正确解析到该登记路径,且同一资源必须在非隐藏活动 Canvas 的可达执行路径中至少完成一次背景级和两次实体级 `drawImage`。`code-prototype` 必须在用户可见游戏画面中把它作为主要背景、玩家和目标真实加载和绘制。未配置 API,或生成、登记、文件存在、HTML 引用、可见使用任一缺失时,都必须在父 Run 的 `300` 秒累计硬上限内失败关闭,绝不写入 completed、`single_round_converged` 或其它成功结论。首版仍只在 `preview-playtest` 后单轮收束,不进入发布节点。普通 GUI / CLI 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG。 +- 关联验收:快车道必须分别验证 `x/4` 投影、四个专业 Agent 安全 final-reply 逐条入聊天且排除 `art-asset-plan`、240 / 300 秒累计预算、单次 `images/generations`、External Editor API 缺失时失败关闭、`assets/art-spec.png` 生成 / 登记 / 文件 / 引用 / 主要背景与玩家及目标可见使用硬门,以及当前 revision 的静态 smoke 与浏览器试玩;这些规则不改变普通 GUI / CLI 的完整任务图和透明图集硬门。 + ## Runtime 边界 V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .codex / .hermes`;其中 `.agent` 对项目命令隐藏,其余控制目录只读。 @@ -251,7 +261,7 @@ Agent Runtime 负责: - 历史记录(已由 V1.1 独立 Runner 替代):Runtime 最初通过 `resume_game_creator_agent_runtime_tasks` 把本地 JSONL 队列重接到当前 App 进程。当前恢复入口仍保留权限、任务顺序和 `agent.runtime.background_task.recovered` 审计语义,但实际由独立 Runner 接管原 run / session;已发出的上游 LLM 请求仍不能从网络中间点续传。2026-07-27 起,Runner 归 Tauri GUI 生命周期所有,同一 AppData 只允许一个 GUI owner。GUI 启动子进程会显式声明 `--gui-owner-required` 并在就绪后 attach owner;Runner 若在启动检查前已发现 owner 释放则直接失败,不得退化成 CLI-owned Runner。Runner 使用独立 watchdog 线程每 100ms 监控 owner OS 锁,不依赖服务端主循环继续推进;owner 丢失后先触发 1.5 秒共享 deadline 的 draining、Provider 中断和 process session 回收,若主循环或排空链路卡死则在 1.75 秒后由 Runner 自身进程安全硬退出并清理匹配 bootId 的 endpoint。因此正常最终退出、panic、SIGKILL 和 setup 中途失败都不会再因 busy 或主循环卡死而残留后台进程。endpoint 缺失 / 读取失败必须结合 Runner 实例锁判断;GUI 客户端强制兜底在 Linux 使用 pidfd、Windows 使用稳定进程 handle。macOS 没有等价稳定句柄,客户端不得在 start identity 检查后按裸 PID 强杀,而由跨平台 Runner 自身 watchdog 提供硬退出兜底。旧 endpoint 缺 start identity 时,只有认证 ping 精确匹配 PID + bootId 才允许迁移 busy 旧 Runner。未完成任务保持 durable 状态并在下一次启动走 reconciliation / recovery,不能伪造 completed 或重放副作用。关闭单个 WebView / 子窗口和普通 CLI 退出不触发该行为,版本切换与人工命令仍可使用只关闭空闲实例的 `runner.shutdown_if_idle`。 - 2026-07-10 补充,2026-07-16 由 V1.28 澄清:后台 planning 与预算内 final reply 使用专用最小上下文,只预置 Agent 身份、sessionId、runId、执行模式和工具策略;Agent 私有记忆、项目记忆、黑板、对话、资产、项目索引与文件正文只能经对应工具通过权限 gate 后作为 observation 进入下一轮。只有开发窗口的专业 Agent 前台直调可使用对应角色上下文;正式用户前台现已统一进入 `project-supervisor`。长黑板、记忆和对话按尾部截断,确保最新结论与最新定向消息优先保留。 - 2026-07-10 补充,2026-07-16 由 V1.28 澄清:同一 Agent 的开发前台直调、流式调试和后台任务统一使用 `.agent/runtime/locks/.lock` OS 文件锁。开发前台不再在整个 LLM 请求期间占用项目级写锁;同 Agent 后台任务在开发前台运行时只入队,前台成功或失败后把当前 Agent 锁直接移交给 drain,不重新抢锁,也不允许 drain 启动异常把已经完成的调试结果改判为失败。正式用户 GUI 不通过该入口直聊专业 Agent;不同 Agent 继续并行,真实项目写工具只在副作用执行期间短暂申请项目写锁。 -- 2026-07-10 补充:默认 `agent.resume=confirm` 时,客户端自动恢复命令只做 auto gate 并返回待确认错误;主工作区和独立开发 Agent 聊天窗口在首次读取项目 Runtime 时都必须显示 `agent.resume` 确认条,确认对象绑定发起时的项目路径,切换项目会取消旧确认,异步返回后也不得把旧项目 Runtime 合并到新项目 UI。开发者确认后调用独立 `confirm_resume_game_creator_agent_runtime_tasks`,该命令仍执行 deny-only 权限检查后才接回 durable queue。临时调用失败不锁死项目路径,允许后续刷新重试;明确 deny 或取消都不恢复任务。 +- 2026-07-10 补充,2026-08-01 更新:默认 `agent.resume=confirm` 时,客户端自动恢复命令先做只读 recovery preflight。全新项目和已完全终态且没有 task / retry / handoff / finalization / pending action / reconciliation 等 durable recovery work 的项目直接返回空结果,不显示虚假的 `agent.resume` 确认条。确实存在可恢复工作时,自动命令只做 auto gate 并返回待确认错误;主工作区和独立开发 Agent 聊天窗口显示 `agent.resume` 确认条,确认对象绑定发起时的项目路径,切换项目会取消旧确认,异步返回后也不得把旧项目 Runtime 合并到新项目 UI。开发者确认后调用独立 `confirm_resume_game_creator_agent_runtime_tasks`,该命令仍执行 deny-only 权限检查后才接回 durable queue。临时调用失败不锁死项目路径,允许后续刷新重试;明确 deny 或取消都不恢复任务。 - 2026-07-10 补充,2026-07-12 更新,2026-07-15 增加 V1.17 完成门禁并由 V1.21 澄清:后台 Agent 返回空 `actions` 后,只有不存在 `project.verify` 等既有 blocker,且当前结构化计划的全部必要步骤均为 `completed`,才视为 loop 已收束。工具 action 序号和成功 observation 不会自动推进结构化计划;未完成时 Runtime 返回 `runtime.plan_update` blocker,在同一 run 要求 Agent 按真实进度更新。每 6 轮只做进度 checkpoint 与停滞检测;有新的独立 observation 时继续同一 run,最近 6 轮没有独立进展或相邻 checkpoint 重复时终态才写为 `status=failed / phase=budget-exhausted`,error 使用 `loop-budget-exhausted` 机器可读前缀,不再调用 final reply 后写 completed 审计。上下文摘要只由 token 阈值或显式 `/compact` 触发。解析阶段保留过滤后的 action 总数,每轮超过 3 个 action 时写入 `runtime.tool_budget` observation 并只执行前三个,要求下一轮重新排序。Runtime 默认 `allowedTools` 直接由实际可执行工具白名单派生,避免 UI 观测与执行边界漂移。 - 2026-07-15 V1.18 补充:开发单 Agent 对话框使用 `执行 / 聊天 / 目标` 三段模式,Goal 创建/编辑在独立弹层完成,并可查看状态、revision、完成标准以及暂停/恢复/清理;正式用户窗口不展示 Goal 管理控件。Provider 中断边界先持久化可恢复的当前 v5 context;Runner 重启先收束 Goal control,`paused` 在 finalization/pending action 前直接保持休眠。resume 只从 `paused` 续接,先删除同一 run 旧 cancel tombstone;finalization v3 在 assistant 后先投影 Runtime completed,再写 Goal completed 并补 Goal 终态投影。 - 任务图能力:每轮 Orchestrator agenda、ready / active task 选择、Evaluator 结构化返工路由、返工轮 carry-over。