diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index 41f155eb7..6e225ac31 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -11,7 +11,7 @@ "autoCompactTokenLimit": 64000, "toolOutputTokenLimit": 12000, "requestTimeoutMs": 180000, - "maxRetries": 0, + "maxRetries": 2, "retryBackoffMs": 500 }, "agentLlm": {}, diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 879b1dbab..e2982bbc3 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -4,8 +4,8 @@ "version": "0.1.0", "type": "module", "scripts": { - "dev": "npm --prefix ../.. exec tauri -- dev", - "game-chat": "npm --prefix ../.. exec tauri -- dev -- -- --game-chat", + "dev": "node scripts/start-tauri-dev.mjs", + "game-chat": "node scripts/start-tauri-dev.mjs --game-chat", "dev-server": "node scripts/start-dev-server.mjs", "dev-stack": "node scripts/start-dev-stack.mjs", "build": "npm --prefix ../.. exec tauri -- build", diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 2f858797a..df99a183c 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -452,10 +452,7 @@ async function runConfigWizardRegressionChecks() { assert.equal(fs.existsSync(missingLinkedConfigDir), false); assert.equal( await assertSafeGameCreatorConfigDestination(missingLinkedConfigDir), - path.join( - fs.realpathSync.native(realConfigAncestor), - 'missing-appdata', - ), + path.join(fs.realpathSync.native(realConfigAncestor), 'missing-appdata'), ); const gitRoot = path.join(testRoot, 'tracked-repository'); @@ -1260,6 +1257,21 @@ if ( ); } +if (packageConfig.scripts?.dev !== 'node scripts/start-tauri-dev.mjs') { + throw new Error( + 'AI game creator shell dev must run through the managed Tauri dev launcher', + ); +} + +if ( + packageConfig.scripts?.['game-chat'] !== + 'node scripts/start-tauri-dev.mjs --game-chat' +) { + throw new Error( + 'AI game creator shell game-chat must run through the managed Tauri dev launcher', + ); +} + const gameChatInitialUrlApply = 'apply_game_chat_initial_window_url(tauri_context.config_mut(), options)'; const gameChatInitialUrlApplyIndexes = Array.from( diff --git a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs index 2a3f86b18..98bf22ef3 100644 --- a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs @@ -120,7 +120,7 @@ export function deterministicLaneDefenseInitialHtml() { *{box-sizing:border-box}body{margin:0;min-height:100vh;background:#f4f8ee;color:#18351f;font:16px system-ui,sans-serif}main{width:min(960px,100%);margin:auto;padding:18px}h1{margin:0 0 4px;font-size:clamp(28px,7vw,46px)}p{margin:4px 0 14px}.toolbar,.plants{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0}button{min-height:44px;border:1px solid #315d35;background:#fff;color:#18351f;padding:9px 14px;font:inherit;font-weight:700;cursor:pointer}button:hover{background:#e6f3dc}.board{display:grid;gap:10px;background:#d9edc8;border:2px solid #315d35;padding:10px}.lane{display:grid;grid-template-columns:repeat(5,1fr);gap:6px}.cell{min-height:54px;background:#eef8e7}.status{font-weight:700;min-height:24px}#game{display:none;width:100%;height:auto;aspect-ratio:20/9;background:#18351f;border:2px solid #315d35}@media(max-width:520px){main{padding:12px}button{flex:1 1 44%}.cell{min-height:44px}} -
+
Garden defenders

灵露花园

GENARRATIVE_REAL_E2E_VISIBLE

Goal: defend the garden and win every wave.

diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 91bf6d1b2..81fae0bee 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -1,6 +1,7 @@ import { spawn } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import http from 'node:http'; +import net from 'node:net'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -134,6 +135,21 @@ async function readExistingViteServer() { return httpGetText(viteUrl); } +function isVitePortListening() { + return new Promise((resolveRequest) => { + const socket = net.connect({ host: viteHost, port: vitePort }); + socket.once('connect', () => { + socket.destroy(); + resolveRequest(true); + }); + socket.once('error', () => resolveRequest(false)); + socket.setTimeout(1000, () => { + socket.destroy(); + resolveRequest(true); + }); + }); +} + function isAiGameCreatorServer(response) { return ( response && @@ -144,17 +160,6 @@ function isAiGameCreatorServer(response) { ); } -async function isExistingViteProxyReady() { - const response = await httpGetText(`${viteUrl}api/auth/me`, 2000); - return Boolean( - response && - response.statusCode >= 200 && - response.statusCode < 500 && - !response.body.includes('AI 游戏创作') && - !response.body.includes('/src/main.tsx'), - ); -} - async function readExistingViteMarker() { const response = await httpGetText(viteMarkerUrl, 2000); if (!response || response.statusCode !== 200) { @@ -167,24 +172,49 @@ async function readExistingViteMarker() { } } -async function isExistingVitePairedWithBackend(apiTarget) { - const marker = await readExistingViteMarker(); - return Boolean( - marker && - marker.schemaVersion === 1 && - marker.app === 'ai-game-creator-shell' && - marker.apiTarget === apiTarget, +async function preflightExistingVite({ + readServer = readExistingViteServer, + portListening = isVitePortListening, + readMarker = readExistingViteMarker, +} = {}) { + const existing = await readServer(); + if (!existing) { + if (await portListening()) { + throw new Error( + `${viteUrl} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`, + ); + } + return { status: 'available', apiTarget: '' }; + } + + if (!isAiGameCreatorServer(existing)) { + throw new Error( + `${viteUrl} is already in use by another server. Stop it before starting Tauri dev.`, + ); + } + + const marker = await readMarker(); + const markerApiTarget = + marker?.schemaVersion === 1 && + marker?.app === 'ai-game-creator-shell' && + typeof marker?.apiTarget === 'string' + ? marker.apiTarget + : ''; + const actualTarget = markerApiTarget || 'unknown'; + throw new Error( + `${viteUrl} is already running with API target ${actualTarget}. Its owning worktree cannot be proven, so it will not be reused. Stop that Vite dev server before starting Tauri dev.`, ); } function spawnChild(command, args, options, spawnImpl = spawn) { - const useShell = process.platform === 'win32'; + const isPosix = process.platform !== 'win32'; + const useShell = options.shell ?? !isPosix; const child = spawnImpl(command, args, { ...options, shell: useShell, // POSIX 下让每个长驻服务拥有独立进程组,退出时可以连同 npm、 // Node、Cargo 及其子进程一起清理,避免残留订阅任务持续刷日志。 - detached: !useShell, + detached: isPosix, stdio: 'inherit', }); const lifecycle = { @@ -192,7 +222,7 @@ function spawnChild(command, args, options, spawnImpl = spawn) { promise: null, // detached 子进程在 POSIX 下以自身 PID 作为 PGID。leader 退出后 // child.pid 仍是清理其后代的唯一稳定句柄,必须随生命周期保留。 - processGroupId: !useShell && Number.isInteger(child.pid) ? child.pid : null, + processGroupId: isPosix && Number.isInteger(child.pid) ? child.pid : null, }; lifecycle.promise = new Promise((resolveLifecycle) => { child.once('error', (error) => { @@ -270,6 +300,142 @@ function stopChild(child, signal = 'SIGTERM') { } } +function isProcessGroupAlive(processGroupId, killImpl = process.kill) { + if (!Number.isInteger(processGroupId)) { + return false; + } + try { + killImpl(-processGroupId, 0); + return true; + } catch (error) { + return error?.code !== 'ESRCH'; + } +} + +async function waitUntil(check, timeoutMs, pollIntervalMs = 25) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await check()) { + return true; + } + await new Promise((resolveWait) => setTimeout(resolveWait, pollIntervalMs)); + } + return check(); +} + +function runWindowsTaskkill( + processId, + { spawnImpl = spawn, timeoutMs = 5000 } = {}, +) { + return new Promise((resolveRequest) => { + const taskkill = spawnImpl( + 'taskkill.exe', + ['/PID', String(processId), '/T', '/F'], + { + shell: false, + stdio: 'ignore', + windowsHide: true, + }, + ); + let settled = false; + const timeout = setTimeout(() => { + try { + taskkill.kill('SIGKILL'); + } catch { + // ignore taskkill timeout races + } + finish({ timedOut: true, code: null, error: null }); + }, timeoutMs); + const finish = (result) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + resolveRequest(result); + }; + taskkill.once('error', (error) => + finish({ timedOut: false, code: null, error }), + ); + taskkill.once('exit', (code) => + finish({ timedOut: false, code: code ?? 0, error: null }), + ); + }); +} + +async function terminateChildTree( + child, + { + platform = process.platform, + gracefulTimeoutMs = 2500, + forceTimeoutMs = 2000, + killImpl = process.kill, + taskkillImpl = runWindowsTaskkill, + } = {}, +) { + if (!child) { + return { stopped: true, forced: false }; + } + + if (platform === 'win32') { + if (!Number.isInteger(child.pid)) { + stopChild(child, 'SIGTERM'); + return { stopped: true, forced: false }; + } + const result = await taskkillImpl(child.pid); + return { + stopped: + !result?.timedOut && + !result?.error && + [0, 128].includes(result?.code ?? 0), + forced: true, + result, + }; + } + + const processGroupId = childLifecycles.get(child)?.processGroupId; + if (!Number.isInteger(processGroupId)) { + stopChild(child, 'SIGTERM'); + const lifecycle = childLifecycles.get(child); + if (lifecycle) { + await Promise.race([ + lifecycle.promise, + new Promise((resolveWait) => + setTimeout(resolveWait, gracefulTimeoutMs), + ), + ]); + } + if (child.exitCode == null && child.signalCode == null) { + stopChild(child, 'SIGKILL'); + return { stopped: false, forced: true }; + } + return { stopped: true, forced: false }; + } + + stopChild(child, 'SIGTERM'); + if ( + await waitUntil( + () => !isProcessGroupAlive(processGroupId, killImpl), + gracefulTimeoutMs, + ) + ) { + return { stopped: true, forced: false }; + } + + try { + killImpl(-processGroupId, 'SIGKILL'); + } catch (error) { + if (error?.code !== 'ESRCH') { + return { stopped: false, forced: true, error }; + } + } + const stopped = await waitUntil( + () => !isProcessGroupAlive(processGroupId, killImpl), + forceTimeoutMs, + ); + return { stopped, forced: true }; +} + async function waitForBackendReady(backendChild, timeoutMs = 600_000) { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { @@ -340,19 +506,9 @@ async function startVite(apiTarget) { const existing = await readExistingViteServer(); if (existing) { - if ( - isAiGameCreatorServer(existing) && - (await isExistingVitePairedWithBackend(apiTarget)) && - (await isExistingViteProxyReady()) - ) { - console.log( - `[ai-game-creator-shell] reuse existing Vite dev server ${viteUrl}`, - ); - return null; - } if (isAiGameCreatorServer(existing)) { throw new Error( - `${viteUrl} is already running, but its /api proxy is not connected to the paired backend. Stop it before starting Tauri dev.`, + `${viteUrl} is already running and cannot be safely reused. Stop it before starting Tauri dev.`, ); } throw new Error( @@ -384,6 +540,7 @@ async function main() { } try { + await preflightExistingVite(); const backend = await ensureBackend({ onBackendChild(child) { backendChild = child; @@ -422,6 +579,10 @@ async function main() { ); return 1; } finally { + await Promise.all([ + terminateChildTree(viteChild), + terminateChildTree(backendChild), + ]); for (const [signal, handler] of signalHandlers) { process.off(signal, handler); } @@ -439,10 +600,13 @@ export { ensureBackend, formatChildFailure, isDirectModuleExecution, + preflightExistingVite, readChildFailure, resolveBackendTargetsFromState, + runWindowsTaskkill, spawnChild, stopChild, + terminateChildTree, waitForBackendReady, waitForChildTermination, }; diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs new file mode 100644 index 000000000..5c6bba17e --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -0,0 +1,128 @@ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + preflightExistingVite, + spawnChild, + stopChild, + terminateChildTree, + waitForChildTermination, +} from './start-dev-stack.mjs'; + +const appRoot = fileURLToPath(new URL('..', import.meta.url)); +const repoRoot = resolve(appRoot, '../..'); +const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js'); + +function parseLauncherArguments(argv) { + const args = [...argv]; + const gameChat = args[0] === '--game-chat'; + if (gameChat) { + args.shift(); + } + return { gameChat, args }; +} + +function buildTauriArguments(argv) { + const { gameChat, args } = parseLauncherArguments(argv); + if (gameChat) { + return ['dev', '--', '--', '--game-chat', ...args]; + } + return ['dev', ...args]; +} + +function spawnTauriCli(argv) { + return spawnChild(process.execPath, [tauriCliPath, ...argv], { + cwd: appRoot, + shell: false, + }); +} + +async function runTauriDev( + argv = process.argv.slice(2), + { + preflight = preflightExistingVite, + spawnCli = spawnTauriCli, + waitForCli = waitForChildTermination, + terminateTree = terminateChildTree, + } = {}, +) { + await preflight(); + + const tauriArguments = buildTauriArguments(argv); + const child = spawnCli(tauriArguments); + let resolveShutdown; + let shutdownSignal = ''; + let repeatedSignal = false; + const shutdownRequested = new Promise((resolveRequest) => { + resolveShutdown = resolveRequest; + }); + const signalHandlers = new Map(); + + for (const signal of ['SIGINT', 'SIGTERM']) { + const handler = () => { + if (!shutdownSignal) { + shutdownSignal = signal; + stopChild(child, 'SIGTERM'); + resolveShutdown(signal); + return; + } + repeatedSignal = true; + stopChild(child, 'SIGKILL'); + }; + signalHandlers.set(signal, handler); + process.on(signal, handler); + } + + try { + const childResult = waitForCli(child); + const outcome = await Promise.race([ + childResult.then((failure) => ({ type: 'exit', failure })), + shutdownRequested.then((signal) => ({ type: 'signal', signal })), + ]); + const cleanup = await terminateTree(child, { + gracefulTimeoutMs: repeatedSignal ? 0 : 2500, + }); + if (!cleanup.stopped) { + console.error( + '[ai-game-creator-shell] Tauri dev exited, but its process tree could not be fully stopped.', + ); + return 1; + } + + if (outcome.type === 'signal') { + return 1; + } + const { failure } = outcome; + return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0); + } finally { + for (const [signal, handler] of signalHandlers) { + process.off(signal, handler); + } + } +} + +function isDirectModuleExecution() { + return Boolean( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url), + ); +} + +export { + buildTauriArguments, + isDirectModuleExecution, + parseLauncherArguments, + runTauriDev, + spawnTauriCli, +}; + +if (isDirectModuleExecution()) { + try { + process.exitCode = await runTauriDev(); + } catch (error) { + console.error( + `[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/role_briefs.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/role_briefs.rs index c15f1bee5..2139ddfe1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/role_briefs.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/role_briefs.rs @@ -367,10 +367,26 @@ pub(crate) fn has_game_creator_agent_llm_override( config: &GameCreatorAppConfig, agent_id: &str, ) -> bool { - config - .agent_llm - .get(agent_id) - .is_some_and(|patch| !is_empty_game_creator_llm_patch(patch)) + config.agent_llm.get(agent_id).is_some_and(|patch| { + if is_empty_game_creator_llm_patch(patch) { + return false; + } + let only_canonical_reasoning_default = patch.api_key.is_none() + && patch.base_url.is_none() + && patch.model.is_none() + && patch.api_kind.is_none() + && patch.stream.is_none() + && patch.web_search_enabled.is_none() + && patch.context_window_tokens.is_none() + && patch.auto_compact_token_limit.is_none() + && patch.tool_output_token_limit.is_none() + && patch.request_timeout_ms.is_none() + && patch.max_retries.is_none() + && patch.retry_backoff_ms.is_none() + && patch.reasoning_effort.as_deref() + == game_creator_llm_agent_default_reasoning_effort(agent_id); + !only_canonical_reasoning_default + }) } pub(crate) async fn request_agent_role_brief_with_config( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index 2149ad925..cbaaae623 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -283,7 +283,7 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ &action_fingerprint, pending_action, false, - || observe_agent_runtime_task_list(root), + || observe_agent_runtime_task_list(root, agent_id, run_id), ), "task.create" => observe_agent_runtime_task_create(root, agent_id, &action.input), "task.update" => observe_agent_runtime_task_update(root, agent_id, &action.input), @@ -414,7 +414,9 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ action_id, &action.input, ), - "agent.schedule_ready" => observe_agent_runtime_schedule_ready_tasks(root, &action.input), + "agent.schedule_ready" => { + observe_agent_runtime_schedule_ready_tasks(root, agent_id, run_id, &action.input) + } "agent.action_history" => observe_agent_runtime_project_snapshot_with_lock( root, agent_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index eaee84273..8c8c4f998 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 @@ -262,11 +262,15 @@ fn autonomous_initial_delegate_expected_artifacts( pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_contract( plan: &AgentRuntimeToolPlan, ) -> Result<(), String> { - let mut code_prototype = None; - let mut quality_review = None; + let mut design_director = None; let mut art_director = None; - let mut art_asset_plan = None; + let mut code_director = None; for action in &plan.actions { + if action.tool.trim() == "agent.spawn_isolated" { + return Err(autonomous_initial_collaboration_contract_error( + "首批只允许激活 design-director、art-director、code-director,不得启动 isolated child", + )); + } let Some(input) = autonomous_initial_delegate_input(action)? else { continue; }; @@ -284,11 +288,14 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_ )); }; let slot = match target_agent_id { - "code-prototype" => &mut code_prototype, - AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID => &mut quality_review, + "design-director" => &mut design_director, "art-director" => &mut art_director, - "art-asset-plan" => &mut art_asset_plan, - _ => continue, + "code-director" => &mut code_director, + _ => { + return Err(autonomous_initial_collaboration_contract_error(format!( + "首批只允许激活 design-director、art-director、code-director,不得委派底层 Agent:{target_agent_id}" + ))); + } }; if slot.replace(input).is_some() { return Err(autonomous_initial_collaboration_contract_error(format!( @@ -297,111 +304,80 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_ } } - let code_prototype = code_prototype.ok_or_else(|| { - autonomous_initial_collaboration_contract_error("首批缺少 code-prototype 委派") + let design_director = design_director.ok_or_else(|| { + autonomous_initial_collaboration_contract_error("首批缺少 design-director 委派") })?; - let code_task = autonomous_initial_delegate_task(code_prototype, "code-prototype")?; - let code_criteria = agent_runtime_tool_input_string_list( - &serde_json::Value::Object(code_prototype.clone()), + let design_task = autonomous_initial_delegate_task(design_director, "design-director")?; + let design_criteria = agent_runtime_tool_input_string_list( + &serde_json::Value::Object(design_director.clone()), &["acceptanceCriteria", "acceptance_criteria", "criteria"], ); - if std::iter::once(code_task.as_str()) + if !std::iter::once(design_task.as_str()) + .chain(design_criteria.iter().map(String::as_str)) + .any(agent_runtime_task_explicitly_requires_read_only_delivery) + { + return Err(autonomous_initial_collaboration_contract_error( + "首批 design-director task 或 acceptanceCriteria 必须显式声明只读且不得修改项目", + )); + } + let design_artifacts = + autonomous_initial_delegate_expected_artifacts(design_director, "design-director")?; + if !design_artifacts.is_empty() { + return Err(autonomous_initial_collaboration_contract_error( + "首批 design-director 的 expectedArtifacts 必须为 []", + )); + } + + let art_director = art_director.ok_or_else(|| { + autonomous_initial_collaboration_contract_error("首批缺少 art-director 委派") + })?; + let art_task = autonomous_initial_delegate_task(art_director, "art-director")?; + let art_criteria = agent_runtime_tool_input_string_list( + &serde_json::Value::Object(art_director.clone()), + &["acceptanceCriteria", "acceptance_criteria", "criteria"], + ); + if std::iter::once(art_task.as_str()) + .chain(art_criteria.iter().map(String::as_str)) + .any(agent_runtime_task_explicitly_requires_read_only_delivery) + { + return Err(autonomous_initial_collaboration_contract_error( + "首批 art-director 必须是非只读规范图生成任务", + )); + } + let art_artifacts = + autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?; + if !art_artifacts + .iter() + .any(|path| path == "assets/art-spec.png") + { + return Err(autonomous_initial_collaboration_contract_error( + "首批 art-director 的 expectedArtifacts 必须包含 assets/art-spec.png", + )); + } + + let code_director = code_director.ok_or_else(|| { + autonomous_initial_collaboration_contract_error("首批缺少 code-director 委派") + })?; + let code_task = autonomous_initial_delegate_task(code_director, "code-director")?; + let code_criteria = agent_runtime_tool_input_string_list( + &serde_json::Value::Object(code_director.clone()), + &["acceptanceCriteria", "acceptance_criteria", "criteria"], + ); + if !std::iter::once(code_task.as_str()) .chain(code_criteria.iter().map(String::as_str)) .any(agent_runtime_task_explicitly_requires_read_only_delivery) { return Err(autonomous_initial_collaboration_contract_error( - "首批 code-prototype 必须是非只读实现任务", + "首批 code-director task 或 acceptanceCriteria 必须显式声明只读且不得修改项目", )); } let code_artifacts = - autonomous_initial_delegate_expected_artifacts(code_prototype, "code-prototype")?; - if !code_artifacts - .iter() - .any(|path| path == AGENT_RUNTIME_GAME_INDEX_PATH) - { - return Err(autonomous_initial_collaboration_contract_error(format!( - "首批 code-prototype 的 expectedArtifacts 必须包含 {AGENT_RUNTIME_GAME_INDEX_PATH}" - ))); - } - - let quality_review = quality_review.ok_or_else(|| { - autonomous_initial_collaboration_contract_error("首批缺少 quality-review 委派") - })?; - let quality_task = - autonomous_initial_delegate_task(quality_review, AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID)?; - let quality_criteria = agent_runtime_tool_input_string_list( - &serde_json::Value::Object(quality_review.clone()), - &["acceptanceCriteria", "acceptance_criteria", "criteria"], - ); - if !std::iter::once(quality_task.as_str()) - .chain(quality_criteria.iter().map(String::as_str)) - .any(agent_runtime_task_explicitly_requires_read_only_delivery) - { + autonomous_initial_delegate_expected_artifacts(code_director, "code-director")?; + if !code_artifacts.is_empty() { return Err(autonomous_initial_collaboration_contract_error( - "首批 quality-review task 或 acceptanceCriteria 必须显式声明只读且不得修改项目", + "首批 code-director 的 expectedArtifacts 必须为 []", )); } - let quality_artifacts = autonomous_initial_delegate_expected_artifacts( - quality_review, - AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID, - )?; - if !quality_artifacts.is_empty() { - return Err(autonomous_initial_collaboration_contract_error( - "首批 quality-review 的 expectedArtifacts 必须为 []", - )); - } - if let Some(art_director) = art_director { - let art_task = autonomous_initial_delegate_task(art_director, "art-director")?; - let art_criteria = agent_runtime_tool_input_string_list( - &serde_json::Value::Object(art_director.clone()), - &["acceptanceCriteria", "acceptance_criteria", "criteria"], - ); - if std::iter::once(art_task.as_str()) - .chain(art_criteria.iter().map(String::as_str)) - .any(agent_runtime_task_explicitly_requires_read_only_delivery) - { - return Err(autonomous_initial_collaboration_contract_error( - "首批 art-director 必须是非只读规范图生成任务", - )); - } - let art_artifacts = - autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?; - if !art_artifacts - .iter() - .any(|path| path == "assets/art-spec.png") - { - return Err(autonomous_initial_collaboration_contract_error( - "首批 art-director 的 expectedArtifacts 必须包含 assets/art-spec.png", - )); - } - } - if let Some(art_asset_plan) = art_asset_plan { - let art_task = autonomous_initial_delegate_task(art_asset_plan, "art-asset-plan")?; - let art_criteria = agent_runtime_tool_input_string_list( - &serde_json::Value::Object(art_asset_plan.clone()), - &["acceptanceCriteria", "acceptance_criteria", "criteria"], - ); - if std::iter::once(art_task.as_str()) - .chain(art_criteria.iter().map(String::as_str)) - .any(agent_runtime_task_explicitly_requires_read_only_delivery) - { - return Err(autonomous_initial_collaboration_contract_error( - "首批 art-asset-plan 必须是非只读美术生成任务", - )); - } - let art_artifacts = - autonomous_initial_delegate_expected_artifacts(art_asset_plan, "art-asset-plan")?; - let missing_artifacts = ["assets/manifest.art.json", "assets/art-spritesheet.png"] - .into_iter() - .filter(|required| !art_artifacts.iter().any(|path| path == required)) - .collect::>(); - if !missing_artifacts.is_empty() { - return Err(autonomous_initial_collaboration_contract_error(format!( - "首批 art-asset-plan 的 expectedArtifacts 缺少:{}", - missing_artifacts.join(", ") - ))); - } - } Ok(()) } @@ -560,7 +536,34 @@ pub(in crate::agent) fn autonomous_manifest_dag_in_progress_at( root: &Path, ) -> Result { let manifest = read_manifest_for_project(root)?; - let seed_task_ids = new_game_creation_app_seed_tasks() + let source = read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .ok() + .filter(|runtime| { + runtime.state.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && agent_runtime_supervisor_source_is_trusted(&runtime.state.source) + }) + .map(|runtime| runtime.state.source) + .or_else(|| { + let path = game_creator_agent_runtime_task_path( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ); + read_all_game_creator_agent_runtime_tasks(&path) + .ok() + .map(latest_game_creator_agent_runtime_tasks) + .and_then(|records| { + records.into_iter().rev().find_map(|record| { + (record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && record.parent_run_id.is_none() + && agent_runtime_supervisor_source_is_trusted(&record.source)) + .then_some(record.source) + }) + }) + }) + .ok_or_else(|| { + "无法解析当前自主构建根 Run 的可信 source,拒绝按 GUI 完整 DAG 回退".to_string() + })?; + let seed_task_ids = autonomous_manifest_seed_tasks_for_source(&source) .into_iter() .map(|task| task.id) .collect::>(); @@ -1479,6 +1482,24 @@ pub(in crate::agent) fn restrict_agent_runtime_supervisor_collaboration_repair_t Ok(()) } +pub(in crate::agent) fn restrict_agent_runtime_autonomous_initial_collaboration_repair_tools( + request: &mut LlmRunRequest, +) -> Result<(), String> { + let delegate_function = native_runtime_function_name("agent.delegate") + .ok_or_else(|| "无法生成 autonomous 首批协作修复工具名:agent.delegate".to_string())?; + request + .function_tools + .retain(|tool| tool.name == delegate_function); + if !request + .function_tools + .iter() + .any(|tool| tool.name == delegate_function) + { + return Err("autonomous 首批协作修复工具目录缺少 agent.delegate".to_string()); + } + Ok(()) +} + pub(in crate::agent) fn agent_runtime_protocol_error_requires_supervisor_collaboration_repair( error: &str, ) -> bool { @@ -1495,6 +1516,93 @@ pub(in crate::agent) fn agent_runtime_protocol_error_requires_supervisor_collabo mod tests { use super::*; + fn autonomous_initial_delegate( + agent_id: &str, + expected_artifacts: &[&str], + ) -> AgentRuntimeToolAction { + let read_only_planner = matches!(agent_id, "design-director" | "code-director"); + AgentRuntimeToolAction { + tool: "agent.delegate".to_string(), + reason: Some("建立首批 Leader 规划".to_string()), + input: serde_json::json!({ + "agentId": agent_id, + "task": if read_only_planner { + format!("由 {agent_id} 只读完成首轮专业规划,不得修改项目") + } else { + format!("由 {agent_id} 完成首轮专业交付") + }, + "acceptanceCriteria": if read_only_planner { + vec!["只读给出可供后续底层 Agent 按需执行的规划,不得修改项目"] + } else { + vec!["视觉规范可供后续底层 Agent 按需执行"] + }, + "expectedArtifacts": expected_artifacts, + "repairOfDelegationId": null, + "runId": null, + }), + } + } + + fn autonomous_initial_leader_plan() -> AgentRuntimeToolPlan { + AgentRuntimeToolPlan { + thinking_summary: "首批只激活程策美 Leader".to_string(), + plan_update: None, + plan: Vec::new(), + actions: vec![ + autonomous_initial_delegate("design-director", &[]), + autonomous_initial_delegate("art-director", &["assets/art-spec.png"]), + autonomous_initial_delegate("code-director", &[]), + ], + response: String::new(), + } + } + + #[test] + fn autonomous_initial_collaboration_accepts_only_three_leaders() { + validate_agent_runtime_autonomous_initial_collaboration_contract( + &autonomous_initial_leader_plan(), + ) + .expect("three leader initial contract"); + } + + #[test] + fn autonomous_initial_collaboration_rejects_bottom_agent() { + let mut plan = autonomous_initial_leader_plan(); + plan.actions[2] = + autonomous_initial_delegate("code-prototype", &[AGENT_RUNTIME_GAME_INDEX_PATH]); + + let error = validate_agent_runtime_autonomous_initial_collaboration_contract(&plan) + .expect_err("bottom agent must be rejected"); + + assert!(error.contains("不得委派底层 Agent:code-prototype")); + } + + #[test] + fn autonomous_initial_collaboration_rejects_isolated_child() { + let mut plan = autonomous_initial_leader_plan(); + plan.actions.push(AgentRuntimeToolAction { + tool: "agent.spawn_isolated".to_string(), + reason: None, + input: serde_json::json!({"children": [], "joinMode": "all"}), + }); + + let error = validate_agent_runtime_autonomous_initial_collaboration_contract(&plan) + .expect_err("isolated child must be rejected"); + + 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_actions/parallel_read.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs index 19de323b5..a85194f36 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs @@ -115,7 +115,7 @@ pub(in crate::agent) fn execute_game_creator_agent_runtime_parallel_safe_read_at "git.inspect" => observe_agent_runtime_git_inspect(root, &action.input), "file.list" => observe_agent_runtime_file_list(root, &action.input), "file.read" => observe_agent_runtime_file(root, &action.input), - "task.list" => observe_agent_runtime_task_list(root), + "task.list" => observe_agent_runtime_task_list(root, agent_id, run_id), _ => AgentRuntimeToolObservation { tool: tool.to_string(), status: "rejected".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index 18fc74e29..804deadb6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -655,6 +655,20 @@ pub(in crate::agent) fn supervisor_collaboration_policy_completion_blocker_at_lo if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { return None; } + if read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id) + .ok() + .flatten() + .is_some_and(|binding| { + binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + && binding.root_agent_id == binding.agent_id + && binding.root_run_id == binding.run_id + }) + { + // game-chat 首版由 source-aware manifest scheduler 固定编排 + // code -> static smoke -> playtest,不再要求 Provider 建立额外委派波。 + return None; + } let policy = match resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id) { Ok(resolution) => resolution.policy, Err(error) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index bc244bcba..383a7c415 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -2,6 +2,43 @@ use super::*; const AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL: &str = "通用完成阻断规则:如果最新 observation 的 tool 为 runtime.autonomous_completion 且 status 为 blocked,本轮禁止直接调用 respond_to_user,也禁止在 legacy response 中填写最终回复;必须先读取该 observation.detail 的 nextRequiredAction,并据此调用合适的读取、修复和验证工具。只有完成要求的动作、取得后续可信 observation 且完成门禁不再阻断后,才能给最终回复;不得反复提交 final response,也不得按项目正文硬编码某一种 blocker 的处理方式。"; +const GAME_CHAT_CODE_PROTOTYPE_FAST_PATH_PROMPT: &str = "game-chat 首版使用五分钟快车道。当前任务的第一目标是在一次 Provider planning 内产出首个完整可玩版本:如果最新 observation 尚未显示 game/index.html 已由本 run 写入,本响应必须直接调用一次 file.write,把完整、自包含、可运行的 game/index.html 一次写完;禁止先调用读取、搜索、任务查询、委派、只更新计划或提交半成品。HTML 必须满足下方固定试玩合同,包含真实 Canvas 游戏循环、键盘与触控输入、开始、主要操作、重开、胜负状态和移动端布局;可以采用保守的原创玩法默认值。已登记的平台视觉规范图 ../assets/art-spec.png 是首版必需资源,必须在主要游戏画面中显著可见使用:至少把规范图实际绘制为主要背景,并从规范图中绘制玩家角色和目标实体。禁止仅放置隐藏 img、透明或屏外元素、微小水印、不可见预加载或只在源码中引用;也禁止用纯几何图形冒充平台图片使用。若规范图无法加载,游戏必须明确失败关闭,不能退回纯 Canvas 几何兜底。一次写入后不要继续扩写功能;Runtime 会在下一步自动执行静态自检并在通过后立即试玩。"; + +fn game_chat_fast_path_prompt_for_root_source( + agent_id: &str, + root_source: &str, +) -> Option<&'static str> { + (agent_id.trim() == "code-prototype" + && root_source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE) + .then_some(GAME_CHAT_CODE_PROTOTYPE_FAST_PATH_PROMPT) +} + +pub(in crate::agent) fn agent_runtime_root_source_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + let binding = read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? + .ok_or_else(|| "Agent Runtime 缺少 Run Profile 绑定,无法解析 root source".to_string())?; + if binding.root_agent_id == binding.agent_id && binding.root_run_id == binding.run_id { + return Ok(binding.source); + } + let root_binding = read_game_creator_agent_runtime_run_profile_binding( + root, + &binding.root_agent_id, + &binding.root_run_id, + )? + .ok_or_else(|| "Agent Runtime 缺少 root Run Profile 绑定".to_string())?; + if root_binding.agent_id != binding.root_agent_id + || root_binding.run_id != binding.root_run_id + || root_binding.root_agent_id != root_binding.agent_id + || root_binding.root_run_id != root_binding.run_id + { + return Err("Agent Runtime root Run Profile 绑定身份不一致".to_string()); + } + Ok(root_binding.source) +} + fn game_creator_agent_context_preload_notice(agent_id: &str) -> &'static str { if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { "下方已预加载有界仓库启动上下文、Supervisor 当前 Session、legacy 项目对话、项目记忆、黑板和资产摘要;源码正文仍只能通过已获准工具读取" @@ -126,6 +163,11 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( system_prompt.push_str( "\n\n当前 Run Profile 为 autonomous-game-build。不得调用 user.input_request,也不得为了等待确认而中断;对不改变核心目标的缺失细节,直接采用可逆、保守且可试玩的默认值。只使用当前 autoTools 推进项目内实现、委派和验证,不得请求 project.git_commit、command.exec、command.start、command.stdin、command.terminate 或其他仍需确认的动作。Project Supervisor 必须持续编排到最小可玩闭环通过 Runtime 完成门禁;专业 Agent 必须完成自己的合同并把结果交回父 Run。", ); + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + system_prompt.push_str( + "\n\nautonomous-game-build 的正式 manifest 任务图是唯一首轮专业执行链。不得在 manifest 之前另行创建 code-prototype、quality-review、art-director、design-foundation 或 art-asset-plan 的首批 agent.delegate;这些角色会由 Runtime 按 manifest 依赖顺序调度。没有待认领的显式返工合同时也不得额外委派。请直接推进/观察 manifest,Runtime 会在你尝试收束时调度 ready task,并在任务图完成前阻止最终交付。", + ); + } system_prompt.push_str(&format!( "\n\n自主构建专业 Agent 在首次项目修改前最多允许 {AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT} 轮 planning 探索。达到上限后,本响应必须直接调用 file.write、file.patch、file.delete、project.patchset、project.restore、canvas.asset_generate 等实际项目修改工具;若当前专业合同确实只要求只读验收,则必须调用 respond_to_user 交付结论。不得继续只调用 update_agent_plan、读取、搜索、状态查询或空验证。" )); @@ -137,6 +179,15 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( system_prompt.push_str(playtest_contract); system_prompt.push_str(" 只有当前 revision 通过 game.static_smoke,并由 preview.validate 对上述固定状态面和控件完成真实浏览器动作后,Runtime 才允许最终回复;不要伪造已通过 observation。"); } + if autonomous_game_build { + let root_source = agent_runtime_root_source_at(root, agent_id, run_id)?; + if let Some(fast_path_prompt) = + game_chat_fast_path_prompt_for_root_source(agent_id, &root_source) + { + system_prompt.push_str("\n\n"); + system_prompt.push_str(fast_path_prompt); + } + } let mut request = LlmRunRequest::new(vec![ LlmMessage::system(system_prompt), LlmMessage::user(prompt), @@ -344,10 +395,13 @@ pub(in crate::agent) fn build_game_creator_background_agent_context( #[cfg(test)] mod tests { use super::{ + agent_runtime_root_source_at, bind_game_creator_agent_runtime_run_profile_at, build_game_creator_agent_background_tool_plan_request, - game_creator_agent_context_preload_notice, init_local_game_project_at, - start_game_creator_agent_runtime_task_at, GameCreatorMcpCatalog, - AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, + game_chat_fast_path_prompt_for_root_source, game_creator_agent_context_preload_notice, + init_local_game_project_at, start_game_creator_agent_runtime_task_at, AgentRuntimeTaskLink, + GameCreatorMcpCatalog, AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, }; @@ -451,4 +505,87 @@ mod tests { assert!(protocol.contains("不得反复提交 final response")); assert!(protocol.contains("不得按项目正文硬编码")); } + + #[test] + fn game_chat_fast_path_prompt_forces_one_shot_playable_write_only_for_code_agent() { + let prompt = game_chat_fast_path_prompt_for_root_source( + "code-prototype", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ) + .expect("game-chat code fast path prompt"); + + assert!(prompt.contains("五分钟快车道")); + assert!(prompt.contains("一次 Provider planning")); + assert!(prompt.contains("直接调用一次 file.write")); + assert!(prompt.contains("禁止先调用读取、搜索、任务查询、委派")); + assert!(prompt.contains("../assets/art-spec.png")); + assert!(prompt.contains("平台视觉规范图")); + assert!(prompt.contains("主要背景")); + assert!(prompt.contains("玩家角色和目标实体")); + assert!(prompt.contains("禁止仅放置隐藏 img")); + assert!(prompt.contains("不能退回纯 Canvas 几何兜底")); + assert!(!prompt.contains("art-spritesheet.png")); + assert!(game_chat_fast_path_prompt_for_root_source( + "quality-review", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ) + .is_none()); + assert!(game_chat_fast_path_prompt_for_root_source( + "code-prototype", + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + ) + .is_none()); + assert!(game_chat_fast_path_prompt_for_root_source( + "code-prototype", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + ) + .is_none()); + assert!(game_chat_fast_path_prompt_for_root_source( + "code-prototype", + "agent-background-task", + ) + .is_none()); + } + + #[test] + fn root_source_resolver_uses_root_binding_for_game_chat_child() { + let temporary = tempfile::tempdir().expect("temporary project root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "root-source-project", "root source test") + .expect("project init"); + let parent = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "root-source-game-chat-run", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind game-chat root profile"); + let child_link = AgentRuntimeTaskLink { + parent_agent_id: Some(parent.agent_id.clone()), + parent_run_id: Some(parent.run_id.clone()), + delegation_id: Some("root-source-game-chat-child-delegation".to_string()), + }; + let child = bind_game_creator_agent_runtime_run_profile_at( + &root, + "code-prototype", + "root-source-game-chat-child", + "agent-ready-task-scheduler", + None, + Some(&child_link), + ) + .expect("bind game-chat child profile"); + + assert_eq!( + agent_runtime_root_source_at(&root, &parent.agent_id, &parent.run_id) + .expect("resolve root source"), + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + ); + assert_eq!( + agent_runtime_root_source_at(&root, &child.agent_id, &child.run_id) + .expect("resolve child root source"), + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index 21336aba1..556316ca5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -52,7 +52,7 @@ fn supervisor_collaboration_missing_agent_ids(error: &str) -> Vec { .collect::>() }) .unwrap_or_default(); - for agent_id in ["code-prototype", "quality-review", "art-asset-plan"] { + for agent_id in ["design-director", "art-director", "code-director"] { if error.contains(&format!("缺少 {agent_id} 委派")) && !missing.iter().any(|value| value == agent_id) { @@ -62,6 +62,31 @@ fn supervisor_collaboration_missing_agent_ids(error: &str) -> Vec { missing } +fn is_autonomous_initial_leader_delegate_action(action: &AgentRuntimeToolAction) -> bool { + if action.tool.trim() != "agent.delegate" { + return false; + } + let Some(input) = action.input.as_object() else { + return false; + }; + if input + .get("repairOfDelegationId") + .or_else(|| input.get("repair_of_delegation_id")) + .and_then(serde_json::Value::as_str) + .is_some_and(|value| !value.trim().is_empty()) + { + return false; + } + matches!( + input + .get("agentId") + .or_else(|| input.get("agent_id")) + .and_then(serde_json::Value::as_str) + .map(str::trim), + Some("design-director" | "art-director" | "code-director") + ) +} + fn restrict_supervisor_collaboration_repair_to_missing_agents( request: &mut LlmRunRequest, error: &str, @@ -843,9 +868,22 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at if force_supervisor_initial_collaboration { supervisor_collaboration_repair_active = true; if let Some(actions) = supervisor_collaboration_candidate_actions.take() { - supervisor_collaboration_repair_actions = actions; + supervisor_collaboration_repair_actions = + if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + actions + .into_iter() + .filter(is_autonomous_initial_leader_delegate_action) + .collect() + } else { + actions + }; } restrict_agent_runtime_supervisor_collaboration_repair_tools(&mut request)?; + if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + restrict_agent_runtime_autonomous_initial_collaboration_repair_tools( + &mut request, + )?; + } restrict_supervisor_collaboration_repair_to_missing_agents( &mut request, &protocol_error, @@ -854,7 +892,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { format!( - "上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须在同一响应一次性建立完整首批合同:code-prototype 必须是非只读实现任务且 expectedArtifacts 包含 game/index.html;quality-review 的 task 必须显式声明只读、不得修改项目,且 expectedArtifacts 必须为 [];如当前 policy 的 requiredStaticAgentIds 包含 art-director,必须加入非只读规范图委派且 expectedArtifacts 包含 assets/art-spec.png;如包含 design-foundation,必须加入非只读设计委派且 expectedArtifacts 包含 memory/project.md、game/game_design.md 与 assets/ui-prototype.png;如包含 art-asset-plan,还必须加入非只读美术生成委派且 expectedArtifacts 同时包含 assets/manifest.art.json 与 assets/art-spritesheet.png。所有静态委派都使用 agent.delegate,repairOfDelegationId=null、runId=null;如当前 policy 还要求 isolated,再在同批补齐 agent.spawn_isolated。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。" + "上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留 agent.delegate。必须在同一响应一次性建立完整首批合同,且只允许以下三个非 repair 委派,各出现一次:design-director 与 code-director 的 task 或 acceptanceCriteria 必须显式声明只读且不得修改项目,expectedArtifacts 必须为 [];art-director 必须是非只读规范图生成任务,expectedArtifacts 必须包含 assets/art-spec.png。三者都必须提供非空 task、1-8 条 acceptanceCriteria,并设置 repairOfDelegationId=null、runId=null。不得委派 code-prototype、quality-review、design-foundation、art-asset-plan 或其它底层 Agent,不得调用 agent.spawn_isolated,不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。" ) } else { format!( @@ -1081,12 +1119,42 @@ mod supervisor_collaboration_repair_tests { .is_empty()); assert_eq!( supervisor_collaboration_missing_agent_ids( - "Project Supervisor 首批协作不满足项目合同:missingStaticAgents=code-prototype,quality-review · isolatedChildrenTotal=0" + "Project Supervisor 首批协作不满足项目合同:missingStaticAgents=design-director,art-director,code-director · isolatedChildrenTotal=0" ), - vec!["code-prototype".to_string(), "quality-review".to_string()] + vec![ + "design-director".to_string(), + "art-director".to_string(), + "code-director".to_string(), + ] ); } + #[test] + fn autonomous_initial_repair_keeps_only_leader_delegates() { + let actions = [ + collaboration_action( + "agent.delegate", + serde_json::json!({"agentId": "design-director", "repairOfDelegationId": null}), + ), + collaboration_action( + "agent.delegate", + serde_json::json!({"agentId": "code-prototype", "repairOfDelegationId": null}), + ), + collaboration_action( + "agent.spawn_isolated", + serde_json::json!({"children": [], "joinMode": "all"}), + ), + ]; + + let kept = actions + .iter() + .filter(|action| is_autonomous_initial_leader_delegate_action(action)) + .map(|action| action.input["agentId"].as_str().expect("leader id")) + .collect::>(); + + assert_eq!(kept, vec!["design-director"]); + } + #[test] fn isolated_repair_replaces_the_single_accumulated_slot() { let accumulated = vec![collaboration_action( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs index ccba17a4f..ba11e9fd4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs @@ -501,6 +501,90 @@ fn response_stream_finalization_commits_exactly_one_canonical_assistant() { assert_eq!(assistants, vec![response]); } +#[test] +fn non_stream_professional_final_reply_remains_queryable_after_later_project_revision() { + assert!( + !GameCreatorLlmConfig::default().stream, + "the production default exercises the non-stream final-reply path" + ); + let project = tempfile::tempdir().expect("create non-stream professional reply project"); + let root = project.path(); + init_local_game_project_at( + root, + "non-stream-professional-reply", + "非流式专业 Agent 回复", + ) + .expect("initialize non-stream professional reply project"); + let mut state = start_game_creator_agent_runtime_task_at( + root, + "art-director", + "生成 game-chat 首版统一视觉规范", + "non-stream-art-director-run", + "agent-delegate", + "整理专业 Agent 最终回复", + vec!["生成并登记统一视觉规范图".to_string()], + ) + .expect("start non-stream professional runtime"); + state.parent_agent_id = Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()); + state.parent_run_id = Some("game-chat-parent-run".to_string()); + state.delegation_id = Some("game-chat-art-director-delegation".to_string()); + state.loop_iteration = 1; + state.status = "running".to_string(); + state.phase = "response".to_string(); + state.current_action = "直接采用非流式最终回复".to_string(); + state.waiting_on = "finalization 持久化".to_string(); + state.next_step = "提交 durable response stream 投影".to_string(); + state.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &state) + .expect("append non-stream professional runtime task"); + write_game_creator_agent_runtime_state(root, &state) + .expect("write non-stream professional runtime state"); + + let response_revision = read_game_creator_agent_runtime_project_revision(root) + .expect("read non-stream response revision") + .revision; + assert!( + read_game_creator_agent_runtime_response_stream_at(root, &state.agent_id, &state.run_id,) + .expect("read absent pre-finalization response stream") + .is_none(), + "stream=false must enter finalization without a pre-existing stream sidecar" + ); + + let response = "美术 Agent:统一视觉规范图已生成并登记。"; + let completed = finish_game_creator_agent_background_runtime_turn_at( + root, + state.clone(), + response, + response_revision, + &[], + ) + .expect("finalize non-stream professional reply"); + assert!(matches!( + completed, + AgentBackgroundFinalizationOutcome::Completed(_) + )); + + let mut later_revision = read_game_creator_agent_runtime_project_revision(root) + .expect("read project revision before later stage mutation"); + later_revision.revision = later_revision.revision.saturating_add(1); + later_revision.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_project_revision(root, &later_revision) + .expect("simulate a later game-chat stage advancing project revision"); + + let queried = read_game_creator_agent_runtime_at(root, &state.agent_id) + .expect("query completed professional runtime after revision advance"); + let stream = queried + .response_stream + .expect("durable professional final reply remains queryable"); + assert_eq!( + stream.status, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED + ); + assert_eq!(stream.request_kind, "final-reply"); + assert_eq!(stream.response_revision, response_revision); + assert_eq!(stream.accumulated_text, response); +} + #[test] fn finalization_cleanup_closes_entire_tool_plan_repair_chain_before_removal() { let (project, state, response_revision, snapshot) = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index fa86b1a46..bb36a99d4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -100,6 +100,16 @@ pub(crate) const AGENT_RUNTIME_ISOLATED_CHILD_SOURCE: &str = "agent-isolated-chi pub(crate) const AGENT_RUNTIME_ISOLATED_JOIN_SOURCE: &str = "agent-isolated-join"; pub(crate) const AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE: &str = "project-supervisor-gui"; pub(crate) const AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE: &str = "project-supervisor-cli"; +pub(crate) const AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE: &str = "project-supervisor-game-chat"; + +pub(crate) fn agent_runtime_supervisor_source_is_trusted(source: &str) -> bool { + matches!( + source.trim(), + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE + | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE + | AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + ) +} pub(super) const AGENT_RUNTIME_RUN_PROFILE_BINDING_SCHEMA_VERSION: &str = "game-creator-run-profile-binding.v1"; pub(super) const AGENT_RUNTIME_AUTONOMOUS_COMPLETION_CONTRACT_SCHEMA_VERSION: &str = @@ -196,10 +206,13 @@ pub(super) struct AgentRuntimeAutonomousPlaytestReceipt { mod entrypoints; mod finalization; +mod game_chat_fast_path; mod interaction; mod lifecycle_control; mod main_loop; #[cfg(test)] +mod main_loop_deadline_tests; +#[cfg(test)] mod main_loop_tests; mod pending_execution; mod pending_recovery; @@ -210,6 +223,7 @@ mod task_start; pub(in crate::agent) use entrypoints::*; pub(in crate::agent) use finalization::*; +pub(in crate::agent) use game_chat_fast_path::*; pub(in crate::agent) use interaction::*; pub(in crate::agent) use lifecycle_control::*; pub(in crate::agent) use main_loop::*; @@ -268,6 +282,7 @@ pub(crate) use provider_recovery::{ }; pub(crate) use recovery_scan::{ cleanup_game_creator_agent_runtime_completed_finalizations_at, + has_recoverable_game_creator_agent_background_tasks_at, resume_game_creator_agent_background_tasks_at, resume_game_creator_agent_pending_action_for_agent_at, wake_pending_game_creator_agent_background_tasks_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs new file mode 100644 index 000000000..8a8f51cdc --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs @@ -0,0 +1,801 @@ +//! A deterministic, dependency-free game-chat fallback. +//! +//! This module deliberately does not start the runtime or write project files. It only +//! renders a small, self-contained HTML document that the runtime can use when it needs to +//! make a first playable version available before the normal generation pass finishes. + +use super::*; + +pub(crate) const GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS: u64 = 240; +pub(crate) const GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS: u64 = 300; +pub(crate) const GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX: &str = + "game-chat-first-playable-hard-budget-exhausted"; + +const FALLBACK_THEME_MARKER: &str = "__GAME_CHAT_THEME__"; +const FALLBACK_PLATFORM_ART_MARKER: &str = "__GAME_CHAT_PLATFORM_ART__"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct GameChatFastPathBudget { + pub(crate) root_agent_id: String, + pub(crate) root_run_id: String, + pub(crate) baseline_revision: u64, + pub(crate) elapsed_seconds: u64, +} + +pub(crate) fn game_chat_fast_path_budget_at( + root: &Path, + agent_id: &str, + run_id: &str, + now: u64, +) -> Result, String> { + let binding = read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? + .ok_or_else(|| "game-chat 快车道缺少当前 Run Profile 绑定".to_string())?; + let root_binding = + if binding.root_agent_id == binding.agent_id && binding.root_run_id == binding.run_id { + binding + } else { + read_game_creator_agent_runtime_run_profile_binding( + root, + &binding.root_agent_id, + &binding.root_run_id, + )? + .ok_or_else(|| "game-chat 快车道缺少 root Run Profile 绑定".to_string())? + }; + if root_binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || root_binding.source != AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + { + return Ok(None); + } + if root_binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || root_binding.root_agent_id != root_binding.agent_id + || root_binding.root_run_id != root_binding.run_id + { + return Err("game-chat 快车道 root Run Profile 绑定身份不一致".to_string()); + } + let contract = + read_autonomous_completion_contract(root, &root_binding.agent_id, &root_binding.run_id)? + .ok_or_else(|| "game-chat 快车道缺少自主构建完成合同".to_string())?; + if contract.run_profile_binding_fingerprint != root_binding.binding_fingerprint { + return Err("game-chat 快车道完成合同与 root binding 不匹配".to_string()); + } + Ok(Some(GameChatFastPathBudget { + root_agent_id: root_binding.agent_id, + root_run_id: root_binding.run_id, + baseline_revision: contract.baseline_revision, + elapsed_seconds: now.saturating_sub(root_binding.bound_at), + })) +} + +pub(crate) fn game_chat_fast_path_provider_timeout( + budget: &GameChatFastPathBudget, +) -> Option { + GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS + .checked_sub(budget.elapsed_seconds) + .filter(|remaining| *remaining > 0) + .map(std::time::Duration::from_secs) +} + +fn game_chat_fast_path_action(tool: &str, input: serde_json::Value) -> AgentRuntimeToolPlan { + AgentRuntimeToolPlan { + thinking_summary: "game-chat 首版快车道正在按固定最短路径推进。".to_string(), + plan_update: None, + plan: Vec::new(), + actions: vec![AgentRuntimeToolAction { + tool: tool.to_string(), + reason: Some("在五分钟预算内形成并验证首个可玩版本".to_string()), + input, + }], + response: String::new(), + } +} + +fn game_chat_fast_path_fallback_write_plan_for_root( + root: &Path, + task: &str, +) -> Result { + if !game_chat_fast_path_has_platform_art_asset(root) { + return Err( + "game-chat 首版缺少已登记且可验证的平台视觉规范图,拒绝退回纯几何 Canvas".to_string(), + ); + } + Ok(game_chat_fast_path_action( + "file.write", + serde_json::json!({ + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + "content": render_game_chat_fast_path_html(task), + }), + )) +} + +fn game_chat_fast_path_has_platform_art_asset(root: &Path) -> bool { + let Ok(manifest) = read_manifest_for_project(root) else { + return false; + }; + validate_manifest_required_visual_asset(root, &manifest, "art-director").is_ok() +} + +fn game_chat_fast_path_has_visual_asset(root: &Path, task_id: &str) -> bool { + read_manifest_for_project(root).is_ok_and(|manifest| { + validate_manifest_required_visual_asset(root, &manifest, task_id).is_ok() + }) +} + +fn game_chat_fast_path_canvas_generation_failed(runtime: &AgentRuntimeState) -> bool { + runtime.observations.iter().rev().any(|observation| { + [ + "canvas.asset_generate:failed", + "canvas.asset_generate:blocked", + "canvas.asset_generate:rejected", + "canvas.asset_generate:needs-reconciliation", + ] + .iter() + .any(|prefix| observation.starts_with(prefix)) + }) +} + +fn game_chat_fast_path_root_task( + root: &Path, + budget: &GameChatFastPathBudget, +) -> Result { + let root_task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &budget.root_agent_id, + &budget.root_run_id, + )? + .ok_or_else(|| "game-chat 首版快车道缺少 root 任务记录".to_string())? + .task; + if root_task.trim().is_empty() { + return Err("game-chat 首版快车道 root 任务为空".to_string()); + } + Ok(root_task) +} + +fn game_chat_fast_path_canvas_asset_plan(task_id: &str, root_task: &str) -> AgentRuntimeToolPlan { + let theme = safe_theme_summary(root_task); + let (prompt, output_path, aspect_ratio, image_size, asset_kind, asset_label) = match task_id { + "art-director" => ( + format!( + "为原创小游戏“{theme}”生成统一视觉规范图:清晰展示玩家主体、目标物、场景地块、障碍、UI 图标、状态反馈、统一色板和材质规则;同一张图必须可直接作为首版主要背景、玩家和目标的可见绘制来源,不得使用现有知名游戏角色或标识。" + ), + AGENT_RUNTIME_ART_SPEC_PATH, + "1:1", + "1K", + "icon-spec", + "游戏统一视觉规范图", + ), + _ => unreachable!("only deterministic game-chat art tasks use this helper"), + }; + game_chat_fast_path_action( + "canvas.asset_generate", + serde_json::json!({ + "prompt": prompt, + "outputPath": output_path, + "aspectRatio": aspect_ratio, + "imageSize": image_size, + "assetKind": asset_kind, + "assetLabel": asset_label, + "replaceExisting": false, + }), + ) +} + +pub(crate) fn game_chat_fast_path_fallback_write_plan_for_budget_at( + root: &Path, + budget: &GameChatFastPathBudget, + _fallback_task: &str, +) -> Result { + let root_task = game_chat_fast_path_root_task(root, budget)?; + game_chat_fast_path_fallback_write_plan_for_root(root, &root_task) +} + +fn game_chat_fast_path_verified_delivery_plan( + runtime: &AgentRuntimeState, + response: &str, +) -> AgentRuntimeToolPlan { + AgentRuntimeToolPlan { + thinking_summary: "首版快车道已取得当前 revision 的验证证据。".to_string(), + plan_update: agent_runtime_verified_delivery_completion_plan_update(runtime), + plan: Vec::new(), + actions: Vec::new(), + response: response.to_string(), + } +} + +fn game_chat_fast_path_current_revision_is_verified( + root: &Path, + runtime: &AgentRuntimeState, +) -> Result { + let revision = read_game_creator_agent_runtime_project_revision(root)?; + let gate = read_game_creator_agent_runtime_verification_gate( + root, + &runtime.agent_id, + &runtime.run_id, + )?; + Ok(revision.revision > 0 + && gate.verified_revision == Some(revision.revision) + && gate.last_verification_tool.as_deref() == Some("game.static_smoke") + && gate.last_verification_status.as_deref() + == Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED)) +} + +fn game_chat_fast_path_current_revision_has_playtest_receipt( + root: &Path, + budget: &GameChatFastPathBudget, +) -> Result { + let contract = + read_autonomous_completion_contract(root, &budget.root_agent_id, &budget.root_run_id)? + .ok_or_else(|| "game-chat 首版快车道缺少 root 完成合同".to_string())?; + let revision = read_game_creator_agent_runtime_project_revision(root)?; + Ok(read_autonomous_playtest_receipt(root, &contract)? + .is_some_and(|receipt| receipt.revision == revision.revision)) +} + +pub(crate) fn game_chat_fast_path_plan_at( + root: &Path, + runtime: &AgentRuntimeState, + task: &str, + now: u64, +) -> Result, String> { + if runtime.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + return Ok(None); + } + let Some(budget) = + game_chat_fast_path_budget_at(root, &runtime.agent_id, &runtime.run_id, now)? + else { + return Ok(None); + }; + match runtime.agent_id.as_str() { + "art-director" => { + if game_chat_fast_path_has_visual_asset(root, "art-director") { + return Ok(Some(game_chat_fast_path_verified_delivery_plan( + runtime, + "统一视觉规范图已生成并登记。", + ))); + } + if !editor_api_key_is_configured() { + return Err( + "game-chat 首版必须配置 External Editor API Key 才能生成平台美术资源" + .to_string(), + ); + } + if game_chat_fast_path_canvas_generation_failed(runtime) { + return Err( + "game-chat 统一视觉规范图生成失败,拒绝跳过美术阶段或退回纯几何首版" + .to_string(), + ); + } + let root_task = game_chat_fast_path_root_task(root, &budget)?; + Ok(Some(game_chat_fast_path_canvas_asset_plan( + "art-director", + &root_task, + ))) + } + "preview-readiness" => { + if game_chat_fast_path_current_revision_is_verified(root, runtime)? { + Ok(Some(game_chat_fast_path_verified_delivery_plan( + runtime, + "首个可玩版本已通过静态自检。", + ))) + } else { + Ok(Some(game_chat_fast_path_action( + "command.run_limited", + serde_json::json!({ "commandId": "game.static_smoke" }), + ))) + } + } + "preview-playtest" => { + if game_chat_fast_path_current_revision_has_playtest_receipt(root, &budget)? { + Ok(Some(game_chat_fast_path_verified_delivery_plan( + runtime, + "首个可玩版本已通过桌面和移动端试玩。", + ))) + } else { + Ok(Some(game_chat_fast_path_action( + "preview.validate", + serde_json::json!({ + "viewports": ["desktop", "mobile"], + "expectedText": [], + "settleMs": 400, + "failOnConsoleError": true, + "playtestScenario": null, + }), + ))) + } + } + "code-prototype" => { + let revision = read_game_creator_agent_runtime_project_revision(root)?; + let gate = read_game_creator_agent_runtime_verification_gate( + root, + &runtime.agent_id, + &runtime.run_id, + )?; + let owns_current_mutation = gate.mutation_revision == Some(revision.revision); + let current_revision_verified = owns_current_mutation + && gate.verified_revision == Some(revision.revision) + && gate.last_verification_status.as_deref() + == Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED); + let current_revision_failed = owns_current_mutation + && gate.last_verification_status.as_deref() + == Some(AGENT_RUNTIME_VERIFICATION_STATUS_FAILED); + + if current_revision_verified { + return Ok(Some(game_chat_fast_path_verified_delivery_plan( + runtime, + "首个可玩版本代码已生成并通过静态自检。", + ))); + } + if owns_current_mutation && !current_revision_failed { + return Ok(Some(game_chat_fast_path_action( + "command.run_limited", + serde_json::json!({ "commandId": "game.static_smoke" }), + ))); + } + if current_revision_failed + || runtime.loop_iteration > 1 + || budget.elapsed_seconds >= GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS + { + return Ok(Some(game_chat_fast_path_fallback_write_plan_for_budget_at( + root, &budget, task, + )?)); + } + Ok(None) + } + _ => Ok(None), + } +} + +/// Render a safe title/theme summary from the user's request. +/// +/// The summary is escaped before it is inserted into HTML. It is only placed in a data +/// attribute and text nodes; it is never interpolated into JavaScript source. +pub(crate) fn render_game_chat_fast_path_html(prompt: &str) -> String { + let theme = html_escape(&safe_theme_summary(prompt)); + let platform_art = + r#"平台生成的统一视觉规范图"#; + FALLBACK_GAME_HTML + .replace(FALLBACK_THEME_MARKER, &theme) + .replace(FALLBACK_PLATFORM_ART_MARKER, platform_art) +} + +fn safe_theme_summary(prompt: &str) -> String { + let mut summary = String::new(); + let mut previous_was_space = false; + for character in prompt.trim().chars() { + if character.is_control() { + if !previous_was_space { + summary.push(' '); + previous_was_space = true; + } + continue; + } + if character.is_whitespace() { + if !previous_was_space { + summary.push(' '); + previous_was_space = true; + } + continue; + } + summary.push(character); + previous_was_space = false; + if summary.chars().count() >= 56 { + break; + } + } + let summary = summary.trim(); + if summary.is_empty() { + "轻量互动挑战".to_string() + } else { + summary.to_string() + } +} + +fn html_escape(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + _ => escaped.push(character), + } + } + escaped +} + +const FALLBACK_GAME_HTML: &str = r###" + + + + + Genarrative · __GAME_CHAT_THEME__ + + + +
+
+
+

首版可试玩 · __GAME_CHAT_THEME__

+

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

+
+
得分 0准备就绪
+
+
+ __GAME_CHAT_PLATFORM_ART__ + +
点击开始,然后操作收集能量准备就绪
+ +
+ +
+ + + +"###; + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::{validate_game_html_smoke, validate_playable_game_html}; + + fn write_visual_png(path: &Path, alpha: u8) { + image::RgbaImage::from_pixel(4, 4, image::Rgba([80, 160, 220, alpha])) + .save(path) + .expect("write valid PNG fixture"); + } + + fn register_platform_art_spec(root: &Path) { + write_visual_png(&root.join(AGENT_RUNTIME_ART_SPEC_PATH), u8::MAX); + register_local_asset_at( + root, + AGENT_RUNTIME_ART_SPEC_PATH, + "icon-spec", + "image/png", + "canvas", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("platform-art-canvas".to_string()), + resource_id: Some("platform-art-spec-resource".to_string()), + asset_object_id: Some("platform-art-spec-object".to_string()), + task_id: Some("art-director".to_string()), + prompt: None, + model: None, + generation_route: Some("/api/external/v1/editor/images/generations".to_string()), + generation_kind: Some("spec".to_string()), + reference_resource_ids: Vec::new(), + }, + ) + .expect("register platform art spec fixture"); + } + + #[test] + fn budgets_leave_a_soft_and_hard_window() { + assert_eq!(GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS, 240); + assert_eq!(GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS, 300); + assert!( + GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS + < GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS + ); + } + + #[test] + fn fallback_html_satisfies_playable_contract() { + let html = render_game_chat_fast_path_html("星河收集挑战"); + validate_playable_game_html(&html, "game-chat fast path").expect("playable contract"); + validate_game_html_smoke(&html).expect("static smoke contract"); + for marker in [ + "requestAnimationFrame", + "playable-web-game-state.v1", + "data-playtest-id=\"start\"", + "data-playtest-id=\"primary-action\"", + "data-playtest-id=\"restart\"", + "pointerdown", + "keydown", + ] { + assert!(html.contains(marker), "missing fallback marker: {marker}"); + } + assert!(html.contains("src=\"../assets/art-spec.png\"")); + assert!(!html.contains("art-spritesheet.png")); + assert!(html.contains("context.drawImage(platformArt, 0, 0, canvas.width, canvas.height)")); + assert!(html.contains("playerX - 18")); + assert!(html.contains("targetX - 52")); + assert!(!html.contains("opacity: 0")); + assert!(!html.contains("width: 1px")); + } + + #[test] + fn fallback_html_requires_registered_platform_art_and_uses_it_prominently() { + let temporary = tempfile::tempdir().expect("temporary project"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-platform-art", "platform art") + .expect("initialize project"); + + assert!(game_chat_fast_path_fallback_write_plan_for_root( + &root, + "没有美术资源时必须失败关闭", + ) + .is_err()); + + register_platform_art_spec(&root); + + let with_asset = + game_chat_fast_path_fallback_write_plan_for_root(&root, "有美术资源时加载平台规范图") + .expect("render art-backed fallback"); + let with_html = with_asset.actions[0].input["content"] + .as_str() + .expect("fallback html with asset"); + assert!(with_html.contains("src=\"../assets/art-spec.png\"")); + assert!(!with_html.contains("art-spritesheet.png")); + assert!( + with_html.contains("context.drawImage(platformArt, 0, 0, canvas.width, canvas.height)") + ); + assert!(with_html.contains("playerX - 18")); + assert!(with_html.contains("targetX - 52")); + } + + #[test] + fn fallback_html_ignores_registered_art_when_file_is_missing() { + let temporary = tempfile::tempdir().expect("temporary project"); + let root = temporary.path().join("project"); + init_local_game_project_at( + &root, + "game-chat-platform-art-missing", + "platform art missing", + ) + .expect("initialize project"); + register_platform_art_spec(&root); + let asset_path = root.join(AGENT_RUNTIME_ART_SPEC_PATH); + fs::remove_file(asset_path).expect("remove platform art fixture"); + + assert!(game_chat_fast_path_fallback_write_plan_for_root( + &root, + "缺图时拒绝 Canvas fallback", + ) + .is_err()); + } + + #[test] + fn current_revision_is_verified_requires_static_smoke_tool() { + let temporary = tempfile::tempdir().expect("temporary project"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-verification", "verification") + .expect("initialize project"); + let runtime = default_game_creator_agent_runtime_state("preview-readiness", "verify-run"); + + let mut revision = + read_game_creator_agent_runtime_project_revision(&root).expect("read project revision"); + revision.revision = 1; + write_game_creator_agent_runtime_project_revision(&root, &revision) + .expect("write project revision"); + let mut gate = + default_agent_runtime_verification_gate(&root, &runtime.agent_id, &runtime.run_id) + .expect("default verification gate"); + gate.verified_revision = Some(1); + gate.last_verification_status = Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string()); + gate.last_verification_tool = Some("preview.validate".to_string()); + write_game_creator_agent_runtime_verification_gate(&root, &gate) + .expect("write verification gate"); + assert!( + !game_chat_fast_path_current_revision_is_verified(&root, &runtime) + .expect("check preview verification") + ); + + gate.last_verification_tool = Some("game.static_smoke".to_string()); + write_game_creator_agent_runtime_verification_gate(&root, &gate) + .expect("write static smoke gate"); + assert!( + game_chat_fast_path_current_revision_is_verified(&root, &runtime) + .expect("check static smoke verification") + ); + } + + #[test] + fn fallback_budget_uses_root_user_task_instead_of_child_manifest_prompt() { + let temporary = tempfile::tempdir().expect("temporary project"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-fallback-test", "fallback test") + .expect("initialize project"); + let run_id = "game-chat-fallback-root-task"; + let mut root_state = default_game_creator_agent_runtime_state( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ); + root_state.current_task = "制作星空飞船收集能量小游戏".to_string(); + append_game_creator_agent_runtime_task(&root, &root_state).expect("append root task"); + let budget = GameChatFastPathBudget { + root_agent_id: root_state.agent_id.clone(), + root_run_id: root_state.run_id.clone(), + baseline_revision: 0, + elapsed_seconds: 240, + }; + register_platform_art_spec(&root); + + let plan = game_chat_fast_path_fallback_write_plan_for_budget_at( + &root, + &budget, + "处理 manifest ready 任务:任务 ID:code-prototype;专业组:code", + ) + .expect("render fallback from root task"); + let content = plan.actions[0].input["content"] + .as_str() + .expect("fallback html content"); + assert!(content.contains("星空飞船收集能量小游戏")); + assert!(!content.contains("任务 ID:code-prototype")); + } + + #[test] + fn fallback_budget_fails_closed_without_root_task_journal() { + let temporary = tempfile::tempdir().expect("temporary project"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-fallback-missing", "fallback missing") + .expect("initialize project"); + let budget = GameChatFastPathBudget { + root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + root_run_id: "missing-root-run".to_string(), + baseline_revision: 0, + elapsed_seconds: 240, + }; + assert!(game_chat_fast_path_fallback_write_plan_for_budget_at( + &root, + &budget, + "任务 ID:code-prototype", + ) + .is_err()); + } + + #[test] + fn prompt_is_html_escaped_and_never_becomes_script() { + let html = render_game_chat_fast_path_html(" & \"主题\""); + assert!(!html.contains("") else { + return output; + }; + cursor = body_start + end_offset + "".len(); + } + output.push_str(&content[cursor..]); + output +} + +fn relative_visual_url_resolves_to_asset(value: &str, asset_path: &str) -> bool { + let value = value + .trim() + .trim_matches(|character| matches!(character, '\'' | '"')); + let path = value.split(['?', '#']).next().unwrap_or_default().trim(); + if path.is_empty() + || path.starts_with('/') + || path.contains(['\\', '%', ':']) + || asset_path.starts_with('/') + { + return false; + } + let mut components = vec!["game"]; + for component in path.split('/') { + match component { + "" | "." => {} + ".." => { + if components.pop().is_none() { + return false; + } + } + value => components.push(value), + } + } + components.join("/") == asset_path +} + +fn html_attribute_value<'a>(tag: &'a str, attribute: &str) -> Option<&'a str> { + let bytes = tag.as_bytes(); + let attribute_bytes = attribute.as_bytes(); + let mut cursor = 0; + while cursor + attribute_bytes.len() <= bytes.len() { + let offset = tag[cursor..].find(attribute)?; + let start = cursor + offset; + let end = start + attribute_bytes.len(); + let left_boundary = + start == 0 || matches!(bytes[start - 1], b'<' | b' ' | b'\t' | b'\r' | b'\n'); + let right_boundary = + end == bytes.len() || matches!(bytes[end], b'=' | b' ' | b'\t' | b'\r' | b'\n'); + if !left_boundary || !right_boundary { + cursor = end; + continue; + } + let mut value_start = end; + while value_start < bytes.len() && bytes[value_start].is_ascii_whitespace() { + value_start += 1; + } + if bytes.get(value_start) != Some(&b'=') { + cursor = end; + continue; + } + value_start += 1; + while value_start < bytes.len() && bytes[value_start].is_ascii_whitespace() { + value_start += 1; + } + let quote = bytes.get(value_start).copied(); + if matches!(quote, Some(b'\'' | b'"')) { + value_start += 1; + let value_end = bytes[value_start..] + .iter() + .position(|byte| Some(*byte) == quote) + .map(|offset| value_start + offset)?; + return Some(&tag[value_start..value_end]); + } + let value_end = bytes[value_start..] + .iter() + .position(|byte| byte.is_ascii_whitespace() || *byte == b'>') + .map(|offset| value_start + offset) + .unwrap_or(bytes.len()); + return (value_end > value_start).then(|| &tag[value_start..value_end]); + } + None +} + +fn css_numeric_property(value: &str, property: &str) -> Option { + let compact = value + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect::(); + let marker = format!("{property}:"); + let start = compact.find(&marker)? + marker.len(); + compact[start..] + .chars() + .take_while(|character| character.is_ascii_digit() || matches!(character, '.' | '-')) + .collect::() + .parse() + .ok() +} + +fn tag_dimension(tag: &str, attribute: &str, property: &str) -> Option { + html_attribute_value(tag, attribute) + .and_then(|value| value.trim_end_matches("px").parse().ok()) + .or_else(|| { + html_attribute_value(tag, "style") + .and_then(|style| css_numeric_property(style, property)) + }) +} + +fn tag_is_obviously_hidden_or_tiny(tag: &str, default_dimensions: (u32, u32)) -> bool { + let compact = tag + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect::(); + let hidden_attribute = tag + .split(|character: char| character.is_ascii_whitespace() || matches!(character, '<' | '>')) + .any(|token| token == "hidden" || token.starts_with("hidden=")); + if hidden_attribute + || compact.contains("display:none") + || compact.contains("visibility:hidden") + || compact.contains("left:-999") + || compact.contains("top:-999") + || compact.contains("translate(-999") + || css_numeric_property(tag, "opacity").is_some_and(|opacity| opacity < 0.25) + { + return true; + } + let width = tag_dimension(tag, "width", "width").unwrap_or(default_dimensions.0 as f64); + let height = tag_dimension(tag, "height", "height").unwrap_or(default_dimensions.1 as f64); + width < 24.0 || height < 24.0 +} + +fn css_contains_resolving_url(style: &str, asset_path: &str) -> bool { + let mut cursor = 0; + while let Some(offset) = style[cursor..].find("url(") { + let start = cursor + offset + "url(".len(); + let Some(end_offset) = style[start..].find(')') else { + return false; + }; + let end = start + end_offset; + if relative_visual_url_resolves_to_asset(&style[start..end], asset_path) { + return true; + } + cursor = end + 1; + } + false +} + +fn selector_is_bound_to_markup(selector: &str, markup: &str) -> bool { + selector.split(',').any(|selector| { + let selector = selector.trim(); + if let Some(class_name) = selector.strip_prefix('.') { + let class_name = class_name + .split(|character: char| { + !character.is_ascii_alphanumeric() && !matches!(character, '_' | '-') + }) + .next() + .unwrap_or_default(); + return !class_name.is_empty() + && markup.split('<').any(|tag| { + html_attribute_value(tag, "class").is_some_and(|classes| { + classes + .split_ascii_whitespace() + .any(|value| value == class_name) + }) + }); + } + if let Some(id) = selector.strip_prefix('#') { + let id = id + .split(|character: char| { + !character.is_ascii_alphanumeric() && !matches!(character, '_' | '-') + }) + .next() + .unwrap_or_default(); + return !id.is_empty() + && markup + .split('<') + .any(|tag| html_attribute_value(tag, "id") == Some(id)); + } + let tag_name = selector + .split(|character: char| !character.is_ascii_alphanumeric() && character != '-') + .next() + .unwrap_or_default(); + !tag_name.is_empty() + && markup + .split('<') + .any(|tag| tag.trim_start().starts_with(tag_name)) + }) +} + +fn selector_matches_tag(selector: &str, tag: &str) -> bool { + selector.split(',').any(|selector| { + let selector = selector.trim(); + if let Some(class_name) = selector.strip_prefix('.') { + let class_name = class_name + .split(|character: char| { + !character.is_ascii_alphanumeric() && !matches!(character, '_' | '-') + }) + .next() + .unwrap_or_default(); + return html_attribute_value(tag, "class").is_some_and(|classes| { + classes + .split_ascii_whitespace() + .any(|value| value == class_name) + }); + } + if let Some(id) = selector.strip_prefix('#') { + let id = id + .split(|character: char| { + !character.is_ascii_alphanumeric() && !matches!(character, '_' | '-') + }) + .next() + .unwrap_or_default(); + return html_attribute_value(tag, "id") == Some(id); + } + let name = selector + .split(|character: char| !character.is_ascii_alphanumeric() && character != '-') + .next() + .unwrap_or_default(); + !name.is_empty() + && tag + .trim_start_matches(['<', ' ', '\t', '\r', '\n']) + .starts_with(name) + }) +} + +fn tag_is_hidden_by_stylesheet(tag: &str, markup: &str, default_dimensions: (u32, u32)) -> bool { + markup.split("').map(|offset| offset + 1) else { + return false; + }; + let Some(body_end) = tail[body_start..].find("") else { + return false; + }; + tail[body_start..body_start + body_end] + .split('}') + .any(|rule| { + rule.rsplit_once('{') + .is_some_and(|(selector, declarations)| { + selector_matches_tag(selector, tag) + && tag_is_obviously_hidden_or_tiny( + &format!("
"), + default_dimensions, + ) + }) + }) + }) +} + +fn position_is_inside_javascript_string(content: &str, position: usize) -> bool { + let mut quote = None; + let mut escaped = false; + for byte in content.as_bytes().iter().copied().take(position) { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if let Some(active) = quote { + if byte == active { + quote = None; + } + } else if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + } + } + quote.is_some() +} + +fn matching_javascript_brace(content: &str, open: usize) -> Option { + let mut depth = 0usize; + let mut quote = None; + let mut escaped = false; + for (offset, byte) in content.as_bytes()[open..].iter().copied().enumerate() { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if let Some(active) = quote { + if byte == active { + quote = None; + } + } else if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + } else if byte == b'{' { + depth += 1; + } else if byte == b'}' { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some(open + offset); + } + } + } + None +} + +fn named_javascript_function_ranges(content: &str) -> Vec<(String, usize, usize)> { + let mut ranges = Vec::new(); + let mut cursor = 0; + while let Some(offset) = content[cursor..].find("function ") { + let definition_start = cursor + offset; + cursor = definition_start + "function ".len(); + if position_is_inside_javascript_string(content, definition_start) { + continue; + } + while content + .as_bytes() + .get(cursor) + .is_some_and(u8::is_ascii_whitespace) + { + cursor += 1; + } + let name_start = cursor; + while content + .as_bytes() + .get(cursor) + .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_' || *byte == b'$') + { + cursor += 1; + } + if cursor == name_start { + continue; + } + let name = content[name_start..cursor].to_string(); + let Some(open_offset) = content[cursor..].find('{') else { + break; + }; + let open = cursor + open_offset; + let Some(end) = matching_javascript_brace(content, open) else { + break; + }; + ranges.push((name, definition_start, end + 1)); + cursor = open + 1; + } + ranges +} + +fn javascript_named_function_is_reachable( + content: &str, + ranges: &[(String, usize, usize)], + function_index: usize, + visiting: &mut BTreeSet, +) -> bool { + if !visiting.insert(function_index) { + return false; + } + let (name, definition_start, definition_end) = &ranges[function_index]; + let invocation_markers = [ + format!("{name}("), + format!("requestanimationframe({name})"), + format!(",{name})"), + format!(", {name})"), + ]; + for marker in invocation_markers { + let mut cursor = 0; + while let Some(offset) = content[cursor..].find(&marker) { + let call = cursor + offset; + cursor = call + marker.len(); + if (*definition_start..*definition_end).contains(&call) + || position_is_inside_javascript_string(content, call) + { + continue; + } + let parent = ranges + .iter() + .enumerate() + .filter(|(_, (_, start, end))| (*start..*end).contains(&call)) + .min_by_key(|(_, (_, start, end))| end - start) + .map(|(index, _)| index); + if parent.is_none_or(|index| { + javascript_named_function_is_reachable(content, ranges, index, visiting) + }) { + visiting.remove(&function_index); + return true; + } + } + } + visiting.remove(&function_index); + false +} + +fn javascript_position_is_reachable( + content: &str, + ranges: &[(String, usize, usize)], + position: usize, +) -> bool { + let enclosing = ranges + .iter() + .enumerate() + .filter(|(_, (_, start, end))| (*start..*end).contains(&position)) + .min_by_key(|(_, (_, start, end))| end - start) + .map(|(index, _)| index); + enclosing.is_none_or(|index| { + javascript_named_function_is_reachable(content, ranges, index, &mut BTreeSet::new()) + }) +} + +fn identifier_before(content: &str, position: usize) -> Option { + let bytes = content.as_bytes(); + let mut end = position; + while end > 0 && bytes[end - 1].is_ascii_whitespace() { + end -= 1; + } + let mut start = end; + while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'_') { + start -= 1; + } + (start < end).then(|| content[start..end].to_string()) +} + +fn canvas_visual_identifiers(content: &str, markup: &str, asset_path: &str) -> BTreeSet { + let mut identifiers = BTreeSet::new(); + let mut cursor = 0; + while let Some(offset) = content[cursor..].find(".src") { + let dot = cursor + offset; + cursor = dot + 4; + if position_is_inside_javascript_string(content, dot) { + continue; + } + let Some(identifier) = identifier_before(content, dot) else { + continue; + }; + let mut value_start = cursor; + while content + .as_bytes() + .get(value_start) + .is_some_and(u8::is_ascii_whitespace) + { + value_start += 1; + } + if content.as_bytes().get(value_start) != Some(&b'=') { + continue; + } + value_start += 1; + while content + .as_bytes() + .get(value_start) + .is_some_and(u8::is_ascii_whitespace) + { + value_start += 1; + } + let Some(quote @ (b'\'' | b'"' | b'`')) = content.as_bytes().get(value_start).copied() + else { + continue; + }; + value_start += 1; + let Some(end_offset) = content.as_bytes()[value_start..] + .iter() + .position(|byte| *byte == quote) + else { + continue; + }; + if relative_visual_url_resolves_to_asset( + &content[value_start..value_start + end_offset], + asset_path, + ) { + identifiers.insert(identifier); + } + } + + for tag in markup.split('>').filter(|tag| tag.contains(asset_path)) { + let Some(id) = html_attribute_value(tag, "id") else { + continue; + }; + let source_matches = ["src", "href", "data", "poster"].iter().any(|attribute| { + html_attribute_value(tag, attribute) + .is_some_and(|url| relative_visual_url_resolves_to_asset(url, asset_path)) + }); + if !source_matches { + continue; + } + for marker in ["getelementbyid(", "queryselector("] { + let mut binding_cursor = 0; + while let Some(offset) = content[binding_cursor..].find(marker) { + let call = binding_cursor + offset; + binding_cursor = call + marker.len(); + if position_is_inside_javascript_string(content, call) { + continue; + } + let argument_tail = &content[binding_cursor..]; + let Some(argument_end) = argument_tail.find(')') else { + continue; + }; + let argument = argument_tail[..argument_end] + .trim() + .trim_matches(|character| matches!(character, '\'' | '"' | '#')); + if argument != id { + continue; + } + let Some(equals) = content[..call].rfind('=') else { + continue; + }; + if call.saturating_sub(equals) > 24 { + continue; + } + if let Some(identifier) = identifier_before(content, equals) { + identifiers.insert(identifier); + } + } + } + } + identifiers +} + +fn split_javascript_arguments(arguments: &str) -> Vec<&str> { + let mut result = Vec::new(); + let mut start = 0; + let mut depth: usize = 0; + let mut quote = None; + let mut escaped = false; + for (index, byte) in arguments.bytes().enumerate() { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if let Some(active) = quote { + if byte == active { + quote = None; + } + } else if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + } else if byte == b'(' { + depth += 1; + } else if byte == b')' { + depth = depth.saturating_sub(1); + } else if byte == b',' && depth == 0 { + result.push(arguments[start..index].trim()); + start = index + 1; + } + } + result.push(arguments[start..].trim()); + result +} + +fn javascript_call_arguments_end(content: &str, start: usize) -> Option { + let mut depth: usize = 0; + let mut quote = None; + let mut escaped = false; + for (offset, byte) in content.as_bytes()[start..].iter().copied().enumerate() { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if let Some(active) = quote { + if byte == active { + quote = None; + } + } else if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + } else if byte == b'(' { + depth += 1; + } else if byte == b')' { + if depth == 0 { + return Some(start + offset); + } + depth -= 1; + } + } + None +} + +fn draw_image_metrics(arguments: &[&str], asset_dimensions: (u32, u32)) -> (bool, bool, bool) { + let destination = match arguments.len() { + 3 => { + let significant = asset_dimensions.0 >= 32 && asset_dimensions.1 >= 32; + return (significant, significant, false); + } + 5 => arguments.get(3).zip(arguments.get(4)), + 9 => arguments.get(7).zip(arguments.get(8)), + _ => None, + }; + let Some((width, height)) = destination else { + return (false, false, false); + }; + let compact_width = width.split_ascii_whitespace().collect::(); + let compact_height = height.split_ascii_whitespace().collect::(); + let background = compact_width.ends_with(".width") + && compact_height.ends_with(".height") + && compact_width.trim_end_matches(".width") == compact_height.trim_end_matches(".height"); + let parse = |value: &str| value.trim().parse::().ok(); + let numeric = parse(width).zip(parse(height)); + let entity = numeric.is_some_and(|(width, height)| { + width.abs() >= 32.0 && height.abs() >= 32.0 && width.abs() * height.abs() >= 2048.0 + }); + let numeric_background = numeric.is_some_and(|(width, height)| { + width.abs() >= 320.0 && height.abs() >= 180.0 && width.abs() * height.abs() >= 100_000.0 + }); + let background = background || numeric_background; + (background || entity, background, entity) +} + +fn tag_visibly_uses_visual_asset( + tag: &str, + asset_path: &str, + asset_dimensions: (u32, u32), +) -> bool { + let tag = tag.to_ascii_lowercase(); + if !tag.contains(asset_path) || tag_is_obviously_hidden_or_tiny(&tag, asset_dimensions) { + return false; + } + let trimmed = tag.trim_start_matches(['<', ' ', '\t', '\r', '\n']); + let visual_element = ["img", "image", "object", "embed", "video", "input"] + .iter() + .any(|name| { + trimmed.starts_with(name) + && trimmed + .as_bytes() + .get(name.len()) + .is_some_and(|byte| byte.is_ascii_whitespace() || *byte == b'>') + }); + let direct_source = visual_element + && ["src", "href", "data", "poster"].iter().any(|attribute| { + html_attribute_value(&tag, attribute) + .is_some_and(|url| relative_visual_url_resolves_to_asset(url, asset_path)) + }); + direct_source + || html_attribute_value(&tag, "style") + .is_some_and(|style| css_contains_resolving_url(style, asset_path)) +} + +fn game_index_visibly_uses_visual_asset( + html: &[u8], + asset_path: &str, + asset_dimensions: (u32, u32), + require_canvas_composition: bool, +) -> bool { + let asset_path = asset_path.to_ascii_lowercase(); + let Ok(html) = std::str::from_utf8(html) else { + return false; + }; + let content = strip_art_reference_comments(html).to_ascii_lowercase(); + if !content.contains(&asset_path) { + return false; + } + let markup = strip_script_blocks(&content); + + let mut tag_cursor = 0; + while let Some(start_offset) = markup[tag_cursor..].find('<') { + let start = tag_cursor + start_offset; + let Some(end_offset) = markup[start..].find('>') else { + break; + }; + let end = start + end_offset + 1; + let tag = &markup[start..end]; + if !require_canvas_composition + && tag_visibly_uses_visual_asset(tag, &asset_path, asset_dimensions) + && !tag_is_hidden_by_stylesheet(tag, &markup, asset_dimensions) + { + return true; + } + tag_cursor = end; + } + + let mut style_cursor = 0; + while let Some(start_offset) = markup[style_cursor..].find("') else { + break; + }; + let body_start = start + open_end_offset + 1; + let Some(end_offset) = markup[body_start..].find("") else { + break; + }; + let body_end = body_start + end_offset; + let style = &markup[body_start..body_end]; + for rule in style.split('}') { + let Some((selector, declarations)) = rule.rsplit_once('{') else { + continue; + }; + if !require_canvas_composition + && css_contains_resolving_url(declarations, &asset_path) + && selector_is_bound_to_markup(selector, &markup) + && !tag_is_obviously_hidden_or_tiny( + &format!("
"), + (0, 0), + ) + { + return true; + } + } + style_cursor = body_end + "".len(); + } + + let active_canvas = markup.split('>').any(|tag| { + tag.trim_start_matches(['<', ' ', '\t', '\r', '\n']) + .starts_with("canvas") + && !tag_is_obviously_hidden_or_tiny(tag, (300, 150)) + && !tag_is_hidden_by_stylesheet(tag, &markup, (300, 150)) + }); + if !active_canvas { + return false; + } + let identifiers = canvas_visual_identifiers(&content, &markup, &asset_path); + if identifiers.is_empty() { + return false; + } + let mut significant_draws = 0usize; + let mut background_draws = 0usize; + let mut entity_draws = 0usize; + let function_ranges = named_javascript_function_ranges(&content); + let mut draw_cursor = 0; + while let Some(offset) = content[draw_cursor..].find("drawimage(") { + let call = draw_cursor + offset; + let arguments_start = call + "drawimage(".len(); + draw_cursor = arguments_start; + if position_is_inside_javascript_string(&content, call) + || !javascript_position_is_reachable(&content, &function_ranges, call) + { + continue; + } + let Some(arguments_end) = javascript_call_arguments_end(&content, arguments_start) else { + break; + }; + let arguments = split_javascript_arguments(&content[arguments_start..arguments_end]); + if arguments + .first() + .is_some_and(|identifier| identifiers.contains(*identifier)) + { + let (significant, background, entity) = + draw_image_metrics(&arguments, asset_dimensions); + significant_draws += usize::from(significant); + background_draws += usize::from(background); + entity_draws += usize::from(entity); + if !require_canvas_composition && significant { + return true; + } + } + draw_cursor = arguments_end + 1; + } + require_canvas_composition + && significant_draws >= 3 + && background_draws >= 1 + && entity_draws >= 2 +} + pub(in crate::agent) fn game_creation_app_task_status_label( status: &GameCreationAppTaskStatus, ) -> String { @@ -397,7 +1229,20 @@ fn autonomous_manifest_parent_completion_gaps_at( contract: &AgentRuntimeAutonomousCompletionContract, ) -> Result<(Vec, Vec), String> { let manifest = read_manifest_for_project(root)?; - let seed_tasks = new_game_creation_app_seed_tasks(); + let binding = read_game_creator_agent_runtime_run_profile_binding( + root, + &contract.agent_id, + &contract.run_id, + )? + .ok_or_else(|| "自主构建根 Supervisor Run 缺少 Run Profile 绑定".to_string())?; + let seed_tasks = crate::agent::autonomous_manifest_seed_tasks_for_source(&binding.source); + let required_visual_task_id = if binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE { + Some("art-director") + } else if editor_api_key_is_configured() { + Some("art-asset-plan") + } else { + None + }; let mut missing_tasks = Vec::new(); let mut missing_paths = Vec::new(); for seed_task in &seed_tasks { @@ -416,6 +1261,26 @@ fn autonomous_manifest_parent_completion_gaps_at( contract.baseline_index_sha256.as_deref(), &contract.baseline_artifacts, )?); + if required_visual_task_id == Some(seed_task.id.as_str()) { + if let Err(error) = + validate_manifest_required_visual_asset(root, &manifest, &seed_task.id) + { + missing_paths.push(AutonomousManifestArtifactGap::new(format!( + "{}(canvas-registration-invalid:{})", + seed_task.id, + sanitize_agent_runtime_text(&error, 240) + ))); + } + } + if seed_task.id == "code-prototype" { + if let Some(gap) = autonomous_code_prototype_art_asset_reference_gap_at( + root, + &seed_task.id, + required_visual_task_id, + )? { + missing_paths.push(AutonomousManifestArtifactGap::new(gap)); + } + } } missing_paths.sort_by(|left, right| left.summary.cmp(&right.summary)); missing_paths.dedup_by(|left, right| left.summary == right.summary); @@ -451,10 +1316,31 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( format!("missingTask={}", state.agent_id), )); }; + let root_source = match agent_runtime_root_source_at(root, &state.agent_id, &state.run_id) { + Ok(source) => source, + Err(error) => { + return Some(autonomous_completion_blocker( + "autonomous ready-task 无法解析 root source", + error, + )); + } + }; + // game-chat 可能在 UI 完成项目 hydration 前并行启动 source-aware lane 的首波 + // ready child,随后初始化写回会短暂把这些零依赖任务恢复成 Pending。child binding、owner + // artifact 和验证门仍能确认当前 run,因此仅允许当前 source 的零依赖首波收束, + // 再由 terminal projection 写入权威 Completed 状态。后续 preview 与中间任务继续 + // 严格要求 Running/Completed,不得借 hydration 例外越过依赖。 + // GUI/CLI 以及后续 preview 任务继续严格要求 Running/Completed。 + let game_chat_hydration_pending = root_source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + && task.status == GameCreationAppTaskStatus::Pending + && crate::agent::autonomous_manifest_seed_tasks_for_source(&root_source) + .iter() + .any(|seed_task| seed_task.id == state.agent_id && seed_task.dependencies.is_empty()); if !matches!( task.status, GameCreationAppTaskStatus::Running | GameCreationAppTaskStatus::Completed - ) { + ) && !game_chat_hydration_pending + { return Some(autonomous_completion_blocker( "autonomous ready-task manifest 状态不允许完成", format!( @@ -464,6 +1350,102 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( ), )); } + if state.agent_id == "preview-readiness" { + let revision = match read_game_creator_agent_runtime_project_revision(root) { + Ok(revision) => revision, + Err(error) => { + return Some(autonomous_completion_blocker( + "preview-readiness 无法读取当前项目 revision", + error, + )); + } + }; + let gate = match read_game_creator_agent_runtime_verification_gate( + root, + &state.agent_id, + &state.run_id, + ) { + Ok(gate) => gate, + Err(error) => { + return Some(autonomous_completion_blocker( + "preview-readiness 静态验证凭证不可用", + error, + )); + } + }; + if gate.last_verification_tool.as_deref() != Some("game.static_smoke") + || gate.last_verification_status.as_deref() + != Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED) + || gate.verified_revision != Some(revision.revision) + { + return Some(autonomous_completion_blocker( + "preview-readiness 尚未通过当前 revision 的 game.static_smoke", + format!( + "currentRevision={}, verifiedRevision={}", + revision.revision, + gate.verified_revision + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()) + ), + )); + } + } + if state.agent_id == "preview-playtest" { + let contract = match autonomous_playtest_completion_contract_for_state_at(root, state) { + Ok(Some(contract)) => contract, + Ok(None) => { + return Some(autonomous_completion_blocker( + "preview-playtest 缺少自主试玩完成合同", + "当前 child run 无法绑定父 Supervisor 的试玩场景与 revision。", + )); + } + Err(error) => { + return Some(autonomous_completion_blocker( + "preview-playtest 自主试玩完成合同不可用", + error, + )); + } + }; + let revision = match read_game_creator_agent_runtime_project_revision(root) { + Ok(revision) => revision, + Err(error) => { + return Some(autonomous_completion_blocker( + "preview-playtest 无法读取当前项目 revision", + error, + )); + } + }; + let receipt = match read_autonomous_playtest_receipt(root, &contract) { + Ok(Some(receipt)) => receipt, + Ok(None) => { + return Some(autonomous_completion_blocker( + "preview-playtest 尚未形成成功浏览器试玩回执", + "必须由 preview.validate 在当前 revision 生成 passed report 与桌面、移动截图。", + )); + } + Err(error) => { + return Some(autonomous_completion_blocker( + "preview-playtest 浏览器试玩回执不可用", + error, + )); + } + }; + if receipt.revision != revision.revision { + return Some(autonomous_completion_blocker( + "preview-playtest 浏览器试玩回执不属于当前 revision", + format!( + "receiptRevision={}, currentRevision={}", + receipt.revision, revision.revision + ), + )); + } + if let Err(error) = verify_autonomous_playtest_evidence_files_at(root, &receipt) { + return Some(autonomous_completion_blocker( + "preview-playtest 浏览器试玩证据复核未通过", + error, + )); + } + } let parent_contract = match read_autonomous_completion_contract( root, binding @@ -500,6 +1482,49 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( )); } }; + let required_visual_task_id = if root_source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE { + Some("art-director") + } else if editor_api_key_is_configured() { + Some("art-asset-plan") + } else { + None + }; + if required_visual_task_id == Some(state.agent_id.as_str()) { + if let Err(error) = + validate_manifest_required_visual_asset(root, &manifest, &state.agent_id) + { + return Some(autonomous_completion_blocker( + "autonomous ready-task 缺少有效的 Canvas 美术资产登记", + format!( + "task={} validationError={}", + state.agent_id, + sanitize_agent_runtime_text(&error, 240) + ), + )); + } + } + match autonomous_code_prototype_art_asset_reference_gap_at( + root, + &state.agent_id, + required_visual_task_id, + ) { + Ok(Some(gap)) => { + return Some(autonomous_completion_blocker( + "code-prototype 必须实际使用平台生成的美术资源", + format!( + "task={} missingPaths={},请先通过 asset.list 核对 Canvas 来源并在 game/index.html 中可见使用对应平台美术资源", + state.agent_id, gap + ), + )); + } + Ok(None) => {} + Err(error) => { + return Some(autonomous_completion_blocker( + "code-prototype 美术资源引用无法安全核对", + error, + )); + } + } if gaps.is_empty() { return None; } @@ -758,6 +1783,7 @@ pub(in crate::agent) fn validate_autonomous_completion_contract( || binding.parent_run_id.is_some() || binding.root_agent_id != contract.agent_id || binding.root_run_id != contract.run_id + || !agent_runtime_supervisor_source_is_trusted(&binding.source) { return Err("自主构建完成合同与根 Supervisor Run 不匹配".to_string()); } @@ -797,10 +1823,7 @@ pub(in crate::agent) fn ensure_autonomous_completion_contract_for_task_at( return Ok(()); } if task.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || !matches!( - task.source.as_str(), - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE - ) + || !agent_runtime_supervisor_source_is_trusted(&task.source) { return Err("自主构建完成合同只允许可信根 Supervisor Run".to_string()); } @@ -1201,6 +2224,35 @@ pub(in crate::agent) fn autonomous_completion_contract_for_state_at( Ok(Some(contract)) } +pub(in crate::agent) fn autonomous_playtest_completion_contract_for_state_at( + root: &Path, + state: &AgentRuntimeState, +) -> Result, String> { + if let Some(contract) = autonomous_completion_contract_for_state_at(root, state)? { + return Ok(Some(contract)); + } + if state.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + return Ok(None); + } + let binding = + read_game_creator_agent_runtime_run_profile_binding(root, &state.agent_id, &state.run_id)? + .ok_or_else(|| "自主试玩 child Runtime 缺少 Run Profile 绑定".to_string())?; + if binding.parent_agent_id.as_deref() != Some(binding.root_agent_id.as_str()) + || binding.parent_run_id.as_deref() != Some(binding.root_run_id.as_str()) + { + return Err("自主试玩只接受根 Supervisor 的直接 manifest child".to_string()); + } + let contract = + read_autonomous_completion_contract(root, &binding.root_agent_id, &binding.root_run_id)? + .ok_or_else(|| "自主试玩 child Runtime 缺少根 Supervisor 完成合同".to_string())?; + if binding.parent_binding_fingerprint.as_deref() + != Some(contract.run_profile_binding_fingerprint.as_str()) + { + return Err("自主试玩 child Runtime 与根 Supervisor 完成合同绑定不匹配".to_string()); + } + Ok(Some(contract)) +} + pub(in crate::agent) fn autonomous_game_build_completion_blocker_at_locked( root: &Path, state: &AgentRuntimeState, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index d8a893254..90c87ba3a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -12,6 +12,156 @@ fn autonomous_fixture( autonomous_fixture_with_setup(task, run_id, |_| {}) } +fn autonomous_fixture_with_source( + task: &str, + run_id: &str, + source: &str, +) -> ( + tempfile::TempDir, + PathBuf, + AgentRuntimeState, + AgentRuntimeAutonomousCompletionContract, +) { + let temporary = tempfile::tempdir().expect("create autonomous fixture root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "autonomous-project", task).expect("init project"); + let session_id = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve supervisor session"); + let record = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &session_id, + task, + run_id, + source, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue autonomous task"); + let state = agent_runtime_state_from_task_record(&record); + let contract = read_autonomous_completion_contract( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &record.run_id, + ) + .expect("read completion contract") + .expect("completion contract exists"); + prepare_completed_autonomous_manifest_fixture(&root); + (temporary, root, state, contract) +} + +#[test] +fn autonomous_supervisor_source_allowlist_includes_game_chat_only() { + assert!(agent_runtime_supervisor_source_is_trusted( + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE + )); + assert!(agent_runtime_supervisor_source_is_trusted( + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE + )); + assert!(agent_runtime_supervisor_source_is_trusted( + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + )); + assert!(!agent_runtime_supervisor_source_is_trusted( + "project-supervisor-forged" + )); +} + +#[test] +fn game_chat_manifest_seed_projection_starts_three_directors_then_runs_three_task_lane() { + let game_chat_tasks = + autonomous_manifest_seed_tasks_for_source(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE); + assert_eq!( + game_chat_tasks + .iter() + .map(|task| task.id.as_str()) + .collect::>(), + [ + "design-director", + "art-director", + "code-director", + "code-prototype", + "preview-readiness", + "preview-playtest", + ] + ); + assert_eq!(game_chat_tasks[0].dependencies, Vec::::new()); + assert_eq!(game_chat_tasks[1].dependencies, Vec::::new()); + assert_eq!(game_chat_tasks[2].dependencies, Vec::::new()); + assert_eq!( + game_chat_tasks[3].dependencies, + vec![ + "design-director".to_string(), + "art-director".to_string(), + "code-director".to_string(), + ] + ); + assert_eq!( + game_chat_tasks[4].dependencies, + vec!["code-prototype".to_string()] + ); + assert_eq!( + game_chat_tasks[5].dependencies, + vec!["preview-readiness".to_string()] + ); + + let full_seed_tasks = new_game_creation_app_seed_tasks(); + for source in [ + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + ] { + assert_eq!( + autonomous_manifest_seed_tasks_for_source(source), + full_seed_tasks + ); + } + + let mut manifest_tasks = full_seed_tasks; + for task in &mut manifest_tasks { + if matches!( + task.id.as_str(), + "design-director" + | "art-director" + | "code-director" + | "code-prototype" + | "preview-readiness" + | "preview-playtest" + ) { + task.status = GameCreationAppTaskStatus::Pending; + } + } + assert_eq!( + autonomous_manifest_ready_task_ids( + &manifest_tasks, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ), + vec![ + "design-director".to_string(), + "art-director".to_string(), + "code-director".to_string(), + ] + ); + + for task_id in ["design-director", "art-director", "code-director"] { + manifest_tasks + .iter_mut() + .find(|task| task.id == task_id) + .unwrap_or_else(|| panic!("{task_id} task exists")) + .status = GameCreationAppTaskStatus::Completed; + } + assert_eq!( + autonomous_manifest_ready_task_ids( + &manifest_tasks, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ), + vec!["code-prototype".to_string()] + ); +} + fn autonomous_fixture_with_setup( task: &str, run_id: &str, @@ -57,16 +207,13 @@ fn autonomous_fixture_with_setup( } fn register_autonomous_visual_fixture(root: &Path, local_path: &str, kind: &str) { - let bytes = if kind == "art-spritesheet" { - use image::ImageEncoder; - let mut bytes = Vec::new(); - image::codecs::png::PngEncoder::new(&mut bytes) - .write_image(&[12, 34, 56, 0], 1, 1, image::ColorType::Rgba8.into()) - .expect("encode transparent autonomous visual fixture"); - bytes - } else { - b"\x89PNG\r\n\x1a\nfixture".to_vec() - }; + use image::ImageEncoder; + let alpha = if kind == "art-spritesheet" { 0 } else { 255 }; + let pixels = [12, 34, 56, alpha].repeat(64 * 64); + let mut bytes = Vec::new(); + image::codecs::png::PngEncoder::new(&mut bytes) + .write_image(&pixels, 64, 64, image::ColorType::Rgba8.into()) + .expect("encode autonomous visual fixture"); fs::write(root.join(local_path), bytes).expect("write autonomous visual fixture"); let (generation_route, generation_kind, reference_resource_ids) = match kind { "icon-spec" => ( @@ -461,6 +608,96 @@ fn autonomous_preview_manifest_roles_keep_their_fixed_read_only_core() { assert!(publish_package.contains("所有 Markdown checklist 必须使用 [x] 或 [X]")); } +#[test] +fn autonomous_preview_manifest_tasks_require_current_revision_receipts_before_completion() { + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("做一个完整小游戏", "autonomous-preview-task-receipt-parent"); + for (task_id, expected_summary) in [ + ("preview-readiness", "尚未通过当前 revision"), + ("preview-playtest", "preview-playtest"), + ] { + update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Running) + .expect("mark preview manifest task running"); + let child = queue_autonomous_manifest_child_fixture(&root, &parent_state, task_id); + let blocker = autonomous_game_build_completion_blocker_at_locked( + &root, + &agent_runtime_state_from_task_record(&child), + ) + .expect("preview task without current receipt must be blocked"); + assert!(blocker.summary.contains(expected_summary)); + update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Pending) + .expect("reset preview manifest task"); + } +} + +#[test] +fn autonomous_preview_manifest_tasks_accept_bound_current_revision_receipts() { + let (_temporary, root, parent_state, _contract) = autonomous_fixture( + "做一个完整小游戏", + "autonomous-preview-readiness-receipt-parent", + ); + update_manifest_task_status_at( + &root, + "preview-readiness", + GameCreationAppTaskStatus::Running, + ) + .expect("mark preview readiness running"); + let readiness_child = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-readiness"); + let readiness_state = agent_runtime_state_from_task_record(&readiness_child); + advance_game_index_revision( + &root, + &parent_state, + "静态检查通过", + ); + mark_verification_passed(&root, &readiness_state, "game.static_smoke"); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &readiness_state).is_none()); + + let (_temporary, root, parent_state, contract) = autonomous_fixture( + "做一个完整小游戏", + "autonomous-preview-playtest-receipt-parent", + ); + update_manifest_task_status_at( + &root, + "preview-playtest", + GameCreationAppTaskStatus::Running, + ) + .expect("mark preview playtest running"); + let playtest_child = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-playtest"); + let playtest_state = agent_runtime_state_from_task_record(&playtest_child); + let revision = advance_game_index_revision( + &root, + &parent_state, + "浏览器试玩通过", + ); + let result = browser_result_fixture( + &root, + &parent_state, + revision, + BrowserPlaytestScenario::GenericV1, + ); + let action = AgentRuntimeToolAction { + tool: "preview.validate".to_string(), + reason: Some("验证当前 revision 的真实可玩闭环".to_string()), + input: serde_json::json!({}), + }; + let action_fingerprint = + agent_runtime_tool_action_fingerprint(&action, &playtest_state.current_task); + let action_id = + agent_runtime_tool_action_id(&playtest_state.run_id, 1, 0, 1, &action_fingerprint); + write_autonomous_playtest_receipt_at( + &root, + &contract, + &action_id, + &action_fingerprint, + revision, + &result, + ) + .expect("persist child-bound autonomous playtest receipt"); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &playtest_state).is_none()); +} + #[test] fn autonomous_completion_rejects_formal_artifact_unchanged_from_run_baseline() { let baseline_bytes = @@ -843,6 +1080,473 @@ fn autonomous_parent_completion_lists_missing_seed_tasks_and_formal_artifacts() assert!(detail.contains("missingPaths=game/balance.json")); } +#[test] +fn game_chat_parent_completion_stops_after_preview_playtest_without_publish_tasks() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, state, contract) = autonomous_fixture_with_source( + "创建一轮植物塔防游戏", + "game-chat-single-round-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at( + &root, + "publish-strategy", + GameCreationAppTaskStatus::Pending, + ) + .expect("leave publish strategy pending"); + update_manifest_task_status_at(&root, "publish-package", GameCreationAppTaskStatus::Pending) + .expect("leave publish package pending"); + for task in new_game_creation_app_seed_tasks() { + if !matches!( + task.id.as_str(), + "design-director" + | "art-director" + | "code-director" + | "code-prototype" + | "preview-readiness" + | "preview-playtest" + ) { + update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Pending) + .unwrap_or_else(|error| panic!("leave non-fast-path task pending: {error}")); + } + } + let revision = advance_game_index_revision( + &root, + &state, + "", + ); + mark_verification_passed(&root, &state, "game.static_smoke"); + let result = browser_result_fixture(&root, &state, revision, contract.playtest_scenario); + let action = AgentRuntimeToolAction { + tool: "preview.validate".to_string(), + reason: Some("game-chat single round preview".to_string()), + input: serde_json::json!({}), + }; + let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task); + let action_id = agent_runtime_tool_action_id(&state.run_id, 1, 0, 1, &action_fingerprint); + write_autonomous_playtest_receipt_at( + &root, + &contract, + &action_id, + &action_fingerprint, + revision, + &result, + ) + .expect("persist game-chat playtest receipt"); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &state).is_none()); +} + +#[test] +fn game_chat_schedule_ready_tool_cannot_bypass_the_single_round_publish_boundary() { + let (_temporary, root, state, _contract) = autonomous_fixture_with_source( + "创建一轮植物塔防游戏", + "game-chat-schedule-ready-boundary", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + for task_id in ["publish-strategy", "publish-package"] { + update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Pending) + .expect("leave game-chat publish task pending"); + } + + let observation = observe_agent_runtime_schedule_ready_tasks( + &root, + &state.agent_id, + &state.run_id, + &serde_json::json!({ "limit": 16 }), + ); + + assert_eq!(observation.status, "ok"); + assert_eq!(observation.summary, "已调度 0 个 Ready 任务"); + let manifest = read_manifest_for_project(&root).expect("read game-chat manifest"); + for task_id in ["publish-strategy", "publish-package"] { + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == task_id) + .map(|task| &task.status), + Some(&GameCreationAppTaskStatus::Pending), + "game-chat must not schedule {task_id} after preview-playtest" + ); + } +} + +#[test] +fn game_chat_code_prototype_requires_registered_canvas_art_spec_visible_use() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"game-chat-art-gate-key"}}"#.to_string(), + ); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮植物塔防游戏", + "game-chat-code-art-gate-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("mark code prototype running"); + let code_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); + let code_state = agent_runtime_state_from_task_record(&code_record); + advance_game_index_revision( + &root, + &code_state, + "", + ); + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("missing art-spec reference must block code prototype"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("assets/art-spec.png"))); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!( + autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some(), + "URL relative to game/index.html must resolve to the registered asset" + ); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("an unused art-spec string must not satisfy visible-use validation"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("missing-visible-art-spec-use"))); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + for hidden_html in [ + "", + "", + "", + ] { + advance_game_index_revision(&root, &code_state, hidden_html); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + } + + advance_game_index_revision( + &root, + &code_state, + "
", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); +} + +#[test] +fn cli_code_prototype_keeps_registered_canvas_spritesheet_gate_when_editor_is_configured() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"cli-art-gate-key"}}"#.to_string(), + ); + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("创建完整小游戏", "cli-code-art-gate-parent"); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("mark CLI code prototype running"); + let code_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); + let code_state = agent_runtime_state_from_task_record(&code_record); + advance_game_index_revision( + &root, + &code_state, + "", + ); + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("CLI must still require the art spritesheet"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("assets/art-spritesheet.png"))); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); +} + +#[test] +fn game_chat_requires_canvas_art_spec_even_without_editor_configuration() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮必须使用平台美术的小游戏", + "game-chat-unconfigured-art-gate-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("mark code prototype running"); + let code_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); + let code_state = agent_runtime_state_from_task_record(&code_record); + fs::remove_file(root.join("assets/art-spec.png")).expect("remove registered art-spec file"); + advance_game_index_revision( + &root, + &code_state, + "", + ); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("game-chat must fail closed without a valid Canvas art spec"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("canvas-registration-invalid"))); +} + +#[test] +fn game_chat_art_stage_fails_closed_without_editor_configuration_or_canvas_asset() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮必须先完成美术阶段的小游戏", + "game-chat-unconfigured-art-stage-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at(&root, "art-director", GameCreationAppTaskStatus::Running) + .expect("mark art director running"); + let art_record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "art-director"); + let art_state = agent_runtime_state_from_task_record(&art_record); + fs::remove_file(root.join("assets/art-spec.png")).expect("remove Canvas art spec file"); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &art_state) + .expect("game-chat art stage must fail closed without a valid Canvas asset"); + assert!(blocker.summary.contains("Canvas")); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("task=art-director"))); +} + +#[test] +fn game_chat_initial_directors_can_converge_after_hydration_restores_manifest_to_pending() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮星空收集游戏", + "game-chat-ready-child-hydration-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + for task_id in ["design-director", "art-director", "code-director"] { + update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Pending) + .unwrap_or_else(|error| panic!("restore initial {task_id} to pending: {error}")); + let record = queue_autonomous_manifest_child_fixture(&root, &parent_state, task_id); + let mut state = agent_runtime_state_from_task_record(&record); + + assert!( + autonomous_game_build_completion_blocker_at_locked(&root, &state).is_none(), + "bound game-chat initial child {task_id} must survive late hydration" + ); + state.status = "completed".to_string(); + state.phase = "completed".to_string(); + assert!( + project_autonomous_manifest_ready_task_terminal_at(&root, &state) + .unwrap_or_else(|error| panic!("project completed {task_id}: {error}")) + ); + } + let manifest = read_manifest_for_project(&root).expect("read projected game-chat manifest"); + for task_id in ["design-director", "art-director", "code-director"] { + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == task_id) + .map(|task| &task.status), + Some(&GameCreationAppTaskStatus::Completed) + ); + } +} + +#[test] +fn game_chat_later_code_child_rejects_pending_then_projects_verified_completion() { + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮星空收集游戏", + "game-chat-code-pending-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Pending) + .expect("leave later code prototype pending"); + let code_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); + let mut code_state = agent_runtime_state_from_task_record(&code_record); + advance_game_index_revision( + &root, + &code_state, + "", + ); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("later code child must keep the strict manifest status gate"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("status=pending"))); + + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("restore the later code child to its authoritative running state"); + mark_verification_passed(&root, &code_state, "game.static_smoke"); + code_state.status = "completed".to_string(); + code_state.phase = "completed".to_string(); + assert!( + project_autonomous_manifest_ready_task_terminal_at(&root, &code_state) + .expect("project completed game-chat code child") + ); + let manifest = read_manifest_for_project(&root).expect("read projected game-chat manifest"); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .map(|task| &task.status), + Some(&GameCreationAppTaskStatus::Completed) + ); + let root_gate = read_game_creator_agent_runtime_verification_gate( + &root, + &parent_state.agent_id, + &parent_state.run_id, + ) + .expect("read projected root verification gate"); + assert_eq!(root_gate.verified_revision, Some(1)); + assert_eq!(root_gate.agent_id, parent_state.agent_id); + assert_eq!(root_gate.run_id, parent_state.run_id); + assert!(!root_gate.requires_verification); + assert_eq!(root_gate.mutation_revision, None); + assert_eq!( + root_gate.last_verification_status.as_deref(), + Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED) + ); + assert_eq!( + root_gate.last_verification_tool.as_deref(), + Some("game.static_smoke") + ); + for task_id in ["preview-readiness", "preview-playtest"] { + update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Completed) + .unwrap_or_else(|error| panic!("complete {task_id}: {error}")); + } + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &parent_state) + .expect("missing root playtest receipt must still block completion"); + assert!(blocker.summary.contains("交互试玩回执")); +} + +#[test] +fn game_chat_preview_child_still_rejects_pending_manifest_status() { + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮星空收集游戏", + "game-chat-preview-pending-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at( + &root, + "preview-readiness", + GameCreationAppTaskStatus::Pending, + ) + .expect("leave preview readiness pending"); + let preview_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-readiness"); + let preview_state = agent_runtime_state_from_task_record(&preview_record); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &preview_state) + .expect("preview child must keep the strict manifest status gate"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("status=pending"))); +} + +#[test] +fn gui_ready_child_still_rejects_pending_manifest_status() { + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("创建完整小游戏", "gui-ready-child-pending-manifest-parent"); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Pending) + .expect("mark GUI code prototype pending"); + let code_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); + let code_state = agent_runtime_state_from_task_record(&code_record); + advance_game_index_revision( + &root, + &code_state, + "", + ); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("GUI child must keep the strict manifest status gate"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("status=pending"))); +} + #[test] fn autonomous_ready_child_missing_or_invalid_owner_artifact_is_blocked() { let (_temporary, root, parent_state, _contract) = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs index 490a355e0..6c162f963 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs @@ -523,8 +523,14 @@ pub(in crate::agent) fn read_game_creator_agent_runtime_context_bundle_with_supe { return Err("Agent Runtime context bundle 的停滞标记只能出现在上下文窗口边界".to_string()); } + let loop_limit = u32::try_from(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT).unwrap_or(1); + let completed_loop_remainder = bundle.next_loop_index % loop_limit; let max_completed_loops = - bundle.next_loop_index % u32::try_from(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT).unwrap_or(1); + if bundle.next_loop_index > 0 && completed_loop_remainder == 0 && !bundle.context_stalled { + loop_limit.saturating_sub(1) + } else { + completed_loop_remainder + }; if bundle.window_completed_loops > max_completed_loops { return Err(format!( "Agent Runtime context bundle 窗口轮次无效:windowCompletedLoops={} max={max_completed_loops}", @@ -809,3 +815,65 @@ pub(in crate::agent) fn persist_game_creator_agent_runtime_pause_boundary_contex context_tracker, ) } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn agent_runtime_context_bundle_restores_pre_checkpoint_window_boundary() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "genarrative-context-window-boundary-{}-{unique}", + std::process::id() + )); + init_local_game_project_at(&root, "project-1", "上下文窗口边界恢复项目") + .expect("project init"); + let mut runtime = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "验证窗口边界恢复", + "design-context-window-boundary-run", + "agent-background-task", + "窗口边界恢复测试", + vec!["恢复 checkpoint 前的窗口状态".to_string()], + ) + .expect("start window boundary runtime state"); + let mut tracker = AgentRuntimeContextWindowTracker::default(); + for next_loop_index in 1..AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { + assert_eq!( + tracker.complete_loop(next_loop_index), + AgentRuntimeContextCheckpoint::Continue + ); + } + assert_eq!(tracker.completed_loops, 5); + + let bundle = build_game_creator_agent_runtime_context_bundle( + &root, + &runtime, + &runtime.current_task, + &AgentRuntimeToolPlan::default(), + &[], + AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT, + &tracker, + ) + .expect("build pre-checkpoint boundary bundle"); + assert_eq!(bundle.next_loop_index, 6); + assert_eq!(bundle.context_window, 2); + assert_eq!(bundle.window_completed_loops, 5); + write_game_creator_agent_runtime_context_bundle(&root, &bundle) + .expect("write pre-checkpoint boundary bundle"); + + runtime.loop_iteration = 6; + let loaded = read_game_creator_agent_runtime_context_bundle(&root, &runtime) + .expect("pre-checkpoint boundary bundle must remain recoverable") + .expect("pre-checkpoint boundary bundle exists"); + assert_eq!(loaded.window_completed_loops, 5); + + fs::remove_dir_all(root).ok(); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs index 209a344da..7352ad379 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs @@ -185,8 +185,6 @@ pub(in crate::agent) fn visible_game_creator_agent_runtime_response_stream_at( if stream.task_id != state.task_id || stream.session_id != state.session_id || stream.applied_steer_cursor != state.applied_steer_cursor - || stream.response_revision - != read_game_creator_agent_runtime_project_revision(root)?.revision || stream.request_slot != game_creator_agent_runtime_response_stream_request_slot( state, @@ -195,10 +193,14 @@ pub(in crate::agent) fn visible_game_creator_agent_runtime_response_stream_at( { return Ok(None); } + let response_revision_is_current = stream.response_revision + == read_game_creator_agent_runtime_project_revision(root)?.revision; let visible = match stream.status.as_str() { AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING | AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY => { - state.status == "running" && matches!(state.phase.as_str(), "response" | "finalizing") + response_revision_is_current + && state.status == "running" + && matches!(state.phase.as_str(), "response" | "finalizing") } AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED => { matches!(state.status.as_str(), "idle" | "completed") && state.phase == "completed" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs index c1ce7a907..dac96b4e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs @@ -111,10 +111,7 @@ pub(in crate::agent) fn validate_agent_runtime_run_profile_binding_record( } if binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && (binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || !matches!( - binding.source.as_str(), - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE - )) + || !agent_runtime_supervisor_source_is_trusted(&binding.source)) { return Err("自主构建 Run Profile 只允许可信 Supervisor 入口绑定".to_string()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 24927fb05..12f7ff72f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -1,5 +1,118 @@ use super::*; +static AGENT_RUNTIME_EVENT_ID_SEQUENCE: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(1); + +fn new_game_creator_agent_runtime_event_id( + state: &AgentRuntimeState, + event_type: &str, + phase: &str, + action_id: Option<&str>, +) -> String { + if let Some(action_id) = action_id { + return format!( + "runtime-event-action-{}-{}-{}-{}", + state.run_id, event_type, phase, action_id + ); + } + let sequence = + AGENT_RUNTIME_EVENT_ID_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + format!( + "runtime-event-{}-{}-{}-{}", + std::process::id(), + unix_millis(), + sequence, + event_type + ) +} + +fn game_creator_agent_runtime_event_type_is_public(event_type: &str) -> bool { + matches!( + event_type, + "thinking_summary" + | "plan" + | "plan_update" + | "action" + | "observation" + | "turn.started" + | "turn.progress" + | "turn.completed" + | "turn.failed" + | "turn.budget_exhausted" + | "turn.cancelled" + | "response" + | "response.stale" + | "goal.paused" + | "goal.resumed" + | "agent.delegate.result" + | "agent.delegate.result_failed" + ) || event_type.starts_with("tool_confirmation.") + || event_type.starts_with("user_input.") +} + +fn game_creator_agent_runtime_public_event_text( + root: &Path, + event_type: &str, + summary: &str, +) -> Option { + let event_type = event_type.trim(); + if !game_creator_agent_runtime_event_type_is_public(event_type) { + return None; + } + let summary = redact_agent_runtime_error(root, summary.trim(), 240); + if summary.is_empty() { + return None; + } + let lower = summary.to_ascii_lowercase(); + if [ + "runtime.", + "agent.runtime.", + "provider.", + "provider_request.", + "parallel_read_batch.", + "provider_action_batch.", + "finalization.", + "context.", + "process_session.", + "steer.", + "autonomous_manifest.parent_wake", + "agent.delegate.parent_wake", + "command.exec:", + "command.exec:", + "command.output_read:", + "command.output_read:", + "agent.action_history:", + "agent.action_history:", + ] + .iter() + .any(|prefix| lower.starts_with(prefix)) + { + return None; + } + if [ + "sha256", + "fingerprint", + "authorization", + "bearer", + "api key", + "api_key", + "password", + "secret", + "cookie", + "token=", + "private process output", + " { Ok(None) @@ -2318,10 +2436,12 @@ pub(super) fn append_game_creator_agent_runtime_event_with_action( run_id: state.run_id.clone(), source: state.source.clone(), event_type: event_type.to_string(), + event_id: new_game_creator_agent_runtime_event_id(state, event_type, phase, action_id), action_id: action_id.map(ToString::to_string), status: status.to_string(), phase: phase.to_string(), summary: summary.to_string(), + public_text: game_creator_agent_runtime_public_event_text(root, event_type, summary), detail: detail .filter(|_| { !(event_type == "observation" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index f5aa2c596..cb3addee3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -228,6 +228,34 @@ pub(in crate::agent) fn render_static_delegate_task_contract( Ok(rendered) } +fn validate_publish_delegate_run_profile_at( + root: &Path, + agent_id: &str, + parent_run_id: &str, + target_agent_id: &str, +) -> Result<(), String> { + if !matches!(target_agent_id, "publish-strategy" | "publish-package") { + return Ok(()); + } + let current_binding = + read_game_creator_agent_runtime_run_profile_binding(root, agent_id, parent_run_id)? + .ok_or_else(|| { + "agent.delegate 缺少当前 Run Profile binding,已拒绝发布委派".to_string() + })?; + let root_binding = read_game_creator_agent_runtime_run_profile_binding( + root, + ¤t_binding.root_agent_id, + ¤t_binding.root_run_id, + )? + .ok_or_else(|| "agent.delegate 缺少 root Run Profile binding,已拒绝发布委派".to_string())?; + if root_binding.source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE { + return Err(format!( + "game-chat Run Profile 禁止委派 {target_agent_id},未创建 child runtime" + )); + } + Ok(()) +} + pub(crate) fn observe_agent_runtime_agent_delegate( root: &Path, agent_id: &str, @@ -359,6 +387,16 @@ pub(crate) fn observe_agent_runtime_agent_delegate( detail: None, }; } + if let Err(error) = + validate_publish_delegate_run_profile_at(root, agent_id, parent_run_id, &target_agent_id) + { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } let action_identity = action_id .filter(|value| !value.trim().is_empty()) .map(str::to_string) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs index 0f2815ff7..321e21aec 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs @@ -1347,12 +1347,31 @@ pub(in crate::agent) fn record_game_creator_agent_runtime_receipt_start_warning( pub(in crate::agent) fn observe_agent_runtime_schedule_ready_tasks( root: &Path, + agent_id: &str, + run_id: &str, input: &serde_json::Value, ) -> AgentRuntimeToolObservation { let limit = agent_runtime_tool_input_usize(input, &["limit", "maxTasks"]) .map(|value| value.clamp(1, 16)) .unwrap_or(16); - match schedule_game_creator_agent_ready_tasks_at(root, limit) { + let scheduled = + match read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id) { + Ok(Some(binding)) + if binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD => + { + schedule_autonomous_game_build_ready_tasks_at( + root, + &binding.root_agent_id, + &binding.root_run_id, + limit.min(3), + ) + } + Ok(_) => schedule_game_creator_agent_ready_tasks_at(root, limit), + Err(error) => Err(format!( + "agent.schedule_ready 无法核对当前 Run Profile 绑定:{error}" + )), + }; + match scheduled { Ok(results) => { let detail = results .iter() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs index d32bb1d3a..3fd2ab07a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs @@ -1,5 +1,64 @@ use super::*; +const AGENT_RUNTIME_PREVIEW_INFRASTRUCTURE_ERROR_KIND: &str = "preview-infrastructure-unavailable"; + +fn agent_runtime_preview_infrastructure_failure_kind(error: &str) -> Option<&'static str> { + if error.contains("before websocket URL could be resolved") + || error.contains("启动浏览器失败") + || error.contains("启动浏览器超时") + { + Some("browser-launch-failed") + } else if error.contains("未发现可用的 Google Chrome") { + Some("browser-not-found") + } else if error.contains("创建浏览器临时目录失败") + || error.contains("创建浏览器临时 Profile 失败") + || error.contains("构建浏览器配置失败") + { + Some("browser-environment-invalid") + } else { + None + } +} + +fn agent_runtime_preview_infrastructure_observation( + root: &Path, + revision: u64, + failure_kind: &str, + error: &str, +) -> AgentRuntimeToolObservation { + let detail = serde_json::to_string(&serde_json::json!({ + "errorKind": AGENT_RUNTIME_PREVIEW_INFRASTRUCTURE_ERROR_KIND, + "failureKind": failure_kind, + "revision": revision, + "diagnostic": redact_agent_runtime_project_paths(root, error, 500), + })) + .ok(); + AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "blocked".to_string(), + summary: "浏览器验证基础设施不可用,当前任务已停止,未重复重试".to_string(), + detail, + } +} + +pub(in crate::agent) fn agent_runtime_preview_infrastructure_blocker( + observation: &AgentRuntimeToolObservation, +) -> Option { + if observation.tool != "preview.validate" || observation.status != "blocked" { + return None; + } + let detail = serde_json::from_str::(observation.detail.as_deref()?).ok()?; + (detail.get("errorKind").and_then(serde_json::Value::as_str) + == Some(AGENT_RUNTIME_PREVIEW_INFRASTRUCTURE_ERROR_KIND)) + .then(|| { + detail + .get("failureKind") + .and_then(serde_json::Value::as_str) + .unwrap_or("browser-infrastructure") + .to_string() + }) +} + pub(in crate::agent) fn observe_agent_runtime_preview_start( root: &Path, agent_id: &str, @@ -115,17 +174,18 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( }; } }; - let completion_contract = match autonomous_completion_contract_for_state_at(root, &runtime) { - Ok(contract) => contract, - 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 completion_contract = + match autonomous_playtest_completion_contract_for_state_at(root, &runtime) { + Ok(contract) => contract, + 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)), + }; + } + }; if let (Some(contract), Some(requested)) = ( completion_contract.as_ref(), input.playtest_scenario.as_ref(), @@ -143,8 +203,10 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( .as_ref() .map(|contract| contract.playtest_scenario.clone()) .or(input.playtest_scenario); - if completion_contract.is_some() { - if let Err(error) = remove_autonomous_playtest_receipt(root, agent_id, run_id) { + if let Some(contract) = completion_contract.as_ref() { + if let Err(error) = + remove_autonomous_playtest_receipt(root, &contract.agent_id, &contract.run_id) + { return AgentRuntimeToolObservation { tool: "preview.validate".to_string(), status: "failed".to_string(), @@ -186,10 +248,14 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( } }, }; + let (evidence_agent_id, evidence_run_id) = completion_contract + .as_ref() + .map(|contract| (contract.agent_id.as_str(), contract.run_id.as_str())) + .unwrap_or((agent_id, run_id)); let evidence_relative_root = format!( ".agent/runtime/browser-validations/{}/{}/{}", - agent_runtime_confirmation_path_component(agent_id, "agent"), - agent_runtime_confirmation_path_component(run_id, "run"), + agent_runtime_confirmation_path_component(evidence_agent_id, "agent"), + agent_runtime_confirmation_path_component(evidence_run_id, "run"), revision_before.revision, ); let evidence_root = match resolve_local_project_path(root, &evidence_relative_root) { @@ -219,6 +285,14 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( let result = match validation { Ok(result) => result, Err(error) => { + if let Some(failure_kind) = agent_runtime_preview_infrastructure_failure_kind(&error) { + return agent_runtime_preview_infrastructure_observation( + root, + revision_before.revision, + failure_kind, + &error, + ); + } return AgentRuntimeToolObservation { tool: "preview.validate".to_string(), status: "failed".to_string(), @@ -271,7 +345,10 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( } } - if completion_contract.is_some() && !result.passed { + let contract_belongs_to_runtime = completion_contract.as_ref().is_some_and(|contract| { + contract.agent_id == runtime.agent_id && contract.run_id == runtime.run_id + }); + if contract_belongs_to_runtime && !result.passed { if let Err(error) = invalidate_agent_runtime_project_verification_after_preview_failure_at( root, agent_id, @@ -317,18 +394,20 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( }; } }; - if let Err(error) = clear_agent_runtime_failed_playtest_at( - root, - agent_id, - run_id, - revision_after.revision, - ) { - return AgentRuntimeToolObservation { - tool: "preview.validate".to_string(), - status: "failed".to_string(), - summary: "浏览器验证已通过,但失败试玩凭证无法安全清除".to_string(), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }; + if contract_belongs_to_runtime { + if let Err(error) = clear_agent_runtime_failed_playtest_at( + root, + agent_id, + run_id, + revision_after.revision, + ) { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器验证已通过,但失败试玩凭证无法安全清除".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } } receipt } @@ -399,3 +478,38 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( detail, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn browser_websocket_launch_exit_is_classified_as_infrastructure_failure() { + let error = "启动浏览器失败:Browser process exited with status ExitStatus(0) before websocket URL could be resolved, stderr=\"\""; + assert_eq!( + agent_runtime_preview_infrastructure_failure_kind(error), + Some("browser-launch-failed") + ); + let observation = agent_runtime_preview_infrastructure_observation( + Path::new("/project"), + 19, + "browser-launch-failed", + error, + ); + assert_eq!(observation.status, "blocked"); + assert_eq!( + agent_runtime_preview_infrastructure_blocker(&observation).as_deref(), + Some("browser-launch-failed") + ); + } + + #[test] + fn gameplay_validation_failure_is_not_an_infrastructure_failure() { + assert_eq!( + agent_runtime_preview_infrastructure_failure_kind( + "浏览器验证未通过,请根据诊断修复后重试" + ), + None + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs index bf93ebda3..e8769c440 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs @@ -2,15 +2,37 @@ use super::*; pub(in crate::agent) fn observe_agent_runtime_task_list( root: &Path, + agent_id: &str, + run_id: &str, ) -> AgentRuntimeToolObservation { - let result = read_manifest_for_project(root).map(|manifest| { - let ready_task_ids = ready_task_ids_for_tasks(&manifest.tasks); - let seed_task_ids = new_game_creation_app_seed_tasks() - .into_iter() - .map(|task| task.id) - .collect::>(); - let seed_tasks = manifest - .tasks + let result = (|| -> Result { + let game_chat_single_round = root_run_source_is_game_chat(root, agent_id, run_id)?; + let manifest = read_manifest_for_project(root)?; + let visible_tasks = if game_chat_single_round { + autonomous_manifest_seed_tasks_for_source(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE) + .into_iter() + .map(|mut projected| { + if let Some(persisted) = + manifest.tasks.iter().find(|task| task.id == projected.id) + { + projected.status = persisted.status.clone(); + } + projected + }) + .collect::>() + } else { + manifest.tasks.clone() + }; + let ready_task_ids = ready_task_ids_for_tasks(&visible_tasks); + let seed_task_ids = autonomous_manifest_seed_tasks_for_source(if game_chat_single_round { + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + } else { + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE + }) + .into_iter() + .map(|task| task.id) + .collect::>(); + let seed_tasks = visible_tasks .iter() .filter(|task| seed_task_ids.contains(&task.id)) .collect::>(); @@ -19,28 +41,23 @@ pub(in crate::agent) fn observe_agent_runtime_task_list( } else { ready_task_ids.join(", ") }; - let completed = manifest - .tasks + let completed = visible_tasks .iter() .filter(|task| task.status == GameCreationAppTaskStatus::Completed) .count(); - let running = manifest - .tasks + let running = visible_tasks .iter() .filter(|task| task.status == GameCreationAppTaskStatus::Running) .count(); - let pending = manifest - .tasks + let pending = visible_tasks .iter() .filter(|task| task.status == GameCreationAppTaskStatus::Pending) .count(); - let waiting = manifest - .tasks + let waiting = visible_tasks .iter() .filter(|task| task.status == GameCreationAppTaskStatus::WaitingForConfirmation) .count(); - let failed = manifest - .tasks + let failed = visible_tasks .iter() .filter(|task| task.status == GameCreationAppTaskStatus::Failed) .count(); @@ -72,10 +89,10 @@ pub(in crate::agent) fn observe_agent_runtime_task_list( ), format!( "taskCounts: completed={completed} running={running} pending={pending} waiting={waiting} failed={failed} total={}", - manifest.tasks.len() + visible_tasks.len() ), ]; - lines.extend(manifest.tasks.iter().map(|task| { + lines.extend(visible_tasks.iter().map(|task| { let dependencies = if task.dependencies.is_empty() { "-".to_string() } else { @@ -97,11 +114,146 @@ pub(in crate::agent) fn observe_agent_runtime_task_list( artifacts ) })); - lines.join("\n") - }); + Ok(lines.join("\n")) + })(); observation_from_text_result("task.list", result, "已读取 manifest 任务图") } +fn root_run_source_is_game_chat(root: &Path, agent_id: &str, run_id: &str) -> Result { + let binding = read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? + .ok_or_else(|| "task.list 缺少当前 Run Profile binding,无法确认运行来源".to_string())?; + let root_binding = if binding.root_agent_id == binding.agent_id + && binding.root_run_id == binding.run_id + { + binding + } else { + read_game_creator_agent_runtime_run_profile_binding( + root, + &binding.root_agent_id, + &binding.root_run_id, + )? + .ok_or_else(|| "task.list 缺少 root Run Profile binding,无法确认运行来源".to_string())? + }; + Ok(root_binding.source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn game_chat_task_list_hides_publish_tasks_and_counts() { + let temporary = tempfile::tempdir().expect("create task list project"); + let root = temporary.path(); + init_local_game_project_at(root, "game-chat-task-list", "game-chat task list") + .expect("initialize project"); + bind_game_creator_agent_runtime_run_profile_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "game-chat-task-list-run", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind game-chat run"); + for task_id in [ + "design-director", + "art-director", + "code-director", + "code-prototype", + "preview-readiness", + "preview-playtest", + ] { + update_manifest_task_status_at(root, task_id, GameCreationAppTaskStatus::Completed) + .expect("complete game-chat seed task"); + } + + let observation = observe_agent_runtime_task_list( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "game-chat-task-list-run", + ); + assert_eq!(observation.status, "ok"); + let detail = observation.detail.expect("task list detail"); + assert!(detail.contains("readyTaskIds: (none)"), "{detail}"); + assert!(!detail.contains("publish-strategy"), "{detail}"); + assert!(!detail.contains("publish-package"), "{detail}"); + assert!( + detail.contains( + "seedTaskCounts: completed=6 running=0 pending=0 waiting=0 failed=0 total=6" + ), + "{detail}" + ); + assert!( + detail + .contains("taskCounts: completed=6 running=0 pending=0 waiting=0 failed=0 total=6"), + "{detail}" + ); + + for task in new_game_creation_app_seed_tasks().into_iter().take(14) { + update_manifest_task_status_at(root, &task.id, GameCreationAppTaskStatus::Completed) + .expect("complete full pre-publish DAG for GUI comparison"); + } + + bind_game_creator_agent_runtime_run_profile_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "gui-task-list-run", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind GUI run"); + let gui_observation = observe_agent_runtime_task_list( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "gui-task-list-run", + ); + assert_eq!(gui_observation.status, "ok"); + let gui_detail = gui_observation.detail.expect("GUI task list detail"); + assert!( + gui_detail.contains("readyTaskIds: publish-strategy"), + "{gui_detail}" + ); + assert!( + gui_detail.contains( + "taskCounts: completed=14 running=0 pending=2 waiting=0 failed=0 total=16" + ), + "{gui_detail}" + ); + assert!(gui_detail.contains("publish-strategy"), "{gui_detail}"); + } + + #[test] + fn task_list_fails_closed_when_current_binding_parent_is_missing() { + let temporary = tempfile::tempdir().expect("create task list project"); + let root = temporary.path(); + init_local_game_project_at(root, "game-chat-task-list-missing-parent", "task list") + .expect("initialize project"); + let parent_run_id = "missing-game-chat-parent"; + let task_link = AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + ..Default::default() + }; + bind_game_creator_agent_runtime_run_profile_at( + root, + "code-prototype", + "game-chat-child-run", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + Some(&task_link), + ) + .expect("bind child run"); + + let observation = + observe_agent_runtime_task_list(root, "code-prototype", "game-chat-child-run"); + assert_eq!(observation.status, "failed"); + assert!(observation.detail.is_none()); + assert!(observation.summary.contains("binding"), "{observation:?}"); + } +} + pub(in crate::agent) fn observe_agent_runtime_task_create( root: &Path, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index 9d60ee983..9f08cf712 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -309,6 +309,9 @@ pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) "llm.toolOutputTokenLimit={}", status.tool_output_token_limit ), + format!("llm.requestTimeoutMs={}", status.request_timeout_ms), + format!("llm.maxRetries={}", status.max_retries), + format!("llm.retryBackoffMs={}", status.retry_backoff_ms), ]; for agent in &status.agents { lines.push(format!( @@ -357,6 +360,18 @@ pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) "llm.agent.{}.toolOutputTokenLimit={}", agent.agent_id, agent.tool_output_token_limit )); + lines.push(format!( + "llm.agent.{}.requestTimeoutMs={}", + agent.agent_id, agent.request_timeout_ms + )); + lines.push(format!( + "llm.agent.{}.maxRetries={}", + agent.agent_id, agent.max_retries + )); + lines.push(format!( + "llm.agent.{}.retryBackoffMs={}", + agent.agent_id, agent.retry_backoff_ms + )); if let Some(error) = agent.error.as_deref() { lines.push(format!("llm.agent.{}.error={error}", agent.agent_id)); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs b/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs index 82ad1f376..0c1a01ee5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs @@ -22,8 +22,6 @@ const SUPERVISOR_COLLABORATION_POLICY_BINDING_LEGACY_CURRENT: &str = const SUPERVISOR_COLLABORATION_MAX_STATIC_DELEGATES: usize = 3; const SUPERVISOR_COLLABORATION_MAX_ISOLATED_CHILDREN: usize = 3; const SUPERVISOR_COLLABORATION_MAX_ISOLATED_GROUPS_BEFORE_CLAIM: usize = 16; -const AUTONOMOUS_GAME_BUILD_BASE_REQUIRED_STATIC_AGENT_IDS: [&str; 2] = - ["code-prototype", "quality-review"]; #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] @@ -67,93 +65,10 @@ impl Default for SupervisorCollaborationPolicy { } } -fn autonomous_game_build_has_canonical_art_asset(root: &Path) -> bool { - read_existing_manifest_for_project(root) - .ok() - .is_some_and(|manifest| { - manifest_has_required_visual_asset(root, &manifest, "art-asset-plan") - }) -} - -fn autonomous_game_build_has_canonical_art_spec(root: &Path) -> bool { - read_existing_manifest_for_project(root) - .ok() - .is_some_and(|manifest| manifest_has_required_visual_asset(root, &manifest, "art-director")) -} - -fn autonomous_game_build_has_canonical_ui_prototype(root: &Path) -> bool { - read_existing_manifest_for_project(root) - .ok() - .is_some_and(|manifest| { - manifest_has_required_visual_asset(root, &manifest, "design-foundation") - }) -} - fn autonomous_game_build_supervisor_collaboration_policy( - root: &Path, + _root: &Path, ) -> SupervisorCollaborationPolicy { - let mut required_static_agent_ids = AUTONOMOUS_GAME_BUILD_BASE_REQUIRED_STATIC_AGENT_IDS - .into_iter() - .map(str::to_string) - .collect::>(); - if editor_api_key_is_configured() { - if !autonomous_game_build_has_canonical_art_spec(root) { - required_static_agent_ids.push("art-director".to_string()); - } else if !autonomous_game_build_has_canonical_ui_prototype(root) { - required_static_agent_ids.push("design-foundation".to_string()); - } else if !autonomous_game_build_has_canonical_art_asset(root) { - required_static_agent_ids.push("art-asset-plan".to_string()); - } - } - SupervisorCollaborationPolicy { - required_initial_wave: SupervisorInitialCollaborationWave::Static, - min_static_delegates: required_static_agent_ids.len(), - required_static_agent_ids, - ..SupervisorCollaborationPolicy::default() - } -} - -fn apply_autonomous_game_build_required_static_agents( - root: &Path, - mut policy: SupervisorCollaborationPolicy, -) -> Result { - if editor_api_key_is_configured() { - if !autonomous_game_build_has_canonical_art_spec(root) - && !policy - .required_static_agent_ids - .iter() - .any(|existing| existing == "art-director") - { - policy - .required_static_agent_ids - .push("art-director".to_string()); - } else if autonomous_game_build_has_canonical_art_spec(root) - && !autonomous_game_build_has_canonical_ui_prototype(root) - && !policy - .required_static_agent_ids - .iter() - .any(|existing| existing == "design-foundation") - { - policy - .required_static_agent_ids - .push("design-foundation".to_string()); - } else if autonomous_game_build_has_canonical_art_spec(root) - && autonomous_game_build_has_canonical_ui_prototype(root) - && !autonomous_game_build_has_canonical_art_asset(root) - && !policy - .required_static_agent_ids - .iter() - .any(|existing| existing == "art-asset-plan") - { - policy - .required_static_agent_ids - .push("art-asset-plan".to_string()); - } - } - policy.min_static_delegates = policy - .min_static_delegates - .max(policy.required_static_agent_ids.len()); - normalize_supervisor_collaboration_policy(policy) + SupervisorCollaborationPolicy::default() } #[derive(Clone, Debug)] @@ -176,10 +91,7 @@ fn read_supervisor_collaboration_unbound_policy_for_run_at( let policy_path = root.join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH); match fs::symlink_metadata(&policy_path) { Ok(_) => { - let mut policy = read_supervisor_collaboration_policy_at(root)?; - if autonomous_supervisor { - policy = apply_autonomous_game_build_required_static_agents(root, policy)?; - } + let policy = read_supervisor_collaboration_policy_at(root)?; return Ok(SupervisorCollaborationUnboundPolicy { policy, source: "project-policy-unbound", diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 67a4f83f4..5c9f77367 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -489,6 +489,7 @@ pub(crate) fn start_game_creator_supervisor_runtime_task( task: String, run_id: String, run_profile: Option, + source: Option, ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; @@ -499,12 +500,20 @@ pub(crate) fn start_game_creator_supervisor_runtime_task( .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(AGENT_RUNTIME_RUN_PROFILE_STANDARD); + let source = source + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE); + if !agent_runtime_supervisor_source_is_trusted(source) { + return Err("Project Supervisor 提交 source 不受信任".to_string()); + } start_game_creator_supervisor_background_task_for_session_at( root, session_id.as_deref(), task.trim(), run_id.trim(), - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + source, run_profile, ) } @@ -663,11 +672,30 @@ pub(crate) fn steer_game_creator_agent_runtime_task( steer_id: String, instruction: String, run_profile: Option, + source: Option, ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; enforce_project_permission_policy(root, "conversation.write")?; enforce_project_permission_policy(root, "agent.run_status")?; + if let Some(source) = source + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if !agent_runtime_supervisor_source_is_trusted(source) { + return Err("Project Supervisor steer source 不受信任".to_string()); + } + let task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + agent_id.trim(), + run_id.trim(), + )? + .ok_or_else(|| "Agent Runtime steer 的 Run 不存在".to_string())?; + if task.source != source { + return Err("Agent Runtime steer source 与当前 Run 不一致".to_string()); + } + } let mut result = steer_game_creator_agent_runtime_task_for_profile_at( root, agent_id.trim(), @@ -840,8 +868,11 @@ pub(crate) fn resume_game_creator_agent_runtime_tasks( ) -> Result, String> { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; enforce_project_permission_policy(root, "agent.run_status")?; + if !has_recoverable_game_creator_agent_background_tasks_at(root)? { + return Ok(Vec::new()); + } + enforce_project_permission_policy(root, "conversation.write")?; enforce_project_auto_permission_policy(root, "agent.resume")?; resume_game_creator_agent_background_tasks_at(root) } @@ -1305,16 +1336,26 @@ pub(crate) fn append_local_conversation_message( agent_id: Option, session_id: Option, message: LocalConversationMessage, + message_id: Option, ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.write")?; let _lock = acquire_project_write_lock(root, "conversation.write")?; - append_local_conversation_message_for_session_at( - root, - agent_id.as_deref(), - session_id.as_deref(), - message, - ) + match message_id.as_deref() { + Some(message_id) => append_local_conversation_message_for_session_idempotent_at( + root, + agent_id.as_deref(), + session_id.as_deref(), + message, + message_id, + ), + None => append_local_conversation_message_for_session_at( + root, + agent_id.as_deref(), + session_id.as_deref(), + message, + ), + } } #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 19fded1e3..7561cb15b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -1,5 +1,37 @@ use super::*; +pub(crate) const GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS: [(&str, &str); 21] = [ + (GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "high"), + ("planner", "high"), + ("orchestrator", "medium"), + ("generator", "high"), + ("evaluator", "high"), + ("design-director", "medium"), + ("design-foundation", "high"), + ("balance-director", "medium"), + ("balance-seed", "medium"), + ("art-director", "high"), + ("art-asset-plan", "high"), + ("art-polish", "medium"), + ("audio-director", "low"), + ("audio-asset-plan", "medium"), + ("code-director", "medium"), + ("code-prototype", "high"), + ("quality-review", "high"), + ("preview-readiness", "low"), + ("preview-playtest", "low"), + ("publish-strategy", "low"), + ("publish-package", "medium"), +]; + +pub(crate) fn game_creator_llm_agent_default_reasoning_effort( + agent_id: &str, +) -> Option<&'static str> { + GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS + .iter() + .find_map(|(candidate, effort)| (*candidate == agent_id).then_some(*effort)) +} + pub(crate) fn build_game_creator_llm_client_from_llm_config( llm: &GameCreatorLlmConfig, config_path: &str, @@ -137,6 +169,9 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi context_window_tokens: DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS, auto_compact_token_limit: DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT, tool_output_token_limit: DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT, + request_timeout_ms: GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS, + max_retries: DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES, + retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS, error: Some(error), agents: Vec::new(), } @@ -248,6 +283,9 @@ pub(crate) fn check_game_creator_llm_config_values( context_window_tokens: config.context_window_tokens, auto_compact_token_limit: config.auto_compact_token_limit, tool_output_token_limit: config.tool_output_token_limit, + request_timeout_ms: config.request_timeout_ms, + max_retries: config.max_retries, + retry_backoff_ms: config.retry_backoff_ms, error, agents: Vec::new(), } @@ -287,6 +325,9 @@ pub(crate) fn check_game_creator_agent_llm_config_values( context_window_tokens: config.context_window_tokens, auto_compact_token_limit: config.auto_compact_token_limit, tool_output_token_limit: config.tool_output_token_limit, + request_timeout_ms: config.request_timeout_ms, + max_retries: config.max_retries, + retry_backoff_ms: config.retry_backoff_ms, error: status.error, } } @@ -634,6 +675,24 @@ pub(crate) fn initialize_windows_game_creator_file_owner_for_current_user( secure_windows_game_creator_path_for_current_user_with_owner_policy(path, false, true, true) } +#[cfg(windows)] +pub(crate) fn windows_private_dacl_security_information( + initialize_owner: bool, + owner_matches: bool, +) -> u32 { + const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001; + const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004; + const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000; + + DACL_SECURITY_INFORMATION + | PROTECTED_DACL_SECURITY_INFORMATION + | if initialize_owner && !owner_matches { + OWNER_SECURITY_INFORMATION + } else { + 0 + } +} + #[cfg(windows)] fn secure_windows_game_creator_path_for_current_user_with_owner_policy( path: &Path, @@ -754,7 +813,6 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( const SE_FILE_OBJECT: u32 = 1; const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001; const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004; - const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000; const SE_DACL_PROTECTED: u16 = 0x1000; const TOKEN_QUERY: u32 = 0x0000_0008; const TOKEN_USER_CLASS: u32 = 1; @@ -884,18 +942,13 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( )); } // SAFETY: path is NUL terminated and private_dacl was allocated by SetEntriesInAclW. + let should_initialize_owner = initialize_owner && !owner_matches; let set_status = unsafe { SetNamedSecurityInfoW( wide_path.as_mut_ptr(), SE_FILE_OBJECT, - DACL_SECURITY_INFORMATION - | PROTECTED_DACL_SECURITY_INFORMATION - | if initialize_owner { - OWNER_SECURITY_INFORMATION - } else { - 0 - }, - if initialize_owner { + windows_private_dacl_security_information(initialize_owner, owner_matches), + if should_initialize_owner { current_user_sid } else { std::ptr::null_mut() @@ -1367,6 +1420,9 @@ pub(crate) fn resolve_game_creator_llm_config_for_agent( agent_id: &str, ) -> GameCreatorLlmConfig { let mut llm = config.llm.clone(); + if let Some(reasoning_effort) = game_creator_llm_agent_default_reasoning_effort(agent_id) { + llm.reasoning_effort = reasoning_effort.to_string(); + } if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { if let Some(patch) = config .agent_llm 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 8f72f2bcc..b27d185f0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -468,6 +468,8 @@ struct AgentRuntimeEvent { #[serde(default)] event_type: String, #[serde(default)] + event_id: String, + #[serde(default)] action_id: Option, #[serde(default)] status: String, @@ -476,6 +478,8 @@ struct AgentRuntimeEvent { #[serde(default)] summary: String, #[serde(default)] + public_text: Option, + #[serde(default)] detail: Option, #[serde(default)] updated_at: u64, @@ -647,6 +651,9 @@ struct GameCreatorLlmConfigStatus { context_window_tokens: u64, auto_compact_token_limit: u64, tool_output_token_limit: u64, + request_timeout_ms: u64, + max_retries: u32, + retry_backoff_ms: u64, error: Option, agents: Vec, } @@ -667,6 +674,9 @@ struct GameCreatorAgentLlmConfigStatus { context_window_tokens: u64, auto_compact_token_limit: u64, tool_output_token_limit: u64, + request_timeout_ms: u64, + max_retries: u32, + retry_backoff_ms: u64, error: Option, } @@ -1969,6 +1979,52 @@ fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) { } } +#[derive(Debug, Eq, PartialEq)] +enum GameChatReleaseClientExitOutcome { + Shutdown, + Busy, + Failed(String), +} + +fn resolve_game_chat_release_client_exit(shutdown: F) -> GameChatReleaseClientExitOutcome +where + F: FnOnce() -> Result, +{ + match shutdown() { + Ok(true) => GameChatReleaseClientExitOutcome::Shutdown, + Ok(false) => GameChatReleaseClientExitOutcome::Busy, + Err(error) => GameChatReleaseClientExitOutcome::Failed(error), + } +} + +fn show_game_chat_release_client_exit_blocked(app: &tauri::AppHandle) { + app.dialog() + .message("当前仍有游戏创作任务或 Provider 请求在运行。为避免结果丢失,已阻止关闭;请先等待任务完成,或在任务页暂停/取消后再退出。") + .title("游戏创作任务仍在运行") + .show(|_| {}); +} + +#[cfg(test)] +mod game_chat_release_client_exit_tests { + use super::*; + + #[test] + fn client_exit_resolution_distinguishes_shutdown_busy_and_failure() { + assert_eq!( + resolve_game_chat_release_client_exit(|| Ok(true)), + GameChatReleaseClientExitOutcome::Shutdown + ); + assert_eq!( + resolve_game_chat_release_client_exit(|| Ok(false)), + GameChatReleaseClientExitOutcome::Busy + ); + assert_eq!( + resolve_game_chat_release_client_exit(|| Err("runner unavailable".to_string())), + GameChatReleaseClientExitOutcome::Failed("runner unavailable".to_string()) + ); + } +} + fn main() { let mut args = std::env::args().skip(1).collect::>(); #[cfg(target_os = "linux")] @@ -2351,17 +2407,28 @@ fn main() { let _ = append_bounded_diagnostic_line(path, "startup.run.begin"); } let shutdown_log = startup_log.clone(); - app.run(move |_, event| { + app.run(move |app_handle, event| { let game_chat_release = cfg!(all(not(debug_assertions), feature = "game-chat-release")); - if game_chat_release && should_shutdown_runner_on_tauri_event(true, &event) { + let game_chat_exit_requested = game_chat_release + && matches!( + &event, + tauri::RunEvent::WindowEvent { + event: tauri::WindowEvent::CloseRequested { .. }, + .. + } | tauri::RunEvent::ExitRequested { .. } + ); + if game_chat_exit_requested { if let Some(path) = shutdown_log.as_deref() { let _ = append_bounded_diagnostic_line( path, "startup.runner.shutdown-for-client-exit.begin", ); } - match shutdown_external_agent_runner_for_client_exit() { - Ok(()) => { + let outcome = resolve_game_chat_release_client_exit( + shutdown_external_agent_runner_for_client_exit, + ); + match &outcome { + GameChatReleaseClientExitOutcome::Shutdown => { if let Some(path) = shutdown_log.as_deref() { let _ = append_bounded_diagnostic_line( path, @@ -2369,7 +2436,15 @@ fn main() { ); } } - Err(error) => { + GameChatReleaseClientExitOutcome::Busy => { + if let Some(path) = shutdown_log.as_deref() { + let _ = append_bounded_diagnostic_line( + path, + "startup.runner.shutdown-for-client-exit.busy", + ); + } + } + GameChatReleaseClientExitOutcome::Failed(error) => { if let Some(path) = shutdown_log.as_deref() { let details = sanitize_diagnostic_message(&error, path.parent()); let _ = append_bounded_diagnostic_line( @@ -2382,6 +2457,17 @@ fn main() { eprintln!("game-chat 客户端退出协议关闭 Agent Runner 失败:{error}") } } + if outcome != GameChatReleaseClientExitOutcome::Shutdown { + match &event { + tauri::RunEvent::WindowEvent { + event: tauri::WindowEvent::CloseRequested { api, .. }, + .. + } => api.prevent_close(), + tauri::RunEvent::ExitRequested { api, .. } => api.prevent_exit(), + _ => {} + } + show_game_chat_release_client_exit_blocked(app_handle); + } } else if !game_chat_release { handle_game_creator_gui_run_event(&event); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index d36f60193..056d25ce3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -84,6 +84,32 @@ impl PreviewRegistry { static GAME_CREATOR_PREVIEW_REGISTRY: OnceLock = OnceLock::new(); +const PREVIEW_REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(2); +const PREVIEW_REQUEST_MAX_HEADER_BYTES: usize = 32 * 1024; +const PREVIEW_REQUEST_MAX_HEADER_LINES: usize = 100; +const PREVIEW_RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_millis(250); +const PREVIEW_RESPONSE_DRAIN_MAX_BYTES: usize = 32 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PreviewListenerAcceptDisposition { + Sleep, + Retry, + Stop, +} + +pub(crate) fn classify_preview_listener_accept_error( + error: &std::io::Error, +) -> PreviewListenerAcceptDisposition { + match error.kind() { + std::io::ErrorKind::WouldBlock => PreviewListenerAcceptDisposition::Sleep, + std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::Interrupted + | std::io::ErrorKind::TimedOut => PreviewListenerAcceptDisposition::Retry, + _ => PreviewListenerAcceptDisposition::Stop, + } +} + pub(crate) fn game_creator_preview_registry() -> PreviewRegistry { GAME_CREATOR_PREVIEW_REGISTRY .get_or_init(PreviewRegistry::default) @@ -392,10 +418,18 @@ pub(crate) fn start_local_game_preview_for_project( } match listener.accept() { Ok((stream, _)) => handle_preview_stream(stream, &served_root), - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(25)); - } - Err(_) => break, + // Chromium can abandon a speculative loopback socket before accept() consumes + // it. Keep the listener alive for that connection; only an unrecoverable listener + // error should tear down the preview server. + Err(error) => match classify_preview_listener_accept_error(&error) { + PreviewListenerAcceptDisposition::Sleep => { + thread::sleep(Duration::from_millis(25)); + } + PreviewListenerAcceptDisposition::Retry => { + thread::sleep(Duration::from_millis(5)); + } + PreviewListenerAcceptDisposition::Stop => break, + }, } }); @@ -410,19 +444,109 @@ pub(crate) fn start_local_game_preview_for_project( } fn handle_preview_stream(mut stream: TcpStream, root: &Path) { - let mut request_line = String::new(); - { - let mut reader = BufReader::new(&mut stream); - if reader.read_line(&mut request_line).is_err() { - return; - } + // The listener is nonblocking so its accept loop can observe the stop channel. Windows may + // inherit that mode on accepted sockets; switch each connection back to blocking mode before + // waiting for Chromium's split request headers. + if stream.set_nonblocking(false).is_err() { + return; } + let request_line = match read_preview_request_line(&mut stream) { + Ok(Some(request_line)) => request_line, + Ok(None) | Err(_) => return, + }; let mut parts = request_line.split_whitespace(); let method = parts.next().unwrap_or_default(); let url_path = parts.next().unwrap_or("/"); let response = build_preview_response(root, method, url_path); - let _ = stream.write_all(&response); + if stream.write_all(&response).is_ok() { + let _ = stream.flush(); + // Explicitly half-close after the complete response, then consume the peer's remaining + // request bytes for a short bounded interval. This lets Windows complete a graceful + // FIN/ACK exchange instead of surfacing the close as WSAECONNABORTED to Chromium. + let _ = stream.shutdown(std::net::Shutdown::Write); + drain_preview_request_after_response(&mut stream); + } +} + +fn drain_preview_request_after_response(stream: &mut TcpStream) { + let _ = stream.set_read_timeout(Some(PREVIEW_RESPONSE_DRAIN_TIMEOUT)); + let mut buffer = [0u8; 4096]; + let mut drained_bytes = 0usize; + while drained_bytes < PREVIEW_RESPONSE_DRAIN_MAX_BYTES { + match stream.read(&mut buffer) { + Ok(0) => break, + Ok(bytes_read) => { + drained_bytes = drained_bytes.saturating_add(bytes_read); + } + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + break; + } + Err(_) => break, + } + } +} + +/// Read the request line and all headers before closing the connection. +/// +/// Chromium can deliver the request line and headers in separate packets. Dropping the +/// stream after only `read_line` leaves unread request bytes on Windows and may make the +/// close look like an abortive RST (`net::ERR_SOCKET_NOT_CONNECTED`). The bounded read keeps +/// slow or malformed clients from occupying a preview thread indefinitely. +fn read_preview_request_line(stream: &mut TcpStream) -> std::io::Result> { + stream.set_read_timeout(Some(PREVIEW_REQUEST_READ_TIMEOUT))?; + let mut reader = BufReader::new(stream); + let mut request_line = Vec::new(); + let mut total_bytes = 0usize; + + for line_index in 0..PREVIEW_REQUEST_MAX_HEADER_LINES { + let mut line = Vec::new(); + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + return Ok(None); + } + let newline_index = available.iter().position(|byte| *byte == b'\n'); + let bytes_to_consume = newline_index + .map(|index| index + 1) + .unwrap_or(available.len()); + if total_bytes.saturating_add(bytes_to_consume) > PREVIEW_REQUEST_MAX_HEADER_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "preview request headers exceed the size limit", + )); + } + line.extend_from_slice(&available[..bytes_to_consume]); + total_bytes += bytes_to_consume; + reader.consume(bytes_to_consume); + if newline_index.is_some() { + break; + } + } + let is_blank_line = line == b"\r\n" || line == b"\n"; + if line_index == 0 { + request_line = line; + } + if is_blank_line { + return String::from_utf8(request_line).map(Some).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "preview request line is not valid UTF-8", + ) + }); + } + } + + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "preview request headers exceed the line limit", + )) } pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str) -> Vec { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index ae54a9ba5..91fb6a9ff 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -258,11 +258,12 @@ pub(crate) fn validate_manifest_required_visual_asset( .and_then(|path| fs::read(path).ok()) .filter(|bytes| bytes.starts_with(b"\x89PNG\r\n\x1a\n")) .ok_or_else(|| format!("规范视觉资产不是有效登记的 PNG 文件:{expected_path}"))?; - if task_id == "art-asset-plan" - && !image::load_from_memory(&bytes) - .ok() - .is_some_and(|image| image.to_rgba8().pixels().any(|pixel| pixel[3] < u8::MAX)) - { + let decoded = image::load_from_memory(&bytes) + .map_err(|_| format!("规范视觉资产 PNG 无法完整解码:{expected_path}"))?; + if decoded.width() == 0 || decoded.height() == 0 { + return Err(format!("规范视觉资产 PNG 尺寸无效:{expected_path}")); + } + if task_id == "art-asset-plan" && !decoded.to_rgba8().pixels().any(|pixel| pixel[3] < u8::MAX) { return Err("首版美术素材图没有真实透明像素".to_string()); } let canvas_project_id = asset diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 975968661..69c7da1c9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -931,11 +931,11 @@ pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> { pub(super) fn shutdown_external_agent_runner_for_client_exit_at( config_dir: &Path, -) -> Result<(), String> { +) -> Result { let Some((endpoint_path, endpoint)) = read_external_agent_runner_endpoint_for_shutdown(config_dir)? else { - return Ok(()); + return Ok(true); }; let request_id = random_identifier(b"genarrative-agent-runner-client-exit-request-id")?; let result = match send_external_agent_runner_request_with_protocol_and_id( @@ -949,7 +949,7 @@ pub(super) fn shutdown_external_agent_runner_for_client_exit_at( Err(error) => { return match read_external_agent_runner_endpoint(&endpoint_path) { Ok(current) if current.boot_id == endpoint.boot_id => Err(error), - _ => Ok(()), + _ => Ok(true), }; } }; @@ -957,25 +957,32 @@ pub(super) fn shutdown_external_agent_runner_for_client_exit_at( .get("accepted") .and_then(Value::as_bool) .ok_or_else(|| "Agent Runner shutdown_for_client_exit 响应缺少 accepted".to_string())?; + let busy = result + .get("busy") + .and_then(Value::as_bool) + .ok_or_else(|| "Agent Runner shutdown_for_client_exit 响应缺少 busy".to_string())?; let will_shutdown = result .get("willShutdown") .and_then(Value::as_bool) .ok_or_else(|| "Agent Runner shutdown_for_client_exit 响应缺少 willShutdown".to_string())?; - if !accepted || !will_shutdown { - return Err("Agent Runner 拒绝按客户端退出协议关闭".to_string()); + match (accepted, busy, will_shutdown) { + (false, true, false) => return Ok(false), + (true, false, true) => {} + _ => return Err("Agent Runner shutdown_for_client_exit 响应状态不一致".to_string()), } wait_for_external_agent_runner_boot_exit( &endpoint_path, &endpoint, AGENT_RUNNER_CLIENT_EXIT_TIMEOUT, "Agent Runner 未在客户端退出期限内停止", - ) + )?; + Ok(true) } -pub(crate) fn shutdown_external_agent_runner_for_client_exit() -> Result<(), String> { +pub(crate) fn shutdown_external_agent_runner_for_client_exit() -> Result { let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); let Some(config_dir) = external_agent_runner_config_dir() else { - return Ok(()); + return Ok(true); }; shutdown_external_agent_runner_for_client_exit_at(&config_dir) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index f0be74db5..5bc8667ef 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -618,12 +618,53 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( ) } "runner.shutdown_for_client_exit" if cfg!(any(test, feature = "game-chat-release")) => { - state.draining.store(true, Ordering::Release); - state.shutdown_requested.store(true, Ordering::Release); - ExternalAgentRunnerResponse::success( - &request.request_id, - json!({ "accepted": true, "willShutdown": true }), - ) + if state.shutdown_requested.load(Ordering::Acquire) { + ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": true, "busy": false, "willShutdown": true }), + ) + } else if state + .draining + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + ExternalAgentRunnerResponse::failure( + &request.request_id, + "runner-draining", + "Agent Runner 已在排空", + ) + } else if state.active_connections.load(Ordering::Acquire) > 1 { + state.draining.store(false, Ordering::Release); + ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": false, "busy": true, "willShutdown": false }), + ) + } else { + match external_agent_runner_known_roots_are_idle(state) { + Ok(false) => { + state.draining.store(false, Ordering::Release); + ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": false, "busy": true, "willShutdown": false }), + ) + } + Ok(true) => { + state.shutdown_requested.store(true, Ordering::Release); + ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": true, "busy": false, "willShutdown": true }), + ) + } + Err(error) => { + state.draining.store(false, Ordering::Release); + ExternalAgentRunnerResponse::failure( + &request.request_id, + "runtime-state-unreadable", + redact_runner_secret(&error, &token), + ) + } + } + } } "runner.shutdown_if_idle" | "shutdown_if_idle" => { if request.params.root.is_some() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index be0931424..98edcf5f0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -150,6 +150,91 @@ fn legacy_runner_force_migration_requires_authenticated_exact_ping_identity() { server.join().expect("join mismatched identity fixture"); } +#[test] +fn client_exit_client_returns_busy_without_waiting_and_accepts_idle_shutdown() { + let directory = unique_test_directory(); + let config_dir = private_runner_test_config_dir(&directory); + let endpoint_path = external_agent_runner_endpoint_path(&config_dir); + let token = "client-exit-response-token-client-exit-response-token"; + + let busy_listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) + .expect("bind busy client-exit fixture"); + let busy_endpoint = test_endpoint( + token, + "client-exit-busy-response-boot", + busy_listener + .local_addr() + .expect("busy fixture address") + .port(), + ); + write_external_agent_runner_endpoint_atomic(&endpoint_path, &busy_endpoint) + .expect("write busy client-exit endpoint"); + let busy_server = std::thread::spawn(move || { + let (mut stream, _) = busy_listener.accept().expect("accept busy client exit"); + let payload = read_external_agent_runner_frame(&mut stream).expect("read busy client exit"); + let request = serde_json::from_slice::(&payload) + .expect("parse busy client exit"); + assert_eq!(request.method, "runner.shutdown_for_client_exit"); + let response = ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": false, "busy": true, "willShutdown": false }), + ); + write_external_agent_runner_frame( + &mut stream, + &serde_json::to_vec(&response).expect("serialize busy client-exit response"), + ) + .expect("write busy client-exit response"); + }); + + let started = Instant::now(); + assert!( + !shutdown_external_agent_runner_for_client_exit_at(&config_dir) + .expect("busy client exit remains a successful refusal") + ); + assert!( + started.elapsed() < Duration::from_secs(2), + "busy client exit must not wait for Runner boot shutdown" + ); + busy_server.join().expect("join busy client-exit fixture"); + + let idle_listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) + .expect("bind idle client-exit fixture"); + let idle_endpoint = test_endpoint( + token, + "client-exit-idle-response-boot", + idle_listener + .local_addr() + .expect("idle fixture address") + .port(), + ); + write_external_agent_runner_endpoint_atomic(&endpoint_path, &idle_endpoint) + .expect("write idle client-exit endpoint"); + let idle_endpoint_path = endpoint_path.clone(); + let idle_server = std::thread::spawn(move || { + let (mut stream, _) = idle_listener.accept().expect("accept idle client exit"); + let payload = read_external_agent_runner_frame(&mut stream).expect("read idle client exit"); + let request = serde_json::from_slice::(&payload) + .expect("parse idle client exit"); + assert_eq!(request.method, "runner.shutdown_for_client_exit"); + let response = ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": true, "busy": false, "willShutdown": true }), + ); + write_external_agent_runner_frame( + &mut stream, + &serde_json::to_vec(&response).expect("serialize idle client-exit response"), + ) + .expect("write idle client-exit response"); + fs::remove_file(idle_endpoint_path).expect("remove idle endpoint after shutdown response"); + }); + + assert!( + shutdown_external_agent_runner_for_client_exit_at(&config_dir) + .expect("idle client exit must complete Runner shutdown") + ); + idle_server.join().expect("join idle client-exit fixture"); +} + #[test] fn endpoint_shape_accepts_legacy_missing_fingerprint_but_rejects_malformed_values() { let endpoint = test_endpoint( @@ -1248,7 +1333,7 @@ fn forced_shutdown_is_accepted_even_when_runtime_is_busy() { } #[test] -fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() { +fn shutdown_for_client_exit_rejects_busy_then_closes_idle_runner_idempotently() { let directory = unique_test_directory(); let root = directory.0.join("project"); let pending = root.join(".agent/runtime/pending-actions/code-prototype/run-client-exit.json"); @@ -1309,10 +1394,50 @@ fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() { assert!(!state.shutdown_requested.load(Ordering::Acquire)); assert!(!state.draining.load(Ordering::Acquire)); + let busy_response = handle_external_agent_runner_request( + ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "shutdown-client-exit-busy-1".to_string(), + token: token.to_string(), + method: "runner.shutdown_for_client_exit".to_string(), + params: ExternalAgentRunnerRequestParams::default(), + }, + &state, + ); + assert!(busy_response.ok); + assert_eq!( + busy_response + .result + .as_ref() + .and_then(|value| value["accepted"].as_bool()), + Some(false) + ); + assert_eq!( + busy_response + .result + .as_ref() + .and_then(|value| value["busy"].as_bool()), + Some(true) + ); + assert_eq!( + busy_response + .result + .as_ref() + .and_then(|value| value["willShutdown"].as_bool()), + Some(false) + ); + assert!(!state.shutdown_requested.load(Ordering::Acquire)); + assert!(!state.draining.load(Ordering::Acquire)); + assert_eq!( + fs::read(&pending).expect("read pending action"), + durable_bytes + ); + + fs::remove_file(&pending).expect("clear pending action before idle client exit"); let shutdown_response = handle_external_agent_runner_request( ExternalAgentRunnerRequest { protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, - request_id: "shutdown-client-exit-force-1".to_string(), + request_id: "shutdown-client-exit-idle-1".to_string(), token: token.to_string(), method: "runner.shutdown_for_client_exit".to_string(), params: ExternalAgentRunnerRequestParams::default(), @@ -1327,6 +1452,13 @@ fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() { .and_then(|value| value["accepted"].as_bool()), Some(true) ); + assert_eq!( + shutdown_response + .result + .as_ref() + .and_then(|value| value["busy"].as_bool()), + Some(false) + ); assert_eq!( shutdown_response .result @@ -1336,35 +1468,6 @@ fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() { ); assert!(state.shutdown_requested.load(Ordering::Acquire)); assert!(state.draining.load(Ordering::Acquire)); - assert_eq!( - fs::read(&pending).expect("read pending action"), - durable_bytes - ); - - let write_response = handle_external_agent_runner_request( - ExternalAgentRunnerRequest { - protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, - request_id: "shutdown-client-exit-write-after-drain".to_string(), - token: token.to_string(), - method: "runtime.continue_action".to_string(), - params: ExternalAgentRunnerRequestParams { - root: Some(root.to_string_lossy().into_owned()), - agent: Some("code-prototype".to_string()), - run_id: Some("run-client-exit".to_string()), - action_id: Some("action-client-exit".to_string()), - ..ExternalAgentRunnerRequestParams::default() - }, - }, - &state, - ); - assert!(!write_response.ok); - assert_eq!( - write_response - .error - .as_ref() - .map(|error| error.code.as_str()), - Some("runner-draining") - ); let repeated_response = handle_external_agent_runner_request( ExternalAgentRunnerRequest { @@ -1384,6 +1487,13 @@ fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() { .and_then(|value| value["accepted"].as_bool()), Some(true) ); + assert_eq!( + repeated_response + .result + .as_ref() + .and_then(|value| value["busy"].as_bool()), + Some(false) + ); assert_eq!( repeated_response .result @@ -1391,10 +1501,7 @@ fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() { .and_then(|value| value["willShutdown"].as_bool()), Some(true) ); - assert_eq!( - fs::read(&pending).expect("reread pending action"), - durable_bytes - ); + assert!(!pending.exists()); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs index fbdd800e6..bcf3f77b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs @@ -356,6 +356,7 @@ fn steer_and_wait_for_swarm_turn( steer_id.clone(), message.to_string(), Some(run_profile.to_string()), + None, )?; writeln!( output, diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs index 6d631df35..99e24580b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs @@ -355,6 +355,7 @@ pub(super) fn wait_for_swarm_turn( steer_id.clone(), message, Some(run_profile.to_string()), + None, )?; writeln!( output, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs index bb3167a00..541b08311 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs @@ -2060,6 +2060,136 @@ fn agent_native_delegate_contract_flows_through_parser_executor_and_delivery() { fs::remove_dir_all(root).ok(); } +#[test] +fn game_chat_autonomous_run_rejects_publish_delegates_before_child_creation() { + for target_agent_id in ["publish-strategy", "publish-package"] { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "game-chat 发布委派门禁") + .expect("project init"); + let parent_run_id = format!("game-chat-publish-deny-{target_agent_id}"); + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_run_id, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind game-chat autonomous profile"); + let action_id = format!("deny-{target_agent_id}"); + let observation = observe_agent_runtime_agent_delegate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_run_id, + Some(&action_id), + &serde_json::json!({ + "agentId": target_agent_id, + "task": "不应创建发布任务", + "acceptanceCriteria": ["必须先被 Runtime 拒绝"], + "expectedArtifacts": [], + "repairOfDelegationId": null, + "runId": null + }), + ); + assert_eq!(observation.status, "failed", "{observation:?}"); + assert!( + observation.summary.contains("game-chat") + || observation.summary.contains("publish") + || observation.summary.contains("发布"), + "{observation:?}" + ); + let delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_run_id, + target_agent_id, + &action_id, + ); + assert!( + read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read rejected delivery") + .is_none(), + "rejected publish delegate must not create delivery" + ); + assert!( + read_latest_game_creator_agent_runtime_task_by_delegation_id( + &root, + target_agent_id, + &delegation_id, + ) + .expect("read rejected child task") + .is_none(), + "rejected publish delegate must not create child runtime" + ); + fs::remove_dir_all(root).ok(); + } +} + +#[test] +fn gui_and_cli_autonomous_runs_still_allow_publish_delegates() { + for source in [ + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + ] { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "GUI CLI 发布委派允许") + .expect("project init"); + let parent_run_id = format!("publish-allow-{source}"); + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_run_id, + source, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind non-game-chat autonomous profile"); + start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "GUI CLI 发布委派父 Runtime", + &parent_run_id, + source, + "准备发布委派", + vec!["验证发布委派仍可创建子 Runtime".to_string()], + ) + .expect("start non-game-chat parent runtime"); + let target_agent_id = "publish-strategy"; + let action_id = format!("allow-{source}"); + let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id) + .expect("acquire publish target lane") + .expect("publish target lane available"); + let observation = observe_agent_runtime_agent_delegate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_run_id, + Some(&action_id), + &serde_json::json!({ + "agentId": target_agent_id, + "task": "允许 GUI CLI 发布策略委派", + "acceptanceCriteria": ["返回可核对回执"], + "expectedArtifacts": [], + "repairOfDelegationId": null, + "runId": null + }), + ); + assert_eq!(observation.status, "ok", "{observation:?}"); + let delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_run_id, + target_agent_id, + &action_id, + ); + assert!( + read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read allowed delivery") + .is_some(), + "non-game-chat publish delegate should create delivery" + ); + drop(target_lock); + fs::remove_dir_all(root).ok(); + } +} + #[test] fn visual_specialist_delegations_require_image_artifacts_but_read_only_work_allows_none() { let _config_guard = crate::tests::write_test_local_config( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs index f0580e9e2..a255f8748 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs @@ -90,6 +90,12 @@ fn project_supervisor_parent_wake_singleflight_coalesces_late_signal() { assert!(autonomous_manifest_parent_wake_error_is_transient( "项目正在被其他写操作占用:$PROJECT_ROOT/.agent/project.lock" )); + assert!(autonomous_manifest_parent_wake_error_is_transient( + "获取 Agent Runtime 系统文件锁失败:$PROJECT_ROOT/.agent/runtime/locks/balance-seed.lock: 另一个程序正在使用此文件。 (os error 32)" + )); + assert!(autonomous_manifest_parent_wake_error_is_transient( + "获取 Agent Runtime 系统文件锁失败:sharing violation" + )); assert!(!autonomous_manifest_parent_wake_error_is_transient( "manifest JSON 已损坏" )); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index d04162240..2ec2dcfc3 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 @@ -281,7 +281,7 @@ fn project_supervisor_llm_config_prefers_specific_patch_and_falls_back_to_legacy assert_eq!(fallback.api_key, "legacy-chat-key"); assert_eq!(fallback.base_url, "https://legacy-chat.example.test/v1"); assert_eq!(fallback.model, "legacy-chat-model"); - assert_eq!(fallback.reasoning_effort, "medium"); + assert_eq!(fallback.reasoning_effort, "high"); assert!(fallback.stream); config.agent_llm.insert( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs index d4448c8d7..641ce4acf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs @@ -1428,29 +1428,53 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio "总控首轮只读逃逸修复测试", ) .expect("project init"); + write_supervisor_collaboration_policy_at( + &root, + SupervisorCollaborationPolicy { + required_initial_wave: SupervisorInitialCollaborationWave::Static, + min_static_delegates: 3, + required_static_agent_ids: vec![ + "design-director".to_string(), + "art-director".to_string(), + "code-director".to_string(), + ], + ..SupervisorCollaborationPolicy::default() + }, + ) + .expect("write explicit collaboration repair policy"); let (sender, receiver) = mpsc::channel(); let delegate_function = native_runtime_function_name("agent.delegate").expect("delegate function"); - let isolated_function = - native_runtime_function_name("agent.spawn_isolated").expect("isolated function"); - let code_arguments = serde_json::json!({ - "reason": "委派原型实现 Agent", + let design_arguments = serde_json::json!({ + "reason": "委派策划 Leader", "input": { - "agentId": "code-prototype", - "task": "实现可直接试玩的游戏原型", - "acceptanceCriteria": ["项目能够启动并完成最小玩法闭环"], - "expectedArtifacts": ["game/index.html"], + "agentId": "design-director", + "task": "只读拆解首轮玩法目标和专业分工,不得修改项目", + "acceptanceCriteria": ["只读给出可供后续底层 Agent 按需执行的策划规划,不得修改项目"], + "expectedArtifacts": [], "repairOfDelegationId": null, "runId": null } }) .to_string(); - let quality_arguments = serde_json::json!({ - "reason": "委派质量评审 Agent", + let art_arguments = serde_json::json!({ + "reason": "委派美术 Leader", "input": { - "agentId": "quality-review", - "task": "只读评审可玩性与闯关闭环,不要修改任何项目文件", - "acceptanceCriteria": ["只读指出阻塞试玩的具体问题并给出验收结论"], + "agentId": "art-director", + "task": "确定首轮原创视觉方向", + "acceptanceCriteria": ["视觉规范可供后续底层 Agent 按需执行"], + "expectedArtifacts": ["assets/art-spec.png"], + "repairOfDelegationId": null, + "runId": null + } + }) + .to_string(); + let code_arguments = serde_json::json!({ + "reason": "委派程序 Leader", + "input": { + "agentId": "code-director", + "task": "只读拆解首轮程序实现边界,不得修改项目", + "acceptanceCriteria": ["只读给出可供后续底层 Agent 按需执行的程序规划,不得修改项目"], "expectedArtifacts": [], "repairOfDelegationId": null, "runId": null @@ -1460,15 +1484,22 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio let base_url = spawn_mock_llm_raw_responses_with_capture( vec![ native_agent_tool_plan_chat_response( - "call-supervisor-initial-code-delegate", + "call-supervisor-initial-design-delegate", delegate_function.as_str(), - code_arguments, - ), - native_agent_tool_plan_chat_response( - "call-supervisor-read-only-quality-delegate", - delegate_function.as_str(), - quality_arguments, + design_arguments, ), + native_agent_tool_plan_chat_response_with_calls(vec![ + ( + "call-supervisor-art-director-delegate", + delegate_function.as_str(), + art_arguments, + ), + ( + "call-supervisor-code-director-delegate", + delegate_function.as_str(), + code_arguments, + ), + ]), ], Some(sender), ); @@ -1498,13 +1529,13 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio let runtime = start_game_creator_agent_runtime_task_at( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "并行完成原型实现与质量评审", + "并行完成程策美 Leader 首轮规划", run_id, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, "读取后建立首批协作", vec![ "读取必要上下文".to_string(), - "一次性委派两个专业 Agent".to_string(), + "一次性委派三个 Leader Agent".to_string(), ], ) .expect("start supervisor runtime"); @@ -1522,13 +1553,14 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio .await .expect("repair read-only initial collaboration plan") .expect("repaired collaboration plan"); - assert_eq!(plan.actions.len(), 2); + assert_eq!(plan.actions.len(), 3); assert!(plan .actions .iter() .all(|action| action.tool == "agent.delegate")); - assert_eq!(plan.actions[0].input["agentId"], "code-prototype"); - assert_eq!(plan.actions[1].input["agentId"], "quality-review"); + assert_eq!(plan.actions[0].input["agentId"], "design-director"); + assert_eq!(plan.actions[1].input["agentId"], "art-director"); + assert_eq!(plan.actions[2].input["agentId"], "code-director"); let initial_request = receiver .recv_timeout(Duration::from_secs(2)) @@ -1537,7 +1569,7 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio let repair_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("supervisor read-only collaboration repair request"); - assert!(repair_request.contains("missingStaticAgents=quality-review")); + assert!(repair_request.contains("missingStaticAgents=art-director,code-director")); let repair_request_json = mock_http_request_json(&repair_request); let repair_function_names = repair_request_json["tools"] .as_array() @@ -1555,7 +1587,7 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio .collect::>(); assert_eq!( repair_function_names, - BTreeSet::from([delegate_function.as_str(), isolated_function.as_str()]) + BTreeSet::from([delegate_function.as_str()]) ); let delegate_schema = repair_request_json["tools"] .as_array() @@ -1577,7 +1609,7 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio .expect("delegate parameters"); assert_eq!( parameters["properties"]["input"]["properties"]["agentId"]["enum"], - serde_json::json!(["quality-review"]) + serde_json::json!(["art-director", "code-director"]) ); assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); @@ -1600,7 +1632,7 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio }) .expect("repaired collaboration protocol audit"); assert_eq!(protocol["repairAttempt"], 1); - assert_eq!(protocol["functionCallCount"], 1); + assert_eq!(protocol["functionCallCount"], 2); let collaboration_state = read_supervisor_collaboration_state_at( &root, @@ -1614,7 +1646,8 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio } #[test] -fn supervisor_autonomous_game_build_without_project_policy_requires_builder_and_reviewer() { +fn supervisor_autonomous_game_build_without_project_policy_uses_manifest_as_the_only_initial_wave() +{ let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -1657,13 +1690,10 @@ fn supervisor_autonomous_game_build_without_project_policy_requires_builder_and_ assert_eq!(resolution.project_policy_status, "absent"); assert_eq!( resolution.policy.required_initial_wave, - SupervisorInitialCollaborationWave::Static - ); - assert_eq!(resolution.policy.min_static_delegates, 2); - assert_eq!( - resolution.policy.required_static_agent_ids, - vec!["code-prototype".to_string(), "quality-review".to_string()] + SupervisorInitialCollaborationWave::Auto ); + assert_eq!(resolution.policy.min_static_delegates, 0); + assert!(resolution.policy.required_static_agent_ids.is_empty()); assert!(!root .join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH) .exists()); @@ -1673,8 +1703,7 @@ fn supervisor_autonomous_game_build_without_project_policy_requires_builder_and_ } #[test] -fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_in_dependency_order() -{ +fn supervisor_autonomous_game_build_with_editor_api_key_keeps_visual_agents_in_manifest_order() { let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -1717,18 +1746,10 @@ fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_i assert_eq!(resolution.project_policy_status, "absent"); assert_eq!( resolution.policy.required_initial_wave, - SupervisorInitialCollaborationWave::Static - ); - assert_eq!(resolution.policy.min_static_delegates, 3); - assert_eq!( - resolution - .policy - .required_static_agent_ids - .iter() - .map(String::as_str) - .collect::>(), - BTreeSet::from(["code-prototype", "quality-review", "art-director"]) + SupervisorInitialCollaborationWave::Auto ); + assert_eq!(resolution.policy.min_static_delegates, 0); + assert!(resolution.policy.required_static_agent_ids.is_empty()); assert!(!root .join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH) .exists()); @@ -1750,16 +1771,11 @@ fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_i design_run_id, ) .expect("resolve autonomous collaboration policy after art spec delivery"); - assert_eq!(design_resolution.policy.min_static_delegates, 3); - assert_eq!( - design_resolution - .policy - .required_static_agent_ids - .iter() - .map(String::as_str) - .collect::>(), - BTreeSet::from(["code-prototype", "quality-review", "design-foundation"]) - ); + assert_eq!(design_resolution.policy.min_static_delegates, 0); + assert!(design_resolution + .policy + .required_static_agent_ids + .is_empty()); register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); let art_run_id = "supervisor-autonomous-art-after-ui-run"; @@ -1778,16 +1794,8 @@ fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_i art_run_id, ) .expect("resolve autonomous collaboration policy after UI delivery"); - assert_eq!(art_resolution.policy.min_static_delegates, 3); - assert_eq!( - art_resolution - .policy - .required_static_agent_ids - .iter() - .map(String::as_str) - .collect::>(), - BTreeSet::from(["code-prototype", "quality-review", "art-asset-plan"]) - ); + assert_eq!(art_resolution.policy.min_static_delegates, 0); + assert!(art_resolution.policy.required_static_agent_ids.is_empty()); register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); let complete_run_id = "supervisor-autonomous-after-all-visual-assets-run"; @@ -1806,18 +1814,18 @@ fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_i complete_run_id, ) .expect("resolve autonomous collaboration policy after all visual deliveries"); - assert_eq!(complete_resolution.policy.min_static_delegates, 2); - assert_eq!( - complete_resolution.policy.required_static_agent_ids, - vec!["code-prototype".to_string(), "quality-review".to_string()] - ); + assert_eq!(complete_resolution.policy.min_static_delegates, 0); + assert!(complete_resolution + .policy + .required_static_agent_ids + .is_empty()); fs::remove_dir_all(root).ok(); fs::remove_dir_all(config_dir).ok(); } #[test] -fn supervisor_autonomous_game_build_augments_existing_project_policy_with_required_art_director() { +fn supervisor_autonomous_game_build_preserves_explicit_project_policy_without_hidden_agents() { let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -1839,8 +1847,16 @@ fn supervisor_autonomous_game_build_augments_existing_project_policy_with_requir "自主构建已有策略美术协作测试", ) .expect("project init"); - write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) - .expect("write default project collaboration policy"); + write_supervisor_collaboration_policy_at( + &root, + SupervisorCollaborationPolicy { + required_initial_wave: SupervisorInitialCollaborationWave::Static, + min_static_delegates: 1, + required_static_agent_ids: vec!["code-prototype".to_string()], + ..SupervisorCollaborationPolicy::default() + }, + ) + .expect("write explicit project collaboration policy"); let run_id = "supervisor-autonomous-existing-policy-art-run"; bind_game_creator_agent_runtime_run_profile_at( &root, @@ -1857,18 +1873,17 @@ fn supervisor_autonomous_game_build_augments_existing_project_policy_with_requir GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, ) - .expect("resolve augmented project collaboration policy"); + .expect("resolve explicit project collaboration policy"); assert_eq!(resolution.source, "project-policy-unbound"); assert_eq!(resolution.project_policy_status, "current"); + assert_eq!( + resolution.policy.required_initial_wave, + SupervisorInitialCollaborationWave::Static + ); assert_eq!(resolution.policy.min_static_delegates, 1); assert_eq!( - resolution - .policy - .required_static_agent_ids - .iter() - .map(String::as_str) - .collect::>(), - BTreeSet::from(["art-director"]) + resolution.policy.required_static_agent_ids, + vec!["code-prototype".to_string()] ); fs::remove_dir_all(root).ok(); @@ -1876,8 +1891,7 @@ fn supervisor_autonomous_game_build_augments_existing_project_policy_with_requir } #[test] -fn supervisor_autonomous_game_build_with_editor_api_key_and_all_visual_assets_skips_visual_delegate( -) { +fn supervisor_autonomous_game_build_visual_asset_state_does_not_add_hidden_delegates() { let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -1923,13 +1937,10 @@ fn supervisor_autonomous_game_build_with_editor_api_key_and_all_visual_assets_sk assert_eq!(resolution.project_policy_status, "absent"); assert_eq!( resolution.policy.required_initial_wave, - SupervisorInitialCollaborationWave::Static - ); - assert_eq!(resolution.policy.min_static_delegates, 2); - assert_eq!( - resolution.policy.required_static_agent_ids, - vec!["code-prototype".to_string(), "quality-review".to_string()] + SupervisorInitialCollaborationWave::Auto ); + assert_eq!(resolution.policy.min_static_delegates, 0); + assert!(resolution.policy.required_static_agent_ids.is_empty()); assert!(!root .join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH) .exists()); @@ -1952,19 +1963,22 @@ fn supervisor_autonomous_game_build_with_editor_api_key_and_all_visual_assets_sk corrupt_run_id, ) .expect("resolve autonomous collaboration policy with corrupt art asset"); - assert_eq!(corrupt_resolution.policy.min_static_delegates, 3); + assert_eq!( + corrupt_resolution.policy.required_initial_wave, + SupervisorInitialCollaborationWave::Auto + ); + assert_eq!(corrupt_resolution.policy.min_static_delegates, 0); assert!(corrupt_resolution .policy .required_static_agent_ids - .iter() - .any(|agent_id| agent_id == "art-asset-plan")); + .is_empty()); fs::remove_dir_all(root).ok(); fs::remove_dir_all(config_dir).ok(); } #[test] -fn supervisor_autonomous_legacy_visual_assets_do_not_skip_the_visual_delegate() { +fn supervisor_autonomous_legacy_visual_assets_do_not_add_hidden_delegate() { let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -2012,12 +2026,12 @@ fn supervisor_autonomous_legacy_visual_assets_do_not_skip_the_visual_delegate() run_id, ) .expect("resolve legacy visual collaboration policy"); - assert_eq!(resolution.policy.min_static_delegates, 3); - assert!(resolution - .policy - .required_static_agent_ids - .iter() - .any(|agent_id| agent_id == "art-director")); + assert_eq!( + resolution.policy.required_initial_wave, + SupervisorInitialCollaborationWave::Auto + ); + assert_eq!(resolution.policy.min_static_delegates, 0); + assert!(resolution.policy.required_static_agent_ids.is_empty()); fs::remove_dir_all(root).ok(); fs::remove_dir_all(config_dir).ok(); 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 a8c3ba393..0276cb20d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -92,6 +92,7 @@ fn config_file_overrides_defaults_without_env() { assert_eq!(generator_llm.base_url, "https://generator.example.test/v1"); assert_eq!(generator_llm.model, "generator-model"); assert_eq!(generator_llm.api_kind, "openai_chat"); + assert_eq!(generator_llm.reasoning_effort, "high"); assert!(generator_llm.web_search_enabled); assert_eq!(config.editor_api.base_url, "http://127.0.0.1:8099"); assert_eq!(config.editor_api.api_key, "editor-key"); @@ -129,6 +130,199 @@ fn legacy_llm_config_deserialization_supplies_context_budget_defaults() { ); } +#[test] +fn canonical_agent_reasoning_effort_defaults_are_exhaustive_and_auditable() { + let expected = BTreeMap::from([ + ("project-supervisor", "high"), + ("planner", "high"), + ("orchestrator", "medium"), + ("generator", "high"), + ("evaluator", "high"), + ("design-director", "medium"), + ("design-foundation", "high"), + ("balance-director", "medium"), + ("balance-seed", "medium"), + ("art-director", "high"), + ("art-asset-plan", "high"), + ("art-polish", "medium"), + ("audio-director", "low"), + ("audio-asset-plan", "medium"), + ("code-director", "medium"), + ("code-prototype", "high"), + ("quality-review", "high"), + ("preview-readiness", "low"), + ("preview-playtest", "low"), + ("publish-strategy", "low"), + ("publish-package", "medium"), + ]); + let rust_defaults = GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS + .iter() + .copied() + .collect::>(); + assert_eq!(rust_defaults, expected); + assert_eq!( + GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS.len(), + rust_defaults.len(), + "规范 Agent 默认映射不能包含重复 ID" + ); + + let status_agent_ids = game_creator_llm_agent_status_definitions() + .into_iter() + .map(|definition| definition.agent_id) + .collect::>(); + assert_eq!( + status_agent_ids, + expected + .keys() + .map(|agent_id| (*agent_id).to_string()) + .collect::>(), + "新增规范 Agent 时必须先显式选择 reasoning effort,不能静默继承全局" + ); + for (agent_id, effort) in &expected { + assert_eq!( + game_creator_llm_agent_default_reasoning_effort(agent_id), + Some(*effort) + ); + parse_game_creator_llm_reasoning_effort(effort).expect("canonical reasoning effort"); + } + + let template = + serde_json::from_str::(DEFAULT_GAME_CREATOR_APP_CONFIG_JSON) + .expect("parse bundled runtime config template"); + assert_eq!( + template.llm.as_ref().and_then(|llm| llm.max_retries), + Some(DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES) + ); + assert!( + template.agent_llm.unwrap_or_default().is_empty(), + "bundled template must not persist canonical defaults as explicit overrides" + ); + + let ui_source = include_str!("../../../src/features/runtime-config/RuntimeConfigDialog.tsx"); + let ui_mapping = ui_source + .split("const runtimeAgentReasoningEffortDefaults = {") + .nth(1) + .and_then(|source| source.split("} as const satisfies").next()) + .expect("frontend Agent reasoning effort contract") + .lines() + .filter_map(|line| { + let line = line.trim().trim_end_matches(','); + let (agent_id, effort) = line.split_once(": ")?; + Some(( + agent_id.trim_matches(&['\'', '"'][..]).to_string(), + effort.trim_matches(&['\'', '"'][..]).to_string(), + )) + }) + .collect::>(); + assert_eq!( + ui_mapping, + expected + .iter() + .map(|(agent_id, effort)| ((*agent_id).to_string(), (*effort).to_string())) + .collect::>() + ); +} + +#[test] +fn canonical_reasoning_only_patch_does_not_activate_role_llm_override() { + let mut config = GameCreatorAppConfig::default(); + for (agent_id, effort) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS { + config.agent_llm.insert( + agent_id.to_string(), + GameCreatorLlmConfigFile { + reasoning_effort: Some(effort.to_string()), + ..GameCreatorLlmConfigFile::default() + }, + ); + assert!( + !has_game_creator_agent_llm_override(&config, agent_id), + "canonical reasoning-only default must not activate {agent_id} role LLM" + ); + } + + config.agent_llm.insert( + "design-director".to_string(), + GameCreatorLlmConfigFile { + reasoning_effort: Some("high".to_string()), + ..GameCreatorLlmConfigFile::default() + }, + ); + assert!(has_game_creator_agent_llm_override( + &config, + "design-director" + )); +} + +#[test] +fn empty_legacy_agent_llm_uses_agent_defaults_and_explicit_patch_wins() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("runtime config dir"); + fs::write( + root.join(GAME_CREATOR_CONFIG_FILE_NAME), + r#"{ + "llm": { + "apiKey": "global-key", + "baseUrl": "https://global.example.test/v1", + "model": "global-model", + "reasoningEffort": "default" + }, + "agentLlm": {} +} +"#, + ) + .expect("write legacy empty Agent config"); + let _guard = use_test_runtime_config_dir(root.clone()); + + let config = load_game_creator_app_config().expect("load legacy empty Agent config"); + assert!(config.agent_llm.is_empty()); + for (agent_id, effort) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS { + assert_eq!( + resolve_game_creator_llm_config_for_agent(&config, agent_id).reasoning_effort, + effort + ); + } + assert_eq!( + resolve_game_creator_llm_config_for_agent(&config, "non-canonical-agent").reasoning_effort, + "default" + ); + + let status = check_game_creator_llm_config_from_config(); + assert!(status.configured, "{:?}", status.error); + for (agent_id, effort) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS { + let agent = status + .agents + .iter() + .find(|agent| agent.agent_id == agent_id) + .expect("canonical Agent status"); + assert_eq!(agent.reasoning_effort, effort, "{agent_id}"); + } + + let mut overridden = config; + overridden.agent_llm.insert( + GAME_CREATOR_LEGACY_CHAT_AGENT_CONFIG_ID.to_string(), + GameCreatorLlmConfigFile { + reasoning_effort: Some("medium".to_string()), + ..GameCreatorLlmConfigFile::default() + }, + ); + for (agent_id, _) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS { + overridden.agent_llm.insert( + agent_id.to_string(), + GameCreatorLlmConfigFile { + reasoning_effort: Some("default".to_string()), + ..GameCreatorLlmConfigFile::default() + }, + ); + assert_eq!( + resolve_game_creator_llm_config_for_agent(&overridden, agent_id).reasoning_effort, + "default", + "显式 agentLlm.{agent_id} patch 必须覆盖规范默认值" + ); + } + + fs::remove_dir_all(root).ok(); +} + #[test] fn runtime_config_dir_supplies_app_config_file() { let root = unique_project_path(); @@ -576,6 +770,19 @@ fn llm_config_check_reports_per_agent_status_without_leaking_keys() { assert!(art.configured); assert_eq!(art.label, "美术组 / Asset"); assert_eq!(art.model.as_deref(), Some("art-model")); + assert_eq!(art.reasoning_effort, "high"); + let orchestrator = status + .agents + .iter() + .find(|agent| agent.agent_id == "orchestrator") + .expect("orchestrator status"); + assert_eq!(orchestrator.reasoning_effort, "medium"); + let preview = status + .agents + .iter() + .find(|agent| agent.agent_id == "preview-readiness") + .expect("preview status"); + assert_eq!(preview.reasoning_effort, "low"); let serialized = serde_json::to_string(&status).expect("status json"); assert!(!serialized.contains("planner-secret-key")); assert!(!serialized.contains("generator-secret-key")); @@ -639,6 +846,9 @@ fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() { context_window_tokens: 128_000, auto_compact_token_limit: 64_000, tool_output_token_limit: 12_000, + request_timeout_ms: 180_000, + max_retries: 2, + retry_backoff_ms: 500, error: Some("Generator:缺少 API Key".to_string()), agents: vec![GameCreatorAgentLlmConfigStatus { agent_id: "generator".to_string(), @@ -654,6 +864,9 @@ fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() { context_window_tokens: 96_000, auto_compact_token_limit: 48_000, tool_output_token_limit: 8_000, + request_timeout_ms: 90_000, + max_retries: 1, + retry_backoff_ms: 250, error: Some("LLM 未配置:请在 agentLlm.generator.apiKey 中设置 API Key".to_string()), }], }; @@ -666,6 +879,8 @@ fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() { assert!(lines.contains("llm.agent.generator.webSearchEnabled=false")); assert!(lines.contains("llm.reasoningEffort=high")); assert!(lines.contains("llm.agent.generator.reasoningEffort=medium")); + assert!(lines.contains("llm.maxRetries=2")); + assert!(lines.contains("llm.agent.generator.maxRetries=1")); assert!(lines.contains("llm.error=Generator:缺少 API Key")); assert!(!lines.contains("sk-")); assert!(!lines.contains("secret")); @@ -1163,6 +1378,25 @@ pub(crate) fn set_windows_test_path_owner_to_distinct_token_owner(path: &Path) - changed } +#[cfg(windows)] +#[test] +fn windows_private_dacl_does_not_reassert_an_owner_that_already_matches() { + const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001; + const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004; + const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000; + + assert_eq!( + windows_private_dacl_security_information(true, true), + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION + ); + assert_eq!( + windows_private_dacl_security_information(true, false), + OWNER_SECURITY_INFORMATION + | DACL_SECURITY_INFORMATION + | PROTECTED_DACL_SECURITY_INFORMATION + ); +} + #[cfg(windows)] #[test] fn windows_appdata_validation_does_not_follow_directory_links() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 550350adc..344461628 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -331,6 +331,17 @@ fn canonical_visual_completion_requires_persisted_route_kind_and_current_spec_re .unwrap_or_else(|error| panic!("{task_id} provenance should pass: {error}")); } + let art_spec_path = root.join("assets/art-spec.png"); + let valid_art_spec = fs::read(&art_spec_path).expect("read valid art spec fixture"); + fs::write(&art_spec_path, &valid_art_spec[..valid_art_spec.len() / 2]) + .expect("write truncated art spec PNG"); + assert!( + validate_manifest_required_visual_asset(&root, &manifest, "art-director") + .expect_err("truncated PNG must fail complete decode") + .contains("无法完整解码") + ); + fs::write(&art_spec_path, valid_art_spec).expect("restore valid art spec PNG"); + let ui = manifest .assets .iter_mut() @@ -3190,6 +3201,77 @@ fn local_preview_server_serves_game_index() { fs::remove_dir_all(root).ok(); } +#[test] +fn local_preview_server_drains_split_browser_headers_before_response() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "鍍忕礌鍔ㄤ綔鍘熷瀷").expect("project init"); + + let (preview, stop) = start_local_game_preview_for_project(&root).expect("preview start"); + let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("read timeout"); + stream + .write_all(b"GET / HTTP/1.1\r\n") + .expect("request line"); + // Chromium may send the request line before the rest of its headers. Keep this split + // deliberate so the server must consume the complete header block before responding. + thread::sleep(Duration::from_millis(100)); + stream + .write_all( + b"Host: 127.0.0.1\r\nConnection: close\r\nUser-Agent: Mozilla/5.0\r\nAccept: text/html\r\n\r\n", + ) + .expect("browser headers"); + let mut response = Vec::new(); + stream.read_to_end(&mut response).expect("response"); + let header_end = response + .windows(b"\r\n\r\n".len()) + .position(|window| window == b"\r\n\r\n") + .expect("complete HTTP header block") + + b"\r\n\r\n".len(); + let headers = String::from_utf8(response[..header_end].to_vec()).expect("HTTP headers"); + assert!(headers.starts_with("HTTP/1.1 200 OK\r\n"), "{headers}"); + let content_length = headers + .lines() + .find_map(|line| line.strip_prefix("Content-Length: ")) + .and_then(|value| value.parse::().ok()) + .expect("valid content length"); + assert_eq!(response.len() - header_end, content_length); + assert!(headers.contains("Connection: close"), "{headers}"); + assert!(String::from_utf8_lossy(&response[header_end..]).contains("还没有生成游戏")); + + let _ = stop.send(()); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn preview_listener_retries_transient_accept_errors() { + assert_eq!( + classify_preview_listener_accept_error(&std::io::Error::from( + std::io::ErrorKind::WouldBlock, + )), + PreviewListenerAcceptDisposition::Sleep + ); + for kind in [ + std::io::ErrorKind::ConnectionAborted, + std::io::ErrorKind::ConnectionReset, + std::io::ErrorKind::Interrupted, + std::io::ErrorKind::TimedOut, + ] { + assert_eq!( + classify_preview_listener_accept_error(&std::io::Error::from(kind)), + PreviewListenerAcceptDisposition::Retry, + "transient accept error {kind:?} must keep preview server alive" + ); + } + assert_eq!( + classify_preview_listener_accept_error(&std::io::Error::from( + std::io::ErrorKind::InvalidData, + )), + PreviewListenerAcceptDisposition::Stop + ); +} + #[test] fn preview_content_type_covers_common_game_assets() { assert_eq!(content_type(Path::new("hero.webp")), "image/webp"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs index 51c511cde..cd234fa8e 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 @@ -1017,7 +1017,7 @@ async fn background_agent_runtime_can_search_patch_and_read_in_sequence() { assert!(plan_request.contains("startLine")); assert!(plan_request.contains("expectedReplacements")); assert!(plan_request.contains("\"max_output_tokens\":4000")); - assert!(plan_request.contains("\"reasoning\":{\"effort\":\"high\"}")); + assert!(plan_request.contains("\"reasoning\":{\"effort\":\"medium\"}")); let verification_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("verification llm request"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs index 3d1d27ab9..204b8d047 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs @@ -1852,6 +1852,147 @@ async fn background_agent_runtime_repairs_terminal_receipt_through_reconciliatio fs::remove_dir_all(root).ok(); } +#[test] +fn background_agent_runtime_resume_preflight_skips_policy_for_fresh_project() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "fresh project").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["conversation.write".to_string(), "agent.resume".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("require confirmation for write permissions"); + + let resumed = resume_game_creator_agent_runtime_tasks(root.to_string_lossy().into_owned()) + .expect("fresh project has no recoverable runtime work"); + assert!(resumed.is_empty()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn background_agent_runtime_resume_preflight_preserves_policy_for_terminal_recovery_artifact() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "terminal recovery artifact") + .expect("project init"); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: "design-director".to_string(), + session_id: "agent-session-design-director".to_string(), + run_id: "design-terminal-artifact-run".to_string(), + source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "terminal runtime with durable artifact".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "completed".to_string(), + terminal_detail: Some("completed".to_string()), + error: None, + updated_at: unix_timestamp(), + }, + ); + let artifact = root + .join(".agent/runtime/pending-actions/design-director/design-terminal-artifact-run.json"); + fs::create_dir_all(artifact.parent().expect("artifact parent")) + .expect("create artifact directory"); + fs::write(&artifact, b"{}\n").expect("write durable recovery artifact"); + + let error = resume_game_creator_agent_runtime_tasks(root.to_string_lossy().into_owned()) + .expect_err("durable recovery artifact still requires agent.resume approval"); + assert!(error.contains("agent.resume"), "{error}"); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn background_agent_runtime_resume_preflight_skips_policy_for_terminal_project() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "terminal project").expect("project init"); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: "design-director".to_string(), + session_id: "agent-session-design-director".to_string(), + run_id: "design-terminal-run".to_string(), + source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "completed runtime".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "completed".to_string(), + terminal_detail: Some("completed".to_string()), + error: None, + updated_at: unix_timestamp(), + }, + ); + + let resumed = resume_game_creator_agent_runtime_tasks(root.to_string_lossy().into_owned()) + .expect("terminal project has no recoverable runtime work"); + assert!(resumed.is_empty()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn background_agent_runtime_resume_preflight_preserves_policy_for_recoverable_task() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "recoverable project").expect("project init"); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: "design-director".to_string(), + session_id: "agent-session-design-director".to_string(), + run_id: "design-recoverable-run".to_string(), + source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "recoverable runtime".to_string(), + status: "pending".to_string(), + phase: "queued".to_string(), + current_action: "waiting for recovery".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }, + ); + + let error = resume_game_creator_agent_runtime_tasks(root.to_string_lossy().into_owned()) + .expect_err("recoverable runtime still requires agent.resume policy approval"); + assert!(error.contains("agent.resume"), "{error}"); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_resume_commands_distinguish_auto_and_confirmed_paths() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index 8fb6c488e..d718069e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -86,6 +86,100 @@ fn runtime_task_reader_rejects_unterminated_non_truncated_syntax_error() { fs::remove_dir_all(root).ok(); } +#[test] +fn runtime_events_expose_stable_ids_and_backend_owned_public_text_only() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("create runtime event fixture directory"); + let state = default_game_creator_agent_runtime_state("code-prototype", "public-event-run"); + + append_game_creator_agent_runtime_action_event( + &root, + &state, + "turn.progress", + "running", + "planning", + "正在生成首个可玩版本", + Some("taskSha256=private-hash"), + "public-event-progress-1", + ) + .expect("append public progress"); + append_game_creator_agent_runtime_action_event( + &root, + &state, + "observation", + "running", + "observation", + "runtime.plan_update:blocked · 内部计划门禁", + Some("fingerprint=private"), + "public-event-internal-1", + ) + .expect("append internal observation"); + append_game_creator_agent_runtime_action_event( + &root, + &state, + "turn.progress", + "running", + "planning", + "Bearer secret-token", + None, + "public-event-sensitive-1", + ) + .expect("append sensitive progress"); + append_game_creator_agent_runtime_action_event( + &root, + &state, + "action", + "running", + "action", + "调用工具 file.write", + Some("raw tool input must stay private"), + "public-event-action-1", + ) + .expect("append public action"); + append_game_creator_agent_runtime_action_event( + &root, + &state, + "action", + "running", + "action", + "调用工具 file.write", + Some("raw tool input must stay private"), + "public-event-action-1", + ) + .expect("repeat public action idempotently"); + + let events = read_recent_game_creator_agent_runtime_events( + &game_creator_agent_runtime_event_path(&root, "code-prototype"), + ) + .expect("read public runtime events"); + assert_eq!(events.len(), 4); + assert!(events.iter().all(|event| !event.event_id.trim().is_empty())); + let mut event_ids = events + .iter() + .map(|event| event.event_id.as_str()) + .collect::>(); + event_ids.sort_unstable(); + event_ids.dedup(); + assert_eq!(event_ids.len(), events.len()); + assert_eq!( + events[0].public_text.as_deref(), + Some("正在生成首个可玩版本") + ); + assert_eq!(events[1].public_text, None); + assert_eq!(events[2].public_text, None); + assert_eq!( + events[3].public_text.as_deref(), + Some("调用工具 file.write") + ); + assert!(!events[3] + .public_text + .as_deref() + .unwrap_or_default() + .contains("raw tool input must stay private")); + + fs::remove_dir_all(root).ok(); +} + #[test] fn runtime_task_reader_rejects_unknown_status_and_phase() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs index 9eb96c7d7..2f73eaf39 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs @@ -2616,6 +2616,7 @@ fn local_conversation_write_respects_project_policy() { content: "should fail".to_string(), agent_id: None, }, + None, ) .expect_err("conversation write denied"); assert!(error.contains("项目权限策略拒绝执行:conversation.write")); @@ -2623,6 +2624,33 @@ fn local_conversation_write_respects_project_policy() { fs::remove_dir_all(root).ok(); } +#[test] +fn local_conversation_command_message_id_is_idempotent() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "game-chat 输出消息").expect("project init"); + let message_id = "game-chat-output-code-prototype-run-1"; + let append = || { + append_local_conversation_message( + root.to_string_lossy().into_owned(), + None, + None, + LocalConversationMessage { + role: "assistant".to_string(), + content: "【程序 Agent】\n首个可玩版本代码已生成。".to_string(), + agent_id: None, + }, + Some(message_id.to_string()), + ) + }; + + append().expect("append game-chat output"); + let repeated = append().expect("repeat game-chat output idempotently"); + assert_eq!(repeated.messages.len(), 1); + assert_eq!(repeated.messages[0].message_id.as_deref(), Some(message_id)); + + fs::remove_dir_all(root).ok(); +} + #[test] fn local_conversation_read_respects_project_policy() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index d9c743258..7d2652110 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -108,12 +108,14 @@ import { isRuntimeConfigMissingError, matchingAgentRuntimeForSteer, mergeAgentRuntimeStateIntoMap, + mergeGameChatRuntimeResponseMessagesIntoHistory, mergeProjectSupervisorConversation, mergeProjectSupervisorResponseStream, normalizeAgentRuntimeState, projectNameFromPath, projectProfessionalAgentLabel, projectSupervisorPendingRepairMatchesProfessional, + projectSupervisorResponseStreamIdentity, readProjectSupervisorActiveSessionId, sameAgentRuntimeRun, submitProjectSupervisorRuntimeTask, @@ -228,6 +230,10 @@ import { buildGameChatProgressEvidence, collectGameChatResultImages, formatGameChatStageRecord, + gameChatFinalReplyMessages, + gameChatRuntimeEventMessages, + mergeGameChatFinalReplyMessagesIntoHistory, + mergeGameChatRuntimeEventMessagesIntoHistory, SupervisorChatOnlyView, } from './features/project-workspace/SupervisorChatOnlyView'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; @@ -261,6 +267,58 @@ type GameChatPreviewValidationCandidate = GameChatPlayableRevision & { playable: boolean; }; +const GAME_CHAT_STAGE_TASK_IDS = [ + 'design-director', + 'art-director', + 'code-director', + 'code-prototype', + 'preview-readiness', + 'preview-playtest', +] as const; + +function gameChatManifestHasTerminalStageTasks( + manifest: GameCreationAppManifest | null, +) { + if (!manifest) { + return false; + } + return GAME_CHAT_STAGE_TASK_IDS.every((taskId) => { + const status = manifest.tasks.find((task) => task.id === taskId)?.status; + return status === 'completed' || status === 'failed'; + }); +} + +function gameChatRuntimeHasTerminalOutcome(runtime: AgentRuntimeState) { + return ( + ['completed', 'failed', 'cancelled'].includes(runtime.status) || + ['completed', 'failed', 'cancelled'].includes(runtime.phase) + ); +} + +function mergeGameChatHydratedConversationMessages( + historyMessages: ChatMessage[], + currentMessages: ChatMessage[], +) { + const mergedMessages = mergeGameChatRuntimeEventMessagesIntoHistory( + mergeGameChatFinalReplyMessagesIntoHistory( + mergeGameChatRuntimeResponseMessagesIntoHistory( + historyMessages, + currentMessages, + ), + currentMessages, + ), + currentMessages, + ); + const historyMessageReferences = new Set(historyMessages); + const pendingMessages = mergedMessages + .filter((message) => !historyMessageReferences.has(message)) + .sort((left, right) => (left.updatedAt ?? 0) - (right.updatedAt ?? 0)); + // Conversation persistence uses a positional cursor. Keep durable history + // as one prefix so newly observed replies cannot sort ahead of that cursor + // and be skipped during hydration. + return [...historyMessages, ...pendingMessages]; +} + function gameChatPlayableRevisionIsAfterAuthorization( revision: GameChatPlayableRevision, authorization: GameChatAutoPreviewAuthorization, @@ -573,6 +631,10 @@ export function App({ const gameChatAutoPreviewAttemptedRef = useRef(new Set()); const gameChatObservedRunKeysRef = useRef(new Set()); const gameChatArchivedRunKeysRef = useRef(new Set()); + const gameChatPendingStageRuntimesRef = useRef( + new Map(), + ); + const gameChatCommittedResponseStreamKeysRef = useRef(new Set()); const initialSupervisorMessageLatchRef = useRef({ projectPath: initialProjectPath, prompt: initialSupervisorMessage.trim(), @@ -812,32 +874,69 @@ export function App({ [gameChatOnly], ); - function updateProjectSupervisorResponseStream( - incoming: AgentRuntimeResponseStream | null | undefined, - runtime: AgentRuntimeState, - ) { - let nextStream = mergeProjectSupervisorResponseStream( - projectSupervisorResponseStreamRef.current, - incoming, - runtime, - ); - const candidateStream = nextStream; - if ( - candidateStream && - latestMessagesRef.current.some( - (message) => - message.runtimeOwned && - message.role === 'assistant' && - message.text === candidateStream.accumulatedText && - typeof message.updatedAt === 'number' && - message.updatedAt >= candidateStream.startedAt, - ) - ) { - nextStream = null; - } - projectSupervisorResponseStreamRef.current = nextStream; - setProjectSupervisorResponseStream(nextStream); - } + const updateProjectSupervisorResponseStream = useCallback( + ( + incoming: AgentRuntimeResponseStream | null | undefined, + runtime: AgentRuntimeState, + ) => { + let nextStream = mergeProjectSupervisorResponseStream( + projectSupervisorResponseStreamRef.current, + incoming, + runtime, + ); + const candidateStream = nextStream; + if (candidateStream?.status === 'ready' && gameChatOnly) { + const text = candidateStream.accumulatedText.trim(); + const responseKey = + projectSupervisorResponseStreamIdentity(candidateStream); + const messageId = `runtime-response:${responseKey}`; + const alreadyCommitted = + gameChatCommittedResponseStreamKeysRef.current.has(responseKey) || + latestMessagesRef.current.some( + (message) => message.messageId === messageId, + ); + if (text && !alreadyCommitted) { + gameChatCommittedResponseStreamKeysRef.current.add(responseKey); + const nextMessage: ChatMessage = { + role: 'assistant', + text: candidateStream.accumulatedText, + messageId, + agentId: PROJECT_SUPERVISOR_AGENT_ID, + updatedAt: candidateStream.updatedAt, + runtimeOwned: true, + }; + setMessages((current) => { + if (current.some((message) => message.messageId === messageId)) { + latestMessagesRef.current = current; + return current; + } + const nextMessages = [...current, nextMessage]; + latestMessagesRef.current = nextMessages; + return nextMessages; + }); + } + // Ready text is now a normal chat message; transientReply must not show + // the same response a second time while polling/event delivery catches up. + nextStream = null; + } else if ( + candidateStream && + latestMessagesRef.current.some( + (message) => + message.runtimeOwned && + message.role === 'assistant' && + message.text === candidateStream.accumulatedText && + typeof message.updatedAt === 'number' && + message.updatedAt >= candidateStream.startedAt, + ) + ) { + // Keep the existing supervisor-chat behavior for restored history. + nextStream = null; + } + projectSupervisorResponseStreamRef.current = nextStream; + setProjectSupervisorResponseStream(nextStream); + }, + [gameChatOnly], + ); function resetProjectSupervisorState() { projectSupervisorHistoryLoadVersionRef.current += 1; @@ -846,6 +945,8 @@ export function App({ projectSupervisorRuntimeRef.current = null; projectSupervisorExpectedRunIdRef.current = null; projectSupervisorResponseStreamRef.current = null; + gameChatPendingStageRuntimesRef.current.clear(); + gameChatCommittedResponseStreamKeysRef.current.clear(); projectSupervisorRuntimeSyncingRef.current.clear(); setProjectSupervisorSessionId(null); setProjectSupervisorRuntime(null); @@ -891,43 +992,207 @@ export function App({ }); } + function flushPendingGameChatStageRecords() { + if (!gameChatOnly || !gameChatManifestHasTerminalStageTasks(manifest)) { + return; + } + for (const [ + archiveKey, + pendingRuntime, + ] of gameChatPendingStageRuntimesRef.current) { + if (gameChatArchivedRunKeysRef.current.has(archiveKey)) { + gameChatPendingStageRuntimesRef.current.delete(archiveKey); + continue; + } + const progress = buildGameChatProgressEvidence( + pendingRuntime, + agentRuntimeById, + manifest, + ); + if (!progress) { + continue; + } + const text = formatGameChatStageRecord( + pendingRuntime, + progress, + collectGameChatResultImages(manifest), + ); + gameChatArchivedRunKeysRef.current.add(archiveKey); + gameChatPendingStageRuntimesRef.current.delete(archiveKey); + setMessages((current) => { + if ( + current.some( + (message) => message.role === 'assistant' && message.text === text, + ) + ) { + return current; + } + // Conversation hydration can update the saved cursor in the same + // turn as this append. Clamp it to the pre-append list so the new + // stage record remains visible to the persistence effect. + const projectPath = archiveKey.split('\n', 1)[0]; + if (savedConversationProjectPathRef.current === projectPath) { + savedConversationCountRef.current = Math.min( + savedConversationCountRef.current, + current.length, + ); + } + return [...current, { role: 'assistant', text }]; + }); + } + } + + function appendGameChatRuntimeEventMessages( + nextProjectPath: string, + runtime: AgentRuntimeState, + ) { + const eventMessages = gameChatRuntimeEventMessages( + runtime, + agentRuntimeById, + ); + if (eventMessages.length === 0) { + return; + } + setMessages((current) => { + const currentMessageIds = new Set( + current + .map((message) => message.messageId?.trim()) + .filter((messageId): messageId is string => Boolean(messageId)), + ); + const missingMessages = eventMessages.filter( + (message) => + message.messageId && !currentMessageIds.has(message.messageId), + ); + if (missingMessages.length === 0) { + return current; + } + if (savedConversationProjectPathRef.current === nextProjectPath) { + savedConversationCountRef.current = Math.min( + savedConversationCountRef.current, + current.length, + ); + } + return [...current, ...missingMessages]; + }); + } + + const appendGameChatFinalReplyMessages = useCallback( + (nextProjectPath: string, runtimeResults: AgentRuntimeResult[]) => { + if (!gameChatOnly || runtimeResults.length === 0) { + return; + } + // A professional Runtime is only part of the active game-chat turn when + // it was delegated by the current Project Supervisor run. This prevents + // a stale child Runtime (or a different app mode) from leaking into the + // project transcript after a restart. + const supervisorRunId = + projectSupervisorRuntimeRef.current?.runId ?? + runtimeResults.find( + (result) => result.state.agentId === PROJECT_SUPERVISOR_AGENT_ID, + )?.state.runId; + if (!supervisorRunId) { + return; + } + const messages = runtimeResults.flatMap((result) => { + const runtime = agentRuntimeStateFromResult(result); + if ( + runtime.parentAgentId !== PROJECT_SUPERVISOR_AGENT_ID || + runtime.parentRunId !== supervisorRunId + ) { + return []; + } + return gameChatFinalReplyMessages([result.responseStream]); + }); + if (messages.length === 0) { + return; + } + setMessages((current) => { + const currentMessageIds = new Set( + current + .map((message) => message.messageId?.trim()) + .filter((messageId): messageId is string => Boolean(messageId)), + ); + const missingMessages = messages.filter( + (message) => + message.messageId && !currentMessageIds.has(message.messageId), + ); + if (missingMessages.length === 0) { + return current; + } + if (savedConversationProjectPathRef.current === nextProjectPath) { + savedConversationCountRef.current = Math.min( + savedConversationCountRef.current, + current.length, + ); + } + const orderedMissingMessages = [...missingMessages].sort( + (left, right) => (left.updatedAt ?? 0) - (right.updatedAt ?? 0), + ); + const nextMessages = [...current, ...orderedMissingMessages]; + latestMessagesRef.current = nextMessages; + return nextMessages; + }); + }, + [gameChatOnly], + ); + function appendGameChatStageRecord( nextProjectPath: string, runtime: AgentRuntimeState, ) { - if (!gameChatOnly || !isAgentRuntimeTerminalState(runtime)) { + if (!gameChatOnly || !gameChatRuntimeHasTerminalOutcome(runtime)) { return; } const archiveKey = `${nextProjectPath}\n${runtime.runId}`; - if ( - !gameChatObservedRunKeysRef.current.has(archiveKey) || - gameChatArchivedRunKeysRef.current.has(archiveKey) - ) { + // A restored terminal runtime may be the first runtime snapshot observed + // after the app opens. Do not require a prior non-terminal event: the + // durable runtime/manifest pair is sufficient evidence for the record. + if (gameChatArchivedRunKeysRef.current.has(archiveKey)) { return; } - const progress = buildGameChatProgressEvidence( - runtime, - agentRuntimeById, - manifest, - ); - if (!progress) { - return; - } - const text = formatGameChatStageRecord( - runtime, - progress, - collectGameChatResultImages(manifest), - ); - gameChatArchivedRunKeysRef.current.add(archiveKey); - setMessages((current) => - current.some( - (message) => message.role === 'assistant' && message.text === text, - ) - ? current - : [...current, { role: 'assistant', text }], - ); + gameChatPendingStageRuntimesRef.current.set(archiveKey, runtime); + flushPendingGameChatStageRecords(); } + useEffect(() => { + const nextProjectPath = localProject?.projectPath; + const runtime = projectSupervisorRuntime; + if (gameChatOnly && nextProjectPath && runtime) { + appendGameChatRuntimeEventMessages(nextProjectPath, runtime); + } + if ( + gameChatOnly && + nextProjectPath && + runtime && + gameChatRuntimeHasTerminalOutcome(runtime) + ) { + // Hydration can restore a terminal root run without delivering a live + // runtime-update event. Feed that snapshot through the same deferred + // archive path used by live terminal updates. A run that was already + // observed in a non-terminal state is archived by its terminal + // conversation refresh instead, avoiding a hydration race with that + // refresh's saved-message cursor. + const archiveKey = `${nextProjectPath}\n${runtime.runId}`; + const refreshKey = `${nextProjectPath}\n${runtime.sessionId}\n${runtime.runId}`; + if ( + !gameChatObservedRunKeysRef.current.has(archiveKey) && + !projectSupervisorRuntimeSyncingRef.current.has(refreshKey) + ) { + appendGameChatStageRecord(nextProjectPath, runtime); + } + } + flushPendingGameChatStageRecords(); + // The terminal Runtime can arrive before the durable manifest refresh. + // Retry when either projection changes, but archive each run only once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + agentRuntimeById, + gameChatOnly, + localProject?.projectPath, + manifest, + projectSupervisorRuntime, + ]); + useEscapeToClose(closeAgentConversation, selectedAgent !== null); useEscapeToClose(cancelUiCommandConfirmation, pendingUiConfirmation !== null); useEscapeToClose( @@ -1036,6 +1301,11 @@ export function App({ return; } const nextRuntime = agentRuntimeStateFromResult(payload.runtime); + if (gameChatOnly && payload.agentId !== PROJECT_SUPERVISOR_AGENT_ID) { + appendGameChatFinalReplyMessages(payload.projectPath, [ + payload.runtime, + ]); + } if (payload.agentId === PROJECT_SUPERVISOR_AGENT_ID) { const expectedSessionId = projectSupervisorSessionIdRef.current; const currentRuntime = projectSupervisorRuntimeRef.current; @@ -1116,7 +1386,12 @@ export function App({ disposed = true; cleanup?.(); }; - }, [updateProjectSupervisorRuntime]); + }, [ + appendGameChatFinalReplyMessages, + gameChatOnly, + updateProjectSupervisorResponseStream, + updateProjectSupervisorRuntime, + ]); useEffect(() => { const invoke = resolveTauriInvoke(); @@ -1258,6 +1533,9 @@ export function App({ ) { return; } + if (gameChatOnly) { + appendGameChatFinalReplyMessages(nextProjectPath, runtimes); + } const nextRuntimes = runtimes.map((runtimeResult) => agentRuntimeStateFromResult(runtimeResult), ); @@ -1731,10 +2009,14 @@ export function App({ { projectPath: nextProjectPath, agentId: null, + ...(message.messageId ? { messageId: message.messageId } : {}), message: { role: message.role, content: message.text, agentId: null, + ...(typeof message.updatedAt === 'number' + ? { updatedAt: message.updatedAt } + : {}), }, }, ); @@ -2445,6 +2727,7 @@ export function App({ ); const transientResponse = projectSupervisorResponseStreamRef.current; if ( + !gameChatOnly && conversationContainsProjectSupervisorResponseStream( supervisorConversation.messages, transientResponse, @@ -2453,11 +2736,25 @@ export function App({ projectSupervisorResponseStreamRef.current = null; setProjectSupervisorResponseStream(null); } - savedConversationProjectPathRef.current = nextProjectPath; - savedConversationCountRef.current = conversationMessages.length; - latestMessagesRef.current = conversationMessages; setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); - setMessages(conversationMessages); + setMessages((current) => { + // React may apply this hydration update after a ready stream callback + // that was queued in the same turn. Read `current` inside the updater + // so a committed game-chat response cannot be overwritten by stale + // `latestMessagesRef` state captured before that callback ran. + const nextMessages = gameChatOnly + ? mergeGameChatHydratedConversationMessages( + conversationMessages, + current, + ) + : conversationMessages; + savedConversationProjectPathRef.current = nextProjectPath; + savedConversationCountRef.current = gameChatOnly + ? conversationMessages.length + : nextMessages.length; + latestMessagesRef.current = nextMessages; + return nextMessages; + }); const terminalRuntime = projectSupervisorRuntimeRef.current; if ( runId && @@ -2557,6 +2854,7 @@ export function App({ supervisorConversation?.messages ?? [], ); if ( + !gameChatOnly && conversationContainsProjectSupervisorResponseStream( supervisorConversation?.messages ?? [], runtimeResponseStream, @@ -2575,6 +2873,12 @@ export function App({ } setProjectSupervisorRuntimeError(runtimeError || resumeError); setMessages((current) => { + const nextConversationMessages = gameChatOnly + ? mergeGameChatHydratedConversationMessages( + conversationMessages, + current, + ) + : conversationMessages; const hasOnlyDefaultGreeting = current.length === 1 && current[0]?.role === 'assistant' && @@ -2594,8 +2898,10 @@ export function App({ } setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); savedConversationProjectPathRef.current = nextProjectPath; - savedConversationCountRef.current = conversationMessages.length; - latestMessagesRef.current = conversationMessages; + savedConversationCountRef.current = gameChatOnly + ? conversationMessages.length + : nextConversationMessages.length; + latestMessagesRef.current = nextConversationMessages; setWorkspaceStatus((workspaceStatus) => { if (mode === 'replace') { return `已读取项目对话历史:${conversationMessages.length} 条`; @@ -2604,7 +2910,7 @@ export function App({ ? `已打开:${nextProjectPath}` : workspaceStatus; }); - return conversationMessages; + return nextConversationMessages; }); } catch (error) { if ( @@ -5366,6 +5672,7 @@ export function App({ prompt, runtime: runtimeAtSubmission, runProfile: submissionRunProfile, + ...(gameChatOnly ? { source: 'project-supervisor-game-chat' } : {}), }); const runtimeResult = submission.runtimeResult; const acceptedRunId = submission.acceptedRunId.trim(); @@ -9883,6 +10190,7 @@ export function App({ } agentRuntimeResumeProjectPathRef.current = nextProjectPath; for (const runtimeResult of resumedRuntimes) { + appendGameChatFinalReplyMessages(nextProjectPath, [runtimeResult]); rememberAgentRuntimeState( agentRuntimeStateFromResult(runtimeResult), ); @@ -9914,6 +10222,9 @@ export function App({ } agentRuntimeResumeProjectPathRef.current = nextProjectPath; for (const runtimeResult of resumedRuntimes) { + appendGameChatFinalReplyMessages(nextProjectPath, [ + runtimeResult, + ]); rememberAgentRuntimeState( agentRuntimeStateFromResult(runtimeResult), ); @@ -9954,6 +10265,7 @@ export function App({ } const nextRuntimes: AgentRuntimeState[] = []; for (const runtimeResult of runtimes) { + appendGameChatFinalReplyMessages(nextProjectPath, [runtimeResult]); nextRuntimes.push(agentRuntimeStateFromResult(runtimeResult)); } const supervisorRuntimeIndex = nextRuntimes.findIndex( diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 4f6cfa6cc..8cec58340 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -312,11 +312,14 @@ export interface AgentRuntimeEventRecord { source: string; runProfile?: 'standard' | 'autonomous-game-build'; runProfileBindingFingerprint?: string; + eventId?: string; + actionId?: string | null; eventType: string; status: string; phase: string; summary: string; detail: string | null; + publicText?: string | null; updatedAt: number; } diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 09b2219f3..b0ef9627a 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -477,6 +477,22 @@ export function sameProjectSupervisorResponseStream( ); } +/** + * Durable identity for one final-reply response. The response text is + * intentionally excluded so two turns with identical wording remain + * distinct messages in game-chat. + */ +export function projectSupervisorResponseStreamIdentity( + stream: Pick< + AgentRuntimeResponseStream, + 'runId' | 'requestSlot' | 'responseRevision' + >, +) { + return [stream.runId, stream.requestSlot, stream.responseRevision].join( + '\u001f', + ); +} + export function mergeProjectSupervisorResponseStream( current: AgentRuntimeResponseStream | null, incoming: AgentRuntimeResponseStream | null | undefined, @@ -523,6 +539,44 @@ export function conversationContainsProjectSupervisorResponseStream( ); } +/** + * Keep final responses that were committed into the game-chat window while + * conversation history is being hydrated. Runtime responses have a durable + * local id; matching by text or timestamp would collapse two different runs + * that happen to produce the same wording. + */ +export function mergeGameChatRuntimeResponseMessagesIntoHistory( + historyMessages: ChatMessage[], + currentMessages: ChatMessage[], +) { + const historyMessageIds = new Set( + historyMessages + .map((message) => message.messageId?.trim()) + .filter((messageId): messageId is string => Boolean(messageId)), + ); + const addedMessageIds = new Set(); + const responsesToKeep = currentMessages.filter((message) => { + const messageId = message.messageId?.trim(); + if ( + !message.runtimeOwned || + message.role !== 'assistant' || + !messageId?.startsWith('runtime-response:') || + historyMessageIds.has(messageId) || + addedMessageIds.has(messageId) + ) { + return false; + } + addedMessageIds.add(messageId); + return true; + }); + if (responsesToKeep.length === 0) { + return historyMessages; + } + return [...historyMessages, ...responsesToKeep].sort( + (left, right) => (left.updatedAt ?? 0) - (right.updatedAt ?? 0), + ); +} + export function agentRuntimeWaitingOnFromPhase(phase: string) { switch (phase) { case 'planning': @@ -765,6 +819,7 @@ export async function submitProjectSupervisorRuntimeTask({ prompt, runtime, runProfile, + source, }: { invoke: TauriInvoke; projectPath: string; @@ -772,6 +827,7 @@ export async function submitProjectSupervisorRuntimeTask({ prompt: string; runtime: AgentRuntimeState | null; runProfile: 'standard' | 'autonomous-game-build'; + source?: string; }) { const steerRuntime = matchingAgentRuntimeForSteer( [runtime], @@ -790,6 +846,7 @@ export async function submitProjectSupervisorRuntimeTask({ steerId: createAgentChatRunId('project-supervisor-steer'), instruction: prompt, runProfile, + ...(source ? { source } : {}), }, ); return { @@ -807,6 +864,7 @@ export async function submitProjectSupervisorRuntimeTask({ task: prompt, runId: requestedRunId, runProfile, + ...(source ? { source } : {}), }, ); return { diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx index fb1421691..7ebbbec4c 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx @@ -14,6 +14,7 @@ import { useEffect, useMemo, useState } from 'react'; import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import type { AgentRuntimeEventRecord, + AgentRuntimeResponseStream, AgentRuntimeState, ChatMessage, LocalPreviewResult, @@ -54,6 +55,25 @@ export type GameChatRuntimeEvent = { event: AgentRuntimeEventRecord; }; +const GAME_CHAT_RUNTIME_EVENT_MESSAGE_PREFIX = 'game-chat-runtime-event:'; +const GAME_CHAT_FINAL_REPLY_MESSAGE_PREFIX = 'game-chat-final-reply:'; +const GAME_CHAT_FINAL_REPLY_AGENT_IDS = new Set([ + 'design-director', + 'art-director', + 'code-director', + 'code-prototype', + 'preview-readiness', + 'preview-playtest', +]); +const GAME_CHAT_INTERNAL_RUNTIME_EVENT_TYPES = new Set([ + 'tool.request', + 'tool.response', + 'tool.result', + 'agent.runtime.tool.request', + 'agent.runtime.tool.response', + 'agent.runtime.tool.result', +]); + export type GameChatProgressEvidence = { key: string; label: string; @@ -289,6 +309,14 @@ function latestEvidenceEvent( return events.find(({ event }) => predicate(event)) ?? null; } +export function formatGameChatRuntimeText(text: string) { + return text.replace(/第\s*\d+\s*轮/gu, '本轮'); +} + +export function formatGameChatRuntimeEvent(event: AgentRuntimeEventRecord) { + return formatGameChatRuntimeText(formatAgentRuntimeEvent(event)); +} + export function buildGameChatProgressEvidence( runtime: AgentRuntimeState | null, runtimeByAgentId: Record, @@ -297,7 +325,16 @@ export function buildGameChatProgressEvidence( if (!runtime?.runId) { return null; } - const tasks = manifest?.tasks ?? []; + const fastPathTaskIds = new Set([ + 'design-director', + 'art-director', + 'code-director', + 'code-prototype', + 'preview-readiness', + 'preview-playtest', + ]); + const tasks = + manifest?.tasks.filter((task) => fastPathTaskIds.has(task.id)) ?? []; const completedTasks = tasks.filter( (task) => task.status === 'completed', ).length; @@ -323,9 +360,6 @@ export function buildGameChatProgressEvidence( .map((professionalRuntime) => { const parts = [ projectProfessionalAgentLabel(professionalRuntime.agentId), - (professionalRuntime.loopIteration ?? 0) > 0 - ? `第 ${professionalRuntime.loopIteration} 轮` - : null, projectRuntimeVisibleCurrentWork(professionalRuntime), ].filter(Boolean); return compactProgressText(parts.join(' · '), 140); @@ -457,21 +491,24 @@ export function buildGameChatProgressEvidence( } return { runId: runtime.runId, - title: - (runtime.loopIteration ?? 0) > 0 - ? `Supervisor 进度播报 · 第 ${runtime.loopIteration} 轮` - : 'Supervisor 进度播报', + // loopIteration is the Runtime's private provider/tool loop, not a + // user-visible game generation round. A single game-chat turn can require + // several of these loops for delegation, repair and playtest. + title: '本轮生成进度', taskProgress: taskParts.join(' · ') || projectSupervisorChatRuntimeStatus(runtime), - currentWork: compactProgressText(projectRuntimeVisibleCurrentWork(runtime)), + currentWork: formatGameChatRuntimeText( + compactProgressText(projectRuntimeVisibleCurrentWork(runtime)), + ), activeAgents, evidence, }; } -export function collectGameChatRuntimeEvents( +function collectGameChatRuntimeEventsInternal( runtime: AgentRuntimeState | null, runtimeByAgentId: Record, + limit: number, ) { const sources = runtime ? [ @@ -492,6 +529,7 @@ export function collectGameChatRuntimeEvents( continue; } const key = [ + event.eventId, event.agentId, event.sessionId, event.runId, @@ -509,7 +547,173 @@ export function collectGameChatRuntimeEvents( } return Array.from(deduplicated.values()) .sort((left, right) => right.event.updatedAt - left.event.updatedAt) - .slice(0, 20); + .slice(0, limit); +} + +export function collectGameChatRuntimeEvents( + runtime: AgentRuntimeState | null, + runtimeByAgentId: Record, +) { + return collectGameChatRuntimeEventsInternal(runtime, runtimeByAgentId, 20); +} + +function gameChatRuntimeEventMessageText(item: GameChatRuntimeEvent) { + const event = item.event; + const eventType = + typeof event.eventType === 'string' + ? event.eventType.trim().toLowerCase() + : ''; + const rawPublicText = + typeof event.publicText === 'string' ? event.publicText.trim() : ''; + const publicText = formatGameChatRuntimeText( + compactProgressText(rawPublicText, 220), + ); + const eventId = typeof event.eventId === 'string' ? event.eventId.trim() : ''; + if ( + !eventId || + !publicText || + GAME_CHAT_INTERNAL_RUNTIME_EVENT_TYPES.has(eventType) + ) { + return null; + } + return `${item.agentLabel}:${publicText}`; +} + +export function gameChatRuntimeEventMessages( + runtime: AgentRuntimeState | null, + runtimeByAgentId: Record, +) { + return collectGameChatRuntimeEventsInternal( + runtime, + runtimeByAgentId, + Number.MAX_SAFE_INTEGER, + ) + .sort((left, right) => { + const updatedAtDelta = left.event.updatedAt - right.event.updatedAt; + return updatedAtDelta !== 0 + ? updatedAtDelta + : left.key.localeCompare(right.key); + }) + .flatMap((item) => { + const text = gameChatRuntimeEventMessageText(item); + const eventId = + typeof item.event.eventId === 'string' ? item.event.eventId.trim() : ''; + if (!text) { + return []; + } + return [ + { + role: 'assistant' as const, + text, + messageId: `${GAME_CHAT_RUNTIME_EVENT_MESSAGE_PREFIX}${eventId}`, + agentId: null, + updatedAt: item.event.updatedAt, + }, + ]; + }); +} + +/** + * Convert a professional Agent's durable final-reply stream into one normal + * project-chat message. Tool plans and in-flight streams intentionally stay + * out of the conversation: the chat is a user-facing transcript, not a + * Runtime protocol log. + */ +export function gameChatFinalReplyMessages( + streams: Array, +) { + return streams.flatMap((stream) => { + const accumulatedText = + typeof stream?.accumulatedText === 'string' + ? stream.accumulatedText.trim() + : ''; + if ( + !stream || + !GAME_CHAT_FINAL_REPLY_AGENT_IDS.has(stream.agentId) || + stream.requestKind !== 'final-reply' || + !['ready', 'committed'].includes(stream.status) || + !accumulatedText + ) { + return []; + } + const streamIdentity = [ + stream.sessionId, + stream.runId, + stream.requestSlot, + stream.responseRevision, + ].join('\u001f'); + return [ + { + role: 'assistant' as const, + text: `${projectProfessionalAgentLabel(stream.agentId)}:${accumulatedText}`, + messageId: `${GAME_CHAT_FINAL_REPLY_MESSAGE_PREFIX}${encodeURIComponent( + `${stream.agentId}\u001f${streamIdentity}`, + )}`, + agentId: stream.agentId, + updatedAt: stream.updatedAt, + }, + ]; + }); +} + +export function mergeGameChatFinalReplyMessagesIntoHistory( + historyMessages: ChatMessage[], + currentMessages: ChatMessage[], +) { + const historyMessageIds = new Set( + historyMessages + .map((message) => message.messageId?.trim()) + .filter((messageId): messageId is string => Boolean(messageId)), + ); + const addedMessageIds = new Set(); + const repliesToKeep = currentMessages.filter((message) => { + const messageId = message.messageId?.trim(); + if ( + !messageId?.startsWith(GAME_CHAT_FINAL_REPLY_MESSAGE_PREFIX) || + historyMessageIds.has(messageId) || + addedMessageIds.has(messageId) + ) { + return false; + } + addedMessageIds.add(messageId); + return true; + }); + if (repliesToKeep.length === 0) { + return historyMessages; + } + return [...historyMessages, ...repliesToKeep].sort( + (left, right) => (left.updatedAt ?? 0) - (right.updatedAt ?? 0), + ); +} + +export function mergeGameChatRuntimeEventMessagesIntoHistory( + historyMessages: ChatMessage[], + currentMessages: ChatMessage[], +) { + const historyMessageIds = new Set( + historyMessages + .map((message) => message.messageId?.trim()) + .filter((messageId): messageId is string => Boolean(messageId)), + ); + const addedMessageIds = new Set(); + const eventsToKeep = currentMessages.filter((message) => { + const messageId = message.messageId?.trim(); + if ( + !messageId?.startsWith(GAME_CHAT_RUNTIME_EVENT_MESSAGE_PREFIX) || + historyMessageIds.has(messageId) || + addedMessageIds.has(messageId) + ) { + return false; + } + addedMessageIds.add(messageId); + return true; + }); + if (eventsToKeep.length === 0) { + return historyMessages; + } + return [...historyMessages, ...eventsToKeep].sort( + (left, right) => (left.updatedAt ?? 0) - (right.updatedAt ?? 0), + ); } type SupervisorChatOnlyViewProps = { @@ -633,7 +837,10 @@ export function SupervisorChatOnlyView({ : runtimeEvents.slice(0, 4); const supervisorProgress = useMemo( () => - gameChatMode && projectReady + gameChatMode && + projectReady && + runtime && + !isAgentRuntimeTerminalState(runtime) ? buildGameChatProgressEvidence(runtime, runtimeByAgentId, manifest) : null, [gameChatMode, manifest, projectReady, runtime, runtimeByAgentId], @@ -781,7 +988,7 @@ export function SupervisorChatOnlyView({ {visibleRuntimeEvents.map((item) => ( {item.agentLabel} - {formatAgentRuntimeEvent(item.event)} + {formatGameChatRuntimeEvent(item.event)} ))}
diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index 2603b72de..3079ffa4d 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -26,6 +26,30 @@ import { type RuntimeMcpStructuredDraft, } from '../../app/types'; +const runtimeAgentReasoningEffortDefaults = { + 'project-supervisor': 'high', + planner: 'high', + orchestrator: 'medium', + generator: 'high', + evaluator: 'high', + 'design-director': 'medium', + 'design-foundation': 'high', + 'balance-director': 'medium', + 'balance-seed': 'medium', + 'art-director': 'high', + 'art-asset-plan': 'high', + 'art-polish': 'medium', + 'audio-director': 'low', + 'audio-asset-plan': 'medium', + 'code-director': 'medium', + 'code-prototype': 'high', + 'quality-review': 'high', + 'preview-readiness': 'low', + 'preview-playtest': 'low', + 'publish-strategy': 'low', + 'publish-package': 'medium', +} as const satisfies Record; + const defaultRuntimeConfigDraft: GameCreatorAppConfig = { llm: { apiKey: '', @@ -1068,6 +1092,10 @@ export function RuntimeConfigDialog({ {runtimeAgentLlmRows.map((agent) => { const agentLlm = runtimeConfigDraft.agentLlm?.[agent.id] ?? {}; + const defaultReasoningEffort = + runtimeAgentReasoningEffortDefaults[ + agent.id as keyof typeof runtimeAgentReasoningEffortDefaults + ]; return (