diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index d0c2877d2..ca5ce15c3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -84,7 +84,8 @@ fn game_creator_codex_cli_executable_candidates() -> Vec { } fn game_creator_codex_cli_version_at(executable: &Path) -> Result { - let output = std::process::Command::new(executable) + let mut command = crate::new_windows_background_std_command(executable); + let output = command .arg("--version") .stdin(Stdio::null()) .stderr(Stdio::null()) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs index 3fd2ab07a..33c6cd649 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs @@ -394,6 +394,84 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( }; } }; + let durable_receipt = match read_autonomous_playtest_receipt(root, contract) { + Ok(Some(receipt)) if receipt.revision == revision_after.revision => receipt, + Ok(Some(_)) => { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器试玩已通过,但持久回执不属于当前 revision".to_string(), + detail: None, + }; + } + Ok(None) => { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器试玩已通过,但持久回执回读失败".to_string(), + detail: None, + }; + } + Err(error) => { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器试玩已通过,但持久回执无法验证".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + }; + { + let _project_lock = + match acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "preview.validate.initial-version", + ) { + Ok(lock) => lock, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器试玩已通过,但首个可玩版本暂时无法登记".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + }; + let locked_revision = match read_game_creator_agent_runtime_project_revision(root) { + Ok(revision) if revision.revision == durable_receipt.revision => revision, + Ok(revision) => { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器试玩回执落盘后项目 revision 已变化,未登记旧版本" + .to_string(), + detail: Some(format!( + "receiptRevision={}, currentRevision={}", + durable_receipt.revision, revision.revision + )), + }; + } + Err(error) => { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器试玩已通过,但登记版本前无法复核项目 revision" + .to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + }; + if let Err(error) = + ensure_initial_game_iteration_version_at(root, locked_revision.revision) + { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器试玩已通过,但首个可玩版本无法登记".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + } if contract_belongs_to_runtime { if let Err(error) = clear_agent_runtime_failed_playtest_at( root, 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 04ce73338..4404da0e7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -373,7 +373,8 @@ pub(crate) fn check_game_creator_codex_cli_available() -> Result<(), String> { fn check_game_creator_codex_app_server_available() -> Result<(), String> { let executable = crate::agent::game_creator_codex_cli_executable_path()?; - let output = std::process::Command::new(executable) + let mut command = crate::new_windows_background_std_command(executable); + let output = command .args(["app-server", "--help"]) .stdin(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 2d533e865..81681f082 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -32,6 +32,7 @@ use shared_contracts::game_creation_app::{ GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor, GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState, GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus, + GameIterationVersion, GameIterationVersionCreatedReason, GameIterationVersionResourceBinding, ProjectResourceCanvasLayout, ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition, UpdateProjectResourceCanvasLayoutResult, UpdateProjectResourceCanvasLayoutStatus, GAME_CREATION_AGENT_CAPABILITIES, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index c078f5b02..85c449ad9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -371,6 +371,53 @@ pub(crate) fn read_existing_manifest_for_project( Ok(manifest) } +/// Registers the first formally playable project version after the current +/// revision has produced a durable successful browser-playtest receipt. +/// Replays are idempotent: once any formal version exists, validation never +/// rewrites or appends another initial record. +pub(crate) fn ensure_initial_game_iteration_version_at( + root: &Path, + project_revision: u64, +) -> Result { + if project_revision == 0 { + return Err("首个可玩版本必须绑定大于 0 的项目 revision".to_string()); + } + let (manifest_path, mut manifest) = read_or_create_manifest(root)?; + if !manifest.versions.is_empty() { + return Ok(false); + } + let resource_bindings = manifest + .assets + .iter() + .map(|asset| GameIterationVersionResourceBinding { + slot_id: format!("asset:{}", asset.id), + resource_id: asset.id.clone(), + }) + .collect(); + manifest.versions.push(GameIterationVersion { + version_id: format!("initial-{project_revision}"), + parent_version_id: None, + project_revision, + resource_bindings, + created_reason: GameIterationVersionCreatedReason::Initial, + created_at: unix_timestamp(), + edit_prompt: None, + }); + match write_manifest(&manifest_path, &manifest) { + Ok(()) => Ok(true), + Err(error) => { + // Another writer may have committed the same logical transition + // after our read. Treat an installed formal version as a replay; + // every other storage failure remains visible to the Runtime. + if read_manifest(&manifest_path).is_ok_and(|current| !current.versions.is_empty()) { + Ok(false) + } else { + Err(error) + } + } + } +} + pub(crate) fn ensure_manifest_has_seed_tasks( root: &Path, goal: Option<&str>, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs index f4fceaf1c..0e6bdd66e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs @@ -63,6 +63,58 @@ fn version_fixture( } } +#[test] +fn successful_first_playable_registration_creates_one_initial_version_with_asset_bindings() { + let root = unique_manifest_test_root("first-playable-version"); + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest = new_game_creation_app_manifest("project-first-playable", "首板项目"); + manifest.assets.push(GameCreationAppAssetManifestEntry { + id: "asset-player".to_string(), + kind: "character".to_string(), + media_type: "image/png".to_string(), + local_path: "assets/player.png".to_string(), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: Some("art-asset-plan".to_string()), + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + }); + write_manifest(&manifest_path, &manifest).expect("write first playable manifest"); + + let created = ensure_initial_game_iteration_version_at(&root, 7) + .expect("register verified first playable"); + assert!(created); + let replayed = ensure_initial_game_iteration_version_at(&root, 7) + .expect("replay verified first playable registration"); + assert!(!replayed); + + let installed = read_manifest(&manifest_path).expect("read versioned manifest"); + assert_eq!(installed.versions.len(), 1); + assert_eq!(installed.versions[0].version_id, "initial-7"); + assert_eq!(installed.versions[0].parent_version_id, None); + assert_eq!(installed.versions[0].project_revision, 7); + assert_eq!( + installed.versions[0].created_reason, + GameIterationVersionCreatedReason::Initial + ); + assert_eq!( + installed.versions[0].resource_bindings, + vec![GameIterationVersionResourceBinding { + slot_id: "asset:asset-player".to_string(), + resource_id: "asset-player".to_string(), + }] + ); + + fs::remove_dir_all(root).ok(); +} + #[test] fn manifest_versions_are_append_only_at_the_storage_boundary() { let root = unique_manifest_test_root("versions-append-only"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/windows.rs b/apps/ai-game-creator-shell/src-tauri/src/windows.rs index b2bc08304..ce93187e4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/windows.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/windows.rs @@ -41,6 +41,15 @@ pub(crate) fn configure_windows_background_std_command( ) { } +pub(crate) fn new_windows_background_std_command(program: S) -> std::process::Command +where + S: AsRef, +{ + let mut command = std::process::Command::new(program); + configure_windows_background_std_command(&mut command, false); + command +} + pub(crate) fn configure_windows_background_tokio_command( command: &mut tokio::process::Command, create_process_group: bool, @@ -48,6 +57,64 @@ pub(crate) fn configure_windows_background_tokio_command( configure_windows_background_std_command(command.as_std_mut(), create_process_group); } +#[cfg(all(test, windows))] +mod windows_background_command_tests { + use super::*; + use std::fs; + use std::process::Stdio; + + const CHILD_ENV: &str = "GENARRATIVE_WINDOWS_NO_CONSOLE_CHILD"; + const RESULT_ENV: &str = "GENARRATIVE_WINDOWS_NO_CONSOLE_RESULT"; + const FIXTURE_TEST: &str = + "windows::windows_background_command_tests::background_command_console_fixture"; + + #[test] + #[ignore = "child-process fixture"] + fn background_command_console_fixture() { + if std::env::var_os(CHILD_ENV).is_none() { + return; + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetConsoleWindow() -> windows_sys::Win32::Foundation::HWND; + } + let result_path = std::env::var_os(RESULT_ENV).expect("result path"); + let has_console_window = unsafe { !GetConsoleWindow().is_null() }; + fs::write( + result_path, + if has_console_window { + "console" + } else { + "hidden" + }, + ) + .expect("write console-window result"); + } + + #[test] + fn background_std_command_does_not_allocate_a_console_window() { + let directory = tempfile::tempdir().expect("create no-console test directory"); + let result_path = directory.path().join("console-window.txt"); + let mut command = new_windows_background_std_command( + std::env::current_exe().expect("current test binary"), + ); + command + .args(["--exact", FIXTURE_TEST, "--ignored"]) + .env(CHILD_ENV, "1") + .env(RESULT_ENV, &result_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let status = command.status().expect("run no-console child fixture"); + assert!(status.success(), "no-console child fixture must succeed"); + assert_eq!( + fs::read_to_string(result_path).expect("read console-window result"), + "hidden", + "CREATE_NO_WINDOW must keep background command probes from flashing a console window" + ); + } +} + #[cfg(all(windows, feature = "game-chat-release"))] pub(crate) fn configure_windows_suspended_background_std_command( command: &mut std::process::Command, 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 88d45c55c..8cfb64d71 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 @@ -413,16 +413,59 @@ export function agentRuntimeStateFromResult( result: AgentRuntimeResult, previous?: AgentRuntimeState | null, ): AgentRuntimeState { + const acceptedRunId = result.acceptedRunId?.trim(); + const acceptedTask = acceptedRunId + ? (result.recentTasks ?? result.state.recentTasks ?? []).find( + (task) => task.runId === acceptedRunId, + ) + : null; + const state = + acceptedTask && result.state.runId !== acceptedRunId + ? { + ...result.state, + agentId: acceptedTask.agentId, + taskId: acceptedTask.taskId, + sessionId: acceptedTask.sessionId, + runId: acceptedTask.runId, + source: acceptedTask.source, + parentAgentId: acceptedTask.parentAgentId ?? null, + parentRunId: acceptedTask.parentRunId ?? null, + delegationId: acceptedTask.delegationId ?? null, + goalId: acceptedTask.goalId ?? null, + goalRevision: acceptedTask.goalRevision ?? 0, + goalStatus: acceptedTask.goalStatus ?? null, + currentTask: acceptedTask.task, + currentGoal: acceptedTask.task, + status: acceptedTask.status, + phase: acceptedTask.phase, + currentAction: acceptedTask.currentAction, + waitingOn: agentRuntimeWaitingOnFromPhase(acceptedTask.phase), + nextStep: agentRuntimeNextStepFromPhase(acceptedTask.phase), + plan: [], + planRevision: undefined, + planExplanation: undefined, + planSteps: [], + activePlanStepIndex: null, + observations: [], + recentToolCalls: [], + pendingToolAction: null, + userInputRequest: null, + lastResponse: null, + error: acceptedTask.error, + startedAt: acceptedTask.updatedAt, + updatedAt: acceptedTask.updatedAt, + } + : result.state; return normalizeAgentRuntimeState( { - ...result.state, - taskQueue: result.taskQueue ?? result.state.taskQueue, - recentEvents: result.recentEvents ?? result.state.recentEvents, - recentTasks: result.recentTasks ?? result.state.recentTasks, + ...state, + taskQueue: result.taskQueue ?? state.taskQueue, + recentEvents: result.recentEvents ?? state.recentEvents, + recentTasks: result.recentTasks ?? state.recentTasks, userInputRequest: result.userInputRequest !== undefined ? result.userInputRequest - : result.state.userInputRequest, + : state.userInputRequest, }, previous, ); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 220a31edf..963ba78d4 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -120,6 +120,16 @@ export function ProjectSupervisorView({ value={chatInput} placeholder="告诉项目总控接下来要做什么" onChange={(event) => onChatInputChange(event.currentTarget.value)} + onKeyDown={(event) => { + if ( + event.key === 'Enter' && + !event.shiftKey && + !event.nativeEvent.isComposing + ) { + event.preventDefault(); + event.currentTarget.form?.requestSubmit(); + } + }} />