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..6e6c215cd 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 游戏循环、键盘与触控输入、开始、主要操作、重开、胜负状态和移动端布局;可以采用保守的原创玩法默认值。HTML 应直接包含 ../assets/art-spritesheet.png 的可选图片引用,并在图片不可用时使用 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,82 @@ 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-spritesheet.png")); + assert!(prompt.contains("Canvas 绘制兜底")); + 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..7a113a513 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,6 +206,7 @@ pub(super) struct AgentRuntimeAutonomousPlaytestReceipt { mod entrypoints; mod finalization; +mod game_chat_fast_path; mod interaction; mod lifecycle_control; mod main_loop; @@ -220,6 +221,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 +280,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..169a54cb3 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs @@ -0,0 +1,731 @@ +//! 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(), + } +} + +pub(crate) fn game_chat_fast_path_fallback_write_plan(task: &str) -> AgentRuntimeToolPlan { + 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_fallback_write_plan_for_root( + root: &Path, + task: &str, +) -> AgentRuntimeToolPlan { + let has_platform_art = game_chat_fast_path_has_platform_art_asset(root); + game_chat_fast_path_action( + "file.write", + serde_json::json!({ + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + "content": render_game_chat_fast_path_html_with_platform_art(task, has_platform_art), + }), + ) +} + +fn game_chat_fast_path_has_platform_art_asset(root: &Path) -> bool { + let Ok(manifest) = read_manifest_for_project(root) else { + return false; + }; + manifest.assets.iter().any(|asset| { + asset.kind == "art-spritesheet" + && asset.local_path == "assets/art-spritesheet.png" + && root.join("assets/art-spritesheet.png").is_file() + }) +} + +pub(crate) fn game_chat_fast_path_fallback_write_plan_for_budget_at( + root: &Path, + budget: &GameChatFastPathBudget, + _fallback_task: &str, +) -> 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(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() { + "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 current_revision_changed = revision.revision > budget.baseline_revision; + let current_revision_verified = current_revision_changed + && gate.verified_revision == Some(revision.revision) + && gate.last_verification_status.as_deref() + == Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED); + let current_revision_failed = current_revision_changed + && 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 current_revision_changed && !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 { + render_game_chat_fast_path_html_with_platform_art(prompt, false) +} + +fn render_game_chat_fast_path_html_with_platform_art( + prompt: &str, + has_platform_art: bool, +) -> String { + let theme = html_escape(&safe_theme_summary(prompt)); + let platform_art = if has_platform_art { + r#""# + } else { + 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}; + + #[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("../assets/art-spritesheet.png")); + assert!(!html.contains(" src=\"../assets/art-spritesheet.png\"")); + } + + #[test] + fn fallback_html_loads_registered_platform_art_only_when_file_exists() { + 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"); + + let without_asset = game_chat_fast_path_fallback_write_plan_for_root( + &root, + "没有美术资源时使用 Canvas fallback", + ); + let without_html = without_asset.actions[0].input["content"] + .as_str() + .expect("fallback html without asset"); + assert!(!without_html.contains(" src=\"../assets/art-spritesheet.png\"")); + + fs::write(root.join("assets/art-spritesheet.png"), b"fixture") + .expect("write platform art fixture"); + register_local_asset_at( + &root, + "assets/art-spritesheet.png", + "art-spritesheet", + "image/png", + "canvas", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("platform-art-canvas".to_string()), + resource_id: Some("platform-art-resource".to_string()), + asset_object_id: Some("platform-art-object".to_string()), + task_id: Some("art-asset-plan".to_string()), + prompt: None, + model: None, + generation_route: None, + generation_kind: Some("art-spritesheet".to_string()), + reference_resource_ids: Vec::new(), + }, + ) + .expect("register platform art fixture"); + + let with_asset = + game_chat_fast_path_fallback_write_plan_for_root(&root, "有美术资源时加载平台图集"); + let with_html = with_asset.actions[0].input["content"] + .as_str() + .expect("fallback html with asset"); + assert!(with_html.contains("src=\"../assets/art-spritesheet.png\"")); + assert!(with_html.contains("drawImage(platformArt")); + } + + #[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"); + let asset_path = root.join("assets/art-spritesheet.png"); + fs::write(&asset_path, b"fixture").expect("write temporary asset"); + register_local_asset_at( + &root, + "assets/art-spritesheet.png", + "art-spritesheet", + "image/png", + "canvas", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("platform-art-canvas".to_string()), + resource_id: Some("platform-art-resource".to_string()), + asset_object_id: None, + task_id: Some("art-asset-plan".to_string()), + prompt: None, + model: None, + generation_route: None, + generation_kind: Some("art-spritesheet".to_string()), + reference_resource_ids: Vec::new(), + }, + ) + .expect("register platform art fixture"); + fs::remove_file(asset_path).expect("remove platform art fixture"); + + let plan = + game_chat_fast_path_fallback_write_plan_for_root(&root, "缺图时仍使用 Canvas fallback"); + let html = plan.actions[0].input["content"] + .as_str() + .expect("fallback html"); + assert!(!html.contains(" src=\"../assets/art-spritesheet.png\"")); + } + + #[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, + }; + + 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("", + ); + + 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..62a99e691 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,139 @@ 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 ["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=3 running=0 pending=0 waiting=0 failed=0 total=3" + ), + "{detail}" + ); + assert!( + detail + .contains("taskCounts: completed=3 running=0 pending=0 waiting=0 failed=0 total=3"), + "{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 dbd8adef2..5c9f77367 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -868,8 +868,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) } @@ -1333,16 +1336,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 0e247a357..b27d185f0 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/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..c1f4aca90 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 @@ -3190,6 +3190,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 21ff93a40..f37880200 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -229,7 +229,11 @@ import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWo import { buildGameChatProgressEvidence, collectGameChatResultImages, + gameChatFinalReplyMessages, + gameChatRuntimeEventMessages, formatGameChatStageRecord, + mergeGameChatFinalReplyMessagesIntoHistory, + mergeGameChatRuntimeEventMessagesIntoHistory, SupervisorChatOnlyView, } from './features/project-workspace/SupervisorChatOnlyView'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; @@ -263,6 +267,31 @@ type GameChatPreviewValidationCandidate = GameChatPlayableRevision & { playable: boolean; }; +const GAME_CHAT_STAGE_TASK_IDS = [ + '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 gameChatPlayableRevisionIsAfterAuthorization( revision: GameChatPlayableRevision, authorization: GameChatAutoPreviewAuthorization, @@ -575,6 +604,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, @@ -886,6 +918,7 @@ export function App({ projectSupervisorRuntimeRef.current = null; projectSupervisorExpectedRunIdRef.current = null; projectSupervisorResponseStreamRef.current = null; + gameChatPendingStageRuntimesRef.current.clear(); gameChatCommittedResponseStreamKeysRef.current.clear(); projectSupervisorRuntimeSyncingRef.current.clear(); setProjectSupervisorSessionId(null); @@ -932,43 +965,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]; + }); + } + + function appendGameChatFinalReplyMessages( + 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; + }); + } + 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( @@ -1077,6 +1274,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; @@ -1299,6 +1501,9 @@ export function App({ ) { return; } + if (gameChatOnly) { + appendGameChatFinalReplyMessages(nextProjectPath, runtimes); + } const nextRuntimes = runtimes.map((runtimeResult) => agentRuntimeStateFromResult(runtimeResult), ); @@ -1772,10 +1977,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 } + : {}), }, }, ); @@ -2502,13 +2711,21 @@ 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( - conversationMessages, + ? mergeGameChatRuntimeEventMessagesIntoHistory( + mergeGameChatFinalReplyMessagesIntoHistory( + mergeGameChatRuntimeResponseMessagesIntoHistory( + conversationMessages, + current, + ), + current, + ), current, ) : conversationMessages; savedConversationProjectPathRef.current = nextProjectPath; - savedConversationCountRef.current = nextMessages.length; + savedConversationCountRef.current = gameChatOnly + ? conversationMessages.length + : nextMessages.length; latestMessagesRef.current = nextMessages; return nextMessages; }); @@ -2631,8 +2848,14 @@ export function App({ setProjectSupervisorRuntimeError(runtimeError || resumeError); setMessages((current) => { const nextConversationMessages = gameChatOnly - ? mergeGameChatRuntimeResponseMessagesIntoHistory( - conversationMessages, + ? mergeGameChatRuntimeEventMessagesIntoHistory( + mergeGameChatFinalReplyMessagesIntoHistory( + mergeGameChatRuntimeResponseMessagesIntoHistory( + conversationMessages, + current, + ), + current, + ), current, ) : conversationMessages; @@ -2655,7 +2878,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') { @@ -9947,6 +10172,7 @@ export function App({ } agentRuntimeResumeProjectPathRef.current = nextProjectPath; for (const runtimeResult of resumedRuntimes) { + appendGameChatFinalReplyMessages(nextProjectPath, [runtimeResult]); rememberAgentRuntimeState( agentRuntimeStateFromResult(runtimeResult), ); @@ -9978,6 +10204,9 @@ export function App({ } agentRuntimeResumeProjectPathRef.current = nextProjectPath; for (const runtimeResult of resumedRuntimes) { + appendGameChatFinalReplyMessages(nextProjectPath, [ + runtimeResult, + ]); rememberAgentRuntimeState( agentRuntimeStateFromResult(runtimeResult), ); @@ -10018,6 +10247,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..3849bca80 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 @@ -13,6 +13,7 @@ import { useEffect, useMemo, useState } from 'react'; import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import type { + AgentRuntimeResponseStream, AgentRuntimeEventRecord, AgentRuntimeState, ChatMessage, @@ -54,6 +55,22 @@ 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([ + '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 +306,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 +322,13 @@ export function buildGameChatProgressEvidence( if (!runtime?.runId) { return null; } - const tasks = manifest?.tasks ?? []; + const fastPathTaskIds = new Set([ + '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 +354,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 +485,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 +523,7 @@ export function collectGameChatRuntimeEvents( continue; } const key = [ + event.eventId, event.agentId, event.sessionId, event.runId, @@ -509,7 +541,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 +831,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 +982,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 91966f530..685fbe0f7 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, + gameChatFinalReplyMessages, + gameChatRuntimeEventMessages, + mergeGameChatFinalReplyMessagesIntoHistory, + formatGameChatStageRecord, + isGameChatStageRecordMessage, 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, }; } @@ -171,9 +182,11 @@ function gameChatPreviewPlaytestRuntime({ function renderGameChatStatus({ runtime, runtimeByAgentId = {}, + manifest = null, }: { runtime: AgentRuntimeState; runtimeByAgentId?: Record; + manifest?: ReturnType | null; }) { return render( React.createElement(SupervisorChatOnlyView, { @@ -203,6 +216,7 @@ function renderGameChatStatus({ workspaceStatus: '已打开', gameChatMode: true, runtimeByAgentId, + manifest, projectReady: true, }), ); @@ -2287,6 +2301,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) => { @@ -2326,6 +2341,7 @@ export function registerProjectSupervisorSurfaceTests() { React.createElement(App, { projectSupervisorOnly: true, gameChatOnly: true, + initialSupervisorMessage, }), ); @@ -2339,6 +2355,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(() => { @@ -2711,6 +2732,371 @@ 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('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(3); + expect(messages.map((message) => message.text)).toEqual([ + expect.stringContaining('代码原型已完成'), + expect.stringContaining('预览就绪检查已完成'), + expect.stringContaining('试玩验证已完成'), + ]); + expect(messages[0]?.messageId).toContain('code-prototype'); + 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('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.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(3); + }); + rendered.unmount(); + rendered = renderRelease(); + await waitFor(() => { + expect(screen.getByText(/代码原型已完成/)).not.toBeNull(); + }); + expect(finalReplyAppends()).toHaveLength(3); + 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({ @@ -3127,7 +3513,56 @@ 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 three 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) => + ['code-prototype', 'preview-readiness', 'preview-playtest'].includes( + 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('任务图 3/3'); + 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({ @@ -3297,19 +3732,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`, + '任务图 0/3 · 进行中 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(); @@ -3318,11 +3752,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, @@ -3367,11 +3809,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`, + '任务图 0/3 · 进行中 1 · 计划 2/3', ), ).not.toBeNull(); expect(within(progress).getByText('安排程序 Agent 返工')).not.toBeNull(); @@ -3384,11 +3827,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 () => { @@ -3602,8 +4043,12 @@ 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) => + ['code-prototype', 'preview-readiness', 'preview-playtest'].includes( + task.id, + ) + ? { ...task, status: 'completed' as const } + : task, ); manifest.assets.push({ id: 'stage-art', @@ -3782,9 +4227,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 完成返工', @@ -3820,6 +4266,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 } + : ['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('2/3'); + }); + + 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) => + ['code-prototype', 'preview-readiness', 'preview-playtest'].includes( + 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) => + ['code-prototype', 'preview-readiness', 'preview-playtest'].includes( + 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 }); @@ -3978,6 +4824,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 46459ec59..207bee19a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5503,8 +5503,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 安全以及桌面 / 移动响应式布局。 @@ -5899,8 +5899,9 @@ - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md`。 ## 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。 +- 背景: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 幂等固化到项目聊天。专业回复只接受 `code-prototype / preview-readiness / preview-playtest` 的 `requestKind=final-reply` 且 `status=ready|committed`;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/3`,终态后移除运行中进度卡。`preview-playtest` 与全部完成门满足且非验证屏障清零后,Runtime 以确定性最终回复完成结构化计划并立即结算父 Run,不再把“是否继续”交给下一次 Provider tool-plan。 - 美术资源门禁:External Editor API 有效时,`code-prototype` 必须通过 `asset.list` 核对 Canvas 登记的 `assets/art-spritesheet.png`,并在 `game/index.html` 真实引用该文件;manifest、文件或 HTML 引用任一缺失均拒绝完成。确定性 Provider fixture 也必须带该引用,不能用占位内容绕过门禁。 - 验证:`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`。 @@ -5950,3 +5951,14 @@ - 决策二(预算):`get_editor_project` 到来源解析结束整体包进 `tokio::time::timeout_at`,超时返回 `504` 且文案指向归属校验而非下载。不重复写 `Instant::now() >= deadline` 预检——紧邻的 `acquire_editor_pixel_art_snap_permit` 已做该预检,成功即意味着未超预算,且本块首个 await 是网络 IO 不会立即就绪,不构成 `timeout_at` 先 poll 再判超时的陷阱。 - 验证:顺序断言新增 `tokio::time::timeout_at(` 与超时文案;参照 `937378ab9` 的做法用 `assert_function_occurrence_count` 把 `resolve_editor_pixel_art_source_for_owner` 内的 `.list_editor_projects(` 和 `.get_editor_asset_library(` 各钉为 1 次,并用 `assert_function_not_contains` 禁止该函数重新调用取数包装。后者断言的是调用形式 `resolve_editor_reference_object_key_for_owner(state` 而非裸函数名,否则会命中生产代码里说明「老包装保持不动」的注释——该陷阱在编写时即由测试抓出。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 +## 2026-08-01 game-chat 首版三任务快车道与受控兜底 + +- 决策:game-chat 首版只投影 `code-prototype`、`preview-readiness`、`preview-playtest` 三个阶段,页面进度显示 `x/3`;完整 GUI / CLI 任务图仍保留原有节点和执行语义,内部 Provider / child loop 不作为用户轮次。 +- 预算:父 Run 接受请求后以 `240` 秒作为首版软预算;父 Run、所有 child Run、等待、回收和确定性验收共享 `300` 秒累计硬上限。软预算后不再扩展 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` 或用固定失败充当完成。可保留 `../assets/art-spritesheet.png` 引用,但图片缺失不能阻止 Canvas fallback 正常运行。 +- 美术边界:game-chat 首版平台图集是非阻塞增强,确定性兜底只在 manifest 已登记且项目文件真实存在时设置图集 `src` 并通过 Canvas 使用,缺失时不发起 404;图集稍后登记后可刷新同一预览。普通 GUI / CLI autonomous 仍执行完整 DAG,配置 Editor API Key 时正式图集产物和 HTML 引用继续作为硬完成门,缺失即失败关闭。 +- 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..87c635b38 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 输出、`code-prototype / preview-readiness / preview-playtest` 三个专业 Agent 的 durable `final-reply`,以及 Rust 明确生成 `eventId + publicText` 的每条公开 Runtime 输出都作为独立 assistant 消息固化在项目聊天;消息通过顶层 `messageId` 幂等追加,事件 / 轮询 / hydration 重放不重复。前端禁止从原始 `summary / detail`、tool plan、Provider / Runner 元数据、命令输出或路径自行拼接持久消息;UI 把一个父 Run 统一显示为“本轮生成进度”,最新状态也不得暴露内部“第 N 轮”,完整 GUI / CLI 任务图可显示 `x/14`,首版快车道显示 `x/3`,终态不保留运行中进度卡;可信 `project-supervisor-game-chat` 一轮完成 `preview-playtest` 后直接收束,不请求下一次 Provider tool-plan;所有调度入口(包括 `agent.schedule_ready`、`task.list` 结果驱动的直接 `agent.delegate`)均不得暴露或启动 `publish-strategy` / `publish-package`,同时 GUI / CLI autonomous 必须继续执行完整发布 DAG;普通 GUI / CLI autonomous 在配置 External Editor API 时,`code-prototype` 的 `game/index.html` 实际引用已由 Canvas 登记的 `assets/art-spritesheet.png`,缺少登记、文件或引用必须失败关闭,game-chat 首版则只在登记与文件均存在时加载图集。对应定向测试至少包括: + +game-chat 首版快车道另有独立三阶段口径:只显示 `code-prototype`、`preview-readiness`、`preview-playtest` 的 `x/3`,不把完整 DAG 或内部 loop 计入分母。父 Run 与全部 child Run 共用 240 秒软预算和 300 秒累计硬上限;首版最多一次 Provider 规划 / 写入请求,软预算后只能运行确定性的本地 fallback、`game.static_smoke`、`preview.validate`,硬上限内未通过完成门必须失败,证据在上限后到齐也不得写 `single_round_converged`。fallback 模板必须自包含、可推进、可重开,从 `ready` 开始并避免固定 `lost`;平台图集在 game-chat 首版是非阻塞增强,只有登记和文件都存在时才设置 `src` 并 `drawImage`,缺失时不发起 404、继续使用 Canvas fallback。普通 GUI / CLI 继续完整 DAG;配置 Editor API Key 时正式 GUI / CLI 美术图集与 HTML 引用仍是硬门。对应定向测试至少包括: + +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 8891958e4..267b55a00 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 终态后,客户端等待 `code-prototype / preview-readiness / preview-playtest` 三项快车道任务也全部投影到 completed / failed 终态,再把本轮、任务 / 计划完成度、最新试玩 / 静态检查、最近返工决定和已登记成果图片路径整理成一条 `【Supervisor 阶段记录】` 项目 assistant 消息。父 run 先终态而 manifest 仍在 hydration 时不得用陈旧 `0/3` 提前归档,要暂存终态 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 继续以稳定身份显示;`code-prototype / preview-readiness / preview-playtest` 的 `requestKind=final-reply` 且 `status=ready|committed` 的非空回复也分别以 Agent、Session、run、request slot 和 response revision 形成 durable message ID,并带 Agent 标签追加到项目聊天。每条 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/3`(详见 2026-08-01 小节),不得把两个发布节点计入任一分母。父 Run 终态后移除运行中进度卡,只保留终态阶段记录与预览。 +- 平台美术资源:本条硬门仅适用于普通 GUI / CLI autonomous。配置 External Editor API 时,普通 GUI / CLI 的 `code-prototype` 必须通过 `asset.list` 核对 Canvas 登记的 `assets/art-spritesheet.png`,并在 `game/index.html` 实际引用该路径;缺少登记、文件或引用均 fail-closed。game-chat 首版按下方 2026-08-01 非阻塞例外执行,确定性兜底只在图集已登记且文件真实存在时加载该路径。 - 验证:前端运行时模型定向测试、Rust completion/source/asset 合同测试、`cargo fmt --check`、`npm run check:encoding` 与 `git diff --check` 必须全部执行;Windows 文件锁竞态只可作为既有测试失败单独记录,不得将其改写为本次改动的通过证据。 +## 2026-08-01 game-chat 首版三任务快车道与受控兜底 + +- 首版任务边界:game-chat 首版只展示 `code-prototype`、`preview-readiness`、`preview-playtest` 三个阶段,进度统一显示为 `x/3`,不把完整 GUI / CLI 任务图的其它节点投影到该页面,也不显示内部 Provider / child loop 轮次。 +- 时间预算:从 game-chat 父 Run 接受用户请求开始,首版可玩版本使用 `240` 秒软预算;父 Run 与其全部 child Run、等待和回收阶段共享 `300` 秒硬上限,不能把硬上限拆成每个 Agent 独立计时。达到软预算后只允许进入确定性的本地兜底、静态 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`,也不得通过固定失败冒充试玩通过。只有 manifest 已登记且项目文件真实存在时,模板才给隐藏图片设置 `../assets/art-spritesheet.png` 的 `src` 并通过 Canvas `drawImage` 使用;缺少登记或文件时不得发起必然 404 的请求,必须继续使用本地 Canvas fallback。 +- 平台图集:game-chat 首版的平台图集属于非阻塞增强。External Editor API 或图集生成不可用时,先交付上述可玩本地视觉;图集稍后登记后可在同一预览 origin 刷新,不得阻塞首版 smoke / playtest。普通 GUI / CLI autonomous 仍执行完整 DAG;配置 Editor API Key 时,GUI / CLI 的正式美术产物与 `game/index.html` 图集引用继续是硬完成门,缺少登记、文件或引用必须失败关闭。 +- 关联验收:快车道必须分别验证 `x/3` 投影、240 / 300 秒累计预算、单次 Provider 请求、兜底模板可推进 / 可重开 / 非固定失败、当前 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。