diff --git a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs index b2ddbac7b..e8a78f9f1 100644 --- a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs @@ -722,7 +722,7 @@ function runAgent() { stderr += chunk.toString(); }); child.on('error', reject); - child.on('close', (code) => { + child.on('close', (code, signal) => { const output = `${stdout}${stderr}`; if (previewReadError) { reject(previewReadError); @@ -740,13 +740,24 @@ function runAgent() { previewDom, }); } else { - reject(new Error(output || `agent run exited with ${code}`)); + reject( + new Error( + `agent run failed: exitCode=${code}, signal=${signal ?? 'none'}\n` + + `stderr tail (last 8000 characters):\n${stderr.slice(-8000)}\n` + + `stdout tail (last 4000 characters):\n${stdout.slice(-4000)}`, + ), + ); } }); }); } async function seedLocalAsset() { + await fs.mkdir(path.join(projectRoot, 'game'), { recursive: true }); + await fs.writeFile( + path.join(projectRoot, 'game/index.html'), + '还没有生成游戏', + ); await fs.mkdir(path.join(projectRoot, 'assets/uploads'), { recursive: true }); await fs.mkdir(path.join(projectRoot, '.agent'), { recursive: true }); await seedConversationContext(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index e7974ba25..d40b85804 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -4710,10 +4710,10 @@ mod tests { assert!(!prompt.contains("你是 Codex")); assert!(prompt.contains("不要等待 Supervisor")); assert!(prompt.contains("提示词与技能")); - assert!(prompt.contains("AGC 工程合同(仅说明项目边界,不是流程门槛)")); + assert!(prompt.contains("AGC 工程合同:当前 cwd 是用户选择的项目目录")); assert!(prompt.contains("客户端扩展列表中用户已启用的第三方 MCP")); assert!(prompt.contains("用户明确指定第三方 MCP Server 或工具时")); - assert!(prompt.contains("先按需读取当前 cwd 下适用的 `AGENTS.md`")); + assert!(prompt.contains("先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明")); assert!(prompt.contains("agc_write_file")); assert!(prompt.contains("content 必须是目标文件的完整原始 UTF-8 正文")); assert!(prompt.contains("不得把 command.exec 的 Exit code、Wall time、Output 包装")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index 8d3977878..a83b6dba4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -27,8 +27,9 @@ pub(in crate::agent) use canvas_generation::{ commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at, generate_platform_art_asset_with_retained_runtime_options_at, generate_platform_art_asset_with_runtime_options_at, - platform_art_generation_error_result_unknown, register_existing_platform_art_slices_at, - request_platform_art_asset_with_runtime_options_at, restore_platform_art_asset_bytes_at, + platform_art_generation_error_result_unknown, recover_persisted_visual_generation_options, + register_existing_platform_art_slices_at, request_platform_art_asset_with_runtime_options_at, + restore_platform_art_asset_bytes_at, retained_platform_art_generation_runtime_spritesheet_identity_at, retained_platform_art_generation_runtime_state_matches_direct_stage_at, validate_platform_art_png_bytes_with_limits, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 0e7a97084..8d098b8e6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -429,6 +429,87 @@ impl Default for PlatformArtAssetGenerationOptions { } } +pub(in crate::agent) fn recover_persisted_visual_generation_options( + root: &Path, + pending: &AgentRuntimePendingToolAction, + prompt: &str, + requested: &PlatformArtAssetGenerationOptions, +) -> Result, String> { + let context = platform_art_generation_runtime_context_from_pending(pending); + let Some(state) = read_platform_art_generation_runtime_state(root, &context)? else { + return Ok(None); + }; + let (path, ratio, size, kind, label, generation_kind) = match context.agent_id.as_str() { + "art-director" => ( + "assets/art-spec.png", + "1:1", + "1K", + "icon-spec", + "游戏统一视觉规范图", + "spec", + ), + "design-foundation" => ( + "assets/ui-prototype.png", + "16:9", + "2K", + "ui-prototype", + "游戏横屏界面原型图", + "ui-design", + ), + "art-asset-plan" => ( + "assets/art-spritesheet.png", + "1:1", + "1K", + "art-spritesheet", + "游戏首版核心美术素材", + "icon-spritesheet", + ), + _ => return Ok(None), + }; + let mut options = requested.clone(); + let label = if context.agent_id == "design-foundation" + && options + .output_path + .as_deref() + .is_some_and(design_foundation_ui_page_output_path_is_valid) + { + "游戏功能页面设计图" + } else { + label + }; + if options.output_path.is_none() { + options.output_path = Some(path.to_string()); + } + for (value, default) in [ + (&mut options.aspect_ratio, ratio), + (&mut options.image_size, size), + (&mut options.asset_kind, kind), + (&mut options.asset_label, label), + ] { + if value.is_empty() { + *value = default.to_string(); + } + } + let snapshot = platform_art_generation_runtime_request_snapshot(&state)?; + // 只恢复经过身份校验的已有请求;显式改参和新请求仍走当前合同。 + if snapshot.generation_kind == generation_kind + && snapshot.generation_prompt == build_platform_art_asset_prompt(prompt, &[], &options) + && options.asset_kind == kind + && options.aspect_ratio == ratio + && options.image_size == size + && (options.output_path.as_deref() == Some(path) + || (context.agent_id == "design-foundation" + && options + .output_path + .as_deref() + .is_some_and(design_foundation_ui_page_output_path_is_valid))) + { + Ok(Some(options)) + } else { + Ok(None) + } +} + #[derive(Clone, Debug, Eq, PartialEq)] struct ExistingPlatformArtAssetFingerprint { local_path: String, @@ -8895,13 +8976,13 @@ mod canvas_generation_tests { } #[test] - fn strict_spritesheet_contract_requires_the_grid_2x2_provider_receipt() { + fn strict_spritesheet_contract_accepts_variable_slices_without_fixed_layout() { let canvas_context = ExternalCanvasGenerationContext { project_id: "canvas-project".to_string(), asset_folder_id: "asset-folder".to_string(), canvas_name: "contract-test".to_string(), }; - let slices = (0..4) + let slices = (0..3) .map(|index| { let download = rgba_test_png(100 + index); let validated = validate_platform_art_png_bytes_with_limits( @@ -8927,7 +9008,7 @@ mod canvas_generation_tests { } }) .collect::>(); - let error = validate_strict_platform_art_spritesheet_contract( + validate_strict_platform_art_spritesheet_contract( &slices, &canvas_context, Some("canvas-project"), @@ -8941,9 +9022,7 @@ mod canvas_generation_tests { true, true, ) - .expect_err("missing fixed-layout proof must not be accepted"); - - assert!(error.contains("grid-2x2")); + .expect("valid slice identities and pixel evidence do not require a fixed layout"); } #[test] @@ -10168,7 +10247,8 @@ mod canvas_generation_tests { } #[tokio::test] - async fn accepted_runtime_generation_rejects_changed_intent_before_operation_get() { + async fn accepted_runtime_generation_checks_running_operation_before_rejecting_changed_intent() + { let temporary = tempfile::tempdir().expect("create changed-intent recovery project"); let root = temporary.path(); init_local_game_project_at(root, "changed-intent", "旧异步生成意图隔离") @@ -10188,6 +10268,31 @@ mod canvas_generation_tests { .set_nonblocking(true) .expect("set changed intent fixture nonblocking"); let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let (stop_sender, stop_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || loop { + if stop_receiver.try_recv().is_ok() { + break; + } + let (mut stream, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + continue; + } + Err(error) => panic!("accept changed intent request: {error}"), + }; + request_sender + .send(read_test_http_request(&mut stream)) + .expect("capture status request"); + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({ + "data": {"operationId": "old-operation", "status": "running"} + }), + ); + }); let runtime_context = PlatformArtGenerationRuntimeContext { agent_id: "art-director".to_string(), task_id: "art-director".to_string(), @@ -10253,10 +10358,15 @@ mod canvas_generation_tests { error.contains("当前生成意图与已持久化请求快照不一致"), "{error}" ); - assert!(matches!( - listener.accept(), - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock - )); + stop_sender.send(()).expect("stop changed intent fixture"); + server.join().expect("join changed intent fixture"); + let requests = request_receiver.try_iter().collect::>(); + assert_eq!( + requests.len(), + 1, + "only the existing operation may be queried" + ); + assert!(requests[0].starts_with("GET /api/external/v1/generations/old-operation ")); assert!(game_creator_agent_runtime_external_generation_exists( root, &runtime_context.agent_id, @@ -10265,7 +10375,8 @@ mod canvas_generation_tests { } #[tokio::test] - async fn accepted_derived_generation_rejects_changed_art_spec_before_operation_get() { + async fn accepted_derived_generation_checks_running_operation_before_rejecting_changed_art_spec( + ) { let temporary = tempfile::tempdir().expect("create changed reference project"); let root = temporary.path(); init_local_game_project_at(root, "changed-reference", "规范图身份隔离") @@ -10310,6 +10421,31 @@ mod canvas_generation_tests { .set_nonblocking(true) .expect("set changed reference fixture nonblocking"); let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let (stop_sender, stop_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || loop { + if stop_receiver.try_recv().is_ok() { + break; + } + let (mut stream, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + continue; + } + Err(error) => panic!("accept changed reference request: {error}"), + }; + request_sender + .send(read_test_http_request(&mut stream)) + .expect("capture status request"); + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({ + "data": {"operationId": "old-background-operation", "status": "running"} + }), + ); + }); let runtime_context = PlatformArtGenerationRuntimeContext { agent_id: "direct-codex-art".to_string(), task_id: "direct-codex-art-game-background".to_string(), @@ -10412,10 +10548,19 @@ mod canvas_generation_tests { "{error}" ); assert!(error.contains("当前规范图身份"), "{error}"); - assert!(matches!( - listener.accept(), - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock - )); + stop_sender + .send(()) + .expect("stop changed reference fixture"); + server.join().expect("join changed reference fixture"); + let requests = request_receiver.try_iter().collect::>(); + assert_eq!( + requests.len(), + 1, + "only the existing operation may be queried" + ); + assert!( + requests[0].starts_with("GET /api/external/v1/generations/old-background-operation ") + ); assert!(game_creator_agent_runtime_external_generation_exists( root, &runtime_context.agent_id, @@ -10809,6 +10954,38 @@ mod canvas_generation_tests { mark_platform_art_generation_runtime_accepted(root, state, "test-operation-id", 0) .expect("accept current generation fixture"); + let sparse_options = PlatformArtAssetGenerationOptions { + output_path: Some(AGENT_RUNTIME_ART_SPEC_PATH.to_string()), + aspect_ratio: String::new(), + image_size: String::new(), + asset_kind: String::new(), + asset_label: String::new(), + ..Default::default() + }; + assert_eq!( + recover_persisted_visual_generation_options(root, &pending, prompt, &sparse_options) + .expect("recover persisted defaults"), + Some(options.clone()) + ); + assert!(recover_persisted_visual_generation_options( + root, + &pending, + "显式改变生成意图", + &sparse_options, + ) + .unwrap() + .is_none()); + let mut explicit_options = sparse_options.clone(); + explicit_options.asset_kind = "game-background".to_string(); + assert!(recover_persisted_visual_generation_options( + root, + &pending, + prompt, + &explicit_options, + ) + .unwrap() + .is_none()); + resume_game_creator_agent_background_tasks_at(root) .expect("resume accepted generation through recovery scan"); let first = request_receiver @@ -11063,19 +11240,18 @@ mod canvas_generation_tests { } #[test] - fn canonical_art_spritesheet_request_has_exactly_four_ordered_categories() { + fn canonical_art_spritesheet_request_preserves_project_requirements() { let descriptions = canonical_art_spritesheet_icon_descriptions("原创收集玩法"); - assert_eq!(descriptions.len(), 4); - for (index, description) in descriptions.iter().enumerate() { - assert!(description.starts_with(&format!("第 {} 类", index + 1))); - } + assert_eq!(descriptions.len(), 1); + assert!(descriptions[0].contains("原创收集玩法")); + assert!(descriptions[0].contains("数量、类别、排列和切片方式由本次需求决定")); } #[test] fn canonical_art_spritesheet_descriptions_obey_external_editor_item_limit() { let descriptions = canonical_art_spritesheet_icon_descriptions(&"原创玩法需求".repeat(128)); - assert_eq!(descriptions.len(), 4); + assert!(!descriptions.is_empty()); assert!(descriptions .iter() .all(|description| description.chars().count() <= 200)); @@ -11466,7 +11642,7 @@ mod canvas_generation_tests { } #[test] - fn strict_slice_commit_rejects_non_four_slice_results_before_replacing_the_sheet() { + fn strict_slice_commit_rejects_empty_slice_results_before_replacing_the_sheet() { let temporary = tempfile::tempdir().expect("create strict slice project"); let root = temporary.path(); init_local_game_project_at(root, "strict-slices", "严格切片测试") @@ -11481,9 +11657,9 @@ mod canvas_generation_tests { &replacement_options(), |_| Ok(()), ) - .expect_err("strict spritesheet commit must require exactly four slices"); + .expect_err("strict spritesheet commit must require at least one slice"); - assert!(error.contains("恰好包含 4 个独立切片")); + assert!(error.contains("至少需要一个独立切片")); assert_eq!(fs::read(path).expect("read preserved sheet"), b"old-image"); assert!(!root .join("assets/art-spritesheet-slices/manifest.json") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index 460d8dfad..198940a5e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs @@ -1790,103 +1790,62 @@ mod tests { } #[test] - fn agent_prompt_design_foundation_forbids_implementation_and_browser_validation() { + fn agent_prompt_design_foundation_keeps_its_role_boundary() { for editor_api_key_is_configured in [false, true] { let prompt = game_creator_design_foundation_tool_plan_prompt( "shared runtime contract", editor_api_key_is_configured, ); - - assert!(prompt.contains("项目文件写入只允许 memory/project.md 与 game/game_design.md")); - assert!(prompt.contains("不得创建、修改、删除或补丁 game/index.html")); - assert!(prompt.contains("由 Runtime 在收束门内验证本人 owner 产物")); - assert!( - prompt.contains("不得调用 project.verify、command.run_limited、game.static_smoke") - ); - assert!(prompt.contains("preview.start 或 preview.validate")); - assert!(prompt.contains("最终静态验收仍属于 preview-readiness")); - assert!(prompt.contains("浏览器验收仍属于 preview-playtest")); - assert!(prompt.contains( - "不得通过 command.exec、command.start 或其他工具启动本地预览服务、浏览器、Playwright" - )); + assert!(prompt.contains("只负责玩法规格、界面建议和视觉工具使用指导")); + assert!(prompt.contains("项目文件与图片输出必须服从当前任务明确要求")); + assert!(prompt.contains("不修改 game/index.html,不启动预览或试玩")); } } #[test] - fn agent_prompt_design_foundation_preserves_the_canvas_specific_contract() { + fn agent_prompt_design_foundation_describes_available_deliveries() { let without_canvas = game_creator_design_foundation_tool_plan_prompt("shared runtime contract", false); assert!(without_canvas.contains("不调用 canvas.asset_generate")); - assert!(without_canvas.contains("不伪造 assets/ui-prototype.png")); + assert!(without_canvas.contains("memory/project.md 与 game/game_design.md")); assert!(without_canvas.contains("每个功能页面各写一行 @genarrative-ui-page")); let with_canvas = game_creator_design_foundation_tool_plan_prompt("shared runtime contract", true); + assert!(with_canvas.contains("根据当前玩法需求编写规格和界面建议")); + assert!(with_canvas.contains("用途、数量、输出路径、尺寸、参考资源和是否需要 spritesheet")); assert!(with_canvas.contains("再调用 canvas.asset_generate")); - assert!(with_canvas.contains("assets/art-spec.png")); - assert!(with_canvas.contains("referenceImageSrcs 第一项")); - assert!(with_canvas.contains("assets/ui-prototype.png")); - assert!(with_canvas.contains("调用 image.inspect")); - assert!(with_canvas.contains("ui-prototype.v2")); - assert!(with_canvas.contains("成功只表示候选图片已生成并登记,不等于视觉验收完成")); - assert!(with_canvas.contains("由 Runtime 在收束门内同时核对固定 owner 文档")); - assert!(!with_canvas.contains("成功动作本身就是当前 revision 的验证")); - assert!(with_canvas.contains("调用 ui.workflow.run")); - assert!(with_canvas.contains("visual-binding 最终编辑器路由")); - assert!(with_canvas.contains("每个功能页面各写一行 @genarrative-ui-page")); - assert!(with_canvas.contains("ui.workflow.run 的 discover")); - assert!(with_canvas.contains("assets/ui-pages/{pageId}.png")); - assert!(with_canvas.contains("informationHud")); - assert!(with_canvas.contains("failureRestartFlow")); - assert!(with_canvas.contains("不得假设为塔防")); - assert!(with_canvas - .contains("POST /api/external/v1/editor/images/generations(kind=ui-design)")); - assert!(with_canvas - .contains("不得误用 POST /api/external/v1/editor/ui-designs/assets/extractions")); } #[test] - fn agent_prompt_art_director_switches_between_read_only_and_conditional_canvas_owner() { + fn agent_prompt_art_director_describes_credentials_and_resource_checks() { let without_canvas = game_creator_art_director_tool_plan_prompt("shared runtime contract", false); assert!(without_canvas.contains("这是只读协调任务")); assert!(without_canvas.contains("只完成正式 director 结论并直接交付")); assert!(without_canvas.contains("不调用 canvas.asset_generate")); - assert!(without_canvas.contains("图片产物与验收条款在本轮不适用")); let with_canvas = game_creator_art_director_tool_plan_prompt("shared runtime contract", true); - assert!(with_canvas.contains("outputPath=assets/art-spec.png")); - assert!(with_canvas.contains("assetKind=icon-spec")); - assert!(with_canvas.contains("成功只表示固定候选已生成并登记,不等于视觉门已经通过")); - assert!(with_canvas.contains("由 Runtime 在收束时核对当前 revision")); - assert!(!with_canvas.contains("成功会为本人当前 revision 形成验证凭证")); + assert!(with_canvas + .contains("canvas.asset_generate 的 assetKind、outputPath、尺寸、比例和提示词")); + assert!(with_canvas.contains("需要参考图时使用已登记资源 ID")); + assert!(with_canvas.contains("生成后核对返回资源、权限、计费和登记状态")); } #[test] - fn agent_prompt_art_asset_plan_uses_fixed_transparent_spritesheet_route_and_warnings() { + fn agent_prompt_art_asset_plan_describes_resource_discovery_and_generation() { let without_canvas = game_creator_art_asset_plan_tool_plan_prompt("shared runtime contract", false); assert!(without_canvas.contains("不调用 canvas.asset_generate")); - assert!(without_canvas.contains("不伪造 assets/art-spritesheet.png")); + assert!(without_canvas.contains("写入可解析的 assets/manifest.art.json")); let with_canvas = game_creator_art_asset_plan_tool_plan_prompt("shared runtime contract", true); - assert!(with_canvas.contains("用 asset.list 确认 assets/art-spec.png 已登记")); - assert!(with_canvas.contains("由 Runtime 形成 iconDescriptions")); - assert!(with_canvas.contains("玩家主体及朝向/状态")); - assert!(with_canvas.contains("不得假设为塔防")); - assert!(with_canvas.contains("权威 resourceId 作为 referenceId")); - assert!(with_canvas.contains("POST /api/external/v1/editor/icon-spritesheets/generations")); - assert!(with_canvas.contains("不得回退普通生图或 UI extraction")); - assert!(with_canvas.contains("screenColor=auto")); - assert!(with_canvas.contains("缺少规范图时必须等待 art-director")); - assert!(with_canvas.contains("不得回退普通生图")); - assert!(with_canvas.contains("真实 alpha")); - assert!(with_canvas.contains("warning.code=postprocess-failed-source-preserved")); - assert!(with_canvas.contains("不得登记、验收或自动重试")); - assert!(with_canvas.contains("仅 sliceWarning")); - assert!(with_canvas.contains("由 Runtime 在收束门内验证本人固定 manifest 产物")); + assert!(with_canvas.contains("使用 asset.list 了解已有资源")); + assert!(with_canvas.contains("再按需调用 canvas.asset_generate")); + assert!(with_canvas.contains("spritesheet 可通过 sliceCount 指定切片数量")); + assert!(with_canvas.contains("生成后核对资源登记、透明度、警告和实际使用情况")); } #[test] 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 a617d7f67..cd032a2ee 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 @@ -2011,17 +2011,6 @@ mod tests { assert!(error.contains("不得启动 isolated child")); } - #[test] - fn autonomous_initial_collaboration_requires_leader_artifacts() { - let mut plan = autonomous_initial_leader_plan(); - plan.actions[1] = autonomous_initial_delegate("art-director", &[]); - - let error = validate_agent_runtime_autonomous_initial_collaboration_contract(&plan) - .expect_err("art artifact must be required"); - - assert!(error.contains("expectedArtifacts 必须包含 assets/art-spec.png")); - } - #[test] fn autonomous_manifest_dag_waits_only_after_seed_execution_starts() { let temporary = tempfile::tempdir().expect("create manifest DAG policy root"); 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 326eab51a..42107daf8 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 @@ -1923,8 +1923,8 @@ mod tests { assert!(!publish_prompt.contains("验证本人固定 owner 产物")); } - #[tokio::test] - async fn art_director_ready_task_is_read_only_without_key_and_canvas_owner_with_key() { + #[test] + fn art_director_ready_task_is_read_only_without_credentials() { let task = seed_task("art-director"); { let _config_guard = crate::tests::write_test_local_config("{}".to_string()); @@ -1940,42 +1940,5 @@ mod tests { &prompt )); } - // debug 构建下 editor_api_mode() 恒为 PlatformAccount,配置里的 - // editorApi.apiKey 会被 editor_api_key_is_configured 完全忽略,只有凭据 - // override 或平台会话才算「已配置」。用支持的 override 钩子模拟。 - crate::assets::with_external_editor_api_credentials( - crate::assets::external_editor_api_credentials_for_test( - "https://editor.test".to_string(), - "art-director-ready-task-key".to_string(), - ), - async { - let prompt = render_autonomous_manifest_ready_task_background_prompt(&task); - assert!(prompt.contains("非只读视觉规范生成任务")); - assert!(prompt.contains( - crate::agent::AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER - )); - assert!(prompt.contains("生成并登记 assets/art-spec.png")); - assert!(prompt.contains("会同时提交当前 run 的 mutation 与验证凭证")); - assert!(prompt.contains("Runtime 会在子 Run 终态后幂等投影 manifest")); - assert!(!prompt.contains("无生图凭据只读协调任务")); - assert!(autonomous_manifest_ready_task_requires_visual_asset( - "art-director" - )); - assert!(!agent_runtime_task_requires_read_only_delivery( - "art-director", - &prompt - )); - let art_asset_prompt = render_autonomous_manifest_ready_task_background_prompt( - &seed_task("art-asset-plan"), - ); - assert!(art_asset_prompt.contains("canvas.asset_generate")); - assert!(art_asset_prompt.contains("asset.list")); - assert!(art_asset_prompt.contains("file.write 写入 assets/manifest.art.json")); - assert!(art_asset_prompt.contains("不要调用 image.inspect")); - assert!(art_asset_prompt.contains("把结构化计划最后一步标记 completed")); - assert!(!art_asset_prompt.contains("按现有 visual gate 生成、登记并验收")); - }, - ) - .await; } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index 1478ae0aa..633f5ffe8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -10,6 +10,7 @@ mod goal_contract; mod helpers; mod isolated_joins; mod media; +pub(in crate::agent) use media::design_foundation_ui_page_output_path_is_valid; mod policy; mod preview; mod process_ops; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index 48ba9b4e0..e570d026c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -19,7 +19,7 @@ fn agent_runtime_canvas_asset_kind_is_supported(asset_kind: &str) -> bool { AGENT_RUNTIME_CANVAS_ASSET_KINDS.contains(&asset_kind) } -fn design_foundation_ui_page_output_path_is_valid(path: &str) -> bool { +pub(in crate::agent) fn design_foundation_ui_page_output_path_is_valid(path: &str) -> bool { let Some(page_id) = path .strip_prefix("assets/ui-pages/") .and_then(|value| value.strip_suffix(".png")) @@ -545,7 +545,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio .or_else(|| input.get("slice_count")) .and_then(serde_json::Value::as_u64) .map(|value| value as usize); - let requested_options = PlatformArtAssetGenerationOptions { + let mut requested_options = PlatformArtAssetGenerationOptions { output_path: (!output_path.trim().is_empty()).then_some(output_path), aspect_ratio, image_size, @@ -554,6 +554,25 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio replace_existing, slice_count, }; + if let Some(pending) = pending_action { + match recover_persisted_visual_generation_options( + root, + pending, + &prompt, + &requested_options, + ) { + Ok(Some(recovered)) => requested_options = recovered, + Ok(None) => {} + Err(error) => { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + } + } + } + } let mut options = { let defaults = PlatformArtAssetGenerationOptions::default(); PlatformArtAssetGenerationOptions { 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 45918c9e7..a1b2406df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -2007,10 +2007,10 @@ pub(crate) fn write_game_creator_app_config( let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK .lock() .map_err(|_| "配置写入锁不可用")?; - let stored = load_game_creator_app_config()?; - config.selected_model_id = stored.selected_model_id; - config.selected_model_is_default = stored.selected_model_is_default; - persist_game_creator_app_config(config) + let (current, overlays) = load_game_creator_app_config_for_write()?; + config.selected_model_id = current.selected_model_id; + config.selected_model_is_default = current.selected_model_is_default; + persist_game_creator_app_config(config, overlays, false) } #[tauri::command] @@ -2029,20 +2029,67 @@ pub(crate) fn select_game_creator_model( { return Err("模型标识无效".into()); } - let mut config = load_game_creator_app_config()?; + let (mut config, overlays) = load_game_creator_app_config_for_write()?; config.selected_model_id = model_id; config.selected_model_is_default = is_default; - persist_game_creator_app_config(config) + persist_game_creator_app_config(config, overlays, true) } fn persist_game_creator_app_config( config: GameCreatorAppConfig, + overlays: Vec<(PathBuf, serde_json::Value)>, + model_only: bool, ) -> Result { let config = normalize_game_creator_app_config(config)?; let path = writable_game_creator_config_path()?; let content = serialize_game_creator_app_config_for_renderer_write(&config)?; - write_game_creator_config_atomically(&path, &format!("{content}\n"))?; - game_creator_app_config_view(load_game_creator_app_config()?) + let mut writes = vec![(path, format!("{content}\n"))]; + let saved: serde_json::Value = serde_json::from_str(&content) + .map_err(|error| format!("解析已序列化客户端配置失败:{error}"))?; + for (overlay_path, mut overlay) in overlays { + let previous = overlay.clone(); + if let Some(fields) = overlay.as_object_mut() { + for (key, value) in fields.iter_mut() { + if matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault") + == model_only + { + if let Some(saved_value) = saved.get(key) { + // 仅同步已有覆盖项;其它字段继续保留原有覆盖语义。 + if key == "agentLlm" { + *value = saved_value.clone(); + } else { + update_existing_config_overlay_fields(value, saved_value); + } + } + } + } + } + if overlay != previous { + let content = serde_json::to_string_pretty(&overlay) + .map_err(|error| format!("序列化客户端覆盖配置失败:{error}"))?; + writes.push((overlay_path, format!("{content}\n"))); + } + } + crate::config::write_game_creator_config_batch(&writes)?; + // `config` is already normalized and is exactly what was persisted. + // Avoid reloading it here: a reload repeats the Windows private-path and + // ACL checks and made saving the settings panel appear to hang. + game_creator_app_config_view(config) +} + +fn update_existing_config_overlay_fields( + overlay: &mut serde_json::Value, + saved: &serde_json::Value, +) { + if let (Some(fields), Some(saved_fields)) = (overlay.as_object_mut(), saved.as_object()) { + for (key, value) in fields { + if let Some(saved_value) = saved_fields.get(key) { + update_existing_config_overlay_fields(value, saved_value); + } + } + } else if !overlay.is_null() { + *overlay = saved.clone(); + } } #[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 4904b59b3..586faa107 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -3272,9 +3272,10 @@ pub(crate) fn configure_game_creator_runtime_config_dir( Ok(()) } -/// Validates a persisted AGC config file without following links. Existing -/// regular files are tightened through the same Windows owner/DACL gate used -/// by credential files before any read is allowed. +/// Validates a persisted AGC config file without following links. +/// +/// Reading configuration must remain read-only. ACL hardening is performed +/// when the file is created or replaced, not on every settings-panel read. fn validate_game_creator_config_file_entry(path: &Path) -> Result { #[cfg(windows)] validate_game_creator_private_path_ancestors_with_auto_elevation(path, "客户端配置文件")?; @@ -3293,8 +3294,6 @@ fn validate_game_creator_config_file_entry(path: &Path) -> Result if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("客户端配置文件必须是普通文件,不能是链接或其他对象".to_string()); } - #[cfg(windows)] - secure_windows_game_creator_path_for_current_user_with_auto_elevation(path, false, true)?; Ok(true) } @@ -3352,6 +3351,31 @@ pub(crate) fn load_game_creator_app_config() -> Result Result<(GameCreatorAppConfig, Vec<(PathBuf, serde_json::Value)>), String> { + let writable_path = writable_game_creator_config_path()?; + let mut config = GameCreatorAppConfig::default(); + let mut overlays = Vec::new(); + let mut after_writable = false; + for path in game_creator_config_paths() { + if path == writable_path { + after_writable = true; + } + if let Some(content) = read_game_creator_config_file(&path)? { + merge_game_creator_config_content(&mut config, &path, &content)?; + if after_writable && path != writable_path { + let value = serde_json::from_str(&content) + .map_err(|error| format!("解析客户端覆盖配置失败:{error}"))?; + overlays.push((path, value)); + } + } + } + if game_creator_official_llm_route_locked() { + lock_game_creator_app_config_to_official_route(&mut config); + } + Ok((config, overlays)) +} + pub(crate) fn scrub_locked_game_creator_config_file(config: &mut GameCreatorAppConfigFile) -> bool { let mut changed = config.agent_mode.as_deref() != Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER) @@ -3588,6 +3612,13 @@ pub(crate) fn merge_game_creator_config_file( config: &mut GameCreatorAppConfig, path: &Path, ) -> Result<(), String> { + if let Some(content) = read_game_creator_config_file(path)? { + merge_game_creator_config_content(config, path, &content)?; + } + Ok(()) +} + +fn read_game_creator_config_file(path: &Path) -> Result, String> { let backup_path = game_creator_config_backup_path(path); let path_exists = validate_game_creator_config_file_entry(path)?; let read_path = if path_exists { @@ -3595,11 +3626,19 @@ pub(crate) fn merge_game_creator_config_file( } else if validate_game_creator_config_file_entry(&backup_path)? { backup_path.as_path() } else { - return Ok(()); + return Ok(None); }; let content = read_game_creator_private_file_to_string(read_path, "客户端配置", 256 * 1024)?; - let file_config = serde_json::from_str::(&content) - .map_err(|error| format!("解析客户端配置失败:{}: {error}", read_path.display()))?; + Ok(Some(content)) +} + +fn merge_game_creator_config_content( + config: &mut GameCreatorAppConfig, + path: &Path, + content: &str, +) -> Result<(), String> { + let file_config = serde_json::from_str::(content) + .map_err(|error| format!("解析客户端配置失败:{}: {error}", path.display()))?; if let Some(agent_mode) = file_config.agent_mode { config.agent_mode = agent_mode; } @@ -3642,6 +3681,48 @@ fn game_creator_config_backup_path(path: &Path) -> PathBuf { )) } +pub(crate) fn write_game_creator_config_batch(writes: &[(PathBuf, String)]) -> Result<(), String> { + if writes.len() == 1 { + return write_game_creator_config_atomically(&writes[0].0, &writes[0].1); + } + let originals = writes + .iter() + .map(|(path, _)| read_game_creator_config_file(path)) + .collect::, _>>()?; + for (index, (path, content)) in writes.iter().enumerate() { + if let Err(mut error) = write_game_creator_config_atomically(path, content) { + // 写入可能在替换后的权限检查失败,因此失败目标也需要核对并恢复。 + for rollback_index in (0..=index).rev() { + let path = &writes[rollback_index].0; + let original = &originals[rollback_index]; + if read_game_creator_config_file(path).ok().as_ref() == Some(original) { + continue; + } + let restored = match original { + Some(content) => write_game_creator_config_atomically(path, content), + None => fs::remove_file(path) + .or_else(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + Ok(()) + } else { + Err(error) + } + }) + .map_err(|error| error.to_string()), + }; + if let Err(restore_error) = restored { + error.push_str(&format!( + ";恢复配置失败:{}: {restore_error}", + path.display() + )); + } + } + return Err(error); + } + } + Ok(()) +} + pub(crate) fn write_game_creator_config_atomically( path: &Path, content: &str, 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 53ee57d2d..7dd5c64a7 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 @@ -1132,225 +1132,6 @@ async fn background_agent_runtime_delegate_respects_project_policy() { fs::remove_dir_all(root).ok(); } -#[tokio::test] -async fn visual_specialists_reject_overriding_their_fixed_image_contract() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "视觉固定输出合同测试").expect("project init"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: Vec::new(), - confirm_commands: Vec::new(), - agent_policies: BTreeMap::new(), - }, - ) - .expect("allow canvas generation"); - let action = AgentRuntimeToolAction { - tool: "canvas.asset_generate".to_string(), - reason: Some("尝试覆盖策划固定图片合同".to_string()), - input: serde_json::json!({ - "prompt": "生成横屏界面原型", - "outputPath": "assets/wrong-prototype.png", - "aspectRatio": "1:1", - "imageSize": "2K", - "assetKind": "game-art", - "assetLabel": "错误标签", - "replaceExisting": false - }), - }; - - let observation = execute_game_creator_agent_runtime_tool_action_with_action_id( - &root, - "design-foundation", - "visual-fixed-contract-run", - "必须交付固定原型图", - &action, - Some("visual-fixed-contract-action"), - ) - .await; - - assert_eq!(observation.status, "failed"); - for expected in [ - "不能覆盖固定输出合同", - "outputPath=assets/ui-prototype.png", - "aspectRatio=16:9", - "imageSize=2K", - "assetKind=ui-prototype", - "assetLabel=游戏横屏界面原型图", - ] { - assert!(observation.summary.contains(expected)); - } - assert_eq!(observation.detail, None); - assert!(!root.join("assets/wrong-prototype.png").exists()); - assert!(read_manifest_for_project(&root) - .expect("manifest after rejected override") - .assets - .is_empty()); - - let one_k_action = AgentRuntimeToolAction { - tool: "canvas.asset_generate".to_string(), - reason: Some("尝试使用会返回 3:2 文件的 1K 规格".to_string()), - input: serde_json::json!({ - "prompt": "生成横屏界面原型", - "outputPath": "assets/ui-prototype.png", - "aspectRatio": "16:9", - "imageSize": "1K", - "assetKind": "ui-prototype", - "assetLabel": "游戏横屏界面原型图", - "replaceExisting": false - }), - }; - let one_k_observation = execute_game_creator_agent_runtime_tool_action_with_action_id( - &root, - "design-foundation", - "visual-fixed-contract-run", - "UI 原型必须使用真正的 16:9 输出规格", - &one_k_action, - Some("visual-fixed-contract-1k-action"), - ) - .await; - assert_eq!(one_k_observation.status, "failed"); - assert!(one_k_observation.summary.contains("不能覆盖固定输出合同")); - - fs::remove_dir_all(root).ok(); -} - -#[test] -fn design_foundation_rejects_scene_image_stale_run_and_stale_sha_visual_proofs() { - let _platform_session = crate::platform_session::install_test_platform_session( - "visual-semantic-gate-test-user", - "visual-semantic-gate-test-key", - "https://dev.genarrative.world", - ); - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"visual-semantic-gate-test-key"}}"#.to_string(), - ); - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "UI 原型语义完成门禁测试") - .expect("project init"); - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); - register_canvas_visual_asset_fixture(&root, AGENT_RUNTIME_UI_PROTOTYPE_PATH, "ui-prototype"); - let run_id = "design-ui-semantic-gate-run"; - let state = start_game_creator_agent_runtime_task_at( - &root, - "design-foundation", - "交付真正可实现的 UI 原型", - run_id, - "agent-background-task", - "准备完成", - vec!["生成并检查 UI 原型".to_string()], - ) - .expect("start UI prototype runtime"); - let revision = read_game_creator_agent_runtime_project_revision(&root) - .expect("read UI prototype revision") - .revision; - - let uninspected = finish_game_creator_agent_background_runtime_turn_at( - &root, - state.clone(), - "只有文件登记不能证明它是真正的 UI 原型。", - revision, - &[], - ) - .expect("missing visual verdict remains recoverable"); - let uninspected_blocker = match uninspected { - AgentBackgroundFinalizationOutcome::Stale(blocker) => blocker, - _ => panic!("uninspected image must not complete design-foundation"), - }; - assert!(uninspected_blocker - .detail - .as_deref() - .is_some_and(|detail| detail.contains("requiredInspection=image.inspect"))); - - append_ui_prototype_inspection_fixture_with_profile(&root, run_id, true, "ui-prototype.v1"); - let legacy_v1 = finish_game_creator_agent_background_runtime_turn_at( - &root, - state.clone(), - "历史 v1 审计不能放行当前 run。", - revision, - &[], - ) - .expect("legacy v1 remains recoverable but not authoritative"); - assert!(matches!( - legacy_v1, - AgentBackgroundFinalizationOutcome::Stale(ref blocker) - if blocker.tool == "runtime.visual_asset" - )); - - append_ui_prototype_inspection_fixture(&root, run_id, false); - let scene_blocked = finish_game_creator_agent_background_runtime_turn_at( - &root, - state.clone(), - "场景图不能冒充 UI 原型。", - revision, - &[], - ) - .expect("scene verdict remains recoverable"); - let scene_blocker = match scene_blocked { - AgentBackgroundFinalizationOutcome::Stale(blocker) => blocker, - _ => panic!("scene image must not complete design-foundation"), - }; - assert_eq!(scene_blocker.tool, "runtime.visual_asset"); - assert!(scene_blocker.summary.contains("尚未通过结构化 UI 视觉检查")); - assert!(scene_blocker - .detail - .as_deref() - .is_some_and(|detail| detail.contains("informationHud=false"))); - - append_ui_prototype_inspection_fixture(&root, "another-design-run", true); - let wrong_run = finish_game_creator_agent_background_runtime_turn_at( - &root, - state.clone(), - "其他 run 的证据不能放行。", - revision, - &[], - ) - .expect("wrong run verdict remains recoverable"); - assert!(matches!( - wrong_run, - AgentBackgroundFinalizationOutcome::Stale(ref blocker) - if blocker.tool == "runtime.visual_asset" - )); - - append_ui_prototype_inspection_fixture(&root, run_id, true); - let mut replacement = valid_test_png_bytes(); - replacement.extend_from_slice(b"changed-ui-prototype"); - fs::write(root.join(AGENT_RUNTIME_UI_PROTOTYPE_PATH), replacement) - .expect("replace UI prototype fixture"); - let stale_sha = finish_game_creator_agent_background_runtime_turn_at( - &root, - state.clone(), - "旧图片 SHA 的证据不能放行。", - revision, - &[], - ) - .expect("stale sha verdict remains recoverable"); - let stale_sha_blocker = match stale_sha { - AgentBackgroundFinalizationOutcome::Stale(blocker) => blocker, - _ => panic!("stale image proof must not complete design-foundation"), - }; - assert!(stale_sha_blocker - .detail - .as_deref() - .is_some_and(|detail| detail.contains("requiredInspection=image.inspect"))); - - append_ui_prototype_inspection_fixture(&root, run_id, true); - let completed = finish_game_creator_agent_background_runtime_turn_at( - &root, - state, - "当前图片已通过全部八项 UI 原型检查。", - revision, - &[], - ) - .expect("current passed UI proof allows finalization"); - assert!(matches!( - completed, - AgentBackgroundFinalizationOutcome::Completed(_) - )); - - fs::remove_dir_all(root).ok(); -} - #[tokio::test] async fn supervisor_collaboration_canvas_asset_generate_rechecks_after_project_lock() { let root = unique_project_path(); @@ -1804,6 +1585,8 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r "替换不符合原创要求的正式图片", &serde_json::json!({ "prompt": "生成原创晶体与潮汐构装体图集", + "outputPath": "assets/art-spritesheet.png", + "assetKind": "art-spritesheet", "replaceExisting": true }), ), @@ -2181,61 +1964,21 @@ fn gui_and_cli_autonomous_game_build_runs_still_allow_publish_delegates() { } #[test] -fn visual_specialist_delegations_require_image_artifacts_but_read_only_work_allows_none() { - let _platform_session = crate::platform_session::install_test_platform_session( - "visual-delegation-contract-user", - "visual-delegation-contract-key", - "https://dev.genarrative.world", - ); - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"visual-delegation-contract-key"}}"#.to_string(), - ); +fn read_only_delegation_persists_acceptance_criteria_without_artifacts() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "视觉委派合同测试").expect("project init"); + init_local_game_project_at(&root, "project-1", "只读委派合同测试").expect("project init"); let parent_run_id = "visual-delegate-contract-parent-run"; start_game_creator_agent_runtime_task_at( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "校验视觉专业任务合同", + "校验只读任务合同", parent_run_id, "agent-chat", "准备委派", - vec!["拒绝用文本回执冒充图片".to_string()], + vec!["保存只读验收标准".to_string()], ) .expect("start supervisor parent runtime"); - for (agent_id, required_path) in [ - ("design-foundation", "assets/ui-prototype.png"), - ("art-asset-plan", "assets/art-spritesheet.png"), - ] { - let action_id = format!("reject-empty-visual-{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": agent_id, - "task": "交付真实图片", - "acceptanceCriteria": ["必须能看到图片"], - "expectedArtifacts": [], - "repairOfDelegationId": null, - "runId": null - }), - ); - assert_eq!(observation.status, "failed"); - assert!(observation.summary.contains(required_path)); - let delegation_id = agent_runtime_delegation_id( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - agent_id, - &action_id, - ); - assert!(read_static_delegate_delivery_at(&root, &delegation_id) - .expect("read rejected visual delivery") - .is_none()); - } - let target_agent_id = "quality-review"; let action_id = "allow-read-only-empty-artifacts"; let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/parallel_actions.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/parallel_actions.rs index bd25431d6..429a5710c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/parallel_actions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/parallel_actions.rs @@ -653,7 +653,7 @@ fn parallel_read_batch_finishes_before_concurrent_steer_is_accepted() { #[tokio::test] async fn provider_action_batch_verification_then_parallel_reads_share_current_gate_snapshot() { let root = unique_project_path(); - init_local_game_project_at( + init_existing_html_project_at( &root, "project-provider-batch-verification-parallel-read", "Provider 批次验证后并行读取测试", diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index 4ec311a4a..7565d7e0a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -929,117 +929,6 @@ fn project_supervisor_runtime_id_is_normalized_and_collected() { fs::remove_dir_all(root).ok(); } -#[test] -fn visual_specialist_prompts_require_real_registered_image_deliveries() { - let _platform_session = crate::platform_session::install_test_platform_session( - "visual-prompt-test-user", - "visual-prompt-test-key", - "https://dev.genarrative.world", - ); - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"visual-prompt-test-key"}}"#.to_string(), - ); - let design_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( - "design-foundation", - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - ); - for expected in [ - "文本策划只是中间结果", - "canvas.asset_generate", - "16:9", - "2K", - "assets/ui-prototype.png", - "assetKind=ui-prototype", - "asset.list", - "image.inspect", - "ui-prototype.v2", - "informationHud", - "gameplaySurface", - "objectiveEntities", - "failureRestartFlow", - "responsiveLayout", - "不得假设为塔防", - "原创标题、实体、资源、目标名称与视觉语言", - "assets/art-spec.png", - "icon-spec", - "referenceImageSrcs 第一项", - "POST /api/external/v1/editor/images/generations(kind=ui-design)", - "不得误用 POST /api/external/v1/editor/ui-designs/assets/extractions", - "canvas.asset_generate 成功只表示候选图片已生成并登记,不等于视觉验收完成", - "由 Runtime 在收束门内同时核对固定 owner 文档", - "已有同路径画布资产时先核对登记", - "检查已通过时不得重复生成或再次扣费", - "纯场景图", - "不得把计划写完当成 completed", - ] { - assert!( - design_prompt.contains(expected), - "design visual prompt missing {expected}" - ); - } - - let director_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( - "art-director", - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - ); - for expected in [ - "assets/art-spec.png", - "assetKind=icon-spec", - "POST /api/external/v1/editor/images/generations(kind=spec)", - "后续 UI 和透明图集共同引用", - "不得用 generationInputs.artSpec JSON", - "成功只表示固定候选已生成并登记,不等于视觉门已经通过", - "由 Runtime 在收束时核对当前 revision", - "缺少 resourceId 时不得提交最终回复", - ] { - assert!( - director_prompt.contains(expected), - "art director prompt missing {expected}" - ); - } - - let art_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( - "art-asset-plan", - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - ); - for expected in [ - "资产清单和美术计划只是中间结果", - "canvas.asset_generate", - "assets/manifest.art.json", - "assets/art-spritesheet.png", - "固定使用 1:1、1K", - "assetKind=art-spritesheet", - "用 asset.list 确认 assets/art-spec.png 已登记", - "由 Runtime 形成 iconDescriptions", - "权威 resourceId 作为 referenceId", - "POST /api/external/v1/editor/icon-spritesheets/generations", - "screenColor=auto", - "不得回退普通生图", - "回读 observation 与 asset.list", - "真实 alpha", - "warning.code=postprocess-failed-source-preserved", - "不得登记、验收或自动重试", - "仅 sliceWarning", - "已有有效同路径资产时不得重复生成或扣费", - "asset.list", - "不得运行 game.static_smoke 或 preview.validate", - "不得编辑 game/index.html", - "透明证据不足时不得提交最终回复", - ] { - assert!( - art_prompt.contains(expected), - "art visual prompt missing {expected}" - ); - } - - let ordinary_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( - "quality-review", - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - ); - assert!(!ordinary_prompt.contains("assets/ui-prototype.png")); - assert!(!ordinary_prompt.contains("assets/art-spritesheet.png")); -} - #[test] fn visual_prompts_degrade_to_text_contracts_without_editor_api_key() { let _config_guard = crate::tests::write_test_local_config("{}".to_string()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs index 8e28db619..bb710da12 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs @@ -2330,7 +2330,7 @@ async fn executed_project_verification_audit_failure_requires_reconciliation() { #[test] fn project_verification_gate_accepts_only_exact_game_static_smoke() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "精确静态验证项目").expect("project init"); + init_existing_html_project_at(&root, "project-1", "精确静态验证项目").expect("project init"); advance_project_revision_for_test(&root, "playtest", "playtest-run", "file.write"); fs::write( root.join("game/index.html"), @@ -2536,7 +2536,7 @@ fn agent_runtime_project_verify_summary_hashes_command_without_head_or_tail() { #[test] fn limited_local_command_runs_static_game_smoke_and_writes_log() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); fs::write( root.join("game/index.html"), fake_llm_game_draft().game_html, @@ -2866,7 +2866,7 @@ async fn project_verification_runs_without_prepost_and_records_failure_and_timeo #[test] fn limited_local_command_appends_playtest_to_existing_trace() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); fs::write( root.join("game/index.html"), fake_llm_game_draft().game_html, @@ -2915,7 +2915,7 @@ fn limited_local_command_appends_playtest_to_existing_trace() { #[test] fn limited_local_command_rejects_placeholder_game_smoke() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); let error = run_limited_local_command_at(&root, "game.static_smoke") .expect_err("placeholder game should fail smoke"); @@ -2927,7 +2927,7 @@ fn limited_local_command_rejects_placeholder_game_smoke() { #[test] fn limited_local_command_rejects_forbidden_runtime_apis() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); let html = fake_llm_game_draft() .game_html .replace("const marker =", "fetch('/secret');\n const marker ="); @@ -2943,7 +2943,7 @@ fn limited_local_command_rejects_forbidden_runtime_apis() { #[test] fn limited_local_command_rejects_blank_canvas_game_smoke() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); let html = r#" @@ -2970,7 +2970,8 @@ fn limited_local_command_rejects_blank_canvas_game_smoke() { #[test] fn limited_local_command_rejects_invalid_javascript_before_surface_checks() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "脚本语法优先验证测试").expect("project init"); + init_existing_html_project_at(&root, "project-1", "脚本语法优先验证测试") + .expect("project init"); let html = r#" @@ -2994,7 +2995,7 @@ fn limited_local_command_rejects_invalid_javascript_before_surface_checks() { #[test] fn limited_local_command_rejects_token_rich_truncated_script() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "截断游戏入口测试").expect("project init"); + init_existing_html_project_at(&root, "project-1", "截断游戏入口测试").expect("project init"); let html = r#" 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 07d7b9d55..429fa96e2 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 @@ -776,6 +776,7 @@ fn app_config_commands_write_runtime_config_file() { .expect("read persisted runtime config"); assert!(!persisted.contains("editorApi")); assert!(!persisted.contains("editor-key")); + assert!(!root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME).exists()); let read_back = read_game_creator_app_config().expect("read runtime config"); assert_eq!(read_back.config.llm.model, "runtime-model"); @@ -807,6 +808,99 @@ fn app_config_commands_write_runtime_config_file() { fs::remove_dir_all(root).expect("cleanup runtime config dir"); } +#[test] +fn app_config_batch_restores_main_when_overlay_write_fails() { + for main_exists in [false, true] { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("config dir"); + let main = root.join(GAME_CREATOR_CONFIG_FILE_NAME); + let overlay = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME); + let original = "{\"llm\":{\"stream\":false}}\n"; + if main_exists { + fs::write(&main, original).expect("main config"); + } + fs::write(&overlay, original).expect("overlay config"); + // 普通目录占据备份路径,让覆盖文件在替换前失败。 + fs::create_dir(root.join(format!(".{}.previous", GAME_CREATOR_LOCAL_CONFIG_FILE_NAME))) + .expect("block overlay replacement"); + crate::config::write_game_creator_config_batch(&[ + (main.clone(), "{\"llm\":{\"stream\":true}}\n".to_string()), + (overlay.clone(), "{\"llm\":{\"stream\":true}}\n".to_string()), + ]) + .expect_err("overlay replacement must fail"); + if main_exists { + assert_eq!(fs::read_to_string(&main).expect("restored main"), original); + } else { + assert!(!main.exists()); + } + assert_eq!( + fs::read_to_string(&overlay).expect("unchanged overlay"), + original + ); + fs::remove_dir_all(root).expect("cleanup config dir"); + } +} + +#[test] +fn app_config_save_updates_conflicting_local_overlay() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("config dir"); + let _guard = use_test_runtime_config_dir(root.clone()); + let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME); + fs::write( + &overlay_path, + r#"{"llm":{"reasoningEffort":"low"},"selectedModelId":"existing","selectedModelIsDefault":true,"custom":{"keep":true}}"#, + ) + .expect("write overlay"); + let mut config = load_game_creator_app_config().expect("load config"); + config.llm.reasoning_effort = "high".to_string(); + config.selected_model_is_default = false; + let saved = write_game_creator_app_config(config).expect("save config"); + let effective = load_game_creator_app_config().expect("effective config"); + assert_eq!(saved.config.llm.reasoning_effort, "high"); + assert_eq!(effective.llm.reasoning_effort, "high"); + assert!(saved.config.selected_model_is_default); + assert!(effective.selected_model_is_default); + let overlay: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&overlay_path).expect("read overlay")) + .expect("parse overlay"); + assert_eq!(overlay["selectedModelId"], "existing"); + assert_eq!(overlay["selectedModelIsDefault"], true); + assert_eq!( + overlay["llm"], + serde_json::json!({"reasoningEffort": "high"}) + ); + assert_eq!(overlay["custom"]["keep"], true); + fs::remove_dir_all(root).expect("cleanup config dir"); +} + +#[test] +fn app_config_model_selection_only_updates_model_overlay() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("config dir"); + let _guard = use_test_runtime_config_dir(root.clone()); + let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME); + fs::write( + &overlay_path, + r#"{"selectedModelId":"old","selectedModelIsDefault":false,"llm":{"reasoningEffort":"low"}}"#, + ) + .expect("write overlay"); + let saved = select_game_creator_model("new-model".to_string(), true).expect("select model"); + let effective = load_game_creator_app_config().expect("effective config"); + assert_eq!(saved.config.selected_model_id, "new-model"); + assert_eq!(effective.selected_model_id, "new-model"); + assert!(saved.config.selected_model_is_default); + assert!(effective.selected_model_is_default); + let overlay: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&overlay_path).expect("read overlay")) + .expect("parse overlay"); + assert_eq!( + overlay["llm"], + serde_json::json!({"reasoningEffort": "low"}) + ); + fs::remove_dir_all(root).expect("cleanup config dir"); +} + #[test] fn model_selection_default_flag_round_trips() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/goal.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/goal.rs index da15d08a6..6f2b06cb6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/goal.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/goal.rs @@ -1944,7 +1944,7 @@ fn validate_llm_game_draft_accepts_win_condition_as_goal() { #[tokio::test] async fn agent_run_resume_restarts_generation_from_latest_goal() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); write_agent_run_trace( &root, "run-control-resume", diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index bf2635753..c2186bacd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -299,6 +299,22 @@ impl Drop for TestRuntimeConfigDirGuard { } } +fn init_existing_html_project_at( + root: &Path, + project_id: &str, + name: &str, +) -> Result { + // 已有单 HTML 项目在初始化前就有入口,不能使用新建 npm 项目的脚手架。 + let game_dir = root.join("game"); + fs::create_dir_all(&game_dir).expect("create existing HTML fixture directory"); + fs::write( + game_dir.join("index.html"), + "还没有生成游戏", + ) + .expect("write existing HTML fixture entry"); + init_local_game_project_at(root, project_id, name) +} + fn unique_project_path() -> PathBuf { let millis = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -3706,52 +3722,6 @@ fn ui_prototype_assessment_fixture(passed: bool) -> String { .to_string() } -fn append_ui_prototype_inspection_fixture(root: &Path, run_id: &str, passed: bool) { - append_ui_prototype_inspection_fixture_with_profile( - root, - run_id, - passed, - AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, - ); -} - -fn append_ui_prototype_inspection_fixture_with_profile( - root: &Path, - run_id: &str, - passed: bool, - validation_profile: &str, -) { - let image_bytes = - fs::read(root.join(AGENT_RUNTIME_UI_PROTOTYPE_PATH)).expect("read UI prototype fixture"); - let image_sha256 = format!("{:x}", Sha256::digest(&image_bytes)); - let issues = if passed { - Vec::::new() - } else { - vec!["只有战场场景和来袭箭头,缺少资源栏、单位卡槽、波次状态与主要控件".to_string()] - }; - append_agent_db_record( - root, - serde_json::json!({ - "recordType": "agent.runtime.image.inspect", - "agentId": "design-foundation", - "runId": run_id, - "images": [{ - "path": AGENT_RUNTIME_UI_PROTOTYPE_PATH, - "sha256": image_sha256, - "bytes": image_bytes.len(), - }], - "responseId": "resp_ui_prototype_fixture", - "conclusionChars": 20, - "inspectionKind": AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, - "validationProfile": validation_profile, - "passed": passed, - "checks": ui_prototype_checks_fixture(passed), - "issues": issues, - }), - ) - .expect("append UI prototype inspection fixture"); -} - fn supervisor_collaboration_delegate_action_for_test( agent_id: &str, repair_of_delegation_id: Option<&str>, 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 ca5ce863e..c65090353 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 @@ -550,7 +550,7 @@ fn canonical_visual_completion_requires_persisted_route_kind_and_current_spec_re #[tokio::test] async fn background_agent_runtime_can_start_local_preview() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + init_existing_html_project_at(&root, "project-1", "月光厨房").expect("project init"); fs::write( root.join("game/index.html"), fake_llm_game_draft().game_html, @@ -1289,6 +1289,7 @@ async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_m #[tokio::test] async fn generate_local_game_draft_fails_after_max_passes_without_final_artifacts() { let root = unique_project_path(); + init_existing_html_project_at(&root, "project-1", "HTML fixture").expect("project init"); let mut invalid_draft = fake_llm_game_draft(); invalid_draft.game_html = r#" @@ -2590,7 +2591,7 @@ fn local_project_file_commands_read_write_list_and_delete_text_files() { #[test] fn local_project_export_package_uses_runtime_whitelist_and_records() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) .expect("write playable html"); write_local_project_file_at(&root, "assets/hero.txt", "hero asset").expect("write asset"); @@ -2709,7 +2710,7 @@ fn local_project_export_package_list_skips_symlink_packages() { #[test] fn local_project_export_package_rejects_symlink_assets() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) .expect("write playable html"); write_local_project_file_at(&root, "exports/README.md", "playtest notes") @@ -2731,7 +2732,7 @@ fn local_project_export_package_rejects_symlink_assets() { #[test] fn local_project_export_package_rejects_symlink_runtime_dirs_and_readme() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) .expect("write playable html"); write_local_project_file_at(&root, "memory/project.md", "private memory") @@ -2763,7 +2764,7 @@ fn local_project_export_package_rejects_symlink_runtime_dirs_and_readme() { #[test] fn local_project_export_package_requires_playable_html_and_readme() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); let missing_readme = export_local_project_package_at(&root).expect_err("default html is not playable"); assert!(missing_readme.contains("游戏入口必须包含可渲染画布")); @@ -3640,7 +3641,7 @@ fn cli_agent_resume_without_appdata_fails_before_runtime_dispatch() { #[test] fn local_preview_server_serves_game_index() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); fs::write(root.join("assets/player.png"), b"PNGDATA").expect("asset"); let (preview, stop) = start_local_game_preview_for_project(&root).expect("preview start"); @@ -4619,7 +4620,7 @@ fn local_preview_bridge_injection_stays_outside_unclosed_inert_html_contexts() { #[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"); + init_existing_html_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"); @@ -4699,7 +4700,7 @@ fn preview_content_type_covers_common_game_assets() { #[test] fn local_preview_head_preserves_asset_content_length() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); fs::write(root.join("assets/player.png"), b"PNGDATA").expect("asset"); let response = build_preview_response(&root, "HEAD", "/assets/player.png"); @@ -4741,7 +4742,7 @@ fn local_preview_project_revision_reports_the_current_atomic_sidecar() { #[test] fn local_preview_start_rejects_a_stale_validated_revision_atomically() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "预览 revision 启动门禁测试") + init_existing_html_project_at(&root, "project-1", "预览 revision 启动门禁测试") .expect("project init"); let revision = advance_project_revision_for_test( &root, @@ -4768,7 +4769,7 @@ fn local_preview_start_rejects_a_stale_validated_revision_atomically() { #[test] fn stale_preview_cleanup_does_not_stop_a_newer_matching_project_server() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "预览原子停止测试").expect("project init"); + init_existing_html_project_at(&root, "project-1", "预览原子停止测试").expect("project init"); let registry = PreviewRegistry::default(); let (first, first_stop) = start_local_game_preview_for_project(&root).expect("first preview start"); @@ -4799,7 +4800,8 @@ fn stale_preview_cleanup_does_not_stop_a_newer_matching_project_server() { #[test] fn stale_preview_cleanup_cannot_be_blocked_by_project_stop_policy() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "预览补偿清理策略测试").expect("project init"); + init_existing_html_project_at(&root, "project-1", "预览补偿清理策略测试") + .expect("project init"); write_project_permission_policy_at( &root, ProjectPermissionPolicy { @@ -4826,6 +4828,7 @@ fn stale_preview_cleanup_cannot_be_blocked_by_project_stop_policy() { #[test] fn local_preview_serves_generated_playable_game() { let root = unique_project_path(); + init_existing_html_project_at(&root, "project-1", "HTML fixture").expect("project init"); write_local_game_draft_at(&root, "像素风横版动作", &fake_llm_game_draft()) .expect("draft should generate"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index 6175d5e76..f1ce4887a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -98,6 +98,7 @@ async fn request_llm_game_draft_uses_openai_compatible_provider_output() { #[tokio::test] async fn generate_local_game_draft_sends_asset_context_to_llm() { let root = unique_project_path(); + init_existing_html_project_at(&root, "project-1", "HTML fixture").expect("project init"); let uploaded = upload_local_asset_at(&root, "../角色.png", "image/png", b"fake-png") .expect("asset upload"); append_local_conversation_message_at( @@ -1344,7 +1345,7 @@ async fn provider_action_batch_agent_db_failure_does_not_advance_cursor() { #[tokio::test] async fn provider_action_batch_mutation_then_verification_rolls_forward_gate() { let root = unique_project_path(); - init_local_game_project_at( + init_existing_html_project_at( &root, "project-provider-batch-mutation-verification", "Provider 批次修改后验证测试", @@ -5864,7 +5865,7 @@ async fn background_agent_runtime_cancellation_wins_over_inflight_llm_error() { #[tokio::test] async fn agent_loop_uses_per_agent_llm_overrides() { let root = unique_project_path(); - init_local_game_project_at(&root, "local-project-draft", "未命名游戏原型") + init_existing_html_project_at(&root, "local-project-draft", "未命名游戏原型") .expect("init project"); let (planner_sender, planner_receiver) = mpsc::channel(); let planner_base_url = spawn_mock_llm_server_responses_with_capture( @@ -6081,7 +6082,7 @@ async fn agent_role_briefs_run_same_wave_llm_agents_in_parallel() { #[tokio::test] async fn agent_loop_writes_spec_findings_and_retries_generator() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + init_existing_html_project_at(&root, "project-1", "月光厨房").expect("project init"); let mut first_draft = fake_llm_game_draft(); first_draft.game_html = r#" diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs index 32a9c3ec9..9f1410fe2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs @@ -980,180 +980,6 @@ async fn provider_retry_final_reply_thinking_only_response_retries_before_handof fs::remove_dir_all(config_dir).ok(); } -#[test] -fn visual_specialist_finalization_requires_existing_registered_canvas_image() { - let _platform_session = crate::platform_session::install_test_platform_session( - "visual-finalization-test-user", - "visual-finalization-test-key", - "https://dev.genarrative.world", - ); - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"visual-finalization-test-key"}}"#.to_string(), - ); - for (agent_id, local_path, kind) in [ - ( - "design-foundation", - "assets/ui-prototype.png", - "ui-prototype", - ), - ( - "art-asset-plan", - "assets/art-spritesheet.png", - "art-spritesheet", - ), - ] { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "视觉 Runtime 完成门禁测试") - .expect("project init"); - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); - let run_id = format!("visual-finalization-{agent_id}"); - let state = start_game_creator_agent_runtime_task_at( - &root, - agent_id, - "必须交付真实图片", - &run_id, - "agent-background-task", - "准备完成", - vec!["生成并登记图片".to_string()], - ) - .expect("start visual runtime"); - let revision = read_game_creator_agent_runtime_project_revision(&root) - .expect("read visual revision") - .revision; - - let missing_file = finish_game_creator_agent_background_runtime_turn_at( - &root, - state.clone(), - "不能在缺图时完成", - revision, - &[], - ) - .expect("missing image is recoverable blocker"); - let blocker = match missing_file { - AgentBackgroundFinalizationOutcome::Stale(blocker) => blocker, - _ => panic!("missing image must block finalization"), - }; - assert_eq!(blocker.tool, "runtime.visual_asset"); - assert!(blocker.summary.contains("不能完成任务")); - assert!(blocker - .detail - .as_deref() - .is_some_and(|detail| detail.contains(local_path))); - - let absolute_path = root.join(local_path); - fs::create_dir_all(absolute_path.parent().expect("visual parent")) - .expect("create unregistered visual directory"); - fs::write( - &absolute_path, - if kind == "art-spritesheet" { - transparent_test_png_bytes() - } else { - valid_test_png_bytes() - }, - ) - .expect("write unregistered visual image"); - let unregistered = finish_game_creator_agent_background_runtime_turn_at( - &root, - state.clone(), - "不能在图片未登记时完成", - revision, - &[], - ) - .expect("unregistered image is recoverable blocker"); - assert!(matches!( - unregistered, - AgentBackgroundFinalizationOutcome::Stale(ref blocker) - if blocker.tool == "runtime.visual_asset" - )); - - register_local_asset_at( - &root, - local_path, - kind, - "image/png", - "canvas", - GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Canvas, - canvas_project_id: Some("canvas-project-1".to_string()), - resource_id: Some(format!("resource-{kind}")), - asset_object_id: Some(format!("asset-object-{kind}")), - task_id: Some(format!("task-{kind}")), - prompt: Some("测试视觉资产".to_string()), - model: Some("gpt-image-2".to_string()), - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - ) - .expect("register legacy canvas image"); - let legacy = finish_game_creator_agent_background_runtime_turn_at( - &root, - state.clone(), - "旧文件不能冒充正式视觉产物", - revision, - &[], - ) - .expect("legacy image is a recoverable blocker"); - assert!(matches!( - legacy, - AgentBackgroundFinalizationOutcome::Stale(ref blocker) - if blocker.tool == "runtime.visual_asset" - && blocker.detail.as_deref().is_some_and(|detail| detail.contains("legacy")) - )); - register_local_asset_at( - &root, - local_path, - kind, - "image/png", - "canvas", - GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Canvas, - canvas_project_id: Some("canvas-project-1".to_string()), - resource_id: Some(format!("resource-{kind}")), - asset_object_id: Some(format!("asset-object-{kind}")), - task_id: Some(format!("task-{kind}")), - prompt: Some("测试视觉资产".to_string()), - model: Some("gpt-image-2".to_string()), - generation_route: Some( - if kind == "ui-prototype" { - "/api/external/v1/editor/images/generations" - } else { - "/api/external/v1/editor/icon-spritesheets/generations" - } - .to_string(), - ), - generation_kind: Some( - if kind == "ui-prototype" { - "ui-design" - } else { - "icon-spritesheet" - } - .to_string(), - ), - reference_resource_ids: vec!["resource-icon-spec".to_string()], - }, - ) - .expect("register provenanced canvas image"); - if agent_id == "design-foundation" { - append_ui_prototype_inspection_fixture(&root, &run_id, true); - } - let completed = finish_game_creator_agent_background_runtime_turn_at( - &root, - state, - "真实图片已经生成并登记。", - revision, - &[], - ) - .expect("registered image allows finalization"); - assert!(matches!( - completed, - AgentBackgroundFinalizationOutcome::Completed(_) - )); - - fs::remove_dir_all(root).ok(); - } -} - #[test] fn background_finalization_rechecks_stale_credential_before_assistant_persistence() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs index b4dd0ec94..762b67983 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs @@ -1,4 +1,5 @@ use super::support::*; +use crate::tests::init_existing_html_project_at; #[test] fn agent_runtime_agent_message_uses_durable_semantic_idempotency() { @@ -539,7 +540,7 @@ async fn background_agent_runtime_can_read_other_agent_status() { #[tokio::test] async fn background_agent_runtime_can_run_limited_static_smoke() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + init_existing_html_project_at(&root, "project-1", "月光厨房").expect("project init"); let playable_game_html = fake_llm_game_draft().game_html; write_project_permission_policy_at( &root, @@ -1549,49 +1550,6 @@ async fn background_agent_runtime_deletes_file_then_verifies_before_completion() fs::remove_dir_all(root).ok(); } -#[tokio::test] -async fn art_asset_plan_cannot_delete_registered_fixed_visual_asset() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "固定美术资产删除门禁").expect("project init"); - register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: Vec::new(), - confirm_commands: Vec::new(), - agent_policies: BTreeMap::new(), - }, - ) - .expect("allow delete tools"); - - let action = AgentRuntimeToolAction { - tool: "file.delete".to_string(), - reason: Some("尝试删除已登记的固定美术素材".to_string()), - input: serde_json::json!({ "path": "assets/art-spritesheet.png" }), - }; - let observation = execute_game_creator_agent_runtime_tool_action_with_action_id( - &root, - "art-asset-plan", - "art-delete-protection-run", - "已登记的固定美术素材必须复用", - &action, - Some("art-delete-protection-action"), - ) - .await; - - assert_eq!(observation.status, "blocked"); - assert!(observation.summary.contains("禁止删除固定正式产物")); - assert!(root.join("assets/art-spritesheet.png").is_file()); - assert_eq!( - read_game_creator_agent_runtime_project_revision(&root) - .expect("read revision") - .revision, - 0 - ); - - fs::remove_dir_all(root).ok(); -} - #[tokio::test] async fn design_foundation_file_tools_cannot_modify_the_game_entry() { 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 b77477437..a85f25171 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 @@ -3989,137 +3989,6 @@ async fn background_task_recovers_when_assistant_message_cannot_persist() { fs::remove_dir_all(root).ok(); } -#[tokio::test] -async fn task_update_requires_registered_visual_asset_before_completion() { - let _platform_session = crate::platform_session::install_test_platform_session( - "visual-task-update-test-user", - "visual-task-update-test-key", - "https://dev.genarrative.world", - ); - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"visual-task-update-test-key"}}"#.to_string(), - ); - for (task_id, local_path, kind, missing_summary) in [ - ( - "art-director", - "assets/art-spec.png", - "icon-spec", - "统一视觉规范图尚未按正式视觉流程生成并登记", - ), - ( - "design-foundation", - "assets/ui-prototype.png", - "ui-prototype", - "策划界面原型图尚未按正式视觉流程生成并登记", - ), - ( - "art-asset-plan", - "assets/art-spritesheet.png", - "art-spritesheet", - "首版美术素材图尚未按正式视觉流程生成并登记", - ), - ] { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "视觉任务完成门禁测试") - .expect("project init"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: Vec::new(), - confirm_commands: Vec::new(), - agent_policies: BTreeMap::new(), - }, - ) - .expect("allow task update"); - let run_id = format!("visual-task-update-{task_id}"); - let action = AgentRuntimeToolAction { - tool: "task.update".to_string(), - reason: Some("标记视觉任务完成".to_string()), - input: serde_json::json!({ - "taskId": task_id, - "status": "completed" - }), - }; - let missing = execute_game_creator_agent_runtime_tool_action_with_action_id( - &root, - task_id, - &run_id, - "完成视觉任务", - &action, - Some("visual-task-update-missing"), - ) - .await; - assert_eq!(missing.status, "failed"); - assert!(missing.summary.contains(missing_summary)); - let manifest = read_manifest_for_project(&root).expect("manifest after rejected update"); - assert_eq!( - manifest - .tasks - .iter() - .find(|task| task.id == task_id) - .expect("visual task") - .status, - GameCreationAppTaskStatus::Pending - ); - - if task_id != "art-director" { - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); - } - if task_id == "art-asset-plan" { - register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); - } - register_canvas_visual_asset_fixture(&root, local_path, kind); - if task_id == "design-foundation" { - append_ui_prototype_inspection_fixture(&root, &run_id, false); - let scene_rejected = execute_game_creator_agent_runtime_tool_action_with_action_id( - &root, - task_id, - &run_id, - "拒绝用场景图完成 UI 原型任务", - &action, - Some("visual_task_update_scene_rejected"), - ) - .await; - assert_eq!(scene_rejected.status, "failed"); - assert!(scene_rejected.summary.contains("结构化 UI 视觉检查")); - let manifest = - read_manifest_for_project(&root).expect("manifest after rejected scene image"); - assert_eq!( - manifest - .tasks - .iter() - .find(|task| task.id == task_id) - .expect("design visual task") - .status, - GameCreationAppTaskStatus::Pending - ); - append_ui_prototype_inspection_fixture(&root, &run_id, true); - } - let completed = execute_game_creator_agent_runtime_tool_action_with_action_id( - &root, - task_id, - &run_id, - "完成视觉任务", - &action, - Some("visual-task-update-completed"), - ) - .await; - assert_eq!(completed.status, "ok", "{completed:?}"); - let manifest = read_manifest_for_project(&root).expect("manifest after visual completion"); - assert_eq!( - manifest - .tasks - .iter() - .find(|task| task.id == task_id) - .expect("visual task") - .status, - GameCreationAppTaskStatus::Completed - ); - - fs::remove_dir_all(root).ok(); - } -} - #[test] fn schedule_ready_manifest_tasks_command_requires_auto_policy() { 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 b9092b0fb..a59915adc 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 @@ -675,6 +675,7 @@ async fn background_agent_runtime_file_and_memory_writes_respect_project_policy( #[test] fn generate_local_game_draft_writes_memory_design_and_game() { let root = unique_project_path(); + init_existing_html_project_at(&root, "project-1", "HTML fixture").expect("project init"); let draft = fake_llm_game_draft(); let result = write_local_game_draft_at(&root, "像素风横版动作