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

灵露花园

GENARRATIVE_REAL_E2E_VISIBLE

Goal: defend the garden and win every wave.

diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index eaee84273..02afd4148 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -560,7 +560,32 @@ pub(in crate::agent) fn autonomous_manifest_dag_in_progress_at( root: &Path, ) -> Result { let manifest = read_manifest_for_project(root)?; - let seed_task_ids = new_game_creation_app_seed_tasks() + let source = read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .ok() + .filter(|runtime| { + runtime.state.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && agent_runtime_supervisor_source_is_trusted(&runtime.state.source) + }) + .map(|runtime| runtime.state.source) + .or_else(|| { + let path = game_creator_agent_runtime_task_path( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ); + read_all_game_creator_agent_runtime_tasks(&path) + .ok() + .map(latest_game_creator_agent_runtime_tasks) + .and_then(|records| { + records.into_iter().rev().find_map(|record| { + (record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && record.parent_run_id.is_none() + && agent_runtime_supervisor_source_is_trusted(&record.source)) + .then_some(record.source) + }) + }) + }) + .unwrap_or_else(|| AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE.to_string()); + let seed_task_ids = autonomous_manifest_seed_tasks_for_source(&source) .into_iter() .map(|task| task.id) .collect::>(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index fa86b1a46..791aca0bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -100,6 +100,16 @@ pub(crate) const AGENT_RUNTIME_ISOLATED_CHILD_SOURCE: &str = "agent-isolated-chi pub(crate) const AGENT_RUNTIME_ISOLATED_JOIN_SOURCE: &str = "agent-isolated-join"; pub(crate) const AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE: &str = "project-supervisor-gui"; pub(crate) const AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE: &str = "project-supervisor-cli"; +pub(crate) const AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE: &str = "project-supervisor-game-chat"; + +pub(crate) fn agent_runtime_supervisor_source_is_trusted(source: &str) -> bool { + matches!( + source.trim(), + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE + | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE + | AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + ) +} pub(super) const AGENT_RUNTIME_RUN_PROFILE_BINDING_SCHEMA_VERSION: &str = "game-creator-run-profile-binding.v1"; pub(super) const AGENT_RUNTIME_AUTONOMOUS_COMPLETION_CONTRACT_SCHEMA_VERSION: &str = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs index 2770eb4de..512271191 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs @@ -761,10 +761,7 @@ pub(crate) fn resolve_game_creator_agent_runtime_retry_configuration_at( if binding.parent_run_id.is_some() || binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || binding.root_run_id != task.run_id - || !matches!( - binding.source.as_str(), - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE - ) + || !agent_runtime_supervisor_source_is_trusted(&binding.source) { return Err("自主构建 Agent Runtime 重试绑定不是可信 Supervisor 根 Run".to_string()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 5b3bc2f85..d50056898 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -119,10 +119,7 @@ pub(crate) fn start_game_creator_supervisor_background_task_for_session_at( source: &str, run_profile: &str, ) -> Result { - if !matches!( - source, - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE - ) { + if !agent_runtime_supervisor_source_is_trusted(source) { return Err("Project Supervisor 提交 source 不受信任".to_string()); } normalize_agent_runtime_run_profile(Some(run_profile))?; @@ -641,10 +638,7 @@ fn current_autonomous_game_build_root_task_at( && record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && record.parent_agent_id.is_none() && record.parent_run_id.is_none() - && matches!( - record.source.as_str(), - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE - ) + && agent_runtime_supervisor_source_is_trusted(&record.source) && seen_run_ids.insert(record.run_id.clone()) { root_run_ids.push(record.run_id.clone()); @@ -658,8 +652,25 @@ fn current_autonomous_game_build_root_task_at( .find(|record| record.run_id == *current_run_id)) } -fn autonomous_manifest_ready_task_ids(tasks: &[GameCreationAppTaskState]) -> Vec { - new_game_creation_app_seed_tasks() +pub(in crate::agent) fn autonomous_manifest_seed_tasks_for_source( + source: &str, +) -> Vec { + let seed_tasks = new_game_creation_app_seed_tasks(); + if source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE { + seed_tasks + .into_iter() + .take_while(|task| task.id != "publish-strategy") + .collect() + } else { + seed_tasks + } +} + +fn autonomous_manifest_ready_task_ids( + tasks: &[GameCreationAppTaskState], + source: &str, +) -> Vec { + autonomous_manifest_seed_tasks_for_source(source) .into_iter() .filter_map(|seed_task| { let task = tasks.iter().find(|task| task.id == seed_task.id)?; @@ -893,9 +904,17 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( .count(); let available = 3usize.saturating_sub(active_count); let seed_task_order = new_game_creation_app_seed_tasks(); + let allowed_seed_task_ids = + autonomous_manifest_seed_tasks_for_source(&parent_binding.source) + .into_iter() + .map(|task| task.id) + .collect::>(); let mut candidates = Vec::new(); for seed_task in &seed_task_order { + if !allowed_seed_task_ids.contains(&seed_task.id) { + continue; + } let Some(task) = manifest.tasks.iter().find(|task| task.id == seed_task.id) else { continue; }; @@ -917,7 +936,7 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( candidates.push((task.clone(), false)); } } - for task_id in autonomous_manifest_ready_task_ids(&manifest.tasks) + for task_id in autonomous_manifest_ready_task_ids(&manifest.tasks, &parent_binding.source) .into_iter() .take(limit.min(available)) { @@ -1183,9 +1202,11 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke }), )?; let completed = status == GameCreationAppTaskStatus::Completed; + let game_chat_single_round = + root_parent_binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE; let root = root.to_path_buf(); tauri::async_runtime::spawn(async move { - if completed { + if completed && !game_chat_single_round { if let Err(error) = schedule_autonomous_game_build_ready_tasks_at( &root, &parent_agent_id, @@ -1242,9 +1263,17 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt( } else { "" }; - return format!( + let code_visual_asset_requirement = if task.id == "code-prototype" + && editor_api_key_is_configured() + { + " External Editor API 已配置时,必须先调用 asset.list 确认 assets/art-spritesheet.png 已登记且 source.kind=canvas、资源有效;game/index.html 必须实际通过 HTML、CSS background 或 Canvas drawImage 引用 assets/art-spritesheet.png 作为游戏 UI 素材,不得只用 emoji、色块、CSS 绘图或占位文本冒充。" + } else { + "" + }; + let owner_prompt = format!( "{base}\n\n这是 autonomous-game-build 的正式 owner 写入任务。必须实际生成并写入非空正式产物:{paths};JSON 文件必须是可解析 JSON,code-prototype 的 game/index.html 不能沿用初始化占位。{publish_package_requirement}任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。完成修改后按当前 run 的验证门完成验证并直接交付结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" ); + return format!("{owner_prompt}{code_visual_asset_requirement}"); } format!( "{base}\n\n这是 autonomous-game-build 的只读协调任务,不要修改项目文件,也不要为了 manifest 内部回执路径写入 memory/、game/、assets/ 或 exports/。只读取当前项目事实,完成方向协调、审查或验收并直接交付结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index 584d8daa6..9343793d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -6,6 +6,7 @@ pub(crate) fn autonomous_game_build_root_run_active_at(root: &Path) -> bool { if read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID).is_ok_and( |runtime| { runtime.state.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && agent_runtime_supervisor_source_is_trusted(&runtime.state.source) && !matches!( runtime.state.phase.as_str(), "completed" | "failed" | "cancelled" | "budget-exhausted" @@ -22,10 +23,7 @@ pub(crate) fn autonomous_game_build_root_run_active_at(root: &Path) -> bool { tasks.into_iter().any(|task| { task.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && task.parent_run_id.is_none() - && matches!( - task.source.as_str(), - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE - ) + && agent_runtime_supervisor_source_is_trusted(&task.source) && matches!( task.status.as_str(), "pending" @@ -383,6 +381,42 @@ fn autonomous_manifest_owner_artifact_gaps_at( Ok(gaps) } +fn autonomous_code_prototype_art_asset_reference_gap_at( + root: &Path, + task_id: &str, +) -> Result, String> { + if task_id != "code-prototype" || !editor_api_key_is_configured() { + return Ok(None); + } + let manifest = read_manifest_for_project(root)?; + if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, "art-asset-plan") { + return Ok(Some(format!( + "assets/art-spritesheet.png(canvas-registration-invalid:{})", + sanitize_agent_runtime_text(&error, 240) + ))); + } + let Some((_, html)) = read_autonomous_evidence_file_at( + root, + AGENT_RUNTIME_GAME_INDEX_PATH, + "autonomous code-prototype 游戏入口", + AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES, + )? + else { + return Ok(Some( + "game/index.html(missing-art-spritesheet-reference)".to_string(), + )); + }; + if !html + .windows(b"assets/art-spritesheet.png".len()) + .any(|window| window == b"assets/art-spritesheet.png") + { + return Ok(Some( + "game/index.html(missing-art-spritesheet-reference)".to_string(), + )); + } + Ok(None) +} + pub(in crate::agent) fn game_creation_app_task_status_label( status: &GameCreationAppTaskStatus, ) -> String { @@ -397,7 +431,13 @@ fn autonomous_manifest_parent_completion_gaps_at( contract: &AgentRuntimeAutonomousCompletionContract, ) -> Result<(Vec, Vec), String> { let manifest = read_manifest_for_project(root)?; - let seed_tasks = new_game_creation_app_seed_tasks(); + let binding = read_game_creator_agent_runtime_run_profile_binding( + root, + &contract.agent_id, + &contract.run_id, + )? + .ok_or_else(|| "自主构建根 Supervisor Run 缺少 Run Profile 绑定".to_string())?; + let seed_tasks = crate::agent::autonomous_manifest_seed_tasks_for_source(&binding.source); let mut missing_tasks = Vec::new(); let mut missing_paths = Vec::new(); for seed_task in &seed_tasks { @@ -416,6 +456,13 @@ fn autonomous_manifest_parent_completion_gaps_at( contract.baseline_index_sha256.as_deref(), &contract.baseline_artifacts, )?); + if seed_task.id == "code-prototype" { + if let Some(gap) = + autonomous_code_prototype_art_asset_reference_gap_at(root, &seed_task.id)? + { + missing_paths.push(AutonomousManifestArtifactGap::new(gap)); + } + } } missing_paths.sort_by(|left, right| left.summary.cmp(&right.summary)); missing_paths.dedup_by(|left, right| left.summary == right.summary); @@ -500,6 +547,24 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( )); } }; + match autonomous_code_prototype_art_asset_reference_gap_at(root, &state.agent_id) { + Ok(Some(gap)) => { + return Some(autonomous_completion_blocker( + "code-prototype 必须实际使用平台生成的美术资源", + format!( + "task={} missingPaths={},请先通过 asset.list 核对 Canvas 来源并在 game/index.html 中引用 assets/art-spritesheet.png", + state.agent_id, gap + ), + )); + } + Ok(None) => {} + Err(error) => { + return Some(autonomous_completion_blocker( + "code-prototype 美术资源引用无法安全核对", + error, + )); + } + } if gaps.is_empty() { return None; } @@ -758,6 +823,7 @@ pub(in crate::agent) fn validate_autonomous_completion_contract( || binding.parent_run_id.is_some() || binding.root_agent_id != contract.agent_id || binding.root_run_id != contract.run_id + || !agent_runtime_supervisor_source_is_trusted(&binding.source) { return Err("自主构建完成合同与根 Supervisor Run 不匹配".to_string()); } @@ -797,10 +863,7 @@ pub(in crate::agent) fn ensure_autonomous_completion_contract_for_task_at( return Ok(()); } if task.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || !matches!( - task.source.as_str(), - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE - ) + || !agent_runtime_supervisor_source_is_trusted(&task.source) { return Err("自主构建完成合同只允许可信根 Supervisor Run".to_string()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index d8a893254..93135187f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -12,6 +12,65 @@ fn autonomous_fixture( autonomous_fixture_with_setup(task, run_id, |_| {}) } +fn autonomous_fixture_with_source( + task: &str, + run_id: &str, + source: &str, +) -> ( + tempfile::TempDir, + PathBuf, + AgentRuntimeState, + AgentRuntimeAutonomousCompletionContract, +) { + let temporary = tempfile::tempdir().expect("create autonomous fixture root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "autonomous-project", task).expect("init project"); + let session_id = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve supervisor session"); + let record = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &session_id, + task, + run_id, + source, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue autonomous task"); + let state = agent_runtime_state_from_task_record(&record); + let contract = read_autonomous_completion_contract( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &record.run_id, + ) + .expect("read completion contract") + .expect("completion contract exists"); + prepare_completed_autonomous_manifest_fixture(&root); + (temporary, root, state, contract) +} + +#[test] +fn autonomous_supervisor_source_allowlist_includes_game_chat_only() { + assert!(agent_runtime_supervisor_source_is_trusted( + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE + )); + assert!(agent_runtime_supervisor_source_is_trusted( + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE + )); + assert!(agent_runtime_supervisor_source_is_trusted( + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + )); + assert!(!agent_runtime_supervisor_source_is_trusted( + "project-supervisor-forged" + )); +} + fn autonomous_fixture_with_setup( task: &str, run_id: &str, @@ -843,6 +902,80 @@ fn autonomous_parent_completion_lists_missing_seed_tasks_and_formal_artifacts() assert!(detail.contains("missingPaths=game/balance.json")); } +#[test] +fn game_chat_parent_completion_stops_after_preview_playtest_without_publish_tasks() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, state, contract) = autonomous_fixture_with_source( + "创建一轮植物塔防游戏", + "game-chat-single-round-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at( + &root, + "publish-strategy", + GameCreationAppTaskStatus::Pending, + ) + .expect("leave publish strategy pending"); + update_manifest_task_status_at(&root, "publish-package", GameCreationAppTaskStatus::Pending) + .expect("leave publish package pending"); + let revision = advance_game_index_revision( + &root, + &state, + "", + ); + mark_verification_passed(&root, &state, "game.static_smoke"); + let result = browser_result_fixture(&root, &state, revision, contract.playtest_scenario); + let action = AgentRuntimeToolAction { + tool: "preview.validate".to_string(), + reason: Some("game-chat single round preview".to_string()), + input: serde_json::json!({}), + }; + let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task); + let action_id = agent_runtime_tool_action_id(&state.run_id, 1, 0, 1, &action_fingerprint); + write_autonomous_playtest_receipt_at( + &root, + &contract, + &action_id, + &action_fingerprint, + revision, + &result, + ) + .expect("persist game-chat playtest receipt"); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &state).is_none()); +} + +#[test] +fn code_prototype_requires_registered_canvas_spritesheet_reference_when_editor_is_configured() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"game-chat-art-gate-key"}}"#.to_string(), + ); + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("创建一轮植物塔防游戏", "game-chat-code-art-gate-parent"); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("mark code prototype running"); + let code_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); + let code_state = agent_runtime_state_from_task_record(&code_record); + advance_game_index_revision( + &root, + &code_state, + "", + ); + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("missing spritesheet reference must block code prototype"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("assets/art-spritesheet.png"))); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); +} + #[test] fn autonomous_ready_child_missing_or_invalid_owner_artifact_is_blocked() { let (_temporary, root, parent_state, _contract) = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs index c1ce7a907..dac96b4e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs @@ -111,10 +111,7 @@ pub(in crate::agent) fn validate_agent_runtime_run_profile_binding_record( } if binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && (binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || !matches!( - binding.source.as_str(), - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE - )) + || !agent_runtime_supervisor_source_is_trusted(&binding.source)) { return Err("自主构建 Run Profile 只允许可信 Supervisor 入口绑定".to_string()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 67a4f83f4..dbd8adef2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -489,6 +489,7 @@ pub(crate) fn start_game_creator_supervisor_runtime_task( task: String, run_id: String, run_profile: Option, + source: Option, ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; @@ -499,12 +500,20 @@ pub(crate) fn start_game_creator_supervisor_runtime_task( .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(AGENT_RUNTIME_RUN_PROFILE_STANDARD); + let source = source + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE); + if !agent_runtime_supervisor_source_is_trusted(source) { + return Err("Project Supervisor 提交 source 不受信任".to_string()); + } start_game_creator_supervisor_background_task_for_session_at( root, session_id.as_deref(), task.trim(), run_id.trim(), - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + source, run_profile, ) } @@ -663,11 +672,30 @@ pub(crate) fn steer_game_creator_agent_runtime_task( steer_id: String, instruction: String, run_profile: Option, + source: Option, ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; enforce_project_permission_policy(root, "conversation.write")?; enforce_project_permission_policy(root, "agent.run_status")?; + if let Some(source) = source + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if !agent_runtime_supervisor_source_is_trusted(source) { + return Err("Project Supervisor steer source 不受信任".to_string()); + } + let task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + agent_id.trim(), + run_id.trim(), + )? + .ok_or_else(|| "Agent Runtime steer 的 Run 不存在".to_string())?; + if task.source != source { + return Err("Agent Runtime steer source 与当前 Run 不一致".to_string()); + } + } let mut result = steer_game_creator_agent_runtime_task_for_profile_at( root, agent_id.trim(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs index fbdd800e6..bcf3f77b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs @@ -356,6 +356,7 @@ fn steer_and_wait_for_swarm_turn( steer_id.clone(), message.to_string(), Some(run_profile.to_string()), + None, )?; writeln!( output, diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs index 6d631df35..99e24580b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs @@ -355,6 +355,7 @@ pub(super) fn wait_for_swarm_turn( steer_id.clone(), message, Some(run_profile.to_string()), + None, )?; writeln!( output, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index d9c743258..f24759d9e 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -108,12 +108,14 @@ import { isRuntimeConfigMissingError, matchingAgentRuntimeForSteer, mergeAgentRuntimeStateIntoMap, + mergeGameChatRuntimeResponseMessagesIntoHistory, mergeProjectSupervisorConversation, mergeProjectSupervisorResponseStream, normalizeAgentRuntimeState, projectNameFromPath, projectProfessionalAgentLabel, projectSupervisorPendingRepairMatchesProfessional, + projectSupervisorResponseStreamIdentity, readProjectSupervisorActiveSessionId, sameAgentRuntimeRun, submitProjectSupervisorRuntimeTask, @@ -573,6 +575,7 @@ export function App({ const gameChatAutoPreviewAttemptedRef = useRef(new Set()); const gameChatObservedRunKeysRef = useRef(new Set()); const gameChatArchivedRunKeysRef = useRef(new Set()); + const gameChatCommittedResponseStreamKeysRef = useRef(new Set()); const initialSupervisorMessageLatchRef = useRef({ projectPath: initialProjectPath, prompt: initialSupervisorMessage.trim(), @@ -822,7 +825,41 @@ export function App({ runtime, ); const candidateStream = nextStream; - if ( + if (candidateStream?.status === 'ready' && gameChatOnly) { + const text = candidateStream.accumulatedText.trim(); + const responseKey = projectSupervisorResponseStreamIdentity( + candidateStream, + ); + const messageId = `runtime-response:${responseKey}`; + const alreadyCommitted = + gameChatCommittedResponseStreamKeysRef.current.has(responseKey) || + latestMessagesRef.current.some( + (message) => message.messageId === messageId, + ); + if (text && !alreadyCommitted) { + gameChatCommittedResponseStreamKeysRef.current.add(responseKey); + const nextMessage: ChatMessage = { + role: 'assistant', + text: candidateStream.accumulatedText, + messageId, + agentId: PROJECT_SUPERVISOR_AGENT_ID, + updatedAt: candidateStream.updatedAt, + runtimeOwned: true, + }; + setMessages((current) => { + if (current.some((message) => message.messageId === messageId)) { + latestMessagesRef.current = current; + return current; + } + const nextMessages = [...current, nextMessage]; + latestMessagesRef.current = nextMessages; + return nextMessages; + }); + } + // Ready text is now a normal chat message; transientReply must not show + // the same response a second time while polling/event delivery catches up. + nextStream = null; + } else if ( candidateStream && latestMessagesRef.current.some( (message) => @@ -833,6 +870,7 @@ export function App({ message.updatedAt >= candidateStream.startedAt, ) ) { + // Keep the existing supervisor-chat behavior for restored history. nextStream = null; } projectSupervisorResponseStreamRef.current = nextStream; @@ -846,6 +884,7 @@ export function App({ projectSupervisorRuntimeRef.current = null; projectSupervisorExpectedRunIdRef.current = null; projectSupervisorResponseStreamRef.current = null; + gameChatCommittedResponseStreamKeysRef.current.clear(); projectSupervisorRuntimeSyncingRef.current.clear(); setProjectSupervisorSessionId(null); setProjectSupervisorRuntime(null); @@ -2445,6 +2484,7 @@ export function App({ ); const transientResponse = projectSupervisorResponseStreamRef.current; if ( + !gameChatOnly && conversationContainsProjectSupervisorResponseStream( supervisorConversation.messages, transientResponse, @@ -2453,11 +2493,23 @@ export function App({ projectSupervisorResponseStreamRef.current = null; setProjectSupervisorResponseStream(null); } - savedConversationProjectPathRef.current = nextProjectPath; - savedConversationCountRef.current = conversationMessages.length; - latestMessagesRef.current = conversationMessages; setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); - setMessages(conversationMessages); + setMessages((current) => { + // React may apply this hydration update after a ready stream callback + // that was queued in the same turn. Read `current` inside the updater + // so a committed game-chat response cannot be overwritten by stale + // `latestMessagesRef` state captured before that callback ran. + const nextMessages = gameChatOnly + ? mergeGameChatRuntimeResponseMessagesIntoHistory( + conversationMessages, + current, + ) + : conversationMessages; + savedConversationProjectPathRef.current = nextProjectPath; + savedConversationCountRef.current = nextMessages.length; + latestMessagesRef.current = nextMessages; + return nextMessages; + }); const terminalRuntime = projectSupervisorRuntimeRef.current; if ( runId && @@ -2557,6 +2609,7 @@ export function App({ supervisorConversation?.messages ?? [], ); if ( + !gameChatOnly && conversationContainsProjectSupervisorResponseStream( supervisorConversation?.messages ?? [], runtimeResponseStream, @@ -2575,6 +2628,12 @@ export function App({ } setProjectSupervisorRuntimeError(runtimeError || resumeError); setMessages((current) => { + const nextConversationMessages = gameChatOnly + ? mergeGameChatRuntimeResponseMessagesIntoHistory( + conversationMessages, + current, + ) + : conversationMessages; const hasOnlyDefaultGreeting = current.length === 1 && current[0]?.role === 'assistant' && @@ -2594,8 +2653,8 @@ export function App({ } setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); savedConversationProjectPathRef.current = nextProjectPath; - savedConversationCountRef.current = conversationMessages.length; - latestMessagesRef.current = conversationMessages; + savedConversationCountRef.current = nextConversationMessages.length; + latestMessagesRef.current = nextConversationMessages; setWorkspaceStatus((workspaceStatus) => { if (mode === 'replace') { return `已读取项目对话历史:${conversationMessages.length} 条`; @@ -2604,7 +2663,7 @@ export function App({ ? `已打开:${nextProjectPath}` : workspaceStatus; }); - return conversationMessages; + return nextConversationMessages; }); } catch (error) { if ( @@ -5366,6 +5425,9 @@ export function App({ prompt, runtime: runtimeAtSubmission, runProfile: submissionRunProfile, + ...(gameChatOnly + ? { source: 'project-supervisor-game-chat' } + : {}), }); const runtimeResult = submission.runtimeResult; const acceptedRunId = submission.acceptedRunId.trim(); diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 09b2219f3..b0ef9627a 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -477,6 +477,22 @@ export function sameProjectSupervisorResponseStream( ); } +/** + * Durable identity for one final-reply response. The response text is + * intentionally excluded so two turns with identical wording remain + * distinct messages in game-chat. + */ +export function projectSupervisorResponseStreamIdentity( + stream: Pick< + AgentRuntimeResponseStream, + 'runId' | 'requestSlot' | 'responseRevision' + >, +) { + return [stream.runId, stream.requestSlot, stream.responseRevision].join( + '\u001f', + ); +} + export function mergeProjectSupervisorResponseStream( current: AgentRuntimeResponseStream | null, incoming: AgentRuntimeResponseStream | null | undefined, @@ -523,6 +539,44 @@ export function conversationContainsProjectSupervisorResponseStream( ); } +/** + * Keep final responses that were committed into the game-chat window while + * conversation history is being hydrated. Runtime responses have a durable + * local id; matching by text or timestamp would collapse two different runs + * that happen to produce the same wording. + */ +export function mergeGameChatRuntimeResponseMessagesIntoHistory( + historyMessages: ChatMessage[], + currentMessages: ChatMessage[], +) { + const historyMessageIds = new Set( + historyMessages + .map((message) => message.messageId?.trim()) + .filter((messageId): messageId is string => Boolean(messageId)), + ); + const addedMessageIds = new Set(); + const responsesToKeep = currentMessages.filter((message) => { + const messageId = message.messageId?.trim(); + if ( + !message.runtimeOwned || + message.role !== 'assistant' || + !messageId?.startsWith('runtime-response:') || + historyMessageIds.has(messageId) || + addedMessageIds.has(messageId) + ) { + return false; + } + addedMessageIds.add(messageId); + return true; + }); + if (responsesToKeep.length === 0) { + return historyMessages; + } + return [...historyMessages, ...responsesToKeep].sort( + (left, right) => (left.updatedAt ?? 0) - (right.updatedAt ?? 0), + ); +} + export function agentRuntimeWaitingOnFromPhase(phase: string) { switch (phase) { case 'planning': @@ -765,6 +819,7 @@ export async function submitProjectSupervisorRuntimeTask({ prompt, runtime, runProfile, + source, }: { invoke: TauriInvoke; projectPath: string; @@ -772,6 +827,7 @@ export async function submitProjectSupervisorRuntimeTask({ prompt: string; runtime: AgentRuntimeState | null; runProfile: 'standard' | 'autonomous-game-build'; + source?: string; }) { const steerRuntime = matchingAgentRuntimeForSteer( [runtime], @@ -790,6 +846,7 @@ export async function submitProjectSupervisorRuntimeTask({ steerId: createAgentChatRunId('project-supervisor-steer'), instruction: prompt, runProfile, + ...(source ? { source } : {}), }, ); return { @@ -807,6 +864,7 @@ export async function submitProjectSupervisorRuntimeTask({ task: prompt, runId: requestedRunId, runProfile, + ...(source ? { source } : {}), }, ); return { diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index caf74586b..7757782ff 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -1,14 +1,221 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; -import type { AgentRuntimeState } from '../src/app/types'; +import type { + AgentRuntimeResponseStream, + AgentRuntimeResult, + AgentRuntimeState, + ChatMessage, +} from '../src/app/types'; import { formatAgentRuntimeEvent, projectRuntimeVisibleCurrentWork, projectRuntimeVisibleError, projectSupervisorChatRuntimeStatus, + projectSupervisorResponseStreamIdentity, projectSupervisorVisibleConversationText, + mergeGameChatRuntimeResponseMessagesIntoHistory, + submitProjectSupervisorRuntimeTask, } from '../src/features/agent-runtime/model'; +describe('Game Chat stream identity and source', () => { + test('response stream identity binds run, slot, and revision', () => { + const base: AgentRuntimeResponseStream = { + schemaVersion: 'game-creator-runtime-response-stream.v1', + agentId: 'project-supervisor', + taskId: 'project-supervisor', + sessionId: 'session-1', + runId: 'run-1', + requestKind: 'final-reply', + requestSlot: 'final-reply-loop-1-revision-0', + appliedSteerCursor: 0, + responseRevision: 0, + sequence: 1, + status: 'ready', + accumulatedText: 'same text', + finishReason: 'stop', + startedAt: 1, + updatedAt: 2, + }; + + expect(projectSupervisorResponseStreamIdentity(base)).toBe( + 'run-1\u001ffinal-reply-loop-1-revision-0\u001f0', + ); + expect( + projectSupervisorResponseStreamIdentity({ ...base, runId: 'run-2' }), + ).not.toBe(projectSupervisorResponseStreamIdentity(base)); + expect( + projectSupervisorResponseStreamIdentity({ + ...base, + requestSlot: 'final-reply-loop-2-revision-0', + }), + ).not.toBe(projectSupervisorResponseStreamIdentity(base)); + expect( + projectSupervisorResponseStreamIdentity({ ...base, responseRevision: 1 }), + ).not.toBe(projectSupervisorResponseStreamIdentity(base)); + }); + + test('game-chat source is forwarded for start and steer without changing default source behavior', async () => { + const runtimeResult = { + state: { + schemaVersion: 'game-creator-agent-runtime.v1', + agentId: 'project-supervisor', + taskId: 'project-supervisor', + sessionId: 'session-provider-retry', + runId: 'accepted-run', + source: 'project-supervisor-chat', + status: 'running', + phase: 'planning', + currentTask: null, + currentAction: null, + waitingOn: null, + nextStep: null, + plan: [], + planSteps: [], + observations: [], + allowedTools: [], + lastResponse: null, + error: null, + updatedAt: 1, + }, + sessionPath: 'session.json', + eventPath: 'events.jsonl', + } satisfies AgentRuntimeResult; + const invoke = vi.fn( + async (_command: string, _args?: Record) => runtimeResult, + ); + + await submitProjectSupervisorRuntimeTask({ + invoke, + projectPath: '/tmp/game-chat', + sessionId: 'session-provider-retry', + prompt: 'make a game', + runtime: null, + runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', + }); + + expect(invoke).toHaveBeenCalledWith( + 'start_game_creator_supervisor_runtime_task', + expect.objectContaining({ source: 'project-supervisor-game-chat' }), + ); + + invoke.mockClear(); + await submitProjectSupervisorRuntimeTask({ + invoke, + projectPath: '/tmp/game-chat', + sessionId: 'session-provider-retry', + prompt: 'chat', + runtime: null, + runProfile: 'standard', + }); + expect(invoke.mock.calls[0]?.[1]).not.toHaveProperty('source'); + + invoke.mockClear(); + const steerRuntime = { + ...runtimeResult.state, + runId: 'steer-run', + runProfile: 'autonomous-game-build', + status: 'running', + phase: 'planning', + } satisfies AgentRuntimeState; + invoke.mockResolvedValueOnce({ + runtime: runtimeResult, + steerId: 'steer-1', + sequence: 1, + status: 'applied', + providerInterrupted: false, + }); + await submitProjectSupervisorRuntimeTask({ + invoke, + projectPath: '/tmp/game-chat', + sessionId: 'session-provider-retry', + prompt: 'continue this run', + runtime: steerRuntime, + runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', + }); + expect(invoke).toHaveBeenCalledWith( + 'steer_game_creator_agent_runtime_task', + expect.objectContaining({ source: 'project-supervisor-game-chat' }), + ); + + invoke.mockClear(); + invoke.mockResolvedValueOnce({ + runtime: runtimeResult, + steerId: 'steer-2', + sequence: 2, + status: 'applied', + providerInterrupted: false, + }); + await submitProjectSupervisorRuntimeTask({ + invoke, + projectPath: '/tmp/game-chat', + sessionId: 'session-provider-retry', + prompt: 'continue this run', + runtime: steerRuntime, + runProfile: 'autonomous-game-build', + }); + expect(invoke.mock.calls[0]?.[1]).not.toHaveProperty('source'); + }); + + test('game-chat hydration keeps identical text from a different runtime response identity', () => { + const history = [ + { + role: 'assistant' as const, + text: 'same text', + messageId: 'persisted-message-1', + updatedAt: 20, + }, + ]; + const pending = [ + { + role: 'assistant' as const, + text: 'same text', + messageId: 'runtime-response:run-2\u001fslot-1\u001f0', + updatedAt: 10, + runtimeOwned: true, + }, + ]; + + expect(mergeGameChatRuntimeResponseMessagesIntoHistory(history, pending)).toEqual([ + pending[0], + history[0], + ]); + expect( + mergeGameChatRuntimeResponseMessagesIntoHistory(history, [ + { ...pending[0], messageId: 'persisted-message-1' }, + ]), + ).toEqual(history); + }); + + test('ready response survives either ordering of runtime commit and conversation hydration', () => { + const history = [ + { + role: 'user' as const, + text: 'build a game', + messageId: 'persisted-message-1', + updatedAt: 1, + }, + ]; + const ready = { + role: 'assistant' as const, + text: 'ready response', + messageId: 'runtime-response:run-1\u001fslot-1\u001f0', + updatedAt: 2, + runtimeOwned: true, + }; + const commitReady = (current: ChatMessage[]) => + current.some((message) => message.messageId === ready.messageId) + ? current + : [...current, ready]; + const hydrateConversation = (current: ChatMessage[]) => + mergeGameChatRuntimeResponseMessagesIntoHistory(history, current); + + expect(hydrateConversation(commitReady([]))).toContainEqual(ready); + expect(commitReady(hydrateConversation([]))).toContainEqual(ready); + }); +}); + function providerRetryRuntime(): AgentRuntimeState { return { schemaVersion: 'game-creator-agent-runtime.v1', 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 6d02ee1b6..91966f530 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 @@ -3916,6 +3916,7 @@ export function registerProjectSupervisorSurfaceTests() { task: '生成第一版可玩原型', runId: expect.stringMatching(/^project-supervisor-task-/), runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', }, ); }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c47b25b3c..0c6fe1086 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5868,3 +5868,11 @@ - 生产接入:AGC Agent interaction 的 stream、普通请求及原 stream-unavailable/empty/deserialize fallback 已改由 `ProviderRegistry` 执行;对外 `LlmStreamDelta`、function name/schema、错误文案、Runner IPC 和 Provider retry/handoff/finalization 持久协议不变。 - 扩展边界:新 Provider 可直接实现 core `ProviderAdapter` 并注册,不修改 core enum/match。`platform-llm` 当前 DTO 不支持的 tool role/result、toolChoice none/specific 和 reasoning minimal/x-high 在 adapter 转换层零网络失败关闭;工具调用仍以最终 response 为权威。 - 关联文档:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` V1.50。 + +## 2026-07-31 game-chat 每条输出入聊天、试玩后收束与平台图集引用 + +- 背景:game-chat 的 ready response 之前只作为 transient stream 展示,刷新或事件 / 轮询重放时可能丢失;自主构建完成后仍可能继续进入发布任务;配置 External Editor API 时,原型 HTML 也可能不实际使用平台生成的 Canvas 美术资源。 +- 决策:game-chat 为每条 Supervisor ready 输出分配由 `runId + requestSlot + responseRevision` 组成的稳定 `runtime-response:*` 消息 ID,并在事件、轮询、StrictMode 和 hydration 中按 ID 幂等固化到聊天框;普通 `supervisor-chat` 不改变。可信 source `project-supervisor-game-chat` 的 seed task 截断在 `preview-playtest`,试玩成功后完成门只验收保留任务、最新 revision、`game.static_smoke` 和 `preview.validate`,不再调度 `publish-strategy` / `publish-package`,GUI / CLI 仍执行完整 DAG。 +- 美术资源门禁:External Editor API 有效时,`code-prototype` 必须通过 `asset.list` 核对 Canvas 登记的 `assets/art-spritesheet.png`,并在 `game/index.html` 真实引用该文件;manifest、文件或 HTML 引用任一缺失均拒绝完成。确定性 Provider fixture 也必须带该引用,不能用占位内容绕过门禁。 +- 验证:`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`。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 4116659d6..012934992 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -81,6 +81,15 @@ 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`,缺少登记、文件或引用必须失败关闭。对应定向测试至少包括: + +```bash +npm run test -- apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts --run +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml autonomous_completion_contract -- --nocapture --test-threads=1 +``` + +source allowlist、game-chat 单轮完成门和 Canvas spritesheet 引用门禁均须命中实际测试;若完整 Rust suite 受 Windows `os error 32` 既有文件锁竞态影响,应单独复跑新增 filter 并如实记录,不能把锁竞态失败改报为本次改动通过。 + Windows release 的非交互后台命令统一使用 `CREATE_NO_WINDOW`,包括 `command.exec / project.verify`、STDIO MCP、Repository Context Git、`git.inspect / project.git_commit` 和 `taskkill` 清理命令;需要进程组终止时再叠加 `CREATE_NEW_PROCESS_GROUP`,不要使用 `DETACHED_PROCESS`。smoke 时应在实际任务运行期间观察无额外控制台窗口,并在关闭客户端后核对整棵后台进程树为零,再重启确认 reconciliation 可继续。 Provider 失败回归必须同时检查等待态和耗尽态:用本地 mock 503 证明 Runtime 从 durable retry record 显示精确 HTTP 状态、真实 `nextAttempt/maxRetries` 和退避秒数;最后一次重试仍失败时,状态卡与持久 conversation 只显示安全中文摘要。测试正文应包含诱饵 Provider URL/query、API Key 和 Windows / Unix 绝对路径,并断言这些正文、内部 fingerprint / chars 与 `[redacted ...]` 占位符均未进入用户可见消息;不能只验证状态卡而漏掉 `SupervisorChatOnlyView` 直接渲染的 conversation。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index f736fc720..def4cefb5 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -50,6 +50,13 @@ - 系统边界:该页面是既有 AI 游戏创作工作台的独立构建例外,不新增平台玩法入口、后端 API、会话库、Runner 或预览服务,也不把入口并回普通正式客户端。原 `supervisor-chat` 继续固定使用 `standard` profile 并保持纯聊天行为,不继承本例外的自主构建、事件聚合或自动预览授权。 - 验证要求:每次修改该 flavor 至少执行壳配置门禁、AppSurface game-chat 定向测试、前端类型检查、AppData / 诊断日志 / release flavor 相关 Rust 定向测试、`npm run check:encoding` 和 `git diff --check`;正式打包必须执行 `npm run agc:build:game-chat-release`,安装或启动产物后确认首屏只能进入本地 game-chat、断开或停止 `api-server` 仍可进入工作台、普通/debug 认证未变化、AppData 使用独立 identifier,并分别 smoke“新目录 owner / DACL 正确”“历史 foreign owner 原目录被同级备份且配置不覆盖”“foreign-owner stale `agent-runner.lock` 在独占句柄下立即修复并启动”“活锁不被截断或接管”“reparse point / hardlink 失败关闭”“AppData 不可写时日志回退 TEMP”“`.setup()` 初始化失败显示日志位置对话框”“503 等待态显示真实 HTTP 状态、重试次数和退避秒数”“503 耗尽后状态卡与 conversation 只显示安全摘要”“后台命令执行期间不出现控制台窗口”“启动活跃任务后关闭主窗口,Runner、MCP、command、ConPTY 及孙进程均消失”“再次启动后 durable 状态进入正确 reconciliation”。日志检查必须同时验证 256 KiB 轮转、仅一份 previous、凭据和绝对路径零泄漏。 +## 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 同步输出图集引用,避免测试绕过该门禁。 +- 验证:前端运行时模型定向测试、Rust completion/source/asset 合同测试、`cargo fmt --check`、`npm run check:encoding` 与 `git diff --check` 必须全部执行;Windows 文件锁竞态只可作为既有测试失败单独记录,不得将其改写为本次改动的通过证据。 + ## Runtime 边界 V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .codex / .hermes`;其中 `.agent` 对项目命令隐藏,其余控制目录只读。