From 26555e66af3fc89c8f5b4d30ab9928874668bc44 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Mon, 27 Jul 2026 21:31:35 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAI=E6=B8=B8=E6=88=8F?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=89=8B=E6=9C=BA=E5=8F=B7=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 密码登录、验证码发送和验证码登录改用国家码与纯手机号字段 非 JSON 认证错误回退为中文操作提示 补充登录契约、错误回退测试和决策记录 --- .../src/services/clientAuth.ts | 50 +++++++--- .../tests/appSurface/auth.suite.ts | 95 ++++++++++++++++++- .../shared-memory/decision-log.md | 1 + 3 files changed, 129 insertions(+), 17 deletions(-) diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index 8ae9207ee..bd4e66d9a 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -1,7 +1,11 @@ import type { + AuthEntryRequest, AuthEntryResponse, AuthMeResponse, + AuthPhoneLoginRequest, AuthPhoneLoginResponse, + AuthPhoneNumberInput, + AuthPhoneSendCodeRequest, AuthPhoneSendCodeResponse, AuthRefreshResponse, LogoutResponse, @@ -16,7 +20,17 @@ const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; const DEFAULT_CLIENT_AUTH_API_BASE_URL = 'http://127.0.0.1:8082'; export function normalizeAuthPhoneInput(phone: string) { - return phone.replace(/[^\d+]/gu, '').trim(); + const compactPhone = phone.replace(/[^\d+]/gu, '').trim(); + const mainlandChinaInternationalPhone = + compactPhone.match(/^\+?86(1\d{10})$/u); + return mainlandChinaInternationalPhone?.[1] ?? compactPhone; +} + +function buildClientAuthPhoneInput(phone: string): AuthPhoneNumberInput { + return { + countryCode: '86', + purePhoneNumber: normalizeAuthPhoneInput(phone), + }; } export function getStoredAuthAccessToken() { @@ -89,8 +103,13 @@ async function readAuthErrorMessage(response: Response, fallback: string) { if (!text.trim()) { return fallback; } + let parsed: unknown; + try { + parsed = JSON.parse(text) as unknown; + } catch { + return fallback; + } try { - const parsed = JSON.parse(text) as unknown; unwrapApiResponse(parsed); } catch (error) { return error instanceof Error ? error.message : fallback; @@ -164,15 +183,16 @@ export async function refreshClientAuthAccessToken() { } export async function loginClientWithPassword(phone: string, password: string) { + const request: AuthEntryRequest = { + ...buildClientAuthPhoneInput(phone), + password: password.trim(), + }; const response = await requestAuthJson( '/api/auth/entry', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: normalizeAuthPhoneInput(phone), - password: password.trim(), - }), + body: JSON.stringify(request), }, '登录失败', { skipAuth: true }, @@ -182,15 +202,16 @@ export async function loginClientWithPassword(phone: string, password: string) { } export async function sendClientPhoneLoginCode(phone: string) { + const request: AuthPhoneSendCodeRequest = { + ...buildClientAuthPhoneInput(phone), + scene: 'login', + }; return requestAuthJson( '/api/auth/phone/send-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: normalizeAuthPhoneInput(phone), - scene: 'login', - }), + body: JSON.stringify(request), }, '发送验证码失败', { skipAuth: true }, @@ -198,15 +219,16 @@ export async function sendClientPhoneLoginCode(phone: string) { } export async function loginClientWithPhoneCode(phone: string, code: string) { + const request: AuthPhoneLoginRequest = { + ...buildClientAuthPhoneInput(phone), + code: code.trim(), + }; const response = await requestAuthJson( '/api/auth/phone/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: normalizeAuthPhoneInput(phone), - code: code.trim(), - }), + body: JSON.stringify(request), }, '登录失败', { skipAuth: true }, diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 450464c9e..67f247cdd 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -94,7 +94,8 @@ export function registerAuthTests() { } if (url === '/api/auth/phone/send-code') { expect(JSON.parse(String(init?.body))).toMatchObject({ - phone: '13800000000', + countryCode: '86', + purePhoneNumber: '13800000000', scene: 'login', }); return new Response( @@ -109,7 +110,8 @@ export function registerAuthTests() { } if (url === '/api/auth/phone/login') { expect(JSON.parse(String(init?.body))).toMatchObject({ - phone: '13800000000', + countryCode: '86', + purePhoneNumber: '13800000000', code: '123456', }); return new Response( @@ -138,7 +140,7 @@ export function registerAuthTests() { await screen.findByRole('main', { name: '登录' }); fireEvent.change(screen.getByLabelText('手机号'), { - target: { value: '138 0000 0000' }, + target: { value: '+86 138 0000 0000' }, }); fireEvent.click(screen.getByRole('button', { name: '获取验证码' })); expect( @@ -167,6 +169,93 @@ export function registerAuthTests() { ).toHaveLength(1); }); + it('logs in with the current password phone contract', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response('', { status: 401 }); + } + if (url === '/api/auth/entry') { + expect(JSON.parse(String(init?.body))).toEqual({ + countryCode: '86', + purePhoneNumber: '13800000000', + password: 'secret123', + }); + return new Response( + JSON.stringify({ + token: 'password-token', + user: { ...testAuthUser, loginMethod: 'password' }, + }), + { status: 200 }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, ({ user }) => + React.createElement( + 'main', + { 'aria-label': '已登录' }, + user.loginMethod, + ), + ), + ); + + await screen.findByRole('main', { name: '登录' }); + fireEvent.click(screen.getByRole('button', { name: '密码登录' })); + fireEvent.change(screen.getByLabelText('手机号'), { + target: { value: '+86 138 0000 0000' }, + }); + fireEvent.change(screen.getByLabelText('密码'), { + target: { value: ' secret123 ' }, + }); + fireEvent.click(screen.getByRole('button', { name: '登录' })); + + expect(await screen.findByLabelText('已登录')).not.toBeNull(); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe('password-token'); + }); + + it('falls back to the localized action error for non-JSON auth failures', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response('', { status: 401 }); + } + if (url === '/api/auth/phone/login') { + return new Response( + 'Failed to deserialize the JSON body into the target type', + { status: 422, headers: { 'Content-Type': 'text/plain' } }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, () => + React.createElement('main', { 'aria-label': '已登录' }, 'ready'), + ), + ); + + await screen.findByRole('main', { name: '登录' }); + fireEvent.change(screen.getByLabelText('手机号'), { + target: { value: '13800000000' }, + }); + fireEvent.change(screen.getByLabelText('验证码'), { + target: { value: '123456' }, + }); + fireEvent.click(screen.getByRole('button', { name: '登录' })); + + expect(await screen.findByText('登录失败')).not.toBeNull(); + expect(screen.queryByText(/Unexpected|Failed to deserialize/u)).toBeNull(); + }); + it('shows a clear login service error instead of raw Load failed', async () => { vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 805847e31..bbe14cb41 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5522,5 +5522,6 @@ ## 2026-07-23 手机号认证统一使用国家码与纯号码双字段 - 决策:普通手机号认证请求统一使用可选 `countryCode` 与必填 `purePhoneNumber`,省略国家码时默认中国大陆 `86`,直接替换旧 `phone` 字段。前端把浏览器 E.164 自动填充值拆成这两个字段;后端先验证国家码,再复用纯手机号规范化并生成 E.164 存储。 +- 2026-07-27 补齐:AI 游戏创作客户端的密码登录、验证码发送和验证码登录统一复用共享 TypeScript 请求契约,固定把中国大陆输入拆成 `countryCode=86 + purePhoneNumber`,不再发送旧 `phone`。认证 HTTP 错误只有在响应为合法 JSON envelope 时才展示后端安全消息;Axum 422 等非 JSON 正文回退到当前动作的中文错误,不向用户展示 JSON 解析器异常或原始反序列化文本。 - 微信边界:小程序客户端仍只上传 `wechatPhoneCode`;`platform-auth` 必须要求微信成功响应中的 `phoneNumber`、`countryCode` 与 `purePhoneNumber` 均存在且非空,但只使用后两项执行国家码校验和 E.164 构造。腾讯官方仅说明境外 `phoneNumber` 会带区号,并未承诺 E.164 格式,中国号码示例中它与纯号码相同,因此不得校验 `phoneNumber == +{countryCode}{purePhoneNumber}`。微信字段缺失时失败关闭,不能使用普通请求的 `86` 默认值。 - 数据边界:认证投影与 SpacetimeDB 的 `phone_number_e164` 保持不变,不新增国家码或纯号码列,也不需要 schema 迁移或 bindings 生成。 From b1db88c114ff1d97bbe425106de8c221e4c85d7c Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 28 Jul 2026 19:56:33 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E6=80=BB=E6=8E=A7?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E4=B8=8E=E5=B7=A5=E5=85=B7=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E4=BA=A4=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复Windows下tool-plan账本按句柄原子安装失败。 补齐项目总控空态、持久Runtime水合与核对后重试交互。 修正Responses多角色内容映射与steer审计幂等身份。 修复首批协作repair累积、缺失Agent约束和isolated单槽替换。 收紧API Key安全持久化检测并覆盖装饰赋值与自然语言边界。 修复Provider retry恢复扫描与无画布密钥时的视觉产物降级。 补充定向回归测试和项目技术文档。 --- .../src-tauri/Cargo.toml | 2 +- .../pending_confirmation_ledger.rs | 222 +++++++++++- .../runtime_actions/provider_tool_plan.rs | 173 +++++++++ .../src-tauri/src/agent/runtime_driver.rs | 2 + .../agent/runtime_driver/lifecycle_control.rs | 53 ++- .../agent/runtime_driver/main_loop_tests.rs | 25 ++ .../src/agent/runtime_driver/task_start.rs | 10 +- .../src/agent/runtime_tools/delegation.rs | 12 +- .../src-tauri/src/project/agent_db.rs | 65 +++- .../src/project/agent_db/security_tests.rs | 32 ++ .../src-tauri/src/provider_retry.rs | 12 +- .../src/tests/collaboration/delegation.rs | 57 +++ .../collaboration/supervisor_planning.rs | 78 ++-- .../src-tauri/src/tests/mod.rs | 48 +++ .../src-tauri/src/tests/provider.rs | 97 +++++ .../src/tests/runtime_actions/support.rs | 1 + .../tests/runtime_actions/task_lifecycle.rs | 62 ++++ .../src-tauri/src/tool_plan_handoff.rs | 1 + .../src/tool_plan_handoff/storage_windows.rs | 33 +- .../src-tauri/src/tool_plan_handoff/tests.rs | 8 + apps/ai-game-creator-shell/src/App.tsx | 99 ++++- .../src/features/agent-runtime/model.ts | 3 + .../src/features/agent-runtime/panels.tsx | 93 +++-- apps/ai-game-creator-shell/src/styles.css | 15 + .../appSurface/project-development.suite.ts | 341 ++++++++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 15 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 7 + server-rs/crates/platform-llm/src/lib.rs | 119 +++++- 28 files changed, 1571 insertions(+), 114 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 47ef56504..b58fc65b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -37,4 +37,4 @@ tauri-plugin-clipboard-manager = "2.3.2" libc = "0.2" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_JobObjects"] } +windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_JobObjects"] } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs index fcdaa4bd6..82b6476b8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs @@ -28,18 +28,18 @@ pub(in crate::agent) fn validate_agent_runtime_pending_serialized_content( ) -> Result<(), String> { let lower = content.to_ascii_lowercase(); let sensitive_rule = [ - ".env", - "game-creator.config", - "authorization:", - "cookie:", - "api_key", - "apikey", - "api key", - "token=", - "bearer ", + (0, ".env"), + (1, "game-creator.config"), + (2, "authorization:"), + (3, "cookie:"), + (4, "api_key"), + (5, "apikey"), + (7, "token="), + (8, "bearer "), ] .into_iter() - .position(|marker| lower.contains(marker)) + .find_map(|(rule, marker)| lower.contains(marker).then_some(rule)) + .or_else(|| agent_runtime_contains_api_key_assignment(&lower).then_some(6)) .or_else(|| (redact_secret_tokens(content) != content).then_some(9)); if let Some(rule) = sensitive_rule { return Err(format!( @@ -59,6 +59,154 @@ pub(in crate::agent) fn validate_agent_runtime_pending_serialized_content( Ok(()) } +fn agent_runtime_contains_api_key_assignment(lower: &str) -> bool { + lower.match_indices("api key").any(|(index, marker)| { + let Some(value) = + agent_runtime_api_key_assignment_value(lower[index + marker.len()..].trim_start()) + else { + return false; + }; + let value = value + .trim_start() + .trim_start_matches(|character| matches!(character, '"' | '\\')) + .trim_start(); + !agent_runtime_api_key_assignment_is_safe_status(value) + }) +} + +fn agent_runtime_api_key_assignment_value(remainder: &str) -> Option<&str> { + let mut cursor = 0usize; + loop { + if remainder[..cursor].chars().count() > 64 { + return None; + } + let current = &remainder[cursor..]; + let trimmed = current.trim_start(); + cursor += current.len().saturating_sub(trimmed.len()); + let current = &remainder[cursor..]; + let character = current.chars().next()?; + if matches!(character, ':' | '=' | ':') { + return Some(¤t[character.len_utf8()..]); + } + if matches!(character, '*' | '`' | '_' | '~' | '\\' | '\'' | '"' | ']') { + cursor += character.len_utf8(); + continue; + } + + let closing = match character { + '(' => Some(')'), + '(' => Some(')'), + '[' => Some(']'), + '【' => Some('】'), + _ => None, + }; + if let Some(closing) = closing { + let after_open = ¤t[character.len_utf8()..]; + let closing_index = after_open.find(closing)?; + let qualifier = &after_open[..closing_index]; + if qualifier.chars().count() > 32 + || qualifier + .chars() + .any(|value| matches!(value, ':' | '=' | ':' | '"' | '\n' | '\r')) + { + return None; + } + cursor += character.len_utf8() + closing_index + closing.len_utf8(); + continue; + } + + let qualifier = [ + "value", + "production", + "development", + "prod", + "test", + "dev", + "值", + "生产", + "测试", + "开发", + ] + .into_iter() + .find(|qualifier| { + current.strip_prefix(qualifier).is_some_and(|suffix| { + suffix.chars().next().is_none_or(|next| { + next.is_whitespace() + || matches!( + next, + ':' | '=' | ':' | '*' | '`' | '_' | '~' | '(' | '(' | '[' | '【' + ) + }) + }) + })?; + cursor += qualifier.len(); + } +} + +fn agent_runtime_api_key_assignment_is_safe_status(value: &str) -> bool { + let value = agent_runtime_serialized_string_value(value) + .trim() + .trim_end_matches(['。', '.']); + if [ + "当前未配置", + "未配置", + "没有配置", + "未提供", + "缺失", + "不存在", + "不可用", + "为空", + "禁止", + "不要", + "不得", + "无需", + "not configured", + "unconfigured", + "not available", + "unavailable", + "missing", + "absent", + "none", + "empty", + "not provided", + "do not", + "never", + "disabled", + ] + .into_iter() + .any(|safe_status| value == safe_status) + { + return true; + } + + matches!( + value, + "当前未配置,请按无密钥路径降级" + | "未配置,请按无密钥路径降级" + | "not configured; use the text-only fallback" + ) +} + +fn agent_runtime_serialized_string_value(value: &str) -> &str { + for (index, character) in value.char_indices() { + if character != '"' { + continue; + } + let escaped = value[..index] + .as_bytes() + .iter() + .rev() + .take_while(|byte| **byte == b'\\') + .count() + % 2 + == 1; + if !escaped { + return &value[..index]; + } + } + value +} + pub(crate) fn agent_runtime_contains_secret_key_prefix(content: &str, prefix: &str) -> bool { content .match_indices(prefix) @@ -531,3 +679,57 @@ pub(in crate::agent) fn consume_game_creator_agent_runtime_tool_confirmation( })?; Ok(!action_fingerprint.trim().is_empty() && confirmed_fingerprint == action_fingerprint) } + +#[cfg(test)] +mod tests { + use super::validate_agent_runtime_pending_serialized_content; + use std::path::Path; + + #[test] + fn pending_content_allows_api_key_security_guidance_without_secret_material() { + for task in [ + "继续修复失败的视觉任务;不要读取或暴露 External Editor API Key。", + "External Editor API Key:当前未配置,请按无密钥路径降级。", + "External Editor API Key: not configured; use the text-only fallback.", + "不要读取或暴露 External Editor API Key;失败时:改走文本降级。", + "不要暴露 API Key,说明见 https://example.test/docs", + ] { + let content = serde_json::json!({ + "action": { + "tool": "agent.delegate", + "input": { "task": task } + } + }) + .to_string(); + + validate_agent_runtime_pending_serialized_content(Path::new("C:\\workspace"), &content) + .expect("natural-language API Key guidance is not secret material"); + } + } + + #[test] + fn pending_content_still_rejects_api_key_fields_and_secret_tokens() { + let root = Path::new("C:\\workspace"); + for (content, rule) in [ + (r#"{"apiKey":"plain-secret-material"}"#, 5), + (r#"{"api_key":"plain-secret-material"}"#, 4), + (r#"{"task":"API Key: plain-secret-material"}"#, 6), + (r#"{"task":"API Key = plain-secret-material"}"#, 6), + (r#"{"task":"API Key: none-but-real-secret-material"}"#, 6), + ( + r#"{"task":"API Key: not configured; actual value plain-secret-material"}"#, + 6, + ), + (r#"{"task":"API Key: disabled plain-secret-material"}"#, 6), + (r#"{"task":"**API Key**: plain-secret-material"}"#, 6), + (r#"{"task":"`API Key`: plain-secret-material"}"#, 6), + (r#"{"task":"API Key(生产): plain-secret-material"}"#, 6), + (r#"{"token":"token=plain-secret-material"}"#, 7), + (r#"{"note":"sk-prohibitedsecret"}"#, 9), + ] { + let error = validate_agent_runtime_pending_serialized_content(root, content) + .expect_err("sensitive content must remain rejected"); + assert!(error.contains(&format!("#{rule}")), "{content}: {error}"); + } + } +} 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 4c3285ec7..02cf1f9ef 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 @@ -1,5 +1,91 @@ use super::*; +fn supervisor_collaboration_repair_action_key(action: &AgentRuntimeToolAction) -> Option { + match action.tool.trim() { + "agent.delegate" => action + .input + .get("agentId") + .or_else(|| action.input.get("agent_id")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|agent_id| format!("delegate:{agent_id}")), + "agent.spawn_isolated" => Some("isolated".to_string()), + _ => None, + } +} + +fn merge_supervisor_collaboration_repair_actions( + accumulated: &[AgentRuntimeToolAction], + current: &[AgentRuntimeToolAction], +) -> Vec { + let mut merged = accumulated.to_vec(); + for action in current { + let Some(key) = supervisor_collaboration_repair_action_key(action) else { + continue; + }; + if let Some(existing) = merged.iter_mut().find(|candidate| { + supervisor_collaboration_repair_action_key(candidate).as_deref() == Some(key.as_str()) + }) { + *existing = action.clone(); + } else { + merged.push(action.clone()); + } + } + merged +} + +fn supervisor_collaboration_missing_agent_ids(error: &str) -> Vec { + let mut missing = error + .split_once("missingStaticAgents=") + .map(|(_, suffix)| { + suffix + .split(['·', ';', ';', '\n']) + .next() + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|value| { + !value.is_empty() && *value != "-" && !value.eq_ignore_ascii_case("none") + }) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default(); + for agent_id in ["code-prototype", "quality-review", "art-asset-plan"] { + if error.contains(&format!("缺少 {agent_id} 委派")) + && !missing.iter().any(|value| value == agent_id) + { + missing.push(agent_id.to_string()); + } + } + missing +} + +fn restrict_supervisor_collaboration_repair_to_missing_agents( + request: &mut LlmRunRequest, + error: &str, +) -> Result<(), String> { + let missing = supervisor_collaboration_missing_agent_ids(error); + if missing.is_empty() { + return Ok(()); + } + let delegate_function = native_runtime_function_name("agent.delegate") + .ok_or_else(|| "无法生成 Supervisor 首批委派修复工具名".to_string())?; + let delegate = request + .function_tools + .iter_mut() + .find(|tool| tool.name == delegate_function) + .ok_or_else(|| "Supervisor 首批委派修复工具目录缺少 agent.delegate".to_string())?; + let agent_id = delegate + .parameters + .pointer_mut("/properties/input/properties/agentId") + .and_then(serde_json::Value::as_object_mut) + .ok_or_else(|| "Supervisor 首批委派修复 agent.delegate schema 缺少 agentId".to_string())?; + agent_id.insert("enum".to_string(), serde_json::json!(missing)); + Ok(()) +} + pub(in crate::agent) fn append_game_creator_agent_tool_plan_audit_idempotent( root: &Path, record: serde_json::Value, @@ -27,6 +113,10 @@ pub(in crate::agent) fn append_game_creator_agent_tool_plan_audit_idempotent( .get("repairAttempt") .and_then(serde_json::Value::as_u64) .ok_or_else(|| "tool-plan 审计缺少 repairAttempt".to_string())?; + record + .get("appliedSteerCursor") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| "tool-plan 审计缺少 appliedSteerCursor".to_string())?; if request_slot != format!("loop-{loop_iteration}-repair-{repair_attempt}") { return Err("tool-plan 审计 requestSlot 与 loop/repair 身份不匹配".to_string()); } @@ -182,6 +272,8 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD || agent_runtime_autonomous_project_verify_available(root); let mut autonomous_scaffold_repair_active = false; + let mut supervisor_collaboration_repair_active = false; + let mut supervisor_collaboration_repair_actions = Vec::new(); for repair_attempt in 0..=format_repair_attempts { if game_creator_agent_runtime_cancel_requested_for(root, agent_id, run_id) { return Err("Agent 后台任务已收到取消请求".to_string()); @@ -275,10 +367,29 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at "{:x}", Sha256::digest(response_handoff.provider_request_id.as_bytes()) ); + let mut supervisor_collaboration_candidate_actions = None; let parsed = parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified( &response, &mcp_catalog, ) + .map(|mut parsed| { + let merged = merge_supervisor_collaboration_repair_actions( + if supervisor_collaboration_repair_active { + &supervisor_collaboration_repair_actions + } else { + &[] + }, + &parsed.plan.actions, + ); + supervisor_collaboration_candidate_actions = Some(merged.clone()); + if supervisor_collaboration_repair_active { + parsed.plan.plan_update = None; + parsed.plan.plan.clear(); + parsed.plan.response.clear(); + parsed.plan.actions = merged; + } + parsed + }) .and_then(|parsed| { let source_payload = if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { let verification_gate = read_game_creator_agent_runtime_verification_gate( @@ -522,6 +633,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at "loopIteration": loop_index, "repairAttempt": repair_attempt, "requestSlot": request_slot, + "appliedSteerCursor": request_snapshot.applied_steer_cursor, "responseFingerprint": response_fingerprint, "providerRequestIdSha256": provider_request_id_sha256, "protocol": protocol, @@ -586,6 +698,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at "loopIteration": loop_index, "repairAttempt": repair_attempt, "requestSlot": request_slot, + "appliedSteerCursor": request_snapshot.applied_steer_cursor, "responseFingerprint": response_fingerprint, "providerRequestIdSha256": provider_request_id_sha256, "attempt": next_attempt, @@ -728,7 +841,15 @@ 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; + } restrict_agent_runtime_supervisor_collaboration_repair_tools(&mut request)?; + restrict_supervisor_collaboration_repair_to_missing_agents( + &mut request, + &protocol_error, + )?; let instruction = if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { @@ -939,3 +1060,55 @@ pub(crate) async fn request_game_creator_agent_background_tool_plan_for_test( } } } + +#[cfg(test)] +mod supervisor_collaboration_repair_tests { + use super::*; + + fn collaboration_action(tool: &str, input: serde_json::Value) -> AgentRuntimeToolAction { + AgentRuntimeToolAction { + tool: tool.to_string(), + reason: None, + input, + } + } + + #[test] + fn missing_static_agents_ignores_none_sentinel() { + assert!(supervisor_collaboration_missing_agent_ids( + "Project Supervisor 首批协作不满足项目合同:static=1/2 · missingStaticAgents=none" + ) + .is_empty()); + assert_eq!( + supervisor_collaboration_missing_agent_ids( + "Project Supervisor 首批协作不满足项目合同:missingStaticAgents=code-prototype,quality-review · isolatedChildrenTotal=0" + ), + vec!["code-prototype".to_string(), "quality-review".to_string()] + ); + } + + #[test] + fn isolated_repair_replaces_the_single_accumulated_slot() { + let accumulated = vec![collaboration_action( + "agent.spawn_isolated", + serde_json::json!({ + "children": [{"templateAgentId": "quality-review", "task": "旧检查任务"}], + "joinMode": "all" + }), + )]; + let replacement = collaboration_action( + "agent.spawn_isolated", + serde_json::json!({ + "children": [{"templateAgentId": "quality-review", "task": "修正后的检查任务"}], + "joinMode": "all" + }), + ); + + let merged = merge_supervisor_collaboration_repair_actions( + &accumulated, + std::slice::from_ref(&replacement), + ); + + assert_eq!(merged, vec![replacement]); + } +} 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 99919b715..45289ff7e 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 @@ -238,6 +238,8 @@ pub(crate) use interaction::{ answer_game_creator_agent_runtime_user_input_at, confirm_game_creator_agent_runtime_task_at, pending_repository_context_drift_observation, reject_game_creator_agent_runtime_task_at, }; +#[cfg(test)] +pub(crate) use lifecycle_control::resolve_game_creator_agent_runtime_retry_configuration_at; pub(crate) use lifecycle_control::{ append_game_creator_agent_runtime_queued_cancellation, cancel_game_creator_agent_runtime_task_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs index cc5f5ca82..26a309b6d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs @@ -738,6 +738,44 @@ pub(crate) fn append_game_creator_agent_runtime_queued_cancellation( Ok(()) } +pub(crate) fn resolve_game_creator_agent_runtime_retry_configuration_at( + root: &Path, + task: &AgentRuntimeTaskRecord, + delegated: bool, +) -> Result<(String, String), String> { + let (run_profile, _) = agent_runtime_run_profile_identity_at( + root, + &task.agent_id, + &task.run_id, + Some(&task.run_profile), + Some(&task.run_profile_binding_fingerprint), + )?; + let source = if delegated { + "agent-delegate-retry".to_string() + } else if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + let binding = read_game_creator_agent_runtime_run_profile_binding( + root, + &task.agent_id, + &task.run_id, + )? + .ok_or_else(|| "自主构建 Agent Runtime 重试缺少 Run Profile 绑定".to_string())?; + if binding.parent_run_id.is_some() + || binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || binding.root_run_id != task.run_id + || !matches!( + binding.source.as_str(), + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE + ) + { + return Err("自主构建 Agent Runtime 重试绑定不是可信 Supervisor 根 Run".to_string()); + } + binding.source + } else { + "agent-background-task".to_string() + }; + Ok((run_profile, source)) +} + pub(crate) fn retry_game_creator_agent_runtime_task_at( root: &Path, agent_id: &str, @@ -848,11 +886,12 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at( }), _ => None, }; - let retry_source = if retry_link.is_some() { - "agent-delegate-retry" - } else { - "agent-background-task" - }; + let (retry_run_profile, retry_source) = + resolve_game_creator_agent_runtime_retry_configuration_at( + root, + &task, + retry_link.is_some(), + )?; let (mut result, actual_retry_run_id) = with_agent_conversation_session_lane_at( root, &agent_id, @@ -864,8 +903,8 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at( Some(&task.session_id), &task.task, &retry_run_id, - retry_source, - Some(&task.run_profile), + &retry_source, + Some(&retry_run_profile), retry_link.as_ref(), ) }, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index 092785609..3da956099 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -205,6 +205,31 @@ fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState revision } +#[test] +fn autonomous_visual_ready_tasks_only_require_images_when_editor_api_key_is_configured() { + { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + assert!(!autonomous_manifest_ready_task_requires_visual_asset( + "design-foundation" + )); + assert!(!autonomous_manifest_ready_task_requires_visual_asset( + "art-asset-plan" + )); + } + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"visual-ready-task-test-key"}}"#.to_string(), + ); + assert!(autonomous_manifest_ready_task_requires_visual_asset( + "design-foundation" + )); + assert!(autonomous_manifest_ready_task_requires_visual_asset( + "art-asset-plan" + )); + assert!(!autonomous_manifest_ready_task_requires_visual_asset( + "code-prototype" + )); +} + #[test] fn autonomous_supervisor_empty_plan_uses_deterministic_final_reply_fallback() { assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 8f8fb1844..bd776a517 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -1117,10 +1117,7 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke &task_text, )?; if status == GameCreationAppTaskStatus::Completed - && matches!( - manifest_task.id.as_str(), - "design-foundation" | "art-asset-plan" - ) + && autonomous_manifest_ready_task_requires_visual_asset(&manifest_task.id) && !manifest_has_required_visual_asset(root, &manifest, &manifest_task.id) { status = GameCreationAppTaskStatus::Failed; @@ -1199,6 +1196,11 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke Ok(true) } +pub(super) fn autonomous_manifest_ready_task_requires_visual_asset(task_id: &str) -> bool { + editor_api_key_is_configured() + && matches!(task_id, "design-foundation" | "art-asset-plan") +} + pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt( task: &GameCreationAppTaskState, ) -> String { 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 ce7888497..f5aa2c596 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 @@ -318,10 +318,14 @@ pub(crate) fn observe_agent_runtime_agent_delegate( agent_runtime_tool_input_text(input, &["repairOfDelegationId", "repair_of_delegation_id"]); let repair_of_delegation_id = (!repair_of_delegation_id.is_empty()).then_some(repair_of_delegation_id); - let required_visual_artifact = match target_agent_id.as_str() { - "design-foundation" => Some("assets/ui-prototype.png"), - "art-asset-plan" => Some("assets/art-spritesheet.png"), - _ => None, + let required_visual_artifact = if editor_api_key_is_configured() { + match target_agent_id.as_str() { + "design-foundation" => Some("assets/ui-prototype.png"), + "art-asset-plan" => Some("assets/art-spritesheet.png"), + _ => None, + } + } else { + None }; if repair_of_delegation_id.is_none() && required_visual_artifact.is_some_and(|required| { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index ae125af23..b34c59124 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -1610,6 +1610,7 @@ pub(crate) fn append_agent_db_tool_plan_audit_idempotent( "loopIteration", "repairAttempt", "requestSlot", + "appliedSteerCursor", "responseFingerprint", "providerRequestIdSha256", "protocol", @@ -1637,6 +1638,7 @@ pub(crate) fn append_agent_db_tool_plan_audit_idempotent( "loopIteration", "repairAttempt", "requestSlot", + "appliedSteerCursor", "responseFingerprint", "providerRequestIdSha256", "protocol", @@ -1706,6 +1708,13 @@ pub(crate) fn append_agent_db_tool_plan_audit_idempotent( return Err(format!("Agent DB tool-plan 幂等审计字段无效:{field}")); } } + if record + .get("appliedSteerCursor") + .and_then(serde_json::Value::as_u64) + .is_none() + { + return Err("Agent DB tool-plan 幂等审计字段无效:appliedSteerCursor".to_string()); + } let is_null_or_sha256 = |field: &str| match record.get(field) { Some(serde_json::Value::Null) => true, Some(serde_json::Value::String(value)) => is_valid_agent_db_sha256(value), @@ -2398,6 +2407,10 @@ fn validate_agent_db_tool_plan_audit_records_unlocked( .and_then(serde_json::Value::as_str) .expect("validated tool-plan audit identity") }); + let applied_steer_cursor = expected + .get("appliedSteerCursor") + .and_then(serde_json::Value::as_u64) + .expect("validated tool-plan audit applied steer cursor"); file.seek(SeekFrom::Start(0)) .map_err(|error| format!("定位 Agent 本地索引失败:{}: {error}", path.display()))?; let mut reader = BufReader::new(file); @@ -2423,7 +2436,7 @@ fn validate_agent_db_tool_plan_audit_records_unlocked( } let record = serde_json::from_slice::(&line.content) .map_err(|error| format!("解析 Agent 本地索引失败:{}: {error}", path.display()))?; - let matches_key = [ + let matches_legacy_key = [ "recordType", "agentId", "taskId", @@ -2437,20 +2450,32 @@ fn validate_agent_db_tool_plan_audit_records_unlocked( .all(|(field, value)| { record.get(field).and_then(serde_json::Value::as_str) == Some(*value) }); - if !matches_key { + if !matches_legacy_key { continue; } - if !agent_db_stored_record_matches_expected_payload(&record, expected) { + let stored_applied_steer_cursor = match record.get("appliedSteerCursor") { + None => 0, + Some(value) => value.as_u64().ok_or_else(|| { + format!( + "Agent 本地索引 tool-plan 审计 appliedSteerCursor 无效:{}", + path.display() + ) + })?, + }; + if stored_applied_steer_cursor != applied_steer_cursor { + continue; + } + if !agent_db_tool_plan_stored_record_matches_expected_payload(&record, expected) { return Err(format!( - "Agent 本地索引 tool-plan 幂等审计内容冲突:{}/{}/{}/{}", - identity[0], identity[1], identity[4], identity[6] + "Agent 本地索引 tool-plan 幂等审计内容冲突:{}/{}/{}/{}/steer-{}", + identity[0], identity[1], identity[4], identity[6], applied_steer_cursor )); } exact_matches = exact_matches.saturating_add(1); if exact_matches > 1 { return Err(format!( - "Agent 本地索引 tool-plan 幂等审计重复:{}/{}/{}/{}", - identity[0], identity[1], identity[4], identity[6] + "Agent 本地索引 tool-plan 幂等审计重复:{}/{}/{}/{}/steer-{}", + identity[0], identity[1], identity[4], identity[6], applied_steer_cursor )); } } @@ -2464,6 +2489,32 @@ fn validate_agent_db_tool_plan_audit_records_unlocked( Ok(exact_matches == 1) } +fn agent_db_tool_plan_stored_record_matches_expected_payload( + stored: &serde_json::Value, + expected: &serde_json::Value, +) -> bool { + if stored.get("appliedSteerCursor").is_some() { + return agent_db_stored_record_matches_expected_payload(stored, expected); + } + if expected + .get("appliedSteerCursor") + .and_then(serde_json::Value::as_u64) + != Some(0) + { + return false; + } + + let mut normalized = stored.clone(); + let Some(object) = normalized.as_object_mut() else { + return false; + }; + object.insert( + "appliedSteerCursor".to_string(), + serde_json::Value::Number(serde_json::Number::from(0)), + ); + agent_db_stored_record_matches_expected_payload(&normalized, expected) +} + fn validate_agent_db_action_records_unlocked( file: &mut File, path: &Path, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs index 59a92fdb9..46aa7822f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs @@ -48,6 +48,7 @@ fn tool_plan_protocol_audit_record( "loopIteration": 0, "repairAttempt": 0, "requestSlot": request_slot, + "appliedSteerCursor": 0, "responseFingerprint": "1".repeat(64), "providerRequestIdSha256": "2".repeat(64), "protocol": "native_runtime_tools", @@ -82,6 +83,7 @@ fn tool_plan_repair_audit_record( "loopIteration": 0, "repairAttempt": 0, "requestSlot": request_slot, + "appliedSteerCursor": 0, "responseFingerprint": "1".repeat(64), "providerRequestIdSha256": "2".repeat(64), "protocol": "native_runtime_tools", @@ -760,6 +762,16 @@ fn tool_plan_audit_append_is_atomic_conflict_checked_and_agent_scoped() { .expect_err("same tool-plan audit identity with new payload must conflict"); assert!(error.contains("内容冲突"), "{error}"); + let mut steered = record.clone(); + steered["appliedSteerCursor"] = serde_json::json!(1); + steered["responseFingerprint"] = serde_json::json!("3".repeat(64)); + assert!( + append_agent_db_tool_plan_audit_idempotent(&root, steered.clone()) + .expect("a new steer cursor must own a distinct tool-plan audit slot") + ); + assert!(!append_agent_db_tool_plan_audit_idempotent(&root, steered) + .expect("same steered tool-plan audit remains idempotent")); + let same_run_other_agent = tool_plan_protocol_audit_record("art-director", "shared-run-id", "loop-0-repair-0"); assert!( @@ -808,6 +820,26 @@ fn tool_plan_audit_append_is_atomic_conflict_checked_and_agent_scoped() { fs::remove_dir_all(&root).ok(); } +#[test] +fn legacy_tool_plan_audit_without_steer_cursor_matches_cursor_zero() { + let root = unique_agent_db_test_root("legacy-tool-plan-audit-cursor-zero"); + fs::create_dir_all(root.join(".agent")).expect("create legacy Agent DB directory"); + let current = tool_plan_repair_audit_record("design-director", "legacy-run", "loop-0-repair-0"); + let mut legacy = current.clone(); + legacy + .as_object_mut() + .expect("legacy audit object") + .remove("appliedSteerCursor"); + let line = serialize_agent_db_record(legacy).expect("serialize legacy tool-plan audit"); + fs::write(root.join(".agent/agent.db"), format!("{line}\n")) + .expect("write legacy tool-plan audit"); + + assert!(!append_agent_db_tool_plan_audit_idempotent(&root, current) + .expect("cursor zero must reuse the matching legacy audit")); + + fs::remove_dir_all(&root).ok(); +} + #[test] fn tool_plan_protocol_audit_is_idempotent_across_processes() { let record_type = "agent.runtime.tool_plan.protocol"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/provider_retry.rs b/apps/ai-game-creator-shell/src-tauri/src/provider_retry.rs index 132f9e1a5..24cbfb575 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/provider_retry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/provider_retry.rs @@ -197,11 +197,17 @@ pub(crate) fn list_at(root: &Path) -> Result, _>>()? + .join("/"); let record = read_agent_runtime_json_sidecar_with_max_bytes( root, - relative_path_text, + &relative_path_text, PROVIDER_RETRY_LABEL, PROVIDER_RETRY_SIDECAR_MAX_BYTES, )? 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 67c225b94..b9a041327 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 @@ -2039,6 +2039,9 @@ fn agent_native_delegate_contract_flows_through_parser_executor_and_delivery() { #[test] fn visual_specialist_delegations_require_image_artifacts_but_read_only_work_allows_none() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"visual-delegation-contract-key"}}"#.to_string(), + ); let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "视觉委派合同测试").expect("project init"); let parent_run_id = "visual-delegate-contract-parent-run"; @@ -2120,3 +2123,57 @@ fn visual_specialist_delegations_require_image_artifacts_but_read_only_work_allo fs::remove_dir_all(root).ok(); } + +#[test] +fn visual_specialist_delegation_degrades_to_text_artifacts_without_editor_api_key() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "无图片密钥委派合同测试") + .expect("project init"); + let parent_run_id = "text-only-design-delegate-parent-run"; + start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "委派文本玩法规格", + parent_run_id, + "agent-chat", + "准备委派", + vec!["允许无图片密钥降级".to_string()], + ) + .expect("start supervisor parent runtime"); + let target_agent_id = "design-foundation"; + let action_id = "allow-text-only-design-artifacts"; + let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id) + .expect("acquire design target lane") + .expect("design 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": "完成玩法规格与双视口界面说明", + "acceptanceCriteria": ["玩法规格可直接指导程序实现"], + "expectedArtifacts": ["memory/project.md", "game/game_design.md"], + "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, + ); + let delivery = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read text-only design delivery") + .expect("text-only design delivery exists"); + assert_eq!( + delivery.expected_artifacts, + vec!["memory/project.md", "game/game_design.md"] + ); + drop(target_lock); + fs::remove_dir_all(root).ok(); +} 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 f8365d780..148120d25 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 @@ -1421,7 +1421,7 @@ async fn supervisor_collaboration_empty_initial_plan_repairs_into_required_stati } #[tokio::test] -async fn supervisor_collaboration_read_only_first_window_repairs_with_collaboration_tools_only() { +async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboration_tools_only() { let root = unique_project_path(); init_local_game_project_at( &root, @@ -1430,15 +1430,6 @@ async fn supervisor_collaboration_read_only_first_window_repairs_with_collaborat ) .expect("project init"); let (sender, receiver) = mpsc::channel(); - let update_arguments = serde_json::json!({ - "explanation": "继续读取项目后再决定委派", - "steps": [ - {"step": "读取项目入口", "status": "in_progress"}, - {"step": "建立专业协作", "status": "pending"} - ] - }) - .to_string(); - let read_arguments = serde_json::json!({"reason": "继续读取项目索引", "input": {}}).to_string(); let delegate_function = native_runtime_function_name("agent.delegate").expect("delegate function"); let isolated_function = @@ -1469,32 +1460,16 @@ async fn supervisor_collaboration_read_only_first_window_repairs_with_collaborat .to_string(); let base_url = spawn_mock_llm_raw_responses_with_capture( vec![ - native_agent_tool_plan_chat_response_with_calls(vec![ - ( - "call-supervisor-read-only-plan", - AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, - update_arguments, - ), - ( - "call-supervisor-read-only-index", - native_runtime_function_name("project.index") - .expect("index function") - .as_str(), - read_arguments, - ), - ]), - native_agent_tool_plan_chat_response_with_calls(vec![ - ( - "call-supervisor-read-only-code-delegate", - delegate_function.as_str(), - code_arguments, - ), - ( - "call-supervisor-read-only-quality-delegate", - delegate_function.as_str(), - quality_arguments, - ), - ]), + native_agent_tool_plan_chat_response( + "call-supervisor-initial-code-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, + ), ], Some(sender), ); @@ -1563,8 +1538,7 @@ async fn supervisor_collaboration_read_only_first_window_repairs_with_collaborat let repair_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("supervisor read-only collaboration repair request"); - assert!(repair_request.contains("当前已到第 7 轮")); - assert!(repair_request.contains("不得继续只更新计划")); + assert!(repair_request.contains("missingStaticAgents=quality-review")); let repair_request_json = mock_http_request_json(&repair_request); let repair_function_names = repair_request_json["tools"] .as_array() @@ -1584,6 +1558,28 @@ async fn supervisor_collaboration_read_only_first_window_repairs_with_collaborat repair_function_names, BTreeSet::from([delegate_function.as_str(), isolated_function.as_str()]) ); + let delegate_schema = repair_request_json["tools"] + .as_array() + .and_then(|tools| { + tools.iter().find(|tool| { + tool.get("name").and_then(serde_json::Value::as_str) + == Some(delegate_function.as_str()) + || tool + .get("function") + .and_then(|function| function.get("name")) + .and_then(serde_json::Value::as_str) + == Some(delegate_function.as_str()) + }) + }) + .expect("missing-quality repair keeps agent.delegate"); + let parameters = delegate_schema + .get("parameters") + .or_else(|| delegate_schema.pointer("/function/parameters")) + .expect("delegate parameters"); + assert_eq!( + parameters["properties"]["input"]["properties"]["agentId"]["enum"], + serde_json::json!(["quality-review"]) + ); assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); let records = read_agent_db_records_for_test(&root); @@ -1594,7 +1590,9 @@ async fn supervisor_collaboration_read_only_first_window_repairs_with_collaborat }) .collect::>(); assert_eq!(repairs.len(), 1); - assert_eq!(repairs[0]["protocolErrorKind"], "plan-semantics"); + assert!(repairs + .iter() + .all(|repair| repair["protocolErrorKind"] == "plan-semantics")); assert_eq!(repairs[0]["repairAttempt"], 0); let protocol = records .iter() @@ -1603,7 +1601,7 @@ async fn supervisor_collaboration_read_only_first_window_repairs_with_collaborat }) .expect("repaired collaboration protocol audit"); assert_eq!(protocol["repairAttempt"], 1); - assert_eq!(protocol["functionCallCount"], 2); + assert_eq!(protocol["functionCallCount"], 1); let collaboration_state = read_supervisor_collaboration_state_at( &root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 1fa809253..5eabb36ac 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -1385,6 +1385,54 @@ fn spawn_mock_llm_server_responses_with_capture( base_url } +fn spawn_mock_llm_scripted_responses_with_capture( + response_contents: Vec>, + request_sender: mpsc::Sender, +) -> String { + let listener = bind_test_tcp_listener("mock scripted llm bind"); + let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); + std::thread::spawn(move || { + for response_content in response_contents { + let (mut stream, _) = listener.accept().expect("mock scripted llm accept"); + let request_text = read_mock_http_request(&mut stream); + let _ = request_sender.send(request_text.clone()); + let Some(response_content) = response_content else { + drop(stream); + continue; + }; + let body = if request_text.contains("POST /responses HTTP/1.1") { + serde_json::json!({ + "id": "resp_game_creator_scripted_mock", + "model": "mock-game-model", + "output_text": response_content, + "status": "completed", + "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } + }) + } else { + serde_json::json!({ + "id": "chatcmpl_game_creator_scripted_mock", + "model": "mock-game-model", + "choices": [{ + "message": { "content": response_content }, + "finish_reason": "stop" + }], + "usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 } + }) + } + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .expect("mock scripted llm response"); + } + }); + base_url +} + fn spawn_interactive_mock_llm_server_with_capture( response_count: usize, request_sender: mpsc::Sender, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index e441dc806..038d5bfe3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -4411,6 +4411,103 @@ async fn provider_retry_waiting_steer_supersedes_old_attempt_and_wakes_same_run( fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn provider_repair_retry_waiting_steer_uses_distinct_audit_cursor() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "Provider repair 等待 steer 测试") + .expect("project init"); + let (request_sender, request_receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_scripted_responses_with_capture( + vec![ + Some("first-invalid-tool-plan".to_string()), + None, + Some("steered-invalid-tool-plan".to_string()), + Some(final_tool_plan_response("steer 后 repair 已完成")), + ], + request_sender, + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_chat", + "stream": false, + "maxRetries": 1, + "retryBackoffMs": 30000 + }} + }} +}}"# + )); + let run_id = "design-provider-repair-wait-steer-run"; + let started = start_game_creator_agent_background_task_at( + &root, + "design-director", + "先写 repair 审计,再进入 Provider retry 等待", + run_id, + ) + .expect("start repair retry steer task"); + + request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("initial invalid tool-plan request"); + request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("repair transport failure request"); + wait_for_agent_runtime_phase(&root, "design-director", "waiting-for-provider-retry"); + + steer_game_creator_agent_runtime_task_at( + &root, + "design-director", + &started.state.session_id, + run_id, + "provider-repair-retry-steer-1", + "用新指令重新规划,不要复用旧 repair 审计槽", + "test", + ) + .expect("steer waiting repair Provider retry"); + + request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("fresh invalid tool-plan request after steer"); + request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("fresh repair request after steer"); + let completed = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(completed.phase, "completed"); + assert_eq!(completed.run_id, run_id); + assert_eq!(completed.applied_steer_cursor, 1); + assert_eq!( + completed.last_response.as_deref(), + Some("steer 后 repair 已完成") + ); + + let records = read_agent_db_records_for_test(&root); + let repair_audits = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_plan.repair" + && record["runId"] == run_id + && record["requestSlot"] == "loop-1-repair-0" + }) + .collect::>(); + assert_eq!(repair_audits.len(), 2); + assert_eq!( + repair_audits + .iter() + .filter_map(|record| record["appliedSteerCursor"].as_u64()) + .collect::>(), + BTreeSet::from([0, 1]) + ); + assert!(records.iter().all(|record| { + record["recordType"] != "agent.runtime.background_task.failed" || record["runId"] != run_id + })); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn provider_retry_waiting_exhaustion_fails_and_removes_sidecar() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index 5af24a99a..fd53d1831 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -75,6 +75,7 @@ pub(super) use crate::{ read_recent_game_creator_agent_runtime_events, redact_secret_tokens, reject_game_creator_agent_runtime_task, reject_game_creator_agent_runtime_task_at, render_evaluator_findings, request_game_creator_agent_background_tool_plan_for_test, + resolve_game_creator_agent_runtime_retry_configuration_at, resume_game_creator_agent_background_tasks_at, resume_game_creator_agent_runtime_tasks, retry_game_creator_agent_runtime_task_at, schedule_game_creator_agent_ready_tasks_at, start_game_creator_agent_background_task_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs index c95e284f3..905bfc1fa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs @@ -272,6 +272,68 @@ async fn background_agent_runtime_can_cancel_active_task_and_retry_it() { fs::remove_dir_all(root).ok(); } +#[test] +fn autonomous_supervisor_retry_restores_trusted_source_from_run_profile_binding() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-autonomous-retry-source", "自主重试来源测试") + .expect("project init"); + let original_run_id = "supervisor-autonomous-retry-source"; + let binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + original_run_id, + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous Supervisor run"); + let task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + task_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + session_id: "agent-session-project-supervisor".to_string(), + run_id: original_run_id.to_string(), + source: "agent-background-task".to_string(), + run_profile: binding.profile, + run_profile_binding_fingerprint: binding.binding_fingerprint, + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "构建并验证一个完整的自主游戏".to_string(), + status: "failed".to_string(), + phase: "failed".to_string(), + current_action: "测试失败".to_string(), + terminal_detail: Some("测试失败".to_string()), + error: Some("测试失败".to_string()), + updated_at: unix_timestamp(), + }; + let (profile, source) = + resolve_game_creator_agent_runtime_retry_configuration_at(&root, &task, false) + .expect("resolve autonomous Supervisor retry configuration"); + assert_eq!(profile, AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD); + assert_eq!(source, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE); + + let standard_task = AgentRuntimeTaskRecord { + run_id: "standard-retry-source".to_string(), + run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), + run_profile_binding_fingerprint: String::new(), + ..task + }; + let (_, standard_source) = + resolve_game_creator_agent_runtime_retry_configuration_at(&root, &standard_task, false) + .expect("resolve standard retry configuration"); + assert_eq!(standard_source, "agent-background-task"); + let (_, delegated_source) = + resolve_game_creator_agent_runtime_retry_configuration_at(&root, &standard_task, true) + .expect("resolve delegated retry configuration"); + assert_eq!(delegated_source, "agent-delegate-retry"); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_can_cancel_pending_task_before_drain() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs index fabd6acd3..18dd3373e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs @@ -46,6 +46,7 @@ pub(crate) fn failure_kind(error: &str) -> &'static str { } else if error.contains("写入") || error.contains("读取") || error.contains("创建") + || error.contains("安装") || error.contains("目录") || error.contains("文件") || error.contains("权限") diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/storage_windows.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/storage_windows.rs index 8b695b239..b7cef4e2d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/storage_windows.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/storage_windows.rs @@ -751,9 +751,10 @@ fn rename_windows_tool_plan_file_at( ) -> Result<(), String> { use std::os::windows::ffi::OsStrExt; use std::os::windows::io::AsRawHandle; - use windows_sys::Win32::Storage::FileSystem::{ - FileRenameInfo, SetFileInformationByHandle, FILE_RENAME_INFO, + use windows_sys::Wdk::Storage::FileSystem::{ + FileRenameInformation, NtSetInformationFile, FILE_RENAME_INFORMATION, }; + use windows_sys::Win32::System::IO::IO_STATUS_BLOCK; let wide_name = std::ffi::OsStr::new(new_name) .encode_wide() @@ -763,13 +764,13 @@ fn rename_windows_tool_plan_file_at( .checked_mul(2) .and_then(|value| u32::try_from(value).ok()) .ok_or_else(|| format!("{label} 目标名称过长"))?; - let header_bytes = std::mem::offset_of!(FILE_RENAME_INFO, FileName); + let header_bytes = std::mem::offset_of!(FILE_RENAME_INFORMATION, FileName); let total_bytes = header_bytes .checked_add(name_bytes as usize) .ok_or_else(|| format!("{label} 重命名缓冲区过大"))?; let word_bytes = std::mem::size_of::(); let mut buffer = vec![0usize; total_bytes.div_ceil(word_bytes)]; - let information = buffer.as_mut_ptr().cast::(); + let information = buffer.as_mut_ptr().cast::(); // SAFETY: buffer is aligned and sized for the fixed header plus the complete UTF-16 name. unsafe { (*information).Anonymous.ReplaceIfExists = replace; @@ -781,19 +782,29 @@ fn rename_windows_tool_plan_file_at( wide_name.len(), ); } - // SAFETY: file owns a DELETE-capable handle and information spans total_bytes bytes. - if unsafe { - SetFileInformationByHandle( + let mut io_status = IO_STATUS_BLOCK::default(); + // SAFETY: file owns a DELETE-capable handle, parent is a verified directory handle, + // and information spans the fixed header plus the complete relative UTF-16 name. + // NtSetInformationFile is required here because SetFileInformationByHandle rejects + // a non-null RootDirectory with ERROR_INVALID_PARAMETER on Windows. + let status = unsafe { + NtSetInformationFile( file.as_raw_handle().cast(), - FileRenameInfo, + &mut io_status, information.cast(), total_bytes as u32, + FileRenameInformation, ) - } == 0 - { + }; + if status < 0 { + unsafe extern "system" { + fn RtlNtStatusToDosError(status: i32) -> u32; + } + // SAFETY: conversion accepts any NTSTATUS and returns a Win32 error code. + let code = unsafe { RtlNtStatusToDosError(status) }; return Err(format!( "按句柄安装 {label} 失败:{}", - std::io::Error::last_os_error() + std::io::Error::from_raw_os_error(code as i32) )); } Ok(()) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs index dc88d02b2..5d2711eb9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs @@ -45,6 +45,14 @@ use crate::agent::{ }; use crate::provider_retry::{self, AgentRuntimeProviderRetryIdentity}; +#[test] +fn tool_plan_handoff_classifies_windows_handle_install_failure_as_storage() { + assert_eq!( + failure_kind("按句柄安装 tool-plan 成功响应交接账本 失败:参数错误。 (os error 87)"), + "tool-plan-storage" + ); +} + fn identity(slot: &str) -> AgentRuntimeProviderRetryIdentity { identity_for(slot, "project-supervisor", "run-tool-plan-handoff") } diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index cb13791ee..ae45796e9 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -4884,11 +4884,82 @@ export function App({ if (!invoke || !nextProjectPath || !currentRuntime || chatAgentBusy) { throw new Error('项目总控状态已变化,请等待刷新后重试'); } + const needsReconciliation = + currentRuntime.status === 'needs-reconciliation' || + currentRuntime.phase === 'needs-reconciliation'; + if (needsReconciliation) { + if ( + currentRuntime.runId !== runtime.runId || + currentRuntime.agentId !== PROJECT_SUPERVISOR_AGENT_ID + ) { + throw new Error('项目总控待核对任务已变化,请等待刷新'); + } + const previousRunId = currentRuntime.runId; + setChatAgentBusy(true); + setProjectSupervisorRuntimeError(''); + try { + const result = await invoke( + 'cancel_game_creator_agent_runtime_task', + { + projectPath: nextProjectPath, + agentId: PROJECT_SUPERVISOR_AGENT_ID, + runId: previousRunId, + }, + ); + if ( + localProjectPathRef.current !== nextProjectPath || + projectSupervisorRuntimeRef.current?.runId !== previousRunId + ) { + return '项目已切换,未把旧项目的取消状态合并到当前界面'; + } + const nextRuntime = agentRuntimeStateFromResult(result, currentRuntime); + if ( + nextRuntime.agentId !== PROJECT_SUPERVISOR_AGENT_ID || + nextRuntime.runId !== previousRunId || + nextRuntime.sessionId !== currentRuntime.sessionId + ) { + throw new Error('项目总控取消后的 Runtime 身份不匹配'); + } + updateProjectSupervisorRuntime(nextRuntime); + updateProjectSupervisorResponseStream( + result.responseStream, + nextRuntime, + ); + setCommandLog((current) => [ + ...current, + 'agent.runtime.cancel project-supervisor reconciliation', + ]); + const queuePending = nextRuntime.taskQueue?.pending ?? 0; + if ( + nextRuntime.status === 'cancelled' || + nextRuntime.phase === 'cancelled' + ) { + return queuePending > 0 + ? `旧任务已结束,队列中还有 ${queuePending} 个待处理任务,队列将继续处理` + : '旧任务已结束,当前队列为空,可重新启动项目总控'; + } + return '已提交结束旧任务请求,正在同步取消状态'; + } catch (error) { + throw new Error( + `项目总控旧任务结束失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } finally { + setChatAgentBusy(false); + } + } + const cancelledWithEmptyQueue = + (currentRuntime.status === 'cancelled' || + currentRuntime.phase === 'cancelled') && + (currentRuntime.taskQueue?.pending ?? 0) === 0; if ( currentRuntime.runId !== runtime.runId || currentRuntime.agentId !== PROJECT_SUPERVISOR_AGENT_ID || !( - currentRuntime.status === 'failed' || currentRuntime.phase === 'failed' + currentRuntime.status === 'failed' || + currentRuntime.phase === 'failed' || + cancelledWithEmptyQueue ) || currentRuntime.pendingToolAction ) { @@ -9025,6 +9096,32 @@ export function App({ for (const runtimeResult of runtimes) { nextRuntimes.push(agentRuntimeStateFromResult(runtimeResult)); } + const supervisorRuntimeIndex = nextRuntimes.findIndex( + (runtime) => runtime.agentId === PROJECT_SUPERVISOR_AGENT_ID, + ); + const persistedSupervisorRuntime = + supervisorRuntimeIndex >= 0 + ? nextRuntimes[supervisorRuntimeIndex]! + : null; + if (projectSupervisorOnly && persistedSupervisorRuntime) { + const currentRuntime = projectSupervisorRuntimeRef.current; + if ( + !currentRuntime || + persistedSupervisorRuntime.updatedAt >= currentRuntime.updatedAt + ) { + if (persistedSupervisorRuntime.sessionId) { + projectSupervisorSessionIdRef.current = + persistedSupervisorRuntime.sessionId; + setProjectSupervisorSessionId(persistedSupervisorRuntime.sessionId); + } + updateProjectSupervisorRuntime(persistedSupervisorRuntime); + updateProjectSupervisorResponseStream( + runtimes[supervisorRuntimeIndex]?.responseStream, + persistedSupervisorRuntime, + ); + setProjectSupervisorRuntimeError(''); + } + } setAgentRuntimeById((current) => nextRuntimes.reduce( (next, runtime) => mergeAgentRuntimeStateIntoMap(next, runtime, true), 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 fb76f67b7..eccdd06ce 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 @@ -1268,6 +1268,9 @@ export function isAgentFinalizationMessageId( } export function projectRuntimeStatusPresentation(runtime: AgentRuntimeState) { + if (runtime.phase === 'needs-reconciliation') { + return { label: '待核对', tone: 'failed' }; + } if ( runtime.userInputRequest || runtime.status === 'waiting-for-user-input' || diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx b/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx index fb2095acd..492556ce3 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx @@ -699,7 +699,8 @@ export function ProjectSupervisorRuntimePanel({ setSupervisorRetryFeedback(''); } }, [runtime?.phase, runtime?.runId, runtime?.status]); - const status = projectSupervisorRuntimeStatusLabel(runtime, error); + const status = + projectSupervisorRuntimeStatusLabel(runtime, error) ?? '尚未开始'; const collaboratingRuntimes = projectSupervisorCollaboratingAgentRuntimes( runtime, runtimeByAgentId, @@ -727,9 +728,6 @@ export function ProjectSupervisorRuntimePanel({ panelRef.current.scrollTop = 0; } }, [visibleSnapshotKey]); - if (!status) { - return null; - } const rawStatusDetail = error || runtime?.error || ''; const statusDetail = rawStatusDetail ? projectRuntimeVisibleError(rawStatusDetail, '项目总控 Agent', true) @@ -752,10 +750,22 @@ export function ProjectSupervisorRuntimePanel({ : null; const userInputRequest = runtime?.userInputRequest ?? null; const needsUserInput = agentRuntimeNeedsUserInput(runtime); + const needsSupervisorReconciliation = Boolean( + runtime && + (runtime.status === 'needs-reconciliation' || + runtime.phase === 'needs-reconciliation'), + ); + const cancelledSupervisorQueuePending = + runtime && (runtime.status === 'cancelled' || runtime.phase === 'cancelled') + ? (runtime.taskQueue?.pending ?? 0) + : 0; const canRetrySupervisor = Boolean( runtime && !pendingToolAction && - (runtime.status === 'failed' || runtime.phase === 'failed') && + (runtime.status === 'failed' || + runtime.phase === 'failed' || + ((runtime.status === 'cancelled' || runtime.phase === 'cancelled') && + cancelledSupervisorQueuePending === 0)) && agentRuntimeCanRetry(runtime.status), ); const activeCollaboratingAgents = collaboratingRuntimes.filter( @@ -788,6 +798,15 @@ export function ProjectSupervisorRuntimePanel({ ) : null} {statusDetail ? {statusDetail} : null} + {!runtime && !statusDetail ? ( +
+
+ ) : null} {runtime ? (
{projectRuntimeVisibleCurrentWork(runtime)} @@ -808,22 +827,35 @@ export function ProjectSupervisorRuntimePanel({ {compactProgress ? ( {compactProgress} ) : null} - {canRetrySupervisor && runtime ? ( + {(needsSupervisorReconciliation || canRetrySupervisor) && runtime ? (
- {activeCollaboratingAgents.length > 0 - ? `${activeCollaboratingAgents - .map((professionalRuntime) => - projectProfessionalAgentLabel( - professionalRuntime.agentId, - ), - ) - .join('、')}仍在运行。` - : '本轮项目总控已停止。'} - 在当前项目重新启动总控,不会新建项目。 + {needsSupervisorReconciliation ? ( + <> + 本轮工具动作的结果不确定,需要先结束旧任务。 + 不会直接重试,避免重复执行未核对的动作。 + + ) : ( + <> + {activeCollaboratingAgents.length > 0 + ? `${activeCollaboratingAgents + .map((professionalRuntime) => + projectProfessionalAgentLabel( + professionalRuntime.agentId, + ), + ) + .join('、')}仍在运行。` + : '本轮项目总控已停止。'} + 在当前项目重新启动总控,不会新建项目。 + + )}
) : null} + {cancelledSupervisorQueuePending > 0 && !supervisorRetryFeedback ? ( + + {`旧任务已结束,队列中还有 ${cancelledSupervisorQueuePending} 个待处理任务,队列将继续处理`} + + ) : null} {supervisorRetryFeedback ? ( {supervisorRetryFeedback} ) : null} - {pendingToolAction && pendingActionPresentation ? ( + {pendingToolAction && + pendingActionPresentation && + !needsSupervisorReconciliation ? (
small[aria-label='项目总控 Agent 进度'] { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 3f129ef63..3a3080251 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -702,6 +702,347 @@ export function registerUserSurfaceBoundaryTests() { } export function registerProjectSupervisorSurfaceTests() { + it('keeps the Project Supervisor welcome and empty runtime surface before the first message', async () => { + const projectPath = '/tmp/launcher-empty-supervisor-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-empty-supervisor-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + initialSessionExists: false, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-empty-supervisor-game', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: projectPath }, + }); + fireEvent.click(screen.getByRole('button', { name: '打开' })); + + const supervisorSurface = await screen.findByLabelText('项目总控对话'); + const messageList = + within(supervisorSurface).getByLabelText('项目总控消息'); + expect( + await within(messageList).findByText('想做什么游戏?'), + ).not.toBeNull(); + expect( + within(supervisorSurface).getByLabelText('项目总控 Agent 状态'), + ).not.toBeNull(); + expect( + within(supervisorSurface).getByText('项目总控 Agent · 尚未开始'), + ).not.toBeNull(); + expect( + within(supervisorSurface).getByText('告诉陶泥儿你想做什么游戏'), + ).not.toBeNull(); + expect( + within(supervisorSurface).getByRole('button', { name: '发送' }), + ).toHaveProperty('disabled', false); + }); + + it('hydrates a persisted needs-reconciliation Supervisor runtime without an active Session index', async () => { + const projectPath = '/tmp/launcher-reconciliation-supervisor-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-reconciliation-supervisor-game', + ); + const reconciliationRuntime = { + schemaVersion: 'game-creator-agent-runtime.v1', + agentId: 'project-supervisor', + taskId: 'project-supervisor', + sessionId: 'persisted-supervisor-session', + runId: 'persisted-reconciliation-run', + source: 'project-supervisor', + status: 'needs-reconciliation', + phase: 'needs-reconciliation', + currentTask: '帮我生成一个贪吃蛇', + currentGoal: '完成贪吃蛇原型', + currentAction: '等待核对 Provider 回复交接', + waitingOn: '人工核对', + nextStep: '核对后继续或取消', + plan: [], + observations: [], + allowedTools: [], + pendingToolAction: null, + lastResponse: null, + error: 'tool-plan-unknown', + updatedAt: 7000, + }; + const cancelledQueue = { + total: 2, + pending: 1, + running: 0, + waitingForConfirmation: 0, + waitingForUserInput: 0, + cancelled: 1, + completed: 0, + failed: 0, + latestRunId: 'persisted-reconciliation-run', + updatedAt: 8000, + }; + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + initialSessionExists: false, + initialRuntime: reconciliationRuntime, + runtimeMapLoader: async () => [reconciliationRuntime], + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-reconciliation-supervisor-game', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'cancel_game_creator_agent_runtime_task') { + const state = supervisorHarness.runtimeState({ + ...reconciliationRuntime, + status: 'cancelled', + phase: 'cancelled', + currentAction: '待核对的旧任务已结束', + waitingOn: '队列中的下一个任务', + error: null, + taskQueue: cancelledQueue, + updatedAt: 8000, + }); + return { + ...supervisorHarness.runtimeResult(state), + taskQueue: cancelledQueue, + }; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: projectPath }, + }); + fireEvent.click(screen.getByRole('button', { name: '打开' })); + + const supervisorSurface = await screen.findByLabelText('项目总控对话'); + expect( + await within(supervisorSurface).findByText('项目总控 Agent · 失败'), + ).not.toBeNull(); + expect( + within(supervisorSurface).getByText('当前阶段:待核对'), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_game_creator_agent_runtimes', { + projectPath, + }); + const reconcileButton = within(supervisorSurface).getByRole('button', { + name: '已核对,结束旧任务', + }); + fireEvent.click(reconcileButton); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'cancel_game_creator_agent_runtime_task', + { + projectPath, + agentId: 'project-supervisor', + runId: 'persisted-reconciliation-run', + }, + ); + }); + expect(invoke).not.toHaveBeenCalledWith( + 'confirm_retry_game_creator_agent_runtime_task', + expect.anything(), + ); + expect( + await within(supervisorSurface).findByText( + '旧任务已结束,队列中还有 1 个待处理任务,队列将继续处理', + ), + ).not.toBeNull(); + expect( + within(supervisorSurface).queryByRole('button', { + name: '在当前项目重试总控', + }), + ).toBeNull(); + }); + + it('allows retrying a cancelled reconciled Supervisor only after its queue is empty', async () => { + const projectPath = '/tmp/launcher-reconciliation-empty-queue'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-reconciliation-empty-queue', + ); + const runId = 'reconciliation-empty-queue-run'; + const reconciliationRuntime = { + schemaVersion: 'game-creator-agent-runtime.v1', + agentId: 'project-supervisor', + taskId: 'project-supervisor', + sessionId: 'reconciliation-empty-queue-session', + runId, + source: 'project-supervisor', + status: 'needs-reconciliation', + phase: 'needs-reconciliation', + currentTask: '生成贪吃蛇原型', + currentGoal: '完成可玩原型', + currentAction: '等待核对 Provider 回复交接', + waitingOn: '人工核对', + nextStep: '核对后结束旧任务', + plan: [], + observations: [], + allowedTools: [], + pendingToolAction: null, + lastResponse: null, + error: 'tool-plan-unknown', + updatedAt: 7000, + }; + const emptyQueue = { + total: 1, + pending: 0, + running: 0, + waitingForConfirmation: 0, + waitingForUserInput: 0, + cancelled: 1, + completed: 0, + failed: 0, + latestRunId: runId, + updatedAt: 8000, + }; + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + sessionId: 'reconciliation-empty-queue-session', + initialSessionExists: false, + initialRuntime: reconciliationRuntime, + runtimeMapLoader: async () => [reconciliationRuntime], + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-reconciliation-empty-queue', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'cancel_game_creator_agent_runtime_task') { + const state = supervisorHarness.runtimeState({ + ...reconciliationRuntime, + status: 'cancelled', + phase: 'cancelled', + currentAction: '待核对的旧任务已结束', + waitingOn: '', + error: null, + taskQueue: emptyQueue, + updatedAt: 8000, + }); + return { + ...supervisorHarness.runtimeResult(state), + taskQueue: emptyQueue, + }; + } + if (command === 'confirm_retry_game_creator_agent_runtime_task') { + const nextRunId = String(args?.nextRunId ?? ''); + const state = supervisorHarness.runtimeState({ + ...reconciliationRuntime, + runId: nextRunId, + status: 'running', + phase: 'planning', + currentAction: '重新生成项目总控计划', + waitingOn: 'Agent 输出计划或回复', + error: null, + updatedAt: 9000, + }); + return { + ...supervisorHarness.runtimeResult(state), + acceptedRunId: nextRunId, + }; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: projectPath }, + }); + fireEvent.click(screen.getByRole('button', { name: '打开' })); + + const supervisorSurface = await screen.findByLabelText('项目总控对话'); + fireEvent.click( + await within(supervisorSurface).findByRole('button', { + name: '已核对,结束旧任务', + }), + ); + expect( + await within(supervisorSurface).findByText( + '旧任务已结束,当前队列为空,可重新启动项目总控', + ), + ).not.toBeNull(); + const retryButton = await within(supervisorSurface).findByRole('button', { + name: '在当前项目重试总控', + }); + fireEvent.click(retryButton); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'confirm_retry_game_creator_agent_runtime_task', + { + projectPath, + agentId: 'project-supervisor', + runId, + nextRunId: expect.stringMatching(/^project-supervisor-retry-/), + }, + ); + }); + const cancelCallIndex = invoke.mock.calls.findIndex( + ([command]) => command === 'cancel_game_creator_agent_runtime_task', + ); + const retryCallIndex = invoke.mock.calls.findIndex( + ([command]) => + command === 'confirm_retry_game_creator_agent_runtime_task', + ); + expect(cancelCallIndex).toBeGreaterThanOrEqual(0); + expect(retryCallIndex).toBeGreaterThan(cancelCallIndex); + }); + it('loads and continues the active Project Supervisor Session in the standalone chat surface', async () => { const projectPath = '/tmp/supervisor-chat-only-game'; const historyMessage = '已持久化的项目总控历史'; diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d80dbac5f..eb506176a 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3709,12 +3709,27 @@ - 关联:`apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs`、`apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs`、`apps/ai-game-creator-shell/scripts/check-config.mjs`、`apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts`。 - 真实验收状态:外部 Provider 与画布 API 均可调用不等于全链路验收通过。2026-07-27 新起的独立轮次使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**:同一轮完成固定 `16` 个 manifest task exactly-once、七份基础产物、两张真实画布 PNG、当前 revision 静态检查、desktop / mobile `lane-defense-v1` playtest、唯一终态回复和安全清理;`turn.report` 的 busy / pending / running / confirmation / user-input / reconciliation 均为 `0`。此前失败轮、部分产物、单项接口成功和确定性结果仍不得与本轮拼接。 +## 项目总控空态和持久 Runtime 不能依赖同一份 Session 索引 + +- 现象:新项目尚未发消息时右侧总控区域只剩整块空白;已有 `needs-reconciliation` Runtime 的项目重新打开后,也可能看不到失败状态卡。 +- 原因:空 Runtime 直接返回 `null`,没有稳定空态;总控首轮水合又只在 active Session 索引存在时读取单 Agent Runtime。若 Provider 成功响应交接失败并留下 Runtime 文件、但 Session 索引未完成持久化,专业 Agent 列表能读到总控状态,专用总控面板却仍保持 `runtime=null`。 +- 处理:总控面板在无 Runtime 时显示“尚未开始”入口;项目级 Runtime 列表中的 `project-supervisor` 作为缺失 Session 索引时的恢复来源,并同步其 Session、响应流和 Runtime 状态。`needs-reconciliation` 显示为“待核对”,不伪装成执行中。 +- Windows 根因补充:`tool-plan` 成功响应在相对目录句柄下原子安装账本时,不能把非空 `FILE_RENAME_INFO.RootDirectory` 传给 `SetFileInformationByHandle(FileRenameInfo)`;该组合会稳定返回 `ERROR_INVALID_PARAMETER (87)`,导致每轮首个 Provider 响应都进入 `needs-reconciliation`。应使用支持相对根目录句柄的 `NtSetInformationFile(FileRenameInformation)`,继续保留目录句柄锚定,不能退化成可受路径换绑影响的绝对路径 rename。“按句柄安装”失败应归类为 `tool-plan-storage`,不能落入 `tool-plan-unknown`。 +- Responses 协议补充:格式修复会把上一次 Provider 输出作为 `assistant` 消息追加到新请求。OpenAI Responses API 中 system / user 文本使用 `input_text`,assistant 文本必须使用 `output_text`;不区分 role 会收到 `Invalid value: 'input_text'` 的 HTTP 400。assistant 图片不能继续序列化为 `input_image`,应在本地请求校验中失败关闭。 +- Steer 后审计补充:等待持久 Provider retry 时用户 steer 会让同一 run、同一 loop 重新使用 `loop-N-repair-0`;tool-plan protocol / repair 审计的幂等身份必须包含 `appliedSteerCursor`,否则新 cursor 的合法响应会与旧响应误报“内容冲突”。旧审计没有该字段时只按 cursor `0` 兼容;不能删库、忽略冲突或改用 response fingerprint 作逻辑槽唯一键。 +- 单调用 Provider 协作修复补充:若 Provider 每轮只返回一个 function call,Project Supervisor 的首批协作 repair 必须从触发首次协作缺口的响应开始,跨文本 JSON、OpenAI Chat tool call 与 OpenAI Responses function call 等格式修复轮次累积合法的 `agent.delegate / agent.spawn_isolated`。同一 `agentId` 后出现的 action 覆盖较早 action;`agent.spawn_isolated` 是单批唯一槽位,修正版必须覆盖旧 action,不能因输入变化追加第二个 spawn。每轮再按累计结果计算缺失的静态 Agent,并把下一轮 `agentId` enum 收窄到明确缺失集合;`missingStaticAgents=none` 表示没有指定 ID 缺口,不得生成 `enum=["none"]`,避免已满足的委派被重复生成或首批协作永远无法成批提交。 +- Provider action 安全持久化补充:pending / provider action 的泄密检测不能因裸自然语言短语 `api key` 直接拒绝,否则 `agent.delegate` 中“不要暴露 External Editor API Key”等安全约束会被误报并阻断首批协作。赋值形式只允许完整匹配受控的“未配置 / 不可用 / 禁止读取”等状态或固定无密钥降级说明,不能用 `starts_with` 放行 `none-but-secret`、`not configured; actual value ...` 等安全前缀后的凭据;`**API Key**:`、`` `API Key`: ``、`API Key(生产):` 等装饰或限定标签也必须识别为赋值。结构化字段标记 `apiKey / api_key`、`Authorization / Cookie`、`token / Bearer` 以及已知 secret token 形状仍必须检测并失败关闭。 +- Windows retry 扫描补充:`Path::strip_prefix(root)` 在 Windows 上得到的相对 `Path` 转字符串后使用反斜杠,不能直接传给只接受 portable `/` 的 Runtime JSON sidecar 读取器;否则 Runner 重启或显式 `--agent-resume` 扫描已到期 retry 时会报“项目文件路径不能包含反斜杠”,任务持续停在 `waiting-for-provider-retry`。目录扫描应按路径组件重组成 `/` 分隔的 UTF-8 相对路径,不要放宽全局路径校验。 +- 恢复交互:`needs-reconciliation` 即使没有 `pendingToolAction`,也必须提供显式“已核对,结束旧任务”;它只取消旧 run,不直接 retry。若取消后仍有 pending task,由 Runner 自动继续;只有队列为空且旧 run 已取消时,才允许创建新的 retry run,避免重复执行同一用户输入。自主构建 Supervisor 的 retry 不能改写为普通 `agent-background-task` source,必须从已验证的原 Run Profile 绑定恢复 `project-supervisor-gui / project-supervisor-cli` 可信来源;不得只信可追加的 task journal。 +- 验证:前端回归同时覆盖零历史、无 Session 的初始空态、无 active Session 索引但存在持久 `needs-reconciliation` 总控 Runtime 的恢复展示,以及“先取消、队列为空后才重试”;真实 Windows 运行全部 tool-plan handoff 测试,确保相对句柄 rename、覆盖安装、回读和清理均通过。Responses 回归覆盖 system / user / assistant 文本分别序列化,并保留 user `input_text + input_image`;Runtime 回归覆盖“无效计划 → repair transport 等待 → steer → 新 cursor 再修复”,断言 cursor `0 / 1` 各有一条审计且不冲突。 + ## 固定画布产物返工不能变成任意覆盖,design-foundation 不能越权修程序 - 现象:视觉 Agent 发现候选图不合格后,可能先删除 `assets/ui-prototype.png` 或 `assets/art-spritesheet.png`,再用猜测的尺寸、比例或另一条路径重新生成;远端生成期间项目文件又可能被其它 Agent 更新,迟到结果覆盖较新的文件。`design-foundation` 为了让静态或浏览器检查通过,也可能顺手改写 `game/index.html` 或自行启动 preview。 - 原因:把“允许一次语义返工”误解成“视觉 Agent 可以任意覆盖”,且只在 prompt 中描述角色职责,没有在 replacement 授权、文件写入、工具策略和提交时 fingerprint 上强制执行。 - 处理:固定 UI 与 spritesheet 路径、比例、尺寸、kind 和 label;普通生成 `replaceExisting=false`。只有 Project Supervisor 对已认领原 delivery 建立的唯一静态 repair,且父 run、目标 Agent 与 `expectedArtifacts` 全部匹配时,才允许 `replaceExisting=true` 原位替换;不得先删除固定正式产物,也不得对 repair 再 repair。请求外部生成前记录原路径 SHA-256,取得写锁准备提交时复算;不一致即按 stale fingerprint 失败关闭并保留当前文件。 - 职责隔离:`design-foundation` 只写 `memory/project.md`、`game/game_design.md` 和可选固定 UI 原型。Runtime 必须同时在单文件写入、patchset、delete 与工具 policy 层拒绝其修改 `game/index.html`、其它实现文件、启动 preview / playtest、运行进程、调用 `game.static_smoke` 或整项目恢复;只有 `preview-readiness` 可执行固定 smoke,只有 `preview-playtest` 可执行浏览器验收。 +- 画布配置一致性:未配置 External Editor API Key 时,`design-foundation / art-asset-plan` 的委派合同与 manifest 终态投影必须一起降级为文本产物,不能仍把 `assets/ui-prototype.png / assets/art-spritesheet.png` 作为完成条件;配置 Key 时两张固定图片继续是严格必需产物。委派、完成合同和 manifest 投影必须读取同一配置事实,禁止一层降级、另一层仍要求图片。 - 验证与状态:当前回归已覆盖固定合同拒绝漂移、已登记 spritesheet 删除保护、静态 repair 授权、并发修改触发 stale fingerprint、`design-foundation` 的 write / patchset / delete 和 preview 工具拒绝。2026-07-27 的独立 75 分钟上限外部 E2E 已在同一轮完成两张真实画布图片、固定 `16` 任务、当前 revision 静态与双视口试玩并安全清理,当前状态为 **PASS**;后续改动仍须新轮复验。 ## 完成合同不能只绑定一个入口摘要,公开资源审计不能保存完整 prompt diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 244a40416..0423fdf40 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -726,10 +726,17 @@ game-project/ - 2026-07-25 已落地:`autonomous-game-build` 的完成合同从“交付可玩原型”升级为“交付正式项目产物”。Runtime 按 seed manifest 的 16 个 task 依赖分波推进:先设计打底,再并行完成数值、美术和音频需求,然后程序整合,之后执行质量审查、当前 revision 静态检查与真实试玩,最后生成发布包装。每个新根 run 先重置本轮 seed task;普通 preview / smoke bookkeeping 不得代替自主 task 的真实执行和终态投影。 - DAG 只能在 Project Supervisor 的 `agent.run_status` 回执认领已可靠观察、静态委派屏障清空后启动;正常执行、pending 续跑和重启恢复复用同一入口。调度等待项目写锁,不能因短暂锁竞争进入 reconciliation。专业子 run 的验证允许 `verifiedRevision >= mutationRevision`,但 Supervisor 的最终静态和试玩证据仍必须精确覆盖最新全局 revision。 - 正式项目基础产物固定为 `memory/project.md`、`game/game_design.md`、`game/balance.json`、`assets/manifest.art.json`、`assets/manifest.audio.json`、`game/index.html` 和 `exports/README.md`。未配置画布 API Key 时,设计与美术 Agent 交付明确记录界面结构、素材需求和“尚未生成”状态的文本 / JSON,不暴露 `canvas.asset_generate`,也不得伪造图片;配置 Key 时额外强制生成、登记并验收 `assets/ui-prototype.png` 与 `assets/art-spritesheet.png`,生成失败不得完成。`assets/manifest.audio.json` 当前只表示 BGM 与关键音效需求,不得宣称已生成真实音频文件。 +- 2026-07-28 画布配置最终一致性补充:未配置 External Editor API Key 时,`design-foundation / art-asset-plan` 的委派 `expectedArtifacts` 与对应 manifest task 终态投影必须同步采用上述文本产物降级,不得继续强制 `assets/ui-prototype.png / assets/art-spritesheet.png`;配置 Key 时两张固定图片仍为严格完成条件。委派合同、完成合同与 manifest 投影必须以同一配置事实派生。 - Project Supervisor 只有在本轮必需 manifest tasks 全部 `completed`、当前配置对应的正式路径齐全且通过类型 / 可解析性检查、最新 project revision 的 `game.static_smoke` 与 `preview.validate` 都通过后,才能写入唯一最终回复。delivery 的 `completed / evidence-ready`、历史 revision 成功或单个文件存在都不能替代最终集成验收。已 `ready / claimed-by-parent` 的相同终态 delivery 在恢复扫描中按幂等重放,保留首次冻结结果,不再制造重复 `agent.delegate.result_failed`;真实终态冲突仍失败关闭。 - 验证:`npm run agc:test` 已通过确定性 loopback Provider、真实 Runtime、项目写入和浏览器链路验收:同一父 Run 下 16 个 manifest task 均只有一个 logical run、一次 start、一次 completed 和一次 manifest projection,且无 failed / cancelled;父 run 与全部子 run 完成,最终 revision 为 `11`,基础正式产物、静态 smoke、桌面 / 移动 `37/37` 试玩通过,pending、reconciliation、Provider 失败、重复和泄漏计数均为 `0`。该结果不替代独立外部 Provider 验收。 - 2026-07-26 本轮已验证 `npm run agc:config` 的终端配置链路。向导与 GUI 使用同一 Tauri identifier 对应的系统 AppData 和同名 `game-creator.config.json` / 可选 local overlay;读取已有配置时只更新有效 LLM 层,保留 `agentLlm`、`editorApi`、`mcpServers` 等其它配置。API Key 只从隐藏输入读取,拒绝 `--api-key`、仓库内目录、Git 已跟踪配置、符号链接,以及不是以 `world.genarrative.ai-game-creator` 为独立叶目录的 `--config-dir`,防止把任意父目录整体改成私有权限。保存使用同目录 `0600` 临时文件原子替换,POSIX AppData 目录保持 `0700`,Windows 使用当前用户独占 DACL,写后复用真实 `--llm-status` 检查;隐藏输入收到 `SIGINT / SIGTERM / SIGHUP` 时先恢复 raw mode 和 pause 状态再重发原信号,向导启动的 Cargo / npm 使用独立进程组并在信号路径有界收束整棵子进程树。 - 2026-07-27 Windows DACL 启动回归修正:`powershell.exe -Command` 后追加的位置参数会被 PowerShell 5.1 拼接进命令文本,不能用 `$args` 安全接收包含空格的 AppData / 临时目录。DACL 脚本改为从仅传给该子进程的环境变量读取目标绝对路径和目录标记;`npm run agc:typecheck` 必须在真实 Windows 上执行配置回归,保证 `npm run agc` 的 `beforeDevCommand` 不因路径解析失败退出。 +- 2026-07-27 项目总控右栏空态与持久状态水合修正:项目尚未产生 Runtime 时仍显示“尚未开始”状态块和创作入口,不把消息列表的弹性剩余空间裸露为空白;若 active Session 索引缺失但项目内已有 `project-supervisor` Runtime,工作台必须从 `read_game_creator_agent_runtimes` 的权威项目列表恢复总控 Session 与状态。`needs-reconciliation` 统一显示为“失败 / 待核对”,不能因对话索引缺失隐藏已落盘的失败事实。 +- 2026-07-28 Windows `tool-plan` 成功响应交接修正:相对目录句柄下安装 handoff 账本改用 `NtSetInformationFile(FileRenameInformation)`;`SetFileInformationByHandle(FileRenameInfo)` 不接受当前实现所需的非空 `RootDirectory`,会稳定返回 `ERROR_INVALID_PARAMETER (87)` 并让总控首轮进入 `needs-reconciliation`。实现继续绑定已验证的父目录句柄和相对 hash 文件名,不退化为绝对路径 rename;“按句柄安装”归入 `tool-plan-storage`。总控对 reconciliation 提供“已核对,结束旧任务”,取消后有 pending task 时只等待 Runner 续跑,队列为空时才允许显式 retry;自主构建 Supervisor 的 retry source 从原 Run Profile 绑定恢复并重新验证为可信 GUI / CLI 根入口,不降级成普通后台任务来源。 +- 2026-07-28 `tool-plan` 格式修复协议与 steer 审计修正:OpenAI Responses 请求按角色映射内容块,system / user 文本为 `input_text`,assistant 计划预览为 `output_text`,assistant `input_image` 在本地校验阶段拒绝,避免 repair 请求因非法 `input_text` 被上游以 HTTP 400 拒绝。tool-plan protocol / repair 审计增加 `appliedSteerCursor`,幂等键也纳入该 cursor;等待 Provider retry 期间 steer 后,同一 run / loop 可在新 cursor 下合法重用 `repair-0` 逻辑槽。旧记录缺少 cursor 时仅视为 `0`,保持升级后 replay 幂等。回归必须覆盖 Responses 多角色序列化、user 多模态,以及“无效计划 → repair retry 等待 → steer → 新 cursor repair”链路。 +- 2026-07-28 Project Supervisor 首批协作 repair 累积约定:针对每轮只返回单个 function call 的 Provider,Runtime 从首次触发协作缺口的响应开始,跨文本 JSON、OpenAI Chat tool call 与 OpenAI Responses function call 修复轮次累积合法的 `agent.delegate / agent.spawn_isolated`;同一 `agentId` 以最新响应覆盖旧 action,唯一 `agent.spawn_isolated` 槽位也以最新响应覆盖,禁止把修正版追加成同批第二个 spawn。每轮根据累计结果计算尚缺的静态 Agent,并仅在缺失集合含明确 Agent ID 时收窄下一轮 function schema 的 `agentId` enum;`missingStaticAgents=none` 是空集合哨兵,不是 Agent ID。只有累计首批满足完整协作合同时才成批提交,已满足的 Agent 不得因后续修复重复派发。 +- 2026-07-28 pending / provider action 安全持久化约定:自然语言任务中的裸短语 `api key` 不是泄密证据,不能据此拒绝 action;否则 `agent.delegate` 的“不要暴露 External Editor API Key”等安全指令会被误判。API Key 赋值只允许完整受控状态或固定无密钥降级说明,禁止用安全状态前缀放行后续任意内容;`none-but-secret`、`not configured; actual value ...` 等必须失败关闭。Markdown 装饰、反引号或环境限定标签不能改变赋值语义,`**API Key**:`、`` `API Key`: ``、`API Key(生产):` 仍必须进入同一检测。持久化前继续检测结构化 `apiKey / api_key`、`Authorization / Cookie`、`token / Bearer` 标记和已知 secret token 形状,命中真实凭据时仍失败关闭。 +- 2026-07-28 Windows Provider retry 恢复修正:`provider_retry::list_at` 从绝对路径剥离项目 root 后,按路径组件重组成 `/` 分隔的 portable UTF-8 相对路径,再交给 Runtime JSON sidecar 读取器。不能直接使用 Windows `Path::to_str()` 的反斜杠文本,否则应用重启、Runner recovery scan 和正式 `--agent-resume` 都无法推进已到期的 `waiting-for-provider-retry` run。全部 provider retry 列举、previous 恢复、去重和路径冲突回归必须在真实 Windows 通过。 - `npm run agc:test:chat` 未显式指定配置且找不到 AppData 配置时,只在 stdin / stdout 都是 TTY 时询问并启动同一 `agc:config --configure-only` 向导,非 TTY 或显式无效 `--config-dir` 直接失败。测试环境只把主配置和存在时的 local overlay 复制到带随机 sentinel 的单次隔离 AppData;副本必须是独立的无符号链接普通文件,POSIX 权限为目录 `0700` / 文件 `0600`,不复制正式 Runner endpoint、lock 或其它 AppData。自动任务默认 50 分钟且可用 `--timeout-minutes` 显式设置;超时或信号会终止独立子进程树,POSIX 先向进程组发送 `SIGTERM`、等待 10 秒后发送 `SIGKILL` 并再等待 5 秒,Windows 使用 `taskkill /T` 并在强制阶段追加 `/F`。超时和信号分别以 `124 / 130 / 143` 失败退出,隔离 Runner 收束另有 20 秒上限;Runner 未空闲或收束失败时保留隔离配置和项目,验收未完成但 Runner 已安全退出时只保留一次性项目证据,不把中断报告为成功,也不误删正式 AppData。 - 自动验收现在严格要求 manifest 恰好包含固定 16 个不重复 task ID 且全部为 `completed`,并逐任务核对当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物存在并满足文件 / JSON / 非占位入口检查,配置画布 API Key 时再增加两张图片。PNG 验收不止检查 magic / IHDR / 比例,还会校验 chunk CRC、zlib 解压、scanline 长度、索引色 PLTE 和未知 critical chunk。Runtime 根 Supervisor 的完成合同已升级为 `game-creator-autonomous-completion-contract.v2`,`baselineArtifacts` 必填并纳入指纹,旧 v1 或缺基线合同失败关闭;最终门禁要求最后一次验证工具是 `game.static_smoke`、状态通过且 `verifiedRevision == currentRevision`。`preview.validate` 回执必须绑定同一 Agent、run、current revision、当前 `game/index.html` 摘要、固定试玩场景、持久浏览器报告以及 desktop / mobile 两张截图的路径、摘要和 PNG 身份,任一证据缺失、变化、过期或来自其它 run / revision 都阻止最终回复。确定性 `npm run agc:test` 已证明 revision `0 -> 11`、双视口试玩 `37/37` 和终局零残留;该证据仍不替代外部 Provider 单轮验收。 - `design-foundation` 已增加专属职责边界:项目文件只允许写 `memory/project.md` 与 `game/game_design.md`;配置 External Editor API Key 且合同要求界面原型时,只额外允许固定 `assets/ui-prototype.png`。它不得创建、修改、删除或补丁 `game/index.html`,不得改动其它程序实现、发布、音频或美术素材,也不得调用 `preview.start`、`preview.validate`、`game.static_smoke`,或借 `command.exec / command.start / command.run_limited` 启动预览服务、浏览器、Playwright 和桌面 / 移动试玩。程序和质量 Agent 的共享 Runtime 工具合同不因此缩减;有 / 无画布配置和其它 Agent 不受影响的聚焦回归为 `3/3` 通过。 diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 4f9a03b8b..216fe26b2 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -360,6 +360,7 @@ struct ResponsesInputMessage { #[serde(tag = "type", rename_all = "snake_case")] enum ResponsesInputContentPart { InputText { text: String }, + OutputText { text: String }, InputImage { image_url: String }, } @@ -1240,6 +1241,18 @@ impl LlmRunRequest { "LLM message content part 不能为空".to_string(), )); } + + if self.api_kind == LlmApiKind::OpenAiResponses + && message.role == LlmMessageRole::Assistant + && message + .content_parts + .iter() + .any(|part| matches!(part, LlmMessageContentPart::InputImage { .. })) + { + return Err(LlmError::InvalidRequest( + "OpenAI Responses assistant 消息不支持 input_image".to_string(), + )); + } } if let Some(model) = &self.model @@ -2375,9 +2388,10 @@ fn message_text_for_anthropic(message: &LlmMessage) -> Option { fn map_responses_content_parts(message: &LlmMessage) -> Vec { if message.content_parts.is_empty() { - return vec![ResponsesInputContentPart::InputText { - text: message.content.clone(), - }]; + return vec![map_responses_text_content_part( + message.role, + message.content.clone(), + )]; } message @@ -2385,7 +2399,7 @@ fn map_responses_content_parts(message: &LlmMessage) -> Vec { - ResponsesInputContentPart::InputText { text: text.clone() } + map_responses_text_content_part(message.role, text.clone()) } LlmMessageContentPart::InputImage { image_url } => { ResponsesInputContentPart::InputImage { @@ -2396,6 +2410,18 @@ fn map_responses_content_parts(message: &LlmMessage) -> Vec ResponsesInputContentPart { + match role { + LlmMessageRole::System | LlmMessageRole::User => { + ResponsesInputContentPart::InputText { text } + } + LlmMessageRole::Assistant => ResponsesInputContentPart::OutputText { text }, + } +} + fn log_llm_raw_failure( config: &LlmConfig, request: &LlmRunRequest, @@ -3567,6 +3593,26 @@ mod tests { assert_eq!(error, LlmError::EmptyResponse); } + #[test] + fn responses_request_rejects_assistant_input_image() { + let error = LlmRunRequest::new(vec![LlmMessage::multimodal( + LlmMessageRole::Assistant, + vec![LlmMessageContentPart::InputImage { + image_url: "https://example.com/assistant.png".to_string(), + }], + )]) + .with_openai_responses() + .validate() + .expect_err("Responses assistant image should fail locally"); + + assert_eq!( + error, + LlmError::InvalidRequest( + "OpenAI Responses assistant 消息不支持 input_image".to_string() + ) + ); + } + #[tokio::test] async fn run_sends_official_fallback_for_openai_compatible_clients() { let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); @@ -4283,6 +4329,71 @@ mod tests { ); } + #[tokio::test] + async fn responses_request_maps_assistant_text_to_output_text() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener.local_addr().expect("listener should have addr"); + let server_handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("request should connect"); + let request_text = read_request(&mut stream); + write_response( + &mut stream, + MockResponse { + status_line: "200 OK", + content_type: "application/json; charset=utf-8", + body: r#"{"id":"resp_repair","model":"gpt-5","output_text":"修复成功","status":"completed"}"# + .to_string(), + extra_headers: Vec::new(), + }, + ); + request_text + }); + + let client = build_test_client(format!("http://{address}"), 0); + client + .run( + LlmRunRequest::new(vec![ + LlmMessage::system("系统约束"), + LlmMessage::user("原始请求"), + LlmMessage::assistant("需要修复的计划预览"), + LlmMessage::user("请修复格式"), + ]) + .with_openai_responses(), + ) + .await + .expect("Responses repair request should succeed"); + + let request_text = server_handle.join().expect("server thread should join"); + let request_body = request_text + .split("\r\n\r\n") + .nth(1) + .expect("request body should exist"); + let request_json: serde_json::Value = + serde_json::from_str(request_body).expect("request body should be json"); + + assert_eq!( + request_json["input"], + serde_json::json!([ + { + "role": "system", + "content": [{ "type": "input_text", "text": "系统约束" }] + }, + { + "role": "user", + "content": [{ "type": "input_text", "text": "原始请求" }] + }, + { + "role": "assistant", + "content": [{ "type": "output_text", "text": "需要修复的计划预览" }] + }, + { + "role": "user", + "content": [{ "type": "input_text", "text": "请修复格式" }] + } + ]) + ); + } + #[tokio::test] async fn run_accepts_responses_function_call_without_output_text() { let server_url = spawn_mock_server(vec![MockResponse { From dae59992b16a0cbf0f55b82b7ec25a47c66c27db Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Wed, 29 Jul 2026 11:54:06 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E6=80=81=E6=B8=B8=E6=88=8F=E8=81=8A=E5=A4=A9=E5=88=9B=E4=BD=9C?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增仅开发态游戏聊天入口与本地安全预览布局 将Supervisor进度、返工、测试、迭代和图片证据写入聊天 完善自动预览授权持久化、锁竞争重试与Runtime收尾恢复 规范视觉产物走规范图、UI设计和透明图集专用接口 补齐共享契约、回归测试、技术方案和项目记忆 --- .../genarrative-external-editor-api/SKILL.md | 26 + .../references/api-selection.md | 12 +- .../scripts/genarrative_external_api.py | 34 + apps/ai-game-creator-shell/package.json | 1 + .../src-tauri/Cargo.lock | 1 + .../src-tauri/Cargo.toml | 1 + .../src/agent/generation/canvas_generation.rs | 315 ++- .../src-tauri/src/agent/prompt.rs | 82 +- .../src-tauri/src/agent/runtime_actions.rs | 3 + .../src/agent/runtime_actions/action_audit.rs | 76 +- .../runtime_actions/autonomous_policy.rs | 60 +- .../agent/runtime_actions/project_gates.rs | 40 +- .../runtime_actions/provider_tool_plan.rs | 8 +- .../runtime_actions/response_stream_tests.rs | 95 + .../runtime_actions/tool_policy_snapshot.rs | 5 +- .../src-tauri/src/agent/runtime_driver.rs | 12 +- .../src/agent/runtime_driver/main_loop.rs | 31 +- .../agent/runtime_driver/main_loop_tests.rs | 42 +- .../agent/runtime_driver/provider_recovery.rs | 7 + .../src/agent/runtime_driver/recovery_scan.rs | 269 +++ .../src/agent/runtime_driver/task_start.rs | 35 +- .../src-tauri/src/agent/runtime_protocol.rs | 1 + .../autonomous_completion_contract_tests.rs | 83 +- .../agent/runtime_protocol/finalization.rs | 83 + .../agent/runtime_protocol/response_stream.rs | 27 + .../src/agent/runtime_protocol/steering.rs | 11 +- .../src-tauri/src/agent/runtime_tools.rs | 3 +- .../src/agent/runtime_tools/file_ops.rs | 11 +- .../src/agent/runtime_tools/media.rs | 61 +- .../src/agent/runtime_tools/policy.rs | 14 +- .../src-tauri/src/agent_native_tools.rs | 4 +- .../src-tauri/src/assets.rs | 12 + .../src-tauri/src/cli.rs | 8 +- .../src-tauri/src/collaboration.rs | 120 +- .../src-tauri/src/commands.rs | 3 + .../src-tauri/src/main.rs | 23 +- .../src-tauri/src/project/manifest.rs | 115 +- .../src/tests/collaboration/delegation.rs | 35 +- .../src/tests/collaboration/recovery.rs | 6 + .../tests/collaboration/static_deliveries.rs | 59 +- .../collaboration/supervisor_planning.rs | 180 +- .../src-tauri/src/tests/mod.rs | 135 +- .../src-tauri/src/tests/project.rs | 372 ++- .../src-tauri/src/tests/project_tools.rs | 65 + .../src-tauri/src/tests/provider.rs | 8 +- .../src-tauri/src/tests/response_stream.rs | 66 +- .../tests/runtime_actions/action_execution.rs | 81 +- .../planning_strategy/autonomous_build.rs | 18 +- .../src/tests/runtime_actions/support.rs | 14 +- .../src-tauri/src/tests/runtime_state.rs | 22 +- .../src-tauri/src/windows.rs | 98 + apps/ai-game-creator-shell/src/App.tsx | 705 +++++- .../src/features/agent-runtime/model.ts | 15 +- .../project-workspace/GameChatImageViewer.tsx | 192 ++ .../LocalGamePreviewFrame.tsx | 48 + .../SupervisorChatOnlyView.tsx | 1078 +++++++-- apps/ai-game-creator-shell/src/main.tsx | 23 +- apps/ai-game-creator-shell/src/styles.css | 491 +++- .../src/view/project-development/index.tsx | 27 +- .../appSurface/project-development.suite.ts | 2009 ++++++++++++++++- .../assert-planning-and-status-shortcuts.ts | 4 +- .../shared-memory/decision-log.md | 55 +- docs/project-memory/shared-memory/pitfalls.md | 13 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 28 +- package.json | 1 + .../src/contracts/gameCreationApp.test.ts | 17 +- .../shared/src/contracts/gameCreationApp.ts | 34 +- .../shared-contracts/src/game_creation_app.rs | 61 +- 68 files changed, 7146 insertions(+), 548 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/GameChatImageViewer.tsx create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index ac684ddf7..9e9080df7 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -142,6 +142,20 @@ client.generate_image( ) ``` +For a transparent game/UI atlas, call the dedicated helper instead of ordinary image generation: + +```python +client.generate_icon_spritesheet( + "editor-resource-current-art-spec", + ["蛇头四方向", "直身与四种转角", "尾部四方向", "四类可区分食物"], + canvasSession=session, + assetLabel="贪吃蛇透明图集", + screenColor="auto", +) +``` + +Pass the registered visual-spec resource ID as `reference_image_src`; do not pass the UI prototype or a local path. + Use the helper directly from this skill path, or copy it into the caller's project. Do not change the fixed base URL or move the API Key into environment variables. Use this shared base: @@ -326,6 +340,18 @@ Character image generation (including character redraw through `kind: "character - `sliceWarning` is a separate condition used only when transparent spritesheet post-processing succeeded but automatic slicing failed. Keep `sliceWarning.reason` as the original diagnostic and continue using the complete transparent spritesheet; a UI may add context when displaying it, but must not rewrite the stored reason. - The service contract keeps `warning` and `sliceWarning` mutually exclusive. As defensive handling for a malformed response containing both, treat the general `warning` as authoritative and do not misclassify the source-preserved result as a slicing-only warning. +For reusable transparent game/UI sheets, do not substitute ordinary image generation merely because it can draw several objects in one image. Use icon spritesheet generation when a stable visual-spec reference and `iconDescriptions` exist; use UI extraction only for an existing annotated UI design. Pass `screenColor: "auto"` unless the art direction requires one of the supported solid chroma colors. A client must verify the returned full sheet really contains transparency before treating it as a transparent spritesheet. If a source-preserved `warning` is present, do not register the opaque provider source as the requested transparent deliverable. When only `sliceWarning` is present, the full transparent sheet remains usable, but no individual slices may be claimed. + +## AI Game Creator Canonical Visual DAG + +The AI game creator reuses its existing 16-task manifest; do not add a parallel task system or collapse the following artifacts into one ordinary generation request: + +1. `art-director` generates `assets/art-spec.png` with `POST /api/external/v1/editor/images/generations`, `kind: "spec"`, and registers it as `assetKind: "icon-spec"`. This is the real visual-spec image. The JSON value in `generationInputs.artSpec` is supporting structured context and does not replace this image. +2. `design-foundation` uses the registered External Editor resource ID for `assets/art-spec.png` in `referenceImageSrcs`, then generates the complete `assets/ui-prototype.png` through `POST /api/external/v1/editor/images/generations` with `kind: "ui-design"`. +3. `art-asset-plan` uses the same registered `assets/art-spec.png` resource ID as the required `referenceImageSrc` for `POST /api/external/v1/editor/icon-spritesheets/generations`, supplies concrete `iconDescriptions`, and registers the transparent full result as `assets/art-spritesheet.png`. + +Never use `assets/ui-prototype.png` as the icon spritesheet's visual-spec reference. `POST /api/external/v1/editor/ui-designs/assets/extractions` requires an existing UI design image with red-box annotations; it is not UI generation and is not part of this canonical DAG. + ## Guardrails - Do not invent endpoints outside the OpenAPI, especially internal worker or runtime task-list routes. diff --git a/.codex/skills/genarrative-external-editor-api/references/api-selection.md b/.codex/skills/genarrative-external-editor-api/references/api-selection.md index 77f7269e9..18b62f5e1 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-selection.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-selection.md @@ -20,6 +20,14 @@ At the start of a new conversation, ask for a canvas name before the first gener Before generating art assets, normalize the user's request into a current art spec with `assetType`, `subject`, `style`, `palette`, `composition`, `format`, `constraints`, and `references`. Ask follow-up questions only for missing fields that block the selected endpoint. Reuse the current spec automatically when the user asks for another asset without changing style/spec requirements. Put the spec in `generationInputs.artSpec` and summarize it in the prompt when useful. +For the AI game creator's existing 16-task autonomous build, distinguish that JSON art spec from the required visual-spec image and keep this dependency chain: + +1. `art-director` -> `assets/art-spec.png` via `POST /api/external/v1/editor/images/generations`, with `kind=spec` and registered `assetKind=icon-spec`. +2. `design-foundation` -> `assets/ui-prototype.png` via the same image generation endpoint with `kind=ui-design`, using the registered art-spec resource ID in `referenceImageSrcs`. +3. `art-asset-plan` -> transparent `assets/art-spritesheet.png` via `POST /api/external/v1/editor/icon-spritesheets/generations`, using the registered art-spec resource ID as `referenceImageSrc` and providing `iconDescriptions`. + +Do not use the UI prototype as the spritesheet specification. UI extraction requires a stable source image with red-box annotations and is outside this canonical DAG. + ## Intent Routing Infer the endpoint from the user's description. Do not present this as a menu unless the request is genuinely ambiguous. @@ -69,8 +77,8 @@ Ask a follow-up only when two routes could both be correct and produce different | --- | --- | --- | --- | | Generate image/spec/character/UI/publication material | `POST /api/external/v1/editor/images/generations` | `prompt` | `kind`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | | Edit/redraw image | `POST /api/external/v1/editor/images/edits` | `prompt`, `sourceImageSrc` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `sourceResourceId`, `targetLayerId`, `canvasCompletion` | -| Generate icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `referenceImageSrcs`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `canvasCompletion` | -| Extract assets from UI design | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` | +| Generate icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | +| Extract assets from UI design | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` | | Generate character animation | `POST /api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `canvasCompletion`; then create a library asset from the first returned frame | | Generate video | `POST /api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | | Generate sound effect | `POST /api/external/v1/editor/audios/sound-effects/generations` | `prompt`, `duration` | `model`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | diff --git a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py index 6998db36c..23f53c526 100644 --- a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py +++ b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py @@ -450,6 +450,29 @@ class GenarrativeExternalClient: timeout=GENERATION_REQUEST_TIMEOUT_SECONDS, ) + def generate_icon_spritesheet( + self, + reference_image_src: str, + icon_descriptions: list[str], + **fields: Any, + ) -> Any: + descriptions = [item.strip() for item in icon_descriptions if item.strip()] + if not descriptions: + raise GenarrativeApiError("icon_descriptions must contain at least one non-empty item") + label = fields.get("assetLabel", "图标图集") + self._apply_canvas_session_fields(fields, label, 1024, 1024) + fields.setdefault("screenColor", "auto") + return self.request_json( + "POST", + "/api/external/v1/editor/icon-spritesheets/generations", + { + "referenceImageSrc": reference_image_src, + "iconDescriptions": descriptions, + **fields, + }, + timeout=GENERATION_REQUEST_TIMEOUT_SECONDS, + ) + def extract_ui_assets(self, source_image_src: str, image_size: str = "1K", **fields: Any) -> Any: fields.pop("aspectRatio", None) self._apply_canvas_session_fields(fields, fields.get("spritesheetLabel", "UI 素材拆分"), 1024, 1024, "spritesheetLabel") @@ -619,6 +642,17 @@ def _self_test() -> None: assert calls[0]["body"]["canvasCompletion"]["title"] == "角色呼吸动画" assert calls[1]["path"] == "/api/external/v1/editor/assets" assert result["asset"]["assetId"] == "editor-asset-demo" + calls.clear() + client.generate_icon_spritesheet( + "editor-resource-spec", + ["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"], + canvasSession=session, + assetLabel="贪吃蛇透明图集", + ) + assert calls[0]["path"] == "/api/external/v1/editor/icon-spritesheets/generations" + assert calls[0]["body"]["referenceImageSrc"] == "editor-resource-spec" + assert calls[0]["body"]["screenColor"] == "auto" + assert calls[0]["body"]["iconDescriptions"][0] == "蛇头向上" print("self-test ok") diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index c79223d53..e99bc18c3 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "npm --prefix ../.. exec tauri -- dev", + "game-chat": "npm --prefix ../.. exec tauri -- dev -- -- --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/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 3cf4feb64..de032a52b 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1476,6 +1476,7 @@ dependencies = [ "chromiumoxide", "futures", "http", + "image", "libc", "platform-agent", "platform-llm", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 47ef56504..e79109f5c 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -12,6 +12,7 @@ base64 = "0.22" chromiumoxide = "0.9.1" futures = "0.3" http = "1" +image = { version = "0.25", default-features = false, features = ["png"] } rmcp = { version = "2.2.0", default-features = false, features = ["client", "reqwest-native-tls", "transport-child-process", "transport-streamable-http-client-reqwest"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 47d288234..d2dc1f727 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -405,9 +405,88 @@ pub(in crate::agent) struct PreparedPlatformArtAssetGeneration { generated_prompt: Option, model: Option, provider: Option, + slice_warning: Option, + generation_route: String, + generation_kind: String, + reference_resource_ids: Vec, extension: String, } +fn canonical_art_spec_reference_at( + root: &Path, + expected_canvas_project_id: &str, +) -> Result { + let manifest = read_manifest_for_project(root)?; + let source = manifest + .assets + .iter() + .find(|asset| { + asset.local_path == AGENT_RUNTIME_ART_SPEC_PATH + && asset.kind == "icon-spec" + && asset.media_type.starts_with("image/") + && asset.source.kind == GameCreationAppAssetSourceKind::Canvas + }) + .ok_or_else(|| { + "派生视觉资产需要先完成并登记 assets/art-spec.png;请等待 art-director 后重试" + .to_string() + })?; + let source_path = resolve_local_project_path(root, &source.local_path)?; + if !source_path.is_file() { + return Err( + "派生视觉资产的规范图 assets/art-spec.png 不存在;请等待 art-director 后重试" + .to_string(), + ); + } + if source.source.canvas_project_id.as_deref() != Some(expected_canvas_project_id) { + return Err( + "assets/art-spec.png 不属于当前 External Editor 画布项目,不能作为派生视觉资产参考源" + .to_string(), + ); + } + source + .source + .resource_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + "assets/art-spec.png 缺少 External Editor 项目资源 ID,不能伪造规范图引用".to_string() + }) +} + +fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { + let project_context = truncate_inline(prompt.trim(), 320); + [ + "当前玩法的玩家主体、朝向或动作状态与可组合部件", + "当前玩法的目标物、收集物、敌对实体或危险物", + "当前玩法需要的地块、障碍、资源物件与场景装饰", + "得分、受击、成长、失败、胜利与操作反馈特效", + ] + .into_iter() + .map(|category| format!("{category};遵循同一项目视觉规范:{project_context}")) + .collect() +} + +fn platform_art_spritesheet_has_transparent_pixels(download: &CanvasResourceDownload) -> bool { + image::load_from_memory(&download.bytes) + .ok() + .map(|image| image.to_rgba8().pixels().any(|pixel| pixel[3] < u8::MAX)) + .unwrap_or(false) +} + +fn platform_art_generation_postprocess_failure(generated: &serde_json::Value) -> Option { + let warning = generated + .get("warning") + .filter(|warning| !warning.is_null())?; + let code = json_string_field(warning, "code").unwrap_or_else(|| "unknown".to_string()); + let reason = json_string_field(warning, "reason") + .unwrap_or_else(|| "透明背景后处理未生成可用衍生物".to_string()); + Some(format!( + "平台图片生成完成但透明后处理失败({code}):{reason};provider 源图已由服务端保留,不得登记为透明图集或自动重试" + )) +} + pub(in crate::agent) async fn generate_platform_art_asset_with_options_at( root: &Path, prompt: &str, @@ -444,34 +523,69 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at( let canvas_context = prepare_external_canvas_generation_context(root, &client, &api_base_url, &api_key).await?; let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); - let generation_kind = if options.asset_kind == "ui-prototype" { - "ui-design" + let generation_kind = match options.asset_kind.as_str() { + "ui-prototype" => "ui-design", + "art-spritesheet" => "icon-spritesheet", + _ => "spec", + }; + let is_canonical_art_spritesheet = options.asset_kind == "art-spritesheet"; + let canonical_reference = matches!( + options.asset_kind.as_str(), + "ui-prototype" | "art-spritesheet" + ) + .then(|| canonical_art_spec_reference_at(root, &canvas_context.project_id)) + .transpose()?; + let (endpoint, request_body) = if is_canonical_art_spritesheet { + let reference_image_src = canonical_reference + .as_deref() + .ok_or_else(|| "透明美术图集缺少规范图引用".to_string())?; + ( + "/api/external/v1/editor/icon-spritesheets/generations", + serde_json::json!({ + "referenceImageSrc": reference_image_src, + "iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt), + "screenColor": "auto", + "aspectRatio": options.aspect_ratio, + "imageSize": options.image_size, + "assetLabel": options.asset_label, + "projectId": canvas_context.project_id, + "assetFolderId": canvas_context.asset_folder_id, + "generationInputs": { + "artSpec": platform_art_asset_art_spec(options), + }, + "canvasCompletion": { + "title": options.asset_label, + "placeholder": external_canvas_placeholder(&options.aspect_ratio), + }, + }), + ) } else { - "spec" + ( + "/api/external/v1/editor/images/generations", + serde_json::json!({ + "prompt": generation_prompt, + "kind": generation_kind, + "aspectRatio": options.aspect_ratio, + "imageSize": options.image_size, + "assetKind": options.asset_kind, + "assetLabel": options.asset_label, + "projectId": canvas_context.project_id, + "assetFolderId": canvas_context.asset_folder_id, + "generationInputs": { + "artSpec": platform_art_asset_art_spec(options), + }, + "referenceImageSrcs": canonical_reference.clone().into_iter().collect::>(), + "canvasCompletion": { + "title": options.asset_label, + "placeholder": external_canvas_placeholder(&options.aspect_ratio), + }, + }), + ) }; let response = client - .post(format!( - "{}/api/external/v1/editor/images/generations", - api_base_url - )) + .post(format!("{api_base_url}{endpoint}")) .bearer_auth(&api_key) - .json(&serde_json::json!({ - "prompt": generation_prompt, - "kind": generation_kind, - "aspectRatio": options.aspect_ratio, - "imageSize": options.image_size, - "assetKind": options.asset_kind, - "assetLabel": options.asset_label, - "projectId": canvas_context.project_id, - "assetFolderId": canvas_context.asset_folder_id, - "generationInputs": { - "artSpec": platform_art_asset_art_spec(options), - }, - "canvasCompletion": { - "title": options.asset_label, - "placeholder": external_canvas_placeholder(&options.aspect_ratio), - }, - })) + .json(&request_body) .send() .await .map_err(|error| format!("请求平台图片生成失败:{error}"))?; @@ -484,12 +598,39 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at( .await .map_err(|error| format!("解析平台图片生成响应失败:{error}"))?; let generated = payload.get("data").unwrap_or(&payload); - let download = resolve_canvas_resource_download(&client, &api_base_url, &api_key, generated) - .await? - .ok_or_else(|| "平台图片生成响应缺少可下载图片".to_string())?; + if let Some(error) = platform_art_generation_postprocess_failure(generated) { + return Err(error); + } let null = serde_json::Value::Null; - let resource = generated.get("resource").unwrap_or(&null); - let asset = generated.get("asset").unwrap_or(&null); + let resource = if is_canonical_art_spritesheet { + generated.get("spritesheetResource").unwrap_or(&null) + } else { + generated.get("resource").unwrap_or(&null) + }; + let asset = if is_canonical_art_spritesheet { + generated.get("spritesheetAsset").unwrap_or(&null) + } else { + generated.get("asset").unwrap_or(&null) + }; + let download_source = if resource.is_object() { + resource + } else { + generated + }; + let download = + resolve_canvas_resource_download(&client, &api_base_url, &api_key, download_source) + .await? + .ok_or_else(|| "平台图片生成响应缺少可下载图片".to_string())?; + if is_canonical_art_spritesheet && !platform_art_spritesheet_has_transparent_pixels(&download) { + return Err( + "External Editor 返回的 art-spritesheet 没有真实透明像素,已拒绝把不透明源图登记为正式图集" + .to_string(), + ); + } + let slice_warning = generated + .get("sliceWarning") + .filter(|warning| !warning.is_null()) + .and_then(|warning| json_string_field(warning, "reason")); let resource_id = json_string_field(resource, "resourceId"); let task_id = json_string_field(generated, "taskId").or_else(|| json_string_field(resource, "taskId")); @@ -507,8 +648,11 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at( json_string_field(generated, "model").or_else(|| json_string_field(resource, "model")); let provider = json_string_field(generated, "provider") .or_else(|| json_string_field(resource, "provider")); - let source_hint = json_string_field(generated, "objectKey") - .or_else(|| json_string_field(generated, "imageSrc")); + let source_hint = json_string_field(resource, "objectKey") + .or_else(|| json_string_field(resource, "imageSrc")) + .or_else(|| json_string_field(generated, "objectKey")) + .or_else(|| json_string_field(generated, "imageSrc")) + .or_else(|| json_string_field(generated, "spritesheetImageSrc")); let extension = infer_file_extension(source_hint.as_deref(), &download.media_type).to_string(); Ok(PreparedPlatformArtAssetGeneration { requested_output_path, @@ -522,6 +666,10 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at( generated_prompt, model, provider, + slice_warning, + generation_route: endpoint.to_string(), + generation_kind: generation_kind.to_string(), + reference_resource_ids: canonical_reference.into_iter().collect(), extension, }) } @@ -594,6 +742,10 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( generated_prompt, model, provider, + slice_warning, + generation_route, + generation_kind, + reference_resource_ids, extension, } = prepared; let file_stem = resource_id @@ -743,6 +895,9 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( task_id: task_id.clone(), prompt: generated_prompt.clone(), model: model.clone(), + generation_route: Some(generation_route.clone()), + generation_kind: Some(generation_kind.clone()), + reference_resource_ids: reference_resource_ids.clone(), }, ) { Ok(registered) => registered, @@ -776,6 +931,10 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( "provider": provider.clone(), "assetFolderId": canvas_context.asset_folder_id, "canvasName": canvas_context.canvas_name, + "sliceWarning": slice_warning.clone(), + "generationRoute": generation_route, + "generationKind": generation_kind, + "referenceResourceIds": reference_resource_ids, }), )?; Ok(GeneratedPlatformArtAsset { @@ -784,31 +943,44 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( asset_object_id, task_id, model, + slice_warning, }) } pub(crate) fn platform_art_asset_art_spec( options: &PlatformArtAssetGenerationOptions, ) -> serde_json::Value { + if options.asset_kind == "icon-spec" { + return serde_json::json!({ + "assetType": "icon-spec", + "subject": "游戏角色、场景、UI 图标与反馈特效的统一视觉规范", + "style": "原创、正交展示、轮廓清楚、材质与光照一致的视觉规范板", + "palette": "统一主色、辅色、强调色和状态色,保证桌面端与移动端可读性", + "composition": "严格 1:1 规范板;分区展示玩家主体、目标物、场景地块、UI 图标、状态反馈与材质色板", + "format": format!("{} {}", options.aspect_ratio, options.image_size), + "constraints": "这是后续完整 UI 与透明图集共同引用的真实规范图;元素不得相互遮挡,不得做成完整游戏截图、海报或无结构概念画;必须原创,不得复刻现有游戏角色、Logo、贴图、标志性轮廓或受保护视觉语言", + "references": [], + }); + } if options.asset_kind == "ui-prototype" { return serde_json::json!({ "assetType": "ui", - "subject": "完整桌面端游戏 UI 原型,包含 HUD、卡牌控件、战场区和操作控件", + "subject": "严格依据当前项目玩法合同生成的完整游戏 UI 原型,包含状态 HUD、主要可玩区域、关键实体、操作控件、失败与重开流程", "style": "正视角、清晰分区、可指导 HTML/CSS 实现的高保真 UI/UX mockup", "palette": "与原创游戏主题一致,文字与控件对比清楚", - "composition": "严格 16:9 单屏界面;顶部资源与波次 HUD,左侧或顶部单位卡槽,中部战场网格,右侧敌人入口,底部或角落放置开始、暂停、重开和操作提示", + "composition": "严格 16:9 单屏界面;按当前玩法组织状态 HUD、主要可玩区域、目标或收集物说明、开始/移动/暂停/重开控件,并体现移动端触控布局", "format": format!("{} {}", options.aspect_ratio, options.image_size), - "constraints": "必须明显展示资源数值、单位卡牌、冷却/费用、波次进度、开始或暂停或重开控件和操作反馈;不得只生成无 HUD 的场景插画、战斗概念图、地图或宣传图;玩法类型只描述功能,不授权复刻现有作品,必须重建原创标题、资源名、单位名、角色轮廓与场景语言;不得复刻、翻译或近似改写现有游戏角色、单位名、Logo、贴图、标志性布局或受保护视觉语言", + "constraints": "不得假设项目属于塔防或补入玩法合同中不存在的卡牌、波次、敌人入口等结构;必须清楚展示当前玩法实际需要的信息、实体、操作、失败与重开状态;不得只生成无 HUD 的场景插画、概念图、地图或宣传图;必须重建原创标题、资源名、角色轮廓与场景语言,不得复刻、翻译或近似改写现有游戏角色、Logo、贴图、标志性布局或受保护视觉语言", "references": [], }); } serde_json::json!({ "assetType": "art", "subject": format!("{};使用项目原创命名和原创阵营设计", options.asset_label), - "style": "清晰可切分的原创 Web 游戏素材图集;以非拟人生态构装体、晶体、菌丝、雾气或器物轮廓建立独立视觉身份,避免现有塔防游戏的可识别角色语言", - "composition": format!("{} 游戏素材图集,按单位、敌人、地块和特效分区,留出清楚切分间距", options.aspect_ratio), + "style": "清晰可切分的原创 Web 游戏素材图集;严格沿用当前规范图的轮廓、材质、色板与光照,不预设塔防或其他固定玩法", + "composition": format!("{} 游戏素材图集,按玩家主体及状态、目标或收集物、障碍或场景元素、反馈特效分区,留出清楚切分间距", options.aspect_ratio), "format": format!("{} {}", options.aspect_ratio, options.image_size), - "constraints": "必须是可见的真实图片产物,不得用纯文本计划代替;禁止带脸向日葵、嘴状豌豆炮管、草坪横排、僵尸人形、现有游戏 Logo、贴图、图标式角色排布、标志性轮廓或近似配色;植物或生态主题不得直接画成大众熟知塔防角色的变体", + "constraints": "必须是可见的真实透明图片产物,不得用纯文本计划代替;只生成当前项目玩法合同需要的实体和反馈,不得擅自加入单位卡牌、波次敌人或其他玩法;禁止现有游戏 Logo、贴图、标志性角色轮廓、图标排布或近似配色", "references": [], }) } @@ -818,9 +990,15 @@ pub(crate) fn build_platform_art_asset_prompt( briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, ) -> String { + if options.asset_kind == "icon-spec" { + return format!( + "为这个 Web 小游戏生成一张 1:1 的统一视觉规范图,作为后续 UI 设计图和透明游戏图集的共同权威参考。规范板必须分区展示:玩家主体及其成长形态、核心目标或收集物、场景地块与障碍、HUD/操作图标、得分/受击/胜负反馈、主辅强调色与材质规则。所有元素使用一致的正交视角、轮廓、光照和原创视觉语言,留出清楚间距;不要生成完整游戏截图、海报、黑底图集或纯文字说明。玩法机制只用于理解功能,不授权复刻现有作品。\n\n项目视觉需求:{}", + truncate_prompt_context(prompt.trim()) + ); + } if options.asset_kind == "ui-prototype" { return format!( - "生成一张真正的游戏 UI/UX 原型图,不是场景概念图。画面必须是完整 16:9 桌面端单屏界面,明确可见:顶部资源数值与波次/状态 HUD;单位卡牌及费用、冷却状态;中部战场网格;右侧敌人来袭方向;开始、暂停、重开控件;基础操作提示和点击/资源不足等反馈。使用正视角、清晰分区和可读占位文字,使前端开发可直接据此拆分 HTML/CSS。禁止只画草地、角色和敌人的无 HUD 战斗画面,禁止做海报、地图或纯插画。玩法类型和机制词只用于理解功能,不代表允许复刻现有作品;生成前必须把输入中可识别的现有游戏标题、资源、角色或单位名转换为新的原创命名,并重建角色轮廓、配色、场景材质和界面视觉语言。不得在画面中渲染 Sunflower、Peashooter、向日葵、豌豆射手、僵尸等知名塔防元素,也不得使用现有游戏 Logo、贴图、标志性布局或受保护视觉语言。\n\n项目 UI 需求:{}", + "生成一张真正的游戏 UI/UX 原型图,不是场景概念图。必须严格从下方当前项目 UI 需求提取玩法,不得自行假设它属于塔防或加入需求中不存在的单位卡牌、费用、波次、敌人入口等结构。画面是完整 16:9 桌面端单屏界面,并同时明确移动端重排意图;清楚呈现当前玩法所需的分数/资源/生命/局内状态 HUD、主要可玩区域、玩家与目标/收集物/危险物、开始和主要操作、失败状态与重新开始、键盘和触控提示。使用正视角、清晰分区和可读占位文字,使前端开发可直接据此拆分 HTML/CSS。禁止只画无 HUD 场景、概念图、地图、海报或纯插画。玩法机制只用于理解功能,不授权复刻现有作品;必须采用项目已经确定的原创命名、角色轮廓、配色、场景材质和界面视觉语言,不得使用现有游戏 Logo、贴图、标志性布局或受保护视觉语言。\n\n当前项目 UI 需求:{}", truncate_prompt_context(prompt.trim()) ); } @@ -834,7 +1012,7 @@ pub(crate) fn build_platform_art_asset_prompt( .filter(|markdown| !markdown.is_empty()) .unwrap_or("需要一张可直接用于 Web 小游戏首版原型的核心美术素材。"); format!( - "为 Web 小游戏首版原型生成一张可切分的原创核心美术素材图集,适合放入本地 assets 并被 canvas 游戏直接引用。必须先从美术 brief 中提取项目自己的标题、阵营、资源和单位名,并据此创造非拟人、非标志性的新轮廓。即使用户提到植物塔防或相似机制,也禁止画带脸向日葵、嘴状豌豆炮管、草坪横排、僵尸人形、现有游戏 Logo、贴图、标志性角色轮廓、图标式排布或近似配色;不得把知名角色改色、换名或机械化后继续使用。优先采用晶体、菌丝、雾气、陶质器物、潮汐生态构装体等与项目设定一致的独立形态,按守卫、敌人、地块和特效分区并留出清楚切分间距。\n用户需求:{}\n美术资产 brief:{}", + "为 Web 小游戏首版原型生成一张可切分的原创透明核心美术素材图集,适合放入本地 assets 并被游戏直接引用。严格从用户需求和美术 brief 提取当前项目自己的标题、玩法实体、目标物、收集物、障碍、状态与反馈;不得自行假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌等内容。所有元素沿用当前规范图的轮廓、配色、材质和光照,分区排布并留出清楚切分间距。禁止现有游戏 Logo、贴图、标志性角色轮廓、图标式排布或近似配色,不得把知名角色改色、换名或机械化后继续使用。\n用户需求:{}\n美术资产 brief:{}", truncate_inline(prompt, 240), truncate_prompt_context(art_asset_brief) ) @@ -843,6 +1021,61 @@ pub(crate) fn build_platform_art_asset_prompt( #[cfg(test)] mod canvas_generation_tests { use super::*; + use image::{codecs::png::PngEncoder, ColorType, ImageEncoder}; + + fn rgba_test_png(alpha: u8) -> CanvasResourceDownload { + let mut bytes = Vec::new(); + PngEncoder::new(&mut bytes) + .write_image(&[12, 34, 56, alpha], 1, 1, ColorType::Rgba8.into()) + .expect("encode RGBA fixture"); + CanvasResourceDownload { + bytes, + media_type: "image/png".to_string(), + } + } + + #[test] + fn canonical_art_spritesheet_requires_real_transparent_pixels() { + assert!(platform_art_spritesheet_has_transparent_pixels( + &rgba_test_png(0) + )); + assert!(!platform_art_spritesheet_has_transparent_pixels( + &rgba_test_png(u8::MAX) + )); + } + + #[test] + fn derived_visual_asset_waits_for_registered_art_spec() { + let temporary = tempfile::tempdir().expect("create art dependency project"); + let root = temporary.path(); + init_local_game_project_at(root, "art-dependency", "美术依赖测试").expect("init project"); + + let error = canonical_art_spec_reference_at(root, "canvas-project") + .expect_err("missing art spec must block derived visual generation"); + + assert!(error.contains("先完成并登记 assets/art-spec.png")); + assert!(error.contains("等待 art-director")); + } + + #[test] + fn postprocess_warning_wins_over_slice_warning_and_preserves_reason() { + let payload = serde_json::json!({ + "warning": { + "code": "postprocess-failed-source-preserved", + "reason": "抠图服务暂不可用,已保留源图。" + }, + "sliceWarning": { + "code": "insufficient-connected-components", + "reason": "不应覆盖通用告警" + } + }); + let error = platform_art_generation_postprocess_failure(&payload) + .expect("general warning must be authoritative"); + assert!(error.contains("postprocess-failed-source-preserved")); + assert!(error.contains("抠图服务暂不可用,已保留源图。")); + assert!(error.contains("不得登记为透明图集或自动重试")); + assert!(!error.contains("不应覆盖通用告警")); + } fn replacement_options() -> PlatformArtAssetGenerationOptions { PlatformArtAssetGenerationOptions { @@ -878,6 +1111,10 @@ mod canvas_generation_tests { generated_prompt: Some("原创替换图集".to_string()), model: Some("test-image-model".to_string()), provider: Some("test-provider".to_string()), + slice_warning: None, + generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), + generation_kind: "icon-spritesheet".to_string(), + reference_resource_ids: vec!["art-spec-resource".to_string()], extension: "png".to_string(), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index 09fef8c6c..40940b5fb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs @@ -489,7 +489,31 @@ fn game_creator_design_foundation_tool_plan_prompt( ); } format!( - "{prompt}\n\n你负责玩法规格与界面原型交付。玩法类型和机制描述不代表用户授权复刻现有游戏;必须先为项目创造原创标题、阵营、资源、单位名称与视觉语言,并在 memory/project.md、game/game_design.md 和图片提示中保持一致。不得沿用或近似改写 Sunflower、Peashooter、向日葵、豌豆射手、僵尸等知名塔防单位、角色、Logo、界面术语或受保护视觉语言。文本策划只是中间结果;最终必须调用 canvas.asset_generate 生成 16:9、2K 横屏界面原型图并登记到 assets/ui-prototype.png,assetKind=ui-prototype、assetLabel=游戏横屏界面原型图、replaceExisting=false。canvas.asset_generate 成功动作本身就是当前 revision 的验证。已有同路径画布资产时先用 asset.list 核对登记,再在当前 run 对且只对 assets/ui-prototype.png 调用 image.inspect;检查已通过时不得删除、重复生成或再次扣费。只有 ui-prototype.v1 的 resourceBar、unitCardTray、battlefieldGrid、enemyEntryDirection、waveStatus、primaryControls、implementationClarity、originalTheme 八项全部通过才可完成。纯场景图、概念图、地图、海报或只有角色与箭头的战斗画面都不是 UI 原型。视觉检查未通过时不得提交最终回复;只有任务正文明确标识这是带 repairOfDelegationId 的唯一返工轮时,才可用明确修正未通过项且保持原创命名的 prompt、固定输出合同和 replaceExisting=true 原位替换旧候选;普通任务不得覆盖,任何任务都不得先删除正式图片。图片生成未配置、待确认或失败时同样不得提交最终回复,也不得把计划写完当成 completed。{role_boundary}" + "{prompt}\n\n你负责玩法规格与界面原型交付。玩法类型和机制描述不代表用户授权复刻现有游戏;必须先为项目创造原创标题、实体、资源、目标名称与视觉语言,并在 memory/project.md、game/game_design.md 和图片提示中保持一致。不得沿用或近似改写知名游戏单位、角色、Logo、界面术语或受保护视觉语言。文本策划只是中间结果;最终必须先用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,再调用 canvas.asset_generate 生成 16:9、2K 横屏界面原型图并登记到 assets/ui-prototype.png,assetKind=ui-prototype、assetLabel=游戏横屏界面原型图、replaceExisting=false。图片 prompt 必须逐项继承当前任务和 game/game_design.md 的真实玩法、HUD、可玩区域、关键实体、主要操作、失败/重开与移动端触控要求;不得假设为塔防或补入合同中不存在的单位卡牌、费用、波次、敌人入口等结构。Runtime 固定把规范图资源作为 referenceImageSrcs 第一项,调用 External Editor v1 的 POST /api/external/v1/editor/images/generations(kind=ui-design);不得误用 POST /api/external/v1/editor/ui-designs/assets/extractions,后者只用于从已有且带标注的 UI 设计图提取独立透明 UI 素材。缺少规范图时必须等待 art-director 依赖并如实阻塞,不得回退为无规范参考的普通生图。canvas.asset_generate 成功动作本身就是当前 revision 的验证。已有同路径画布资产时先核对登记,再在当前 run 对且只对 assets/ui-prototype.png 调用 image.inspect;检查已通过时不得重复生成或再次扣费。只有 ui-prototype.v2 的 informationHud、gameplaySurface、objectiveEntities、primaryControls、failureRestartFlow、responsiveLayout、implementationClarity、originalTheme 八项检查全部通过才可完成。纯场景图、概念图、地图、海报或只有角色而没有可玩界面的画面都不是 UI 原型。视觉检查未通过时不得提交最终回复;只有任务正文明确标识这是带 repairOfDelegationId 的唯一返工轮时,才可使用固定输出合同和 replaceExisting=true 原位替换旧候选;不得先删除正式图片。图片生成未配置、待确认或失败时同样不得提交最终回复,也不得把计划写完当成 completed。{role_boundary}" + ) +} + +fn game_creator_art_director_tool_plan_prompt( + prompt: &str, + editor_api_key_is_configured: bool, +) -> String { + if !editor_api_key_is_configured { + return format!("{prompt}\n\n你负责确定原创视觉方向。当前未配置 External Editor API Key,只完成正式 director 文档,不调用 canvas.asset_generate,也不伪造 assets/art-spec.png。"); + } + format!("{prompt}\n\n你负责生成项目唯一的统一视觉规范图。视觉方向文档只是中间结果;最终必须调用 canvas.asset_generate,以固定合同 outputPath=assets/art-spec.png、aspectRatio=1:1、imageSize=1K、assetKind=icon-spec、assetLabel=游戏统一视觉规范图、replaceExisting=false 生成真实图片。Runtime 固定调用 External Editor v1 的 POST /api/external/v1/editor/images/generations(kind=spec),并把结果同时登记到同名画布、素材库和项目 manifest。规范图必须覆盖玩家主体、目标物、地块、UI 图标、状态反馈、色板与材质规则,作为后续 UI 和透明图集共同引用的权威资源;不得用 generationInputs.artSpec JSON、纯文本计划、完整游戏截图、海报或普通黑底图集冒充。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可设置 replaceExisting=true 原位替换。生成失败或缺少 resourceId 时不得提交最终回复,也不得把计划写完当成 completed。") +} + +fn game_creator_art_asset_plan_tool_plan_prompt( + prompt: &str, + editor_api_key_is_configured: bool, +) -> String { + if !editor_api_key_is_configured { + return format!( + "{prompt}\n\n你负责首版美术资产清单交付。当前未配置 External Editor API Key,因此本轮必须写入可解析的 assets/manifest.art.json,记录所需素材、用途、推荐规格和当前未生成状态;不调用 canvas.asset_generate,也不伪造 assets/art-spritesheet.png。完成清单并通过当前 revision 的验证后即可交付,不得编辑 game/index.html。" + ); + } + format!( + "{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png,固定使用 1:1、1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材、replaceExisting=false,并写入可解析的 assets/manifest.art.json。调用前必须用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,并依据当前任务、game/game_design.md 与 manifest 逐项说明真实需要的玩家主体及朝向/状态、目标或收集物、障碍/场景元素和反馈特效,由 Runtime 形成 iconDescriptions;不得假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌。Runtime 固定以规范图的权威 resourceId 作为 referenceImageSrc,调用 POST /api/external/v1/editor/icon-spritesheets/generations,并用 screenColor=auto 完成透明后处理;不得把 UI 原型、Data URL、Blob URL、本地路径或结构化 JSON 冒充规范图引用,不得回退普通生图或 UI extraction。缺少规范图时必须等待 art-director 依赖并如实阻塞。成功后回读 observation 与 asset.list,核对服务端返回的透明 spritesheet、真实 alpha、warning 和 sliceWarning。warning.code=postprocess-failed-source-preserved 时没有透明图集,不得登记、验收或自动重试;仅 sliceWarning 时可保留完整透明图集,但不得声称独立切片已生成。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。" ) } @@ -503,26 +527,25 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt_for_agent( editor_api_key_is_configured(), ); } + if agent_id == "art-director" { + return game_creator_art_director_tool_plan_prompt(&prompt, editor_api_key_is_configured()); + } if agent_id == "art-asset-plan" { - if !editor_api_key_is_configured() { - return format!( - "{prompt}\n\n你负责首版美术资产清单交付。当前未配置 External Editor API Key,因此本轮必须写入可解析的 assets/manifest.art.json,记录所需素材、用途、推荐规格和当前未生成状态;不调用 canvas.asset_generate,也不伪造 assets/art-spritesheet.png。完成清单并通过当前 revision 的验证后即可交付,不得编辑 game/index.html。" - ); - } - return format!( - "{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成核心素材图并登记到 assets/art-spritesheet.png,aspectRatio=1:1、imageSize=1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材;普通首轮 replaceExisting=false,并写入可解析的 assets/manifest.art.json,记录素材路径、用途、来源和当前首版范围。图片提示必须明确排除标志性现有塔防角色轮廓:不得出现带脸向日葵、嘴状豌豆炮管、草坪横排、僵尸人形、现有游戏图标式排布或近似配色;应围绕项目已确定的原创名称设计非拟人、非标志性的新轮廓。成功执行 canvas.asset_generate 后,该动作本身就是当前 revision 的验证;随后调用 asset.list 回读并核对 manifest 已登记该 image/* 资产,再核对美术清单与真实文件一致后回复。已有同路径有效画布资产时不得删除、重复生成或扣费,只补齐和校验清单;非 UI spritesheet 不需要调用 image.inspect 做主观返工。只有任务正文明确标识这是带 repairOfDelegationId 的唯一返工轮时,才可沿用固定输出合同并设置 replaceExisting=true 原位替换不合格图片;不得先删除固定正式产物,也不得改用其他 outputPath。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认或失败时不得提交最终回复,也不得把计划写完当成 completed。" + return game_creator_art_asset_plan_tool_plan_prompt( + &prompt, + editor_api_key_is_configured(), ); } if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { return prompt; } let visual_delivery_contract = if editor_api_key_is_configured() { - "design-foundation 是图片产物型任务,expectedArtifacts 必须包含 assets/ui-prototype.png;art-asset-plan 也是图片产物型任务,expectedArtifacts 必须同时包含 assets/manifest.art.json 与 assets/art-spritesheet.png。二者都不能用空 expectedArtifacts 或纯文本回执代替图片" + "art-director 是规范图产物型任务,expectedArtifacts 必须包含 assets/art-spec.png;design-foundation 是图片产物型任务,expectedArtifacts 必须包含 assets/ui-prototype.png;art-asset-plan 也是图片产物型任务,负责透明图集,expectedArtifacts 必须同时包含 assets/manifest.art.json 与 assets/art-spritesheet.png。三者都不能用空 expectedArtifacts 或纯文本回执代替图片" } else { - "当前未配置 External Editor API Key,design-foundation 必须交付 memory/project.md 与 game/game_design.md,art-asset-plan 必须交付 assets/manifest.art.json;不得要求调用 canvas.asset_generate,也不得伪造 assets/ui-prototype.png 或 assets/art-spritesheet.png" + "当前未配置 External Editor API Key,art-director 只交付视觉方向文档,design-foundation 必须交付 memory/project.md 与 game/game_design.md,art-asset-plan 必须交付 assets/manifest.art.json;不得要求调用 canvas.asset_generate,也不得伪造 assets/art-spec.png、assets/ui-prototype.png 或 assets/art-spritesheet.png" }; let prompt = format!( - "{prompt}\n\n你当前是项目唯一面向用户的 Project Supervisor,并拥有最终回复权。每一轮都必须把用户原始目标视为最高层业务目标,专业 Agent 回执只能补充证据,不能把回执内容改写成新目标。总控不能替代已有专业角色完成其领域交付:只要仓库目标同时包含两个以上互不依赖的专业方向,就必须自行查看静态角色目录,选择最匹配的不同专业 Agent,并在同一个 native planning 批次用带 acceptanceCriteria 和 expectedArtifacts 的 agent.delegate 发起委派,让这些方向并行;用户不需要点名 Agent、指定数量或提醒并行。{visual_delivery_contract}。只有没有匹配专业角色、纯协调工作或一两步轻量读取时才由总控直接处理。互不重叠的临时并行检查通过 agent.spawn_isolated 分派;当同一目标同时需要边界清晰的专业委派和互不重叠的临时检查时,必须把两类协作放进同一个 native planning 批次一次性提交,不能拆成先后轮次。提交首个协作批次前,先分别完整枚举当前目标中已经生效的长期专业交付和临时隔离检查;两类都非空时,遗漏任一类的批次都不得提交。仓库合同明确把临时检查分为先行和后续独立阶段时,首批只提交当前已经生效的检查;先行组 ready 后优先创建刚生效的后续组,所有必要组创建前不得调用 agent.run_status 认领先行组,全部 ready 后用一次 agent.run_status 收齐。已有委派未收束时不要重复委派。需要等待专业 Agent 时返回空 response,让 Runtime 的 delegate/all-join 完成屏障保持同一父 run;取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。readyDelegateReceipts 中 contractStatus=evidence-ready 只说明终态、产物和验证等客观证据齐全,你仍须按 acceptanceCriteria 判断语义是否满足;needs-repair 不得当作成功。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。只在所有必要回执已认领、manifest 正式任务图已经完成、所有必要返工也已认领、项目副作用已验证且没有待确认动作或待回答请求时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。" + "{prompt}\n\n你当前是项目唯一面向用户的 Project Supervisor,并拥有最终回复权。每一轮都必须把用户原始目标视为最高层业务目标,专业 Agent 回执只能补充证据,不能把回执内容改写成新目标。总控不能替代已有专业角色完成其领域交付:只要仓库目标同时包含两个以上互不依赖的专业方向,就必须自行查看静态角色目录,选择最匹配的不同专业 Agent,并在同一个 native planning 批次用带 acceptanceCriteria 和 expectedArtifacts 的 agent.delegate 发起委派,让这些方向并行;用户不需要点名 Agent、指定数量或提醒并行。{visual_delivery_contract}。视觉产物始终按 owner 隔离:art-director 只声明 assets/art-spec.png,design-foundation 只声明 assets/ui-prototype.png,art-asset-plan 只声明 assets/art-spritesheet.png;不得把 UI 与图集合并交给 art-director。旧派生图需要原位替换时,先在同一批次分别交给 design-foundation 与 art-asset-plan 建立精确原合同并取得 needs-repair,认领后再在同一批次分别发起各自唯一、完全继承原合同的 repair,两个 repair 共同构成一个显式视觉返工阶段。只有没有匹配专业角色、纯协调工作或一两步轻量读取时才由总控直接处理。互不重叠的临时并行检查通过 agent.spawn_isolated 分派;当同一目标同时需要边界清晰的专业委派和互不重叠的临时检查时,必须把两类协作放进同一个 native planning 批次一次性提交,不能拆成先后轮次。提交首个协作批次前,先分别完整枚举当前目标中已经生效的长期专业交付和临时隔离检查;两类都非空时,遗漏任一类的批次都不得提交。仓库合同明确把临时检查分为先行和后续独立阶段时,首批只提交当前已经生效的检查;先行组 ready 后优先创建刚生效的后续组,所有必要组创建前不得调用 agent.run_status 认领先行组,全部 ready 后用一次 agent.run_status 收齐。已有委派未收束时不要重复委派。需要等待专业 Agent 时返回空 response,让 Runtime 的 delegate/all-join 完成屏障保持同一父 run;取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。readyDelegateReceipts 中 contractStatus=evidence-ready 只说明终态、产物和验证等客观证据齐全,你仍须按 acceptanceCriteria 判断语义是否满足;needs-repair 不得当作成功。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。只在所有必要回执已认领、manifest 正式任务图已经完成、所有必要返工也已认领、项目副作用已验证且没有待确认动作或待回答请求时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。" ); let prompt = format!( "{prompt}\n\n当 collaboration policy 的 minIsolatedGroupsBeforeClaim 大于 0 时,首次 agent.run_status 认领前必须已经建立且 ready 的 isolated group 数量达到该值;不足时 Runtime 会在写 claim 或改 delivery 前失败关闭。已有 durable claim 的恢复不受此门禁影响。只读任务的 writeScopes 也必须填写且不能留空,只能覆盖其 expectedArtifacts 所在的最小目录/**,不能扩大到 sibling 或共同父目录。" @@ -672,9 +695,44 @@ mod tests { let with_canvas = game_creator_design_foundation_tool_plan_prompt("shared runtime contract", true); - assert!(with_canvas.contains("必须调用 canvas.asset_generate")); + assert!(with_canvas.contains("再调用 canvas.asset_generate")); + assert!(with_canvas.contains("assets/art-spec.png")); + assert!(with_canvas.contains("referenceImageSrcs 第一项")); assert!(with_canvas.contains("assets/ui-prototype.png")); assert!(with_canvas.contains("调用 image.inspect")); + assert!(with_canvas.contains("ui-prototype.v2")); + assert!(with_canvas.contains("informationHud")); + assert!(with_canvas.contains("failureRestartFlow")); + assert!(with_canvas.contains("不得假设为塔防")); + assert!(with_canvas + .contains("POST /api/external/v1/editor/images/generations(kind=ui-design)")); + assert!(with_canvas + .contains("不得误用 POST /api/external/v1/editor/ui-designs/assets/extractions")); + } + + #[test] + fn agent_prompt_art_asset_plan_uses_fixed_transparent_spritesheet_route_and_warnings() { + let without_canvas = + game_creator_art_asset_plan_tool_plan_prompt("shared runtime contract", false); + assert!(without_canvas.contains("不调用 canvas.asset_generate")); + assert!(without_canvas.contains("不伪造 assets/art-spritesheet.png")); + + let with_canvas = + game_creator_art_asset_plan_tool_plan_prompt("shared runtime contract", true); + assert!(with_canvas.contains("用 asset.list 确认 assets/art-spec.png 已登记")); + assert!(with_canvas.contains("由 Runtime 形成 iconDescriptions")); + assert!(with_canvas.contains("玩家主体及朝向/状态")); + assert!(with_canvas.contains("不得假设为塔防")); + assert!(with_canvas.contains("权威 resourceId 作为 referenceImageSrc")); + assert!(with_canvas.contains("POST /api/external/v1/editor/icon-spritesheets/generations")); + assert!(with_canvas.contains("不得回退普通生图或 UI extraction")); + assert!(with_canvas.contains("screenColor=auto")); + assert!(with_canvas.contains("缺少规范图时必须等待 art-director")); + assert!(with_canvas.contains("不得回退普通生图")); + assert!(with_canvas.contains("真实 alpha")); + assert!(with_canvas.contains("warning.code=postprocess-failed-source-preserved")); + assert!(with_canvas.contains("不得登记、验收或自动重试")); + assert!(with_canvas.contains("仅 sliceWarning")); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs index 45b158597..fbb15dd19 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs @@ -44,6 +44,8 @@ pub(in crate::agent) use run_status_observation::*; pub(in crate::agent) use structured_plan::*; pub(in crate::agent) use tool_plan_protocol::*; +#[cfg(test)] +pub(crate) use action_audit::agent_runtime_action_receipt_safe_detail_for_owner_for_test; pub(crate) use action_audit::{ agent_runtime_git_commit_safe_detail_value, agent_runtime_tool_action_fingerprint, agent_runtime_tool_action_id, agent_runtime_tool_action_input_summary, @@ -86,6 +88,7 @@ pub(crate) use pending_confirmation_ledger::{ write_game_creator_agent_runtime_tool_confirmation, }; pub(crate) use project_gates::{ + acquire_game_creator_agent_provider_plan_project_write_lock_with_wait, acquire_game_creator_agent_runtime_project_write_lock_with_wait, advance_agent_runtime_project_revision_locked, agent_runtime_observation_advances_project_revision, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs index 274bd3686..cd1e96dd7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs @@ -179,6 +179,21 @@ pub(in crate::agent) fn agent_runtime_action_receipt_safe_detail_for_owner( ) } +#[cfg(test)] +pub(crate) fn agent_runtime_action_receipt_safe_detail_for_owner_for_test( + root: &Path, + receipt_agent_id: &str, + receipt_run_id: &str, + observation: &AgentRuntimeToolObservation, +) -> Option { + agent_runtime_action_receipt_safe_detail_for_owner( + root, + receipt_agent_id, + receipt_run_id, + observation, + ) +} + fn agent_runtime_action_receipt_safe_detail_with_owner( root: &Path, receipt_owner: Option<(&str, &str)>, @@ -279,27 +294,58 @@ fn agent_runtime_action_receipt_safe_detail_with_owner( let validation_profile = detail .get("validationProfile") .and_then(serde_json::Value::as_str) - .filter(|value| *value == AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE); + .filter(|value| { + matches!( + *value, + AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE + | AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE + ) + }); let (passed, checks, issues) = if validation_profile.is_some() { if inspection_kind != Some(AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND) { return None; } let passed = detail.get("passed")?.as_bool()?; - let assessment = AgentRuntimeUiPrototypeAssessment { - checks: serde_json::from_value(detail.get("checks")?.clone()).ok()?, - issues: serde_json::from_value(detail.get("issues")?.clone()).ok()?, - summary: detail.get("conclusion")?.as_str()?.to_string(), + if validation_profile == Some(AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE) { + let assessment = AgentRuntimeUiPrototypeAssessment { + checks: serde_json::from_value(detail.get("checks")?.clone()).ok()?, + issues: serde_json::from_value(detail.get("issues")?.clone()).ok()?, + summary: detail.get("conclusion")?.as_str()?.to_string(), + } + .validate() + .ok()?; + if passed != assessment.passed() || passed != (observation.status == "ok") { + return None; + } + ( + Some(passed), + Some(serde_json::to_value(&assessment.checks).ok()?), + Some(serde_json::to_value(&assessment.issues).ok()?), + ) + } else { + let checks = serde_json::from_value::( + detail.get("checks")?.clone(), + ) + .ok()?; + let issues = + serde_json::from_value::>(detail.get("issues")?.clone()).ok()?; + if issues.len() > 8 + || issues.iter().any(|issue| { + issue.trim().is_empty() + || issue.chars().count() > 160 + || sanitize_agent_runtime_text(issue, 160) != *issue + }) + || passed != (checks.all_passed() && issues.is_empty()) + || passed != (observation.status == "ok") + { + return None; + } + ( + Some(passed), + Some(serde_json::to_value(&checks).ok()?), + Some(serde_json::to_value(&issues).ok()?), + ) } - .validate() - .ok()?; - if passed != assessment.passed() || passed != (observation.status == "ok") { - return None; - } - ( - Some(passed), - Some(serde_json::to_value(&assessment.checks).ok()?), - Some(serde_json::to_value(&assessment.issues).ok()?), - ) } else { if inspection_kind.is_some() || !matches!(detail.get("passed"), None | Some(serde_json::Value::Null)) 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 feb008e4d..eaee84273 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 @@ -264,6 +264,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_ ) -> Result<(), String> { let mut code_prototype = None; let mut quality_review = None; + let mut art_director = None; let mut art_asset_plan = None; for action in &plan.actions { let Some(input) = autonomous_initial_delegate_input(action)? else { @@ -285,6 +286,7 @@ 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, + "art-director" => &mut art_director, "art-asset-plan" => &mut art_asset_plan, _ => continue, }; @@ -348,6 +350,31 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_ "首批 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( @@ -1495,10 +1522,38 @@ mod tests { .expect("resume first manifest task fixture"); for (local_path, kind) in [ + ("assets/art-spec.png", "icon-spec"), ("assets/ui-prototype.png", "ui-prototype"), ("assets/art-spritesheet.png", "art-spritesheet"), ] { - std::fs::write(root.join(local_path), b"visual fixture").expect("write visual fixture"); + let (generation_route, generation_kind, reference_resource_ids) = match kind { + "icon-spec" => ( + "/api/external/v1/editor/images/generations", + "spec", + Vec::new(), + ), + "ui-prototype" => ( + "/api/external/v1/editor/images/generations", + "ui-design", + vec!["resource-icon-spec".to_string()], + ), + _ => ( + "/api/external/v1/editor/icon-spritesheets/generations", + "icon-spritesheet", + vec!["resource-icon-spec".to_string()], + ), + }; + 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 visual fixture"); + bytes + } else { + b"\x89PNG\r\n\x1a\nfixture".to_vec() + }; + std::fs::write(root.join(local_path), bytes).expect("write visual fixture"); register_local_asset_at( &root, local_path, @@ -1513,6 +1568,9 @@ mod tests { task_id: Some(format!("task-{kind}")), prompt: Some("测试视觉资产".to_string()), model: Some("gpt-image-2".to_string()), + generation_route: Some(generation_route.to_string()), + generation_kind: Some(generation_kind.to_string()), + reference_resource_ids, }, ) .expect("register visual fixture"); 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 41f021bd6..18fc74e29 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 @@ -857,13 +857,13 @@ pub(in crate::agent) fn ui_prototype_visual_inspection_blocker_detail_at_locked( return Ok(None); } Ok(Some(format!( - "expectedPath={expected_path} · resourceBar={} · unitCardTray={} · battlefieldGrid={} · enemyEntryDirection={} · waveStatus={} · primaryControls={} · implementationClarity={} · originalTheme={} · issues={}", - assessment.checks.resource_bar, - assessment.checks.unit_card_tray, - assessment.checks.battlefield_grid, - assessment.checks.enemy_entry_direction, - assessment.checks.wave_status, + "expectedPath={expected_path} · informationHud={} · gameplaySurface={} · objectiveEntities={} · primaryControls={} · failureRestartFlow={} · responsiveLayout={} · implementationClarity={} · originalTheme={} · issues={}", + assessment.checks.information_hud, + assessment.checks.gameplay_surface, + assessment.checks.objective_entities, assessment.checks.primary_controls, + assessment.checks.failure_restart_flow, + assessment.checks.responsive_layout, assessment.checks.implementation_clarity, assessment.checks.original_theme, assessment.issues.join(";"), @@ -879,6 +879,7 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked( return None; } let (expected_path, expected_kind, label) = match agent_id { + "art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"), "design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"), "art-asset-plan" => ( "assets/art-spritesheet.png", @@ -898,23 +899,15 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked( }); } }; - let registered = manifest.assets.iter().any(|asset| { - asset.local_path == expected_path - && asset.kind == expected_kind - && asset.media_type.starts_with("image/") - && asset.source.kind == GameCreationAppAssetSourceKind::Canvas - && resolve_local_project_path(root, &asset.local_path) - .ok() - .is_some_and(|path| path.is_file()) - }); - if !registered { + if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, agent_id) { return Some(AgentRuntimeToolObservation { tool: "runtime.visual_asset".to_string(), status: "blocked".to_string(), - summary: format!("{label}尚未生成并登记,不能完成任务"), + summary: format!("{label}尚未按正式视觉流程生成并登记,不能完成任务"), detail: Some(format!( - "expectedPath={expected_path} · expectedKind={expected_kind} · editorApiKeyConfigured={}", - editor_api_key_is_configured() + "expectedPath={expected_path} · expectedKind={expected_kind} · editorApiKeyConfigured={} · reason={}", + editor_api_key_is_configured(), + redact_agent_runtime_project_paths(root, &error, 300), )), }); } @@ -1318,7 +1311,7 @@ pub(crate) fn acquire_game_creator_agent_runtime_project_write_lock_with_wait( root: &Path, command_id: &str, ) -> Result { - const MAX_ATTEMPTS: usize = 200; + const MAX_ATTEMPTS: usize = 2_000; for attempt in 0..MAX_ATTEMPTS { match acquire_project_write_lock(root, command_id) { Err(error) @@ -1332,3 +1325,10 @@ pub(crate) fn acquire_game_creator_agent_runtime_project_write_lock_with_wait( } unreachable!("project write lock retry loop always returns") } + +pub(crate) fn acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( + root: &Path, + command_id: &str, +) -> Result { + acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, command_id) +} 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 4c3285ec7..73557a6ed 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 @@ -53,7 +53,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at agent_runtime_run_profile_identity_at(root, agent_id, run_id, None, None)?; let mcp_catalog = read_game_creator_mcp_catalog_at(root).await?; let mut built_request = { - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( root, "runtime.provider_request.build.tool_plan", )?; @@ -103,7 +103,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at } } built_request = { - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( root, "runtime.provider_request.rebuild.tool_plan", )?; @@ -139,7 +139,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at .map(|sidecar| context_compaction_result(sidecar, true)); } let provider_snapshot = { - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( root, "runtime.provider_request.capture.tool_plan", )?; @@ -733,7 +733,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-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本次修复的原生工具目录只保留首批协作工具。必须在同一响应一次性建立完整首批合同: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,不要代码围栏。" ) } else { format!( 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 bc17d823c..ccba17a4f 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 @@ -768,6 +768,101 @@ fn response_stream_committed_checkpoint_recovers_by_idempotent_cleanup() { .is_none()); } +#[test] +fn completed_orphan_finalization_cleanup_keeps_newer_terminal_run() { + let (project, state, response_revision, snapshot) = + response_stream_fixture("response-stream-completed-orphan-run"); + let root = project.path(); + let response = "旧 run 已完成,只残留幂等清理 journal。"; + write_game_creator_agent_runtime_response_stream_ready_at( + root, + &snapshot, + response_revision, + response, + Some("stop"), + ) + .expect("write orphan ready stream"); + let outcome = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( + root, + state.clone(), + response, + response_revision, + &[], + |checkpoint| { + if checkpoint == AgentRuntimeFinalizationCheckpoint::ResponseStreamCommitted { + Err("injected-orphan-after-response-stream-committed".to_string()) + } else { + Ok(()) + } + }, + ) + .expect("leave completed orphan finalization"); + assert!(matches!( + outcome, + AgentBackgroundFinalizationOutcome::Pending(_) + )); + assert!(read_game_creator_agent_runtime_finalization_journal( + root, + &state.agent_id, + &state.run_id, + ) + .expect("read retained orphan journal") + .is_some()); + let mut stale_stream = + read_game_creator_agent_runtime_response_stream_at(root, &state.agent_id, &state.run_id) + .expect("read committed orphan stream") + .expect("committed orphan stream exists"); + stale_stream.status = AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY.to_string(); + stale_stream.request_slot = "final-reply-stale-slot".to_string(); + stale_stream.response_revision = response_revision.saturating_sub(1); + stale_stream.accumulated_text = "上一版已过期回复".to_string(); + write_game_creator_agent_runtime_response_stream_at(root, &stale_stream) + .expect("replace committed stream with stale ready residue"); + + let newer_run_id = "response-stream-newer-cancelled-run"; + start_game_creator_agent_runtime_task_at( + root, + &state.agent_id, + "更新后的下一轮任务", + newer_run_id, + "agent-background-task", + "等待取消", + vec!["保留新 run 身份".to_string()], + ) + .expect("start newer run"); + cancel_game_creator_agent_runtime_task_at(root, &state.agent_id, newer_run_id) + .expect("cancel newer run"); + + assert_eq!( + cleanup_game_creator_agent_runtime_completed_finalizations_at(root) + .expect("clean completed orphan finalization"), + 1 + ); + assert!(read_game_creator_agent_runtime_finalization_journal( + root, + &state.agent_id, + &state.run_id, + ) + .expect("read cleaned orphan journal") + .is_none()); + let repaired_stream = + read_game_creator_agent_runtime_response_stream_at(root, &state.agent_id, &state.run_id) + .expect("read repaired orphan stream") + .expect("repaired orphan stream exists"); + assert_eq!( + repaired_stream.status, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED + ); + assert_eq!(repaired_stream.accumulated_text, response); + assert_eq!(repaired_stream.response_revision, response_revision); + let current = read_game_creator_agent_runtime_at(root, &state.agent_id) + .expect("read newer runtime after orphan cleanup") + .state; + assert_eq!(current.run_id, newer_run_id); + assert_eq!(current.status, "cancelled"); + assert_eq!(current.phase, "cancelled"); +} + #[tokio::test] async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_response() { let (project, state, _response_revision, snapshot) = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index 0665b3d17..16792e4c0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -47,7 +47,10 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { } fn autonomous_game_build_agent_can_generate_canvas_asset(agent_id: &str) -> bool { - matches!(agent_id.trim(), "design-foundation" | "art-asset-plan") + matches!( + agent_id.trim(), + "art-director" | "design-foundation" | "art-asset-plan" + ) } pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at( 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 99919b715..84c6bb5f4 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 @@ -253,17 +253,19 @@ pub(crate) use pending_execution::{ pub(crate) use pending_recovery::resume_game_creator_agent_pending_tool_action_at; #[cfg(test)] pub(crate) use pending_recovery::resume_game_creator_agent_provider_action_batch_for_test_at; +pub(crate) use provider_recovery::{ + autonomous_manifest_parent_wake_error_is_transient, + schedule_waiting_autonomous_manifest_parent_wake_after_lane_release, + schedule_waiting_static_delegate_parent_wake_after_lane_release, + static_delegate_parent_wake_error_is_transient, +}; #[cfg(test)] pub(crate) use provider_recovery::{ ensure_waiting_provider_retry_records_for_test, probe_static_delegate_parent_wake_singleflight_coalescing, }; -pub(crate) use provider_recovery::{ - schedule_waiting_autonomous_manifest_parent_wake_after_lane_release, - schedule_waiting_static_delegate_parent_wake_after_lane_release, - static_delegate_parent_wake_error_is_transient, -}; pub(crate) use recovery_scan::{ + cleanup_game_creator_agent_runtime_completed_finalizations_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/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index d933eb655..0f40ca3d1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -1,5 +1,31 @@ use super::*; +pub(in crate::agent) fn autonomous_registered_derived_visuals_need_repair_at(root: &Path) -> bool { + let Ok(manifest) = read_manifest_for_project(root) else { + return false; + }; + [ + ( + "design-foundation", + "assets/ui-prototype.png", + "ui-prototype", + ), + ( + "art-asset-plan", + "assets/art-spritesheet.png", + "art-spritesheet", + ), + ] + .into_iter() + .any(|(task_id, local_path, kind)| { + manifest + .assets + .iter() + .any(|asset| asset.local_path == local_path && asset.kind == kind) + && validate_manifest_required_visual_asset(root, &manifest, task_id).is_err() + }) +} + pub(super) fn game_creator_agent_background_final_reply_fallback( plan_response: &str, run_profile: &str, @@ -225,6 +251,7 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c } let autonomous_manifest_can_wait = agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && !autonomous_registered_derived_visuals_need_repair_at(&root) && !game_creator_agent_runtime_provider_action_batch_exists( &root, &agent_id, @@ -1089,8 +1116,8 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c if agent_id == "design-foundation" { runtime.current_action = "等待可验收的 UI 原型图".to_string(); runtime.waiting_on = - "图片生成、manifest 登记与 ui-prototype.v1 结构化视觉检查".to_string(); - runtime.next_step = "缺图时调用 canvas.asset_generate;已有候选时对 assets/ui-prototype.png 调用 image.inspect;未通过则经 file.delete 权限流程删除后重新生成".to_string(); + "图片生成、manifest 登记与 ui-prototype.v2 结构化视觉检查".to_string(); + runtime.next_step = "缺图时调用 canvas.asset_generate;已有候选时对 assets/ui-prototype.png 调用 image.inspect;未通过时如实返回 needs-repair,由 Supervisor 认领后发起唯一 repair 原位替换,禁止先删除正式图片".to_string(); } else { runtime.current_action = "等待实际图片产物".to_string(); runtime.waiting_on = "图片生成确认、配置与本地 manifest 登记".to_string(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index aaaf9ef4f..6de000683 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -17,11 +17,34 @@ fn register_autonomous_recovery_visual_fixture(root: &Path, local_path: &str, ki task_id: Some(format!("task-{kind}")), 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 autonomous recovery visual fixture"); } +#[test] +fn autonomous_parent_keeps_planning_before_scheduling_registered_legacy_derived_visuals() { + let root = std::env::temp_dir().join(format!( + "genarrative-agent-main-loop-legacy-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos() + )); + init_local_game_project_at(&root, "legacy-derived-visuals", "旧派生视觉返工门禁") + .expect("project init"); + assert!(!autonomous_registered_derived_visuals_need_repair_at(&root)); + + register_autonomous_recovery_visual_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); + assert!(autonomous_registered_derived_visuals_need_repair_at(&root)); + + fs::remove_dir_all(root).ok(); +} + fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState) -> u64 { let contract = read_autonomous_completion_contract(root, &state.agent_id, &state.run_id) .expect("read autonomous completion contract") @@ -324,7 +347,7 @@ fn autonomous_manifest_waiting_context_persists_without_finishing_parent_run() { async fn missing_completed_visual_asset_fails_same_child_without_retry() { const PARENT_RUN_ID: &str = "autonomous-visual-recovery-parent"; const PARENT_TASK: &str = "生成完整小游戏并恢复丢失的正式视觉产物"; - const CHILD_ID: &str = "design-foundation"; + const CHILD_ID: &str = "art-director"; let _config_guard = crate::tests::write_test_local_config( r#"{"editorApi":{"apiKey":"visual-recovery-test-key"}}"#.to_string(), ); @@ -358,7 +381,7 @@ async fn missing_completed_visual_asset_fails_same_child_without_retry() { "# 游戏设计\n\n恢复丢失图片后才能完成。\n", ) .expect("write game design fixture"); - register_autonomous_recovery_visual_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); + register_autonomous_recovery_visual_fixture(&root, "assets/art-spec.png", "icon-spec"); update_manifest_task_status_at( &root, "design-director", @@ -400,7 +423,7 @@ async fn missing_completed_visual_asset_fails_same_child_without_retry() { update_manifest_task_status_at(&root, CHILD_ID, GameCreationAppTaskStatus::Completed) .expect("project initial visual completion fixture"); - fs::remove_file(root.join("assets/ui-prototype.png")).expect("remove registered visual file"); + fs::remove_file(root.join("assets/art-spec.png")).expect("remove registered visual file"); let downgraded = read_manifest_for_project(&root).expect("refresh missing visual manifest"); assert_eq!( downgraded @@ -584,6 +607,19 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac } assert_eq!(stream.status, "committed"); assert_eq!(stream.accumulated_text, fallback); + for _ in 0..500 { + if read_game_creator_agent_runtime_finalization_journal( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + RUN_ID, + ) + .expect("poll finalization residue") + .is_none() + { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } assert!(read_game_creator_agent_runtime_finalization_journal( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs index 48f335691..4bdb4c9dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs @@ -1,5 +1,9 @@ use super::*; +pub(crate) fn autonomous_manifest_parent_wake_error_is_transient(error: &str) -> bool { + error.starts_with("项目正在被其他写操作占用:") +} + pub(in crate::agent) fn schedule_waiting_provider_retry_wake_after_lane_release( root: PathBuf, agent_id: String, @@ -127,6 +131,9 @@ async fn drive_waiting_autonomous_manifest_parent_wake_pass( return; } }, + Err(error) if autonomous_manifest_parent_wake_error_is_transient(&error) => { + continue; + } Err(error) => { let _ = mark_autonomous_manifest_parent_wake_needs_reconciliation_at( root, agent_id, run_id, &error, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index d5c7aff2b..5ae051a2e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -149,6 +149,272 @@ pub(in crate::agent) fn remove_game_creator_agent_runtime_provider_recovery_at( provider_retry::remove_at(root, agent_id, run_id) } +pub(crate) fn cleanup_game_creator_agent_runtime_completed_finalizations_at( + root: &Path, +) -> Result { + let journals = list_game_creator_agent_runtime_finalization_journals(root)?; + let mut removed = 0_usize; + for journal in journals { + if journal.status != AGENT_RUNTIME_FINALIZATION_STATUS_RUNTIME_COMPLETED { + continue; + } + let task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &journal.agent_id, + &journal.run_id, + )? + .ok_or_else(|| { + format!( + "已完成 finalization 缺少所属任务:agent={} runId={}", + journal.agent_id, journal.run_id + ) + })?; + if task.status != "completed" + || task.phase != "completed" + || task.agent_id != journal.agent_id + || task.task_id != journal.task_id + || task.session_id != journal.session_id + || task.run_id != journal.run_id + || task.source != journal.source + || task.parent_agent_id != journal.parent_agent_id + || task.parent_run_id != journal.parent_run_id + || task.delegation_id != journal.delegation_id + || task.goal_id != journal.goal_id + || task.goal_revision != journal.goal_revision + { + return Err(format!( + "已完成 finalization 与所属终态任务身份冲突:agent={} runId={}", + journal.agent_id, journal.run_id + )); + } + if !game_creator_agent_runtime_finalization_assistant_exists(root, &journal)? { + return Err(format!( + "已完成 finalization 的 assistant 消息缺失:agent={} runId={}", + journal.agent_id, journal.run_id + )); + } + let (records, scan_truncated) = + read_agent_db_records_bounded(root, AGENT_RUNTIME_ACTION_HISTORY_MAX_DB_BYTES)?; + let finalization_records = records + .iter() + .filter(|record| { + record + .get("finalizationId") + .and_then(serde_json::Value::as_str) + == Some(journal.finalization_id.as_str()) + }) + .collect::>(); + let lifecycle_records = finalization_records + .iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some(AGENT_RUNTIME_FINALIZATION_LIFECYCLE_RECORD_TYPE) + }) + .copied() + .collect::>(); + let lifecycle_stages = lifecycle_records + .iter() + .filter_map(|record| record.get("stage").and_then(serde_json::Value::as_str)) + .collect::>(); + let exact_record_count = |record_type: &str| { + finalization_records + .iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some(record_type) + && record.get("agentId").and_then(serde_json::Value::as_str) + == Some(journal.agent_id.as_str()) + && record.get("taskId").and_then(serde_json::Value::as_str) + == Some(journal.task_id.as_str()) + && record.get("sessionId").and_then(serde_json::Value::as_str) + == Some(journal.session_id.as_str()) + && record.get("runId").and_then(serde_json::Value::as_str) + == Some(journal.run_id.as_str()) + && record.get("messageId").and_then(serde_json::Value::as_str) + == Some(journal.message_id.as_str()) + }) + .count() + }; + let conversation_message_count = finalization_records + .iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some("conversation.message") + && record.get("agentId").and_then(serde_json::Value::as_str) + == Some(journal.agent_id.as_str()) + && record.get("sessionId").and_then(serde_json::Value::as_str) + == Some(journal.session_id.as_str()) + && record.get("messageId").and_then(serde_json::Value::as_str) + == Some(journal.message_id.as_str()) + && record.get("role").and_then(serde_json::Value::as_str) == Some("assistant") + }) + .count(); + let lifecycle_identity_matches = lifecycle_records.iter().all(|record| { + record.get("agentId").and_then(serde_json::Value::as_str) + == Some(journal.agent_id.as_str()) + && record.get("taskId").and_then(serde_json::Value::as_str) + == Some(journal.task_id.as_str()) + && record.get("sessionId").and_then(serde_json::Value::as_str) + == Some(journal.session_id.as_str()) + && record.get("runId").and_then(serde_json::Value::as_str) + == Some(journal.run_id.as_str()) + && record.get("messageId").and_then(serde_json::Value::as_str) + == Some(journal.message_id.as_str()) + && record.get("source").and_then(serde_json::Value::as_str) + == Some(journal.source.as_str()) + }); + if scan_truncated + || finalization_records.len() != 7 + || !lifecycle_identity_matches + || lifecycle_stages + != [ + "prepared", + "assistant-persisted", + "runtime-completed", + "goal-completed", + ] + || conversation_message_count != 1 + || exact_record_count("agent.runtime.completed") != 1 + || exact_record_count("agent.runtime.background_task.completed") != 1 + { + return Err(format!( + "已完成 finalization 缺少完整唯一七槽审计:agent={} runId={} scanTruncated={scan_truncated}", + journal.agent_id, journal.run_id + )); + } + let mut stream = read_game_creator_agent_runtime_response_stream_at( + root, + &journal.agent_id, + &journal.run_id, + )? + .ok_or_else(|| { + format!( + "已完成 finalization 的 committed 回复流缺失:agent={} runId={}", + journal.agent_id, journal.run_id + ) + })?; + let stream_base_identity_conflicts = stream.agent_id != journal.agent_id + || stream.task_id != journal.task_id + || stream.session_id != journal.session_id + || stream.run_id != journal.run_id + || stream.request_kind != "final-reply"; + if stream_base_identity_conflicts { + return Err(format!( + "已完成 finalization 与 committed 回复流身份冲突:agent={} runId={}", + journal.agent_id, journal.run_id + )); + } + let stream_matches = |stream: &AgentRuntimeResponseStream| { + stream.status == AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED + && stream.request_slot == journal.response_request_slot + && stream.applied_steer_cursor == journal.response_steer_cursor + && stream.response_revision == journal.response_revision + && stream.accumulated_text == journal.response + }; + if !stream_matches(&stream) { + if stream.status == AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED { + return Err(format!( + "已完成 finalization 与 committed 回复流身份冲突:agent={} runId={}", + journal.agent_id, journal.run_id + )); + } + let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.finalization.completed-stream-repair", + )?; + remove_game_creator_agent_runtime_response_stream_at( + root, + &journal.agent_id, + &journal.run_id, + )?; + let mut state = agent_runtime_state_from_task_record(&task); + state.applied_steer_cursor = journal.response_steer_cursor; + mark_game_creator_agent_runtime_response_stream_committed_at(root, &state, &journal)?; + stream = read_game_creator_agent_runtime_response_stream_at( + root, + &journal.agent_id, + &journal.run_id, + )? + .ok_or_else(|| "已完成 finalization 的回复流修复后缺失".to_string())?; + if !stream_matches(&stream) { + return Err(format!( + "已完成 finalization 的回复流修复后仍冲突:agent={} runId={}", + journal.agent_id, journal.run_id + )); + } + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.finalization_completed_stream_repaired", + "agentId": journal.agent_id, + "taskId": journal.task_id, + "sessionId": journal.session_id, + "runId": journal.run_id, + "source": journal.source, + }), + ); + } + let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.finalization.completed-cleanup", + )?; + remove_game_creator_agent_runtime_finalization_recovery_sidecars( + root, + &journal.agent_id, + &journal.run_id, + )?; + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.finalization_completed_cleaned", + "agentId": journal.agent_id, + "taskId": journal.task_id, + "sessionId": journal.session_id, + "runId": journal.run_id, + "source": journal.source, + }), + ); + removed = removed.saturating_add(1); + } + Ok(removed) +} + +fn current_game_creator_agent_runtime_finalization_exists_at( + root: &Path, + agent_ids: &std::collections::BTreeSet, +) -> Result { + for agent_id in agent_ids { + let (state, _) = + read_game_creator_agent_runtime_state_for_finalization_resume(root, agent_id)?; + if state.run_id.trim().is_empty() { + continue; + } + let path = game_creator_agent_runtime_finalization_path(root, agent_id, &state.run_id); + match fs::symlink_metadata(&path) { + Ok(_) => return Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取当前 Agent Runtime finalization 元数据失败:{}: {error}", + path.display() + )); + } + } + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + match fs::symlink_metadata(&backup_path) { + Ok(_) => return Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取当前 Agent Runtime finalization 恢复副本元数据失败:{}: {error}", + backup_path.display() + )); + } + } + } + Ok(false) +} + pub(crate) fn resume_game_creator_agent_background_tasks_at( root: &Path, ) -> Result, String> { @@ -165,6 +431,9 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at return read_game_creator_agent_runtimes_at(root); } let agent_ids = collect_game_creator_agent_runtime_agent_ids(root)?; + if !current_game_creator_agent_runtime_finalization_exists_at(root, &agent_ids)? { + cleanup_game_creator_agent_runtime_completed_finalizations_at(root)?; + } for ledger in tool_plan_handoff::list_at(root)? { let agent_id = ledger.agent_id(); let run_id = ledger.run_id(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 8f8fb1844..80fccae90 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -559,6 +559,7 @@ fn validate_autonomous_game_build_ready_task_parent_at( root: &Path, parent_agent_id: &str, parent_run_id: &str, + require_static_delegate_barrier: bool, ) -> Result { let parent_agent_id = normalize_game_creator_runtime_agent_id(parent_agent_id)?; if parent_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { @@ -606,12 +607,14 @@ fn validate_autonomous_game_build_ready_task_parent_at( &agent_runtime_state_from_task_record(¤t_root), )? .ok_or_else(|| "autonomous ready-task scheduler 父 Run 缺少完成合同".to_string())?; - let barrier = static_delegate_completion_barrier_at(root, &parent_agent_id, parent_run_id)?; - if !barrier.is_clear() { - return Err(format!( - "autonomous ready-task scheduler 必须等待静态委派完成屏障收束:{}", - barrier.detail() - )); + if require_static_delegate_barrier { + let barrier = static_delegate_completion_barrier_at(root, &parent_agent_id, parent_run_id)?; + if !barrier.is_clear() { + return Err(format!( + "autonomous ready-task scheduler 必须等待静态委派完成屏障收束:{}", + barrier.detail() + )); + } } Ok(binding) } @@ -880,6 +883,7 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( root, parent_agent_id, parent_run_id, + true, )?; let manifest = read_manifest_for_project(root)?; let active_count = manifest @@ -923,10 +927,12 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( } for (task, transition_to_running) in candidates { + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(root, &task.id)?; let parent_binding = match validate_autonomous_game_build_ready_task_parent_at( root, parent_agent_id, parent_run_id, + true, ) { Ok(binding) => binding, Err(error) => { @@ -940,6 +946,7 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( root, parent_agent_id, parent_run_id, + true, ) { let cancelled = AgentRuntimeTaskRecord { status: "cancelled".to_string(), @@ -965,7 +972,7 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( if game_creator_agent_runtime_terminal_status(&record).is_some() { terminal_records.push(record.clone()); } - scheduled.push((task, result, record, needs_notification)); + scheduled.push((task, result, record, needs_notification, runtime_lock)); } Err(error) => { update_manifest_task_status_at( @@ -995,7 +1002,7 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( } let mut results = Vec::new(); - for (task, result, record, needs_notification) in scheduled { + for (task, result, record, needs_notification, runtime_lock) in scheduled { append_agent_db_record( root, serde_json::json!({ @@ -1013,6 +1020,15 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( "recovered": needs_notification, }), )?; + if game_creator_agent_runtime_terminal_status(&record).is_none() { + if let Some(runtime_lock) = runtime_lock { + spawn_next_game_creator_agent_background_task_drain_with_lock( + root, + &record.agent_id, + runtime_lock, + ); + } + } if needs_notification && game_creator_agent_runtime_terminal_status(&record).is_none() && notify_external_agent_runner_after_background_task_enqueue( @@ -1101,6 +1117,7 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke root, &parent_agent_id, &parent_run_id, + false, )?; let manifest = read_manifest_for_project(root)?; let Some(manifest_task) = manifest.tasks.iter().find(|task| task.id == state.agent_id) else { @@ -1119,7 +1136,7 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke if status == GameCreationAppTaskStatus::Completed && matches!( manifest_task.id.as_str(), - "design-foundation" | "art-asset-plan" + "art-director" | "design-foundation" | "art-asset-plan" ) && !manifest_has_required_visual_asset(root, &manifest, &manifest_task.id) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs index bb39cab08..78dae9847 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -82,6 +82,7 @@ pub(crate) use run_configuration::{ read_game_creator_agent_runtime_run_profile_binding, }; pub(crate) use steering::{ + acquire_game_creator_agent_runtime_steer_project_write_lock_with_wait, consume_game_creator_agent_runtime_steers, game_creator_agent_runtime_accepts_steer, game_creator_agent_runtime_steer_ledger_path, interrupt_game_creator_agent_runtime_provider_request_at, 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 3856088ad..079aa8ca4 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 @@ -57,8 +57,35 @@ fn autonomous_fixture_with_setup( } fn register_autonomous_visual_fixture(root: &Path, local_path: &str, kind: &str) { - fs::write(root.join(local_path), b"\x89PNG\r\n\x1a\nfixture") - .expect("write autonomous visual fixture"); + 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() + }; + fs::write(root.join(local_path), bytes).expect("write autonomous visual fixture"); + let (generation_route, generation_kind, reference_resource_ids) = match kind { + "icon-spec" => ( + "/api/external/v1/editor/images/generations", + "spec", + Vec::new(), + ), + "ui-prototype" => ( + "/api/external/v1/editor/images/generations", + "ui-design", + vec!["resource-icon-spec".to_string()], + ), + "art-spritesheet" => ( + "/api/external/v1/editor/icon-spritesheets/generations", + "icon-spritesheet", + vec!["resource-icon-spec".to_string()], + ), + _ => ("", "", Vec::new()), + }; register_local_asset_at( root, local_path, @@ -73,6 +100,9 @@ fn register_autonomous_visual_fixture(root: &Path, local_path: &str, kind: &str) task_id: Some(format!("task-{kind}")), prompt: None, model: None, + generation_route: (!generation_route.is_empty()).then(|| generation_route.to_string()), + generation_kind: (!generation_kind.is_empty()).then(|| generation_kind.to_string()), + reference_resource_ids, }, ) .expect("register autonomous visual fixture"); @@ -100,6 +130,7 @@ fn prepare_completed_autonomous_manifest_fixture(root: &Path) { .expect("write audio manifest fixture"); fs::write(root.join("exports/README.md"), "# 发布说明\n\n可试玩。\n") .expect("write publish fixture"); + register_autonomous_visual_fixture(root, "assets/art-spec.png", "icon-spec"); register_autonomous_visual_fixture(root, "assets/ui-prototype.png", "ui-prototype"); register_autonomous_visual_fixture(root, "assets/art-spritesheet.png", "art-spritesheet"); for task in new_game_creation_app_seed_tasks() { @@ -1010,6 +1041,54 @@ fn autonomous_scheduler_child_terminal_projects_with_existing_project_lock() { ); } +#[test] +fn autonomous_scheduler_child_terminal_projects_while_static_delegate_is_still_running() { + let (_temporary, root, parent_state, _contract) = autonomous_fixture( + "做一个完整小游戏", + "autonomous-scheduler-static-overlap-parent", + ); + update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Running) + .expect("mark scheduler child running"); + let record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "design-director"); + let waiting_delivery = new_static_delegate_delivery( + &parent_state.agent_id, + &parent_state.session_id, + &parent_state.run_id, + "overlapping-static-action", + "overlapping-static-delegation", + "quality-review", + "overlapping-quality-session", + "overlapping-quality-run", + ); + create_or_read_static_delegate_delivery_at(&root, &waiting_delivery) + .expect("create overlapping static delivery"); + assert!(static_delegate_completion_barrier_at( + &root, + &parent_state.agent_id, + &parent_state.run_id, + ) + .expect("read overlapping static barrier") + .has_waiting()); + + let mut state = agent_runtime_state_from_task_record(&record); + state.status = "completed".to_string(); + state.phase = "completed".to_string(); + assert!( + project_autonomous_manifest_ready_task_terminal_at(&root, &state) + .expect("project scheduler child despite independent static delivery") + ); + + assert_eq!( + read_manifest_for_project(&root) + .expect("read scheduler child manifest") + .tasks + .iter() + .find(|task| task.id == "design-director") + .map(|task| &task.status), + Some(&GameCreationAppTaskStatus::Completed) + ); +} + #[tokio::test] async fn autonomous_scheduler_recovers_running_without_journal_and_journal_without_running() { let (_temporary, root, parent_state, _contract) = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs index 62eef0b60..e4bf34b09 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs @@ -377,6 +377,89 @@ pub(crate) fn read_game_creator_agent_runtime_finalization_journal( Ok(Some(journal)) } +pub(in crate::agent) fn list_game_creator_agent_runtime_finalization_journals( + root: &Path, +) -> Result, String> { + let directory = root.join(".agent/runtime/finalizations"); + let agent_entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(format!( + "读取 Agent Runtime finalization 目录失败:{}: {error}", + directory.display() + )); + } + }; + let mut identities = BTreeSet::<(String, String)>::new(); + for agent_entry in agent_entries { + let agent_entry = agent_entry.map_err(|error| { + format!( + "读取 Agent Runtime finalization Agent 目录项失败:{}: {error}", + directory.display() + ) + })?; + let agent_type = agent_entry.file_type().map_err(|error| { + format!( + "读取 Agent Runtime finalization Agent 类型失败:{}: {error}", + agent_entry.path().display() + ) + })?; + if agent_type.is_symlink() || !agent_type.is_dir() { + return Err("Agent Runtime finalization Agent 项必须是普通目录".to_string()); + } + let agent_id = agent_entry + .file_name() + .to_str() + .ok_or_else(|| "Agent Runtime finalization Agent 目录名必须是 UTF-8".to_string())? + .to_string(); + for journal_entry in fs::read_dir(agent_entry.path()).map_err(|error| { + format!( + "读取 Agent Runtime finalization Agent 目录失败:{}: {error}", + agent_entry.path().display() + ) + })? { + let journal_entry = journal_entry.map_err(|error| { + format!( + "读取 Agent Runtime finalization 目录项失败:{}: {error}", + agent_entry.path().display() + ) + })?; + let journal_type = journal_entry.file_type().map_err(|error| { + format!( + "读取 Agent Runtime finalization 类型失败:{}: {error}", + journal_entry.path().display() + ) + })?; + if journal_type.is_symlink() || !journal_type.is_file() { + return Err("Agent Runtime finalization 项必须是普通文件".to_string()); + } + let file_name = journal_entry + .file_name() + .to_str() + .ok_or_else(|| "Agent Runtime finalization 文件名必须是 UTF-8".to_string())? + .to_string(); + let run_id = file_name + .strip_suffix(".json") + .or_else(|| { + file_name + .strip_prefix('.') + .and_then(|value| value.strip_suffix(".json.previous")) + }) + .ok_or_else(|| "Agent Runtime finalization 目录含未知文件".to_string())?; + identities.insert((agent_id.clone(), run_id.to_string())); + } + } + let mut journals = Vec::with_capacity(identities.len()); + for (agent_id, run_id) in identities { + let journal = + read_game_creator_agent_runtime_finalization_journal(root, &agent_id, &run_id)? + .ok_or_else(|| "Agent Runtime finalization 列举期间消失".to_string())?; + journals.push(journal); + } + Ok(journals) +} + pub(in crate::agent) fn write_game_creator_agent_runtime_finalization_journal( root: &Path, journal: &AgentRuntimeFinalizationJournal, 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 db5fa0e79..209a344da 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 @@ -129,6 +129,33 @@ pub(crate) fn read_game_creator_agent_runtime_response_stream_at( Ok(stream) } +pub(in crate::agent) fn remove_game_creator_agent_runtime_response_stream_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result<(), String> { + let path = game_creator_agent_runtime_response_stream_path(root, agent_id, run_id); + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err("Agent Runtime 回复流必须是普通文件".to_string()) + } + Ok(_) => remove_agent_runtime_json_sidecar_backup(&backup_path, "Agent Runtime 回复流") + .and_then(|_| { + fs::remove_file(&path).map_err(|error| { + format!("删除 Agent Runtime 回复流失败:{}: {error}", path.display()) + }) + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + remove_agent_runtime_json_sidecar_backup(&backup_path, "Agent Runtime 回复流") + } + Err(error) => Err(format!( + "读取 Agent Runtime 回复流元数据失败:{}: {error}", + path.display() + )), + } +} + pub(in crate::agent) fn write_game_creator_agent_runtime_response_stream_at( root: &Path, stream: &AgentRuntimeResponseStream, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs index c47237829..428dcd809 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs @@ -923,10 +923,7 @@ pub(crate) fn consume_game_creator_agent_runtime_steers( next_loop_index: usize, context_tracker: &AgentRuntimeContextWindowTracker, ) -> Result { - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "runtime.steer.consume", - )?; + let _lock = acquire_game_creator_agent_runtime_steer_project_write_lock_with_wait(root)?; let mut snapshot = read_game_creator_agent_runtime_steer_ledger(root, &runtime.agent_id, &runtime.run_id)?; if snapshot.closed_cursor.is_some() { @@ -1063,6 +1060,12 @@ pub(crate) fn consume_game_creator_agent_runtime_steers( Ok(true) } +pub(crate) fn acquire_game_creator_agent_runtime_steer_project_write_lock_with_wait( + root: &Path, +) -> Result { + acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "runtime.steer.consume") +} + pub(crate) fn render_game_creator_agent_runtime_steers_for_prompt( root: &Path, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index 108f7b9fd..5266c63f1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -58,7 +58,8 @@ pub(crate) use isolated_joins::{ pub(crate) use media::observe_agent_runtime_platform_art_asset_generation_after_dispatch_for_test; #[allow(unused_imports)] pub(crate) use media::{ - AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, AGENT_RUNTIME_UI_PROTOTYPE_PATH, + AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, + AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, AGENT_RUNTIME_UI_PROTOTYPE_PATH, AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, }; pub(crate) use policy::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs index 5cdb241f7..07ce9f72c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs @@ -336,7 +336,16 @@ pub(in crate::agent) fn observe_agent_runtime_file_delete( }; } }; - if manifest_has_required_visual_asset(root, &manifest, agent_id) { + let registered_fixed_asset_exists = manifest.assets.iter().any(|asset| { + asset.local_path == "assets/art-spritesheet.png" + && asset.kind == "art-spritesheet" + && asset.media_type.starts_with("image/") + && asset.source.kind == GameCreationAppAssetSourceKind::Canvas + && resolve_local_project_path(root, &asset.local_path) + .ok() + .is_some_and(|path| path.is_file()) + }); + if registered_fixed_asset_exists { return AgentRuntimeToolObservation { tool: "file.delete".to_string(), status: "blocked".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index e47871ba8..c5c017088 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -10,11 +10,39 @@ pub(in crate::agent) struct AgentRuntimeImageInspectInput { pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND: &str = "ui-prototype"; pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_PATH: &str = "assets/ui-prototype.png"; -pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE: &str = "ui-prototype.v1"; +pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE: &str = "ui-prototype.v1"; +pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE: &str = "ui-prototype.v2"; +pub(crate) const AGENT_RUNTIME_ART_SPEC_PATH: &str = "assets/art-spec.png"; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(in crate::agent) struct AgentRuntimeUiPrototypeChecks { + pub(in crate::agent) information_hud: bool, + pub(in crate::agent) gameplay_surface: bool, + pub(in crate::agent) objective_entities: bool, + pub(in crate::agent) primary_controls: bool, + pub(in crate::agent) failure_restart_flow: bool, + pub(in crate::agent) responsive_layout: bool, + pub(in crate::agent) implementation_clarity: bool, + pub(in crate::agent) original_theme: bool, +} + +impl AgentRuntimeUiPrototypeChecks { + fn all_passed(&self) -> bool { + self.information_hud + && self.gameplay_surface + && self.objective_entities + && self.primary_controls + && self.failure_restart_flow + && self.responsive_layout + && self.implementation_clarity + && self.original_theme + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(in crate::agent) struct AgentRuntimeUiPrototypeLegacyChecks { pub(in crate::agent) resource_bar: bool, pub(in crate::agent) unit_card_tray: bool, pub(in crate::agent) battlefield_grid: bool, @@ -25,8 +53,8 @@ pub(in crate::agent) struct AgentRuntimeUiPrototypeChecks { pub(in crate::agent) original_theme: bool, } -impl AgentRuntimeUiPrototypeChecks { - fn all_passed(&self) -> bool { +impl AgentRuntimeUiPrototypeLegacyChecks { + pub(in crate::agent) fn all_passed(&self) -> bool { self.resource_bar && self.unit_card_tray && self.battlefield_grid @@ -279,7 +307,7 @@ pub(in crate::agent) async fn observe_agent_runtime_image_inspect( .join("\n"); let question = sanitize_agent_runtime_text(&question, MAX_QUESTION_CHARS); let inspection_focus = if ui_prototype_inspection { - "请只依据真实可见像素判断这是否是可供前端直接实现的完整游戏 UI 原型,不能依据文件名、生成提示词或图片内自述放行。纯场景图、战斗概念图、地图、海报或仅有角色和箭头的插画必须判定失败。逐项检查:resourceBar=资源数值栏;unitCardTray=单位卡槽及费用/冷却;battlefieldGrid=明确战场网格;enemyEntryDirection=敌人入口/来袭方向;waveStatus=波次或局内状态;primaryControls=开始/暂停/重开等主要控件;implementationClarity=分区、层级和文字清楚到可指导 HTML/CSS;originalTheme=原创主题且未复刻现有游戏角色、Logo、贴图或受保护视觉语言。请只返回一个 JSON object,不要 markdown 或解释,字段必须严格为:{\"checks\":{\"resourceBar\":true,\"unitCardTray\":true,\"battlefieldGrid\":true,\"enemyEntryDirection\":true,\"waveStatus\":true,\"primaryControls\":true,\"implementationClarity\":true,\"originalTheme\":true},\"issues\":[\"未通过项及原因;全部通过时必须为空数组\"],\"summary\":\"500 字以内中文结论\"}。只有八项 checks 全为 true 且 issues 为空才通过。".to_string() + "请只依据真实可见像素判断这是否是可供前端直接实现的完整游戏 UI 原型,不能依据文件名、生成提示词或图片内自述放行。纯场景图、概念图、地图、海报或只展示角色而没有可玩界面的插画必须判定失败;不要假设游戏一定属于塔防或任何固定玩法。逐项检查:informationHud=清楚显示当前玩法需要的分数、资源、生命、关卡或局内状态;gameplaySurface=主要可玩区域及空间规则清楚;objectiveEntities=玩家主体、目标/收集/危险物、谜题或文本选项、轨道等当前玩法等价关键要素可辨;primaryControls=当前玩法需要的开始、移动、暂停或操作控件清楚;failureRestartFlow=存在可识别的结束态表现意图或明确重开入口;responsiveLayout=能从可见布局、触控目标和可重排分组判断移动适配意图,实际双视口另由浏览器验证;implementationClarity=分区、层级和文字清楚到可指导 HTML/CSS;originalTheme=原创主题且未复刻现有游戏角色、Logo、贴图或受保护视觉语言。请只返回一个 JSON object,不要 markdown 或解释,字段必须严格为:{\"checks\":{\"informationHud\":true,\"gameplaySurface\":true,\"objectiveEntities\":true,\"primaryControls\":true,\"failureRestartFlow\":true,\"responsiveLayout\":true,\"implementationClarity\":true,\"originalTheme\":true},\"issues\":[\"未通过项及原因;全部通过时必须为空数组\"],\"summary\":\"500 字以内中文结论\"}。只有八项 checks 全为 true 且 issues 为空才通过。".to_string() } else if question.trim().is_empty() { "请检查布局、遮挡、裁切、视觉层级、素材一致性,以及桌面与移动视口是否可用。".to_string() } else { @@ -480,6 +508,14 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio }; } let canonical_options = match agent_id { + "art-director" => Some(PlatformArtAssetGenerationOptions { + output_path: Some(AGENT_RUNTIME_ART_SPEC_PATH.to_string()), + aspect_ratio: "1:1".to_string(), + image_size: "1K".to_string(), + asset_kind: "icon-spec".to_string(), + asset_label: "游戏统一视觉规范图".to_string(), + replace_existing: false, + }), "design-foundation" => Some(PlatformArtAssetGenerationOptions { output_path: Some("assets/ui-prototype.png".to_string()), aspect_ratio: "16:9".to_string(), @@ -617,7 +653,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio } if !matches!( options.asset_kind.as_str(), - "game-art" | "ui-prototype" | "art-spritesheet" + "game-art" | "icon-spec" | "ui-prototype" | "art-spritesheet" ) { return AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), @@ -767,18 +803,27 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio "model": generated.model.clone(), }), ); + let slice_warning_summary = generated + .slice_warning + .as_deref() + .map(|reason| format!(";透明图集可用,但自动切片未完成:{reason}")) + .unwrap_or_default(); AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), status: "ok".to_string(), - summary: format!("已生成美术素材:{}", generated.asset.local_path), + summary: format!( + "已生成美术素材:{}{slice_warning_summary}", + generated.asset.local_path + ), detail: Some(format!( - "assetId={}, localPath={}, resourceId={}, assetObjectId={}, taskId={}, model={}, verifiedRevision={mutation_revision}", + "assetId={}, localPath={}, resourceId={}, assetObjectId={}, taskId={}, model={}, sliceWarning={}, verifiedRevision={mutation_revision}", generated.asset.id, generated.asset.local_path, generated.resource_id.as_deref().unwrap_or(""), generated.asset_object_id.as_deref().unwrap_or(""), generated.task_id.as_deref().unwrap_or(""), - generated.model.as_deref().unwrap_or("") + generated.model.as_deref().unwrap_or(""), + generated.slice_warning.as_deref().unwrap_or("") )), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs index 9bf7aa3d3..55acb9da4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs @@ -1,7 +1,10 @@ use super::*; fn autonomous_game_build_agent_can_execute_canvas_asset_generate(agent_id: &str) -> bool { - matches!(agent_id.trim(), "design-foundation" | "art-asset-plan") + matches!( + agent_id.trim(), + "art-director" | "design-foundation" | "art-asset-plan" + ) } fn autonomous_design_foundation_command_is_allowed(command_id: &str) -> bool { @@ -73,7 +76,7 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_rule_for_run( && !autonomous_game_build_agent_can_execute_canvas_asset_generate(agent_id) { return Some(AgentRuntimeToolPolicyBlock::Denied(format!( - "自主构建模式只允许 design-foundation 或 art-asset-plan 执行:{command_id}" + "自主构建模式只允许 art-director、design-foundation 或 art-asset-plan 执行:{command_id}" ))); } if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD @@ -394,13 +397,14 @@ mod tests { assert!(matches!( supervisor_block, Some(AgentRuntimeToolPolicyBlock::Denied(reason)) - if reason.contains("只允许 design-foundation 或 art-asset-plan") + if reason.contains("只允许 art-director、design-foundation 或 art-asset-plan") )); let mut bindings = BTreeMap::new(); for agent_id in [ "code-prototype", "quality-review", + "art-director", "design-foundation", "art-asset-plan", ] { @@ -422,10 +426,10 @@ mod tests { assert!(matches!( blocked, Some(AgentRuntimeToolPolicyBlock::Denied(reason)) - if reason.contains("只允许 design-foundation 或 art-asset-plan") + if reason.contains("只允许 art-director、design-foundation 或 art-asset-plan") )); } - for agent_id in ["design-foundation", "art-asset-plan"] { + for agent_id in ["art-director", "design-foundation", "art-asset-plan"] { let binding = bindings.get(agent_id).expect("visual binding"); assert!(game_creator_agent_runtime_tool_policy_rule_for_run( &root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index f69997290..022cb0516 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -845,7 +845,7 @@ fn runtime_tool_description(tool: &str) -> &'static str { "preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。", "image.inspect" => "让视觉模型检查一至两张项目内图片。", "canvas.asset_generate" => { - "通过已配置平台生成图片,写入确定的项目 assets 路径并登记素材;只有唯一返工委派可显式替换已登记正式图片。" + "通过已配置的 External Editor API 生成图片并登记到画布、素材库和项目 assets;art-director 先生成 icon-spec 规范图,ui-prototype 与透明 art-spritesheet 都固定复用该规范图;只有唯一返工委派可显式替换已登记正式图片。" } "blackboard.write" => "向项目级共享黑板追加稳定结论。", "agent.message" => "向一个目标 Agent 写入定向上下文消息。", @@ -1068,7 +1068,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value { "outputPath": { "type": ["string", "null"], "maxLength": 240 }, "aspectRatio": { "type": ["string", "null"], "enum": ["1:1", "2:3", "3:2", "9:16", "16:9", null] }, "imageSize": { "type": ["string", "null"], "enum": ["0.5K", "1K", "2K", null] }, - "assetKind": { "type": ["string", "null"], "enum": ["game-art", "ui-prototype", "art-spritesheet", null] }, + "assetKind": { "type": ["string", "null"], "enum": ["game-art", "icon-spec", "ui-prototype", "art-spritesheet", null] }, "assetLabel": { "type": ["string", "null"], "maxLength": 80 }, "replaceExisting": { "type": "boolean" } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index fad456437..1c7402307 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -37,6 +37,9 @@ pub(crate) fn upload_local_asset_at( task_id: None, prompt: None, model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), }, ) } @@ -97,6 +100,9 @@ pub(crate) fn import_canvas_asset_at( task_id, prompt, model, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), }, ) } @@ -197,6 +203,9 @@ pub(crate) fn import_canvas_export_at( task_id, prompt: Some(layer.title.trim().to_string()).filter(|value| !value.is_empty()), model, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), }, )?); } @@ -319,6 +328,9 @@ pub(crate) async fn sync_canvas_project_assets_at( prompt: json_string_field(resource, "actualPrompt") .or_else(|| json_string_field(resource, "prompt")), model: json_string_field(resource, "model"), + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), }, )?); } 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 76c1b95a3..9d60ee983 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -156,7 +156,10 @@ impl CliCommand { pub(crate) fn requires_started_external_agent_runner(&self) -> bool { self.requires_external_agent_runner() - && !matches!(self, Self::AgentCancel { .. } | Self::RunnerShutdownIfIdle) + && !matches!( + self, + Self::AgentCancel { .. } | Self::AgentResume { .. } | Self::RunnerShutdownIfIdle + ) } fn project_path_mut(&mut self) -> Option<(&mut PathBuf, bool)> { @@ -1262,9 +1265,12 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { } CliCommand::AgentResume { project_path } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; + let completed_finalizations_cleaned = + cleanup_game_creator_agent_runtime_completed_finalizations_at(&project_path)?; require_external_agent_runner_for_cli_runtime_write(&project_path)?; let runtimes = resume_game_creator_agent_background_tasks_at(&project_path)?; println!("agent.resume.accepted"); + println!("completedFinalizationsCleaned={completed_finalizations_cleaned}"); println!( "runtimesJson={}", serialize_agent_runtime_cli_payload(&runtimes)? 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 e7d1c2781..82ad1f376 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs @@ -68,22 +68,24 @@ impl Default for SupervisorCollaborationPolicy { } fn autonomous_game_build_has_canonical_art_asset(root: &Path) -> bool { - const ART_PATH: &str = "assets/art-spritesheet.png"; read_existing_manifest_for_project(root) .ok() .is_some_and(|manifest| { - manifest.assets.iter().any(|asset| { - asset.local_path == ART_PATH - && asset.kind == "art-spritesheet" - && asset.media_type.starts_with("image/") - && asset.source.kind == GameCreationAppAssetSourceKind::Canvas - && resolve_local_project_path(root, ART_PATH) - .ok() - .and_then(|path| fs::read(path).ok()) - .is_some_and(|bytes| { - bytes.len() > 8 && bytes.starts_with(b"\x89PNG\r\n\x1a\n") - }) - }) + 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") }) } @@ -94,8 +96,14 @@ fn autonomous_game_build_supervisor_collaboration_policy( .into_iter() .map(str::to_string) .collect::>(); - if editor_api_key_is_configured() && !autonomous_game_build_has_canonical_art_asset(root) { - required_static_agent_ids.push("art-asset-plan".to_string()); + 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, @@ -109,16 +117,38 @@ fn apply_autonomous_game_build_required_static_agents( root: &Path, mut policy: SupervisorCollaborationPolicy, ) -> Result { - if editor_api_key_is_configured() - && !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()); + 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 @@ -1222,6 +1252,26 @@ fn summarize_supervisor_collaboration_actions( "Project Supervisor agent.delegate 只能指向静态专业 Agent".to_string() ); } + let expected_artifacts = input + .get("expectedArtifacts") + .or_else(|| input.get("expected_artifacts")) + .and_then(Value::as_array) + .into_iter() + .flatten(); + for artifact in expected_artifacts.filter_map(Value::as_str) { + let expected_owner = match artifact.trim() { + "assets/art-spec.png" => Some("art-director"), + "assets/ui-prototype.png" => Some("design-foundation"), + "assets/art-spritesheet.png" => Some("art-asset-plan"), + _ => None, + }; + if expected_owner.is_some_and(|owner| owner != agent_id) { + return Err(format!( + "Project Supervisor 视觉委派不能跨 owner 声明固定产物:{artifact} 必须交给 {}", + expected_owner.expect("checked visual artifact owner") + )); + } + } let repair = input .get("repairOfDelegationId") .or_else(|| input.get("repair_of_delegation_id")) @@ -1802,4 +1852,24 @@ mod tests { .expect_err("dynamic child must not count as static delegate"); assert!(error.contains("静态专业 Agent")); } + + #[test] + fn supervisor_collaboration_policy_rejects_cross_owner_visual_artifacts() { + let mut action = delegate("art-director", None); + action.input["expectedArtifacts"] = serde_json::json!([ + "assets/art-spec.png", + "assets/ui-prototype.png", + "assets/art-spritesheet.png" + ]); + let error = preflight_supervisor_collaboration_plan( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &[action], + &SupervisorCollaborationPolicy::default(), + &SupervisorCollaborationState::default(), + ) + .expect_err("cross-owner visual artifacts must fail before dispatch"); + assert!(error.contains("不能跨 owner")); + assert!(error.contains("assets/ui-prototype.png")); + assert!(error.contains("design-foundation")); + } } 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 85497b903..614254bed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -922,6 +922,9 @@ pub(crate) fn register_local_asset( task_id: trim_optional_string(task_id), prompt: trim_optional_string(prompt), model: trim_optional_string(model), + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), }, ) } 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 150a3008a..5b06bb10d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -869,6 +869,7 @@ struct GeneratedPlatformArtAsset { asset_object_id: Option, task_id: Option, model: Option, + slice_warning: Option, } #[derive(Debug, Eq, PartialEq, Serialize)] @@ -1610,6 +1611,20 @@ fn main() { Err(_) => std::process::exit(125), } } + let game_chat_launch = match parse_game_chat_launch_args(&args) { + Ok(options) => options, + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + }; + #[cfg(not(debug_assertions))] + if game_chat_launch.is_some() { + eprintln!("--game-chat 仅在开发构建中可用"); + std::process::exit(1); + } + #[cfg(test)] + let _ = &game_chat_launch; let runtime_config_dir = match take_cli_runtime_config_dir(&mut args) { Ok(config_dir) => config_dir, Err(error) => { @@ -1679,7 +1694,7 @@ fn main() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_clipboard_manager::init()) .manage(game_creator_preview_registry()) - .setup(|app| { + .setup(move |app| { configure_game_creator_runtime_config_dir(app.handle())?; let config_dir = game_creator_runtime_config_dir().ok_or_else(|| { std::io::Error::new( @@ -1701,7 +1716,11 @@ fn main() { })?; set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); #[cfg(all(debug_assertions, not(test)))] - open_developer_window(app.handle())?; + if let Some(options) = game_chat_launch.as_ref() { + navigate_client_to_game_chat(app.handle(), options)?; + } else { + open_developer_window(app.handle())?; + } Ok(()) }) .invoke_handler(tauri::generate_handler![ 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 27dd50c66..ae54a9ba5 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 @@ -208,7 +208,7 @@ pub(crate) fn ensure_manifest_seed_tasks(root: &Path, manifest: &mut GameCreatio && visual_assets_required && matches!( seed_task.id.as_str(), - "design-foundation" | "art-asset-plan" + "art-director" | "design-foundation" | "art-asset-plan" ) && !visual_asset_ready { @@ -229,20 +229,113 @@ pub(crate) fn manifest_has_required_visual_asset( manifest: &GameCreationAppManifest, task_id: &str, ) -> bool { + validate_manifest_required_visual_asset(root, manifest, task_id).is_ok() +} + +pub(crate) fn validate_manifest_required_visual_asset( + root: &Path, + manifest: &GameCreationAppManifest, + task_id: &str, +) -> Result<(), String> { let (expected_path, expected_kind) = match task_id { + "art-director" => ("assets/art-spec.png", "icon-spec"), "design-foundation" => ("assets/ui-prototype.png", "ui-prototype"), "art-asset-plan" => ("assets/art-spritesheet.png", "art-spritesheet"), - _ => return true, + _ => return Ok(()), }; - manifest.assets.iter().any(|asset| { - asset.local_path == expected_path - && asset.kind == expected_kind - && asset.media_type.starts_with("image/") - && asset.source.kind == GameCreationAppAssetSourceKind::Canvas - && resolve_local_project_path(root, &asset.local_path) - .ok() - .is_some_and(|path| path.is_file()) - }) + let asset = manifest + .assets + .iter() + .find(|asset| asset.local_path == expected_path && asset.kind == expected_kind) + .ok_or_else(|| format!("缺少规范视觉资产:{expected_path} ({expected_kind})"))?; + if !asset.media_type.starts_with("image/") + || asset.source.kind != GameCreationAppAssetSourceKind::Canvas + { + return Err(format!("规范视觉资产文件或来源无效:{expected_path}")); + } + let bytes = resolve_local_project_path(root, &asset.local_path) + .ok() + .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)) + { + return Err("首版美术素材图没有真实透明像素".to_string()); + } + let canvas_project_id = asset + .source + .canvas_project_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("规范视觉资产缺少 canvasProjectId:{expected_path}"))?; + asset + .source + .resource_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("规范视觉资产缺少 resourceId:{expected_path}"))?; + + let expected_route = match task_id { + "art-asset-plan" => "/api/external/v1/editor/icon-spritesheets/generations", + _ => "/api/external/v1/editor/images/generations", + }; + let expected_generation_kind = match task_id { + "art-director" => "spec", + "design-foundation" => "ui-design", + "art-asset-plan" => "icon-spritesheet", + _ => unreachable!("non-visual tasks returned above"), + }; + if asset.source.generation_route.as_deref() != Some(expected_route) + || asset.source.generation_kind.as_deref() != Some(expected_generation_kind) + { + return Err(format!( + "规范视觉资产缺少匹配的持久生成来源,按 legacy 资产处理:{expected_path}" + )); + } + + if task_id == "art-director" { + if !asset.source.reference_resource_ids.is_empty() { + return Err("统一视觉规范图不得声明派生资源引用".to_string()); + } + return Ok(()); + } + + validate_manifest_required_visual_asset(root, manifest, "art-director")?; + let art_spec = manifest + .assets + .iter() + .find(|asset| asset.local_path == "assets/art-spec.png" && asset.kind == "icon-spec") + .ok_or_else(|| "缺少当前统一视觉规范图".to_string())?; + let art_spec_project_id = art_spec + .source + .canvas_project_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "统一视觉规范图缺少 canvasProjectId".to_string())?; + let art_spec_resource_id = art_spec + .source + .resource_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "统一视觉规范图缺少 resourceId".to_string())?; + if canvas_project_id != art_spec_project_id { + return Err(format!( + "派生视觉资产与当前统一视觉规范图不属于同一画布项目:{expected_path}" + )); + } + if asset.source.reference_resource_ids.as_slice() != [art_spec_resource_id] { + return Err(format!( + "派生视觉资产未精确引用当前统一视觉规范图:{expected_path}" + )); + } + Ok(()) } pub(crate) fn set_task_status( 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 67c225b94..94cdd8b83 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 @@ -1223,6 +1223,7 @@ fn design_foundation_rejects_scene_image_stale_run_and_stale_sha_visual_proofs() let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "UI 原型语义完成门禁测试") .expect("project init"); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); register_canvas_visual_asset_fixture(&root, AGENT_RUNTIME_UI_PROTOTYPE_PATH, "ui-prototype"); let run_id = "design-ui-semantic-gate-run"; let state = start_game_creator_agent_runtime_task_at( @@ -1256,6 +1257,21 @@ fn design_foundation_rejects_scene_image_stale_run_and_stale_sha_visual_proofs() .as_deref() .is_some_and(|detail| detail.contains("requiredInspection=image.inspect"))); + append_ui_prototype_inspection_fixture_with_profile(&root, run_id, true, "ui-prototype.v1"); + let legacy_v1 = finish_game_creator_agent_background_runtime_turn_at( + &root, + state.clone(), + "历史 v1 审计不能放行当前 run。", + revision, + &[], + ) + .expect("legacy v1 remains recoverable but not authoritative"); + assert!(matches!( + legacy_v1, + AgentBackgroundFinalizationOutcome::Stale(ref blocker) + if blocker.tool == "runtime.visual_asset" + )); + append_ui_prototype_inspection_fixture(&root, run_id, false); let scene_blocked = finish_game_creator_agent_background_runtime_turn_at( &root, @@ -1274,7 +1290,7 @@ fn design_foundation_rejects_scene_image_stale_run_and_stale_sha_visual_proofs() assert!(scene_blocker .detail .as_deref() - .is_some_and(|detail| detail.contains("resourceBar=false"))); + .is_some_and(|detail| detail.contains("informationHud=false"))); append_ui_prototype_inspection_fixture(&root, "another-design-run", true); let wrong_run = finish_game_creator_agent_background_runtime_turn_at( @@ -1592,6 +1608,7 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r ); init_local_game_project_at(&root, "project-canvas-repair-atomic", "月光厨房") .expect("project init"); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); write_project_permission_policy_at( &root, ProjectPermissionPolicy { @@ -1751,7 +1768,7 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r let captured = request_receiver .recv_timeout(Duration::from_secs(15)) .expect("canvas request before generation response"); - if captured.starts_with("POST /api/external/v1/editor/images/generations ") { + if captured.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ") { saw_generation_request = true; break; } @@ -1784,11 +1801,17 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r .expect("read revision after rejected commit"), revision_before ); + let leftover_replacement_files = fs::read_dir(root.join("assets")) + .expect("read assets directory") + .filter_map(Result::ok) + .filter(|entry| { + let name = entry.file_name(); + let name = name.to_string_lossy(); + name.contains(".replacement.") || name.contains(".previous.") + }) + .count(); assert_eq!( - fs::read_dir(root.join("assets")) - .expect("read assets directory") - .count(), - 1, + leftover_replacement_files, 0, "authorization failure must not leave replacement files" ); 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 08e261524..f0580e9e2 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 @@ -87,6 +87,12 @@ fn project_supervisor_parent_wake_singleflight_coalesces_late_signal() { assert!(static_delegate_parent_wake_error_is_transient( "runtime-wake-retryable: target run is still waiting" )); + assert!(autonomous_manifest_parent_wake_error_is_transient( + "项目正在被其他写操作占用:$PROJECT_ROOT/.agent/project.lock" + )); + assert!(!autonomous_manifest_parent_wake_error_is_transient( + "manifest JSON 已损坏" + )); } #[test] 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 7ea41b081..d04162240 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 @@ -52,14 +52,22 @@ fn visual_specialist_prompts_require_real_registered_image_deliveries() { "assetKind=ui-prototype", "asset.list", "image.inspect", - "ui-prototype.v1", - "resourceBar", - "原创标题、阵营、资源、单位名称与视觉语言", - "Peashooter", + "ui-prototype.v2", + "informationHud", + "gameplaySurface", + "objectiveEntities", + "failureRestartFlow", + "responsiveLayout", + "不得假设为塔防", + "原创标题、实体、资源、目标名称与视觉语言", + "assets/art-spec.png", + "icon-spec", + "referenceImageSrcs 第一项", + "POST /api/external/v1/editor/images/generations(kind=ui-design)", + "不得误用 POST /api/external/v1/editor/ui-designs/assets/extractions", "canvas.asset_generate 成功动作本身就是当前 revision 的验证", - "已有同路径画布资产时先用 asset.list", - "检查已通过时不得删除、重复生成或再次扣费", - "file.delete", + "已有同路径画布资产时先核对登记", + "检查已通过时不得重复生成或再次扣费", "纯场景图", "不得把计划写完当成 completed", ] { @@ -69,23 +77,46 @@ fn visual_specialist_prompts_require_real_registered_image_deliveries() { ); } + let director_prompt = + game_creator_agent_runtime_tool_plan_system_prompt_for_agent("art-director"); + for expected in [ + "assets/art-spec.png", + "assetKind=icon-spec", + "POST /api/external/v1/editor/images/generations(kind=spec)", + "后续 UI 和透明图集共同引用", + "不得用 generationInputs.artSpec JSON", + "缺少 resourceId 时不得提交最终回复", + ] { + assert!( + director_prompt.contains(expected), + "art director prompt missing {expected}" + ); + } + let art_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent("art-asset-plan"); for expected in [ "资产清单和美术计划只是中间结果", "canvas.asset_generate", "assets/manifest.art.json", "assets/art-spritesheet.png", - "aspectRatio=1:1", - "imageSize=1K", + "固定使用 1:1、1K", "assetKind=art-spritesheet", - "该动作本身就是当前 revision 的验证", - "随后调用 asset.list", - "不得删除、重复生成或扣费", - "不需要调用 image.inspect 做主观返工", + "用 asset.list 确认 assets/art-spec.png 已登记", + "由 Runtime 形成 iconDescriptions", + "权威 resourceId 作为 referenceImageSrc", + "POST /api/external/v1/editor/icon-spritesheets/generations", + "screenColor=auto", + "不得回退普通生图", + "回读 observation 与 asset.list", + "真实 alpha", + "warning.code=postprocess-failed-source-preserved", + "不得登记、验收或自动重试", + "仅 sliceWarning", + "已有有效同路径资产时不得重复生成或扣费", "asset.list", "不得运行 game.static_smoke 或 preview.validate", "不得编辑 game/index.html", - "不得把计划写完当成 completed", + "透明证据不足时不得提交最终回复", ] { assert!( art_prompt.contains(expected), 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 f8365d780..4e340a113 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 @@ -649,18 +649,18 @@ fn valid_autonomous_initial_responsibility_actions_for_test() -> Vec AgentRuntimeToolAction { AgentRuntimeToolAction { tool: "agent.delegate".to_string(), - reason: Some("委派美术 Agent 生成首版精灵图".to_string()), + reason: Some("委派美术总监生成统一视觉规范图".to_string()), input: serde_json::json!({ - "agentId": "art-asset-plan", - "task": "生成首版游戏美术资源并写入项目资产目录。", + "agentId": "art-director", + "task": "生成项目统一视觉规范图并写入项目资产目录。", "acceptanceCriteria": [ - "使用画布生成接口产出可直接用于游戏的精灵图", - "生成结果必须登记为项目本地资产" + "使用画布生成接口产出后续 UI 与图集共用的规范图", + "生成结果必须登记为项目本地 icon-spec 资产" ], "expectedArtifacts": expected_artifacts, "repairOfDelegationId": null, @@ -670,8 +670,7 @@ fn autonomous_art_responsibility_action_for_test( } #[tokio::test] -async fn supervisor_autonomous_initial_art_responsibility_requires_canonical_spritesheet_artifact() -{ +async fn supervisor_autonomous_initial_art_director_requires_canonical_art_spec_artifact() { let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -714,7 +713,7 @@ async fn supervisor_autonomous_initial_art_responsibility_requires_canonical_spr ) .expect("start autonomous Supervisor runtime"); let mut actions = valid_autonomous_initial_responsibility_actions_for_test(); - actions.push(autonomous_art_responsibility_action_for_test(&[ + actions.push(autonomous_art_director_responsibility_action_for_test(&[ "assets/art-preview.png", ])); let plan = supervisor_collaboration_plan_for_test(actions); @@ -731,15 +730,15 @@ async fn supervisor_autonomous_initial_art_responsibility_requires_canonical_spr &"e".repeat(64), ) .await - .expect("preflight art responsibility without canonical spritesheet"); + .expect("preflight art director responsibility without canonical art spec"); let AgentRuntimeProviderActionBatchPreparation::Blocked(observation) = preparation else { - panic!("art responsibility without canonical spritesheet must be blocked"); + panic!("art director responsibility without canonical art spec must be blocked"); }; assert_eq!(observation.tool, "runtime.collaboration_policy"); assert_eq!(observation.status, "blocked"); let detail = observation.detail.expect("art responsibility block detail"); - assert!(detail.contains("art-asset-plan")); - assert!(detail.contains("assets/art-spritesheet.png")); + assert!(detail.contains("art-director")); + assert!(detail.contains("assets/art-spec.png")); fs::remove_dir_all(root).ok(); fs::remove_dir_all(config_dir).ok(); @@ -1676,8 +1675,8 @@ fn supervisor_autonomous_game_build_without_project_policy_requires_builder_and_ } #[test] -fn supervisor_autonomous_game_build_with_editor_api_key_and_missing_art_asset_requires_art_delegate( -) { +fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_in_dependency_order() +{ let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -1730,18 +1729,97 @@ fn supervisor_autonomous_game_build_with_editor_api_key_and_missing_art_asset_re .iter() .map(String::as_str) .collect::>(), - BTreeSet::from(["code-prototype", "quality-review", "art-asset-plan"]) + BTreeSet::from(["code-prototype", "quality-review", "art-director"]) ); assert!(!root .join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH) .exists()); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + let design_run_id = "supervisor-autonomous-design-after-art-spec-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + design_run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous supervisor profile after art spec delivery"); + let design_resolution = resolve_supervisor_collaboration_policy_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + 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"]) + ); + + register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); + let art_run_id = "supervisor-autonomous-art-after-ui-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + art_run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous supervisor profile after UI delivery"); + let art_resolution = resolve_supervisor_collaboration_policy_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + 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"]) + ); + + register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); + let complete_run_id = "supervisor-autonomous-after-all-visual-assets-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + complete_run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous supervisor profile after all visual deliveries"); + let complete_resolution = resolve_supervisor_collaboration_policy_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + 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()] + ); + 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_delegate() { +fn supervisor_autonomous_game_build_augments_existing_project_policy_with_required_art_director() { let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -1792,7 +1870,7 @@ fn supervisor_autonomous_game_build_augments_existing_project_policy_with_requir .iter() .map(String::as_str) .collect::>(), - BTreeSet::from(["art-asset-plan"]) + BTreeSet::from(["art-director"]) ); fs::remove_dir_all(root).ok(); @@ -1800,8 +1878,8 @@ fn supervisor_autonomous_game_build_augments_existing_project_policy_with_requir } #[test] -fn supervisor_autonomous_game_build_with_editor_api_key_and_existing_art_asset_skips_art_delegate() -{ +fn supervisor_autonomous_game_build_with_editor_api_key_and_all_visual_assets_skips_visual_delegate( +) { let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -1823,6 +1901,8 @@ fn supervisor_autonomous_game_build_with_editor_api_key_and_existing_art_asset_s "自主构建已有美术资源协作策略测试", ) .expect("project init"); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); let run_id = "supervisor-autonomous-existing-art-collaboration-run"; bind_game_creator_agent_runtime_run_profile_at( @@ -1884,3 +1964,63 @@ fn supervisor_autonomous_game_build_with_editor_api_key_and_existing_art_asset_s 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() { + let root = unique_project_path(); + let config_dir = unique_project_path(); + fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { + "baseUrl": "https://editor.example.test", + "apiKey": "editor-runtime-key" + } + }) + .to_string(), + ) + .expect("write runtime config with editor API key"); + let _config_guard = use_test_runtime_config_dir(config_dir.clone()); + init_local_game_project_at(&root, "legacy-visual-policy", "旧视觉来源协作策略") + .expect("project init"); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); + register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest = read_manifest_for_project(&root).expect("read visual manifest"); + let art_spec = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spec.png") + .expect("art spec asset"); + art_spec.source.generation_route = None; + art_spec.source.generation_kind = None; + write_manifest(&manifest_path, &manifest).expect("persist legacy source fixture"); + + let run_id = "supervisor-autonomous-legacy-visual-policy-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous supervisor profile"); + let resolution = resolve_supervisor_collaboration_policy_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + 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")); + + 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/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 449645312..c0a8ff1d3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -19,6 +19,12 @@ fn valid_test_png_bytes() -> Vec { .expect("valid 1x1 test png") } +fn transparent_test_png_bytes() -> Vec { + base64::engine::general_purpose::STANDARD + .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DAAAAEAQEARwbK3gAAAABJRU5ErkJggg==") + .expect("valid transparent 1x1 test png") +} + pub(crate) struct TestConfigGuard { _lock: StdMutexGuard<'static, ()>, path: PathBuf, @@ -2351,6 +2357,7 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( listener.local_addr().expect("mock canvas api addr") ); let signed_url = format!("{base_url}/signed/hero.png"); + let spritesheet_signed_url = format!("{base_url}/signed/spritesheet.png"); let projects_body = serde_json::json!({ "data": { "projects": [ @@ -2432,6 +2439,44 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( } }) .to_string(); + let icon_spritesheet_body = serde_json::json!({ + "spritesheetImageSrc": "/generated/canvas/spritesheet.png", + "spritesheetWidth": 64, + "spritesheetHeight": 64, + "iconImageSrcs": [], + "sliceWarning": { + "code": "insufficient-connected-components", + "reason": "测试图集保持整图,未生成独立切片。" + }, + "prompt": "原创游戏素材图集", + "actualPrompt": "纯色抠像背景的原创游戏素材图集", + "model": "gpt-image-2", + "provider": "VectorEngine", + "taskId": "task-1", + "priceMudPoints": 3, + "spritesheetResource": { + "resourceId": "resource-1", + "projectId": "canvas-project-1", + "imageSrc": "/generated/canvas/spritesheet.png", + "objectKey": "generated/canvas/spritesheet.png", + "assetObjectId": "asset-object-1", + "width": 64, + "height": 64, + "sourceType": "generated", + "prompt": "原创游戏素材图集", + "actualPrompt": "纯色抠像背景的原创游戏素材图集", + "model": "gpt-image-2", + "provider": "VectorEngine", + "taskId": "task-1", + "assetKind": "icon-spritesheet" + }, + "spritesheetAsset": { + "assetId": "asset-1", + "assetObjectId": "asset-object-1", + "assetKind": "icon-spritesheet" + } + }) + .to_string(); let read_body = serde_json::json!({ "read": { "provider": "aliyun-oss", @@ -2444,6 +2489,18 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( } }) .to_string(); + let spritesheet_read_body = serde_json::json!({ + "read": { + "provider": "aliyun-oss", + "bucket": "mock", + "endpoint": "mock", + "host": "mock", + "objectKey": "generated/canvas/spritesheet.png", + "expiresAt": "2026-06-25T00:10:00Z", + "signedUrl": spritesheet_signed_url + } + }) + .to_string(); std::thread::spawn(move || { let mut generation_response_gate = generation_response_gate; for _ in 0..expected_requests { @@ -2474,13 +2531,35 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( .expect("release mock canvas generation response"); } ("application/json", generation_body.as_bytes().to_vec()) + } else if request + .starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ") + { + assert!(normalized_request.contains("authorization: bearer ")); + if let Some(gate) = generation_response_gate.take() { + gate.recv_timeout(Duration::from_secs(5)) + .expect("release mock canvas generation response"); + } + ( + "application/json", + icon_spritesheet_body.as_bytes().to_vec(), + ) } else if request.starts_with( "GET /api/external/v1/assets/read-url?objectKey=generated%2Fcanvas%2Fhero.png ", ) { assert!(normalized_request.contains("authorization: bearer ")); ("application/json", read_body.as_bytes().to_vec()) + } else if request.starts_with( + "GET /api/external/v1/assets/read-url?objectKey=generated%2Fcanvas%2Fspritesheet.png ", + ) { + assert!(normalized_request.contains("authorization: bearer ")); + ( + "application/json", + spritesheet_read_body.as_bytes().to_vec(), + ) } else if request.starts_with("GET /signed/hero.png ") { ("image/png", valid_test_png_bytes()) + } else if request.starts_with("GET /signed/spritesheet.png ") { + ("image/png", transparent_test_png_bytes()) } else { ("text/plain", b"not found".to_vec()) }; @@ -2590,7 +2669,30 @@ fn register_canvas_visual_asset_fixture(root: &Path, local_path: &str, kind: &st let absolute_path = root.join(local_path); fs::create_dir_all(absolute_path.parent().expect("visual asset parent")) .expect("create visual asset fixture directory"); - fs::write(&absolute_path, valid_test_png_bytes()).expect("write visual asset fixture"); + let bytes = if kind == "art-spritesheet" { + transparent_test_png_bytes() + } else { + valid_test_png_bytes() + }; + fs::write(&absolute_path, bytes).expect("write visual asset fixture"); + let (generation_route, generation_kind, reference_resource_ids) = match kind { + "icon-spec" => ( + "/api/external/v1/editor/images/generations", + "spec", + Vec::new(), + ), + "ui-prototype" => ( + "/api/external/v1/editor/images/generations", + "ui-design", + vec!["resource-icon-spec".to_string()], + ), + "art-spritesheet" => ( + "/api/external/v1/editor/icon-spritesheets/generations", + "icon-spritesheet", + vec!["resource-icon-spec".to_string()], + ), + _ => ("", "", Vec::new()), + }; register_local_asset_at( root, local_path, @@ -2605,6 +2707,9 @@ fn register_canvas_visual_asset_fixture(root: &Path, local_path: &str, kind: &st task_id: Some(format!("task-{kind}")), prompt: Some("测试视觉资产".to_string()), model: Some("gpt-image-2".to_string()), + generation_route: (!generation_route.is_empty()).then(|| generation_route.to_string()), + generation_kind: (!generation_kind.is_empty()).then(|| generation_kind.to_string()), + reference_resource_ids, }, ) .expect("register canvas visual asset fixture"); @@ -2612,12 +2717,12 @@ fn register_canvas_visual_asset_fixture(root: &Path, local_path: &str, kind: &st fn ui_prototype_checks_fixture(passed: bool) -> serde_json::Value { serde_json::json!({ - "resourceBar": passed, - "unitCardTray": passed, - "battlefieldGrid": true, - "enemyEntryDirection": true, - "waveStatus": passed, + "informationHud": passed, + "gameplaySurface": true, + "objectiveEntities": true, "primaryControls": passed, + "failureRestartFlow": passed, + "responsiveLayout": passed, "implementationClarity": passed, "originalTheme": true, }) @@ -2629,7 +2734,7 @@ fn ui_prototype_assessment_fixture(passed: bool) -> String { "issues": if passed { Vec::::new() } else { - vec!["只有战场场景和来袭箭头,缺少资源栏、单位卡槽、波次状态与主要控件".to_string()] + vec!["只有场景和角色,缺少状态 HUD、主要操作、失败重开与移动端布局".to_string()] }, "summary": if passed { "八项 UI 原型检查全部通过。" @@ -2641,6 +2746,20 @@ fn ui_prototype_assessment_fixture(passed: bool) -> String { } fn append_ui_prototype_inspection_fixture(root: &Path, run_id: &str, passed: bool) { + append_ui_prototype_inspection_fixture_with_profile( + root, + run_id, + passed, + AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, + ); +} + +fn append_ui_prototype_inspection_fixture_with_profile( + root: &Path, + run_id: &str, + passed: bool, + validation_profile: &str, +) { let image_bytes = fs::read(root.join(AGENT_RUNTIME_UI_PROTOTYPE_PATH)).expect("read UI prototype fixture"); let image_sha256 = format!("{:x}", Sha256::digest(&image_bytes)); @@ -2663,7 +2782,7 @@ fn append_ui_prototype_inspection_fixture(root: &Path, run_id: &str, passed: boo "responseId": "resp_ui_prototype_fixture", "conclusionChars": 20, "inspectionKind": AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, - "validationProfile": AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, + "validationProfile": validation_profile, "passed": passed, "checks": ui_prototype_checks_fixture(passed), "issues": issues, 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 f57e0a42b..af06a337c 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 @@ -317,6 +317,88 @@ fn platform_art_asset_output_path_rejects_escape_overwrite_and_symlink() { fs::remove_dir_all(root).ok(); } +#[test] +fn canonical_visual_completion_requires_persisted_route_kind_and_current_spec_reference() { + let root = unique_project_path(); + init_local_game_project_at(&root, "visual-provenance", "视觉来源门禁") + .expect("init visual provenance project"); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); + register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); + let mut manifest = read_manifest_for_project(&root).expect("read visual manifest"); + for task_id in ["art-director", "design-foundation", "art-asset-plan"] { + validate_manifest_required_visual_asset(&root, &manifest, task_id) + .unwrap_or_else(|error| panic!("{task_id} provenance should pass: {error}")); + } + + let ui = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/ui-prototype.png") + .expect("ui prototype asset"); + ui.source.reference_resource_ids = vec!["stale-art-spec-resource".to_string()]; + assert!( + validate_manifest_required_visual_asset(&root, &manifest, "design-foundation") + .expect_err("stale UI reference must fail") + .contains("未精确引用当前统一视觉规范图") + ); + + let ui = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/ui-prototype.png") + .expect("ui prototype asset"); + ui.source.reference_resource_ids = vec!["resource-icon-spec".to_string()]; + ui.source.generation_route = + Some("/api/external/v1/editor/icon-spritesheets/generations".to_string()); + assert!( + validate_manifest_required_visual_asset(&root, &manifest, "design-foundation") + .expect_err("wrong UI route must fail") + .contains("legacy") + ); + + let ui = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/ui-prototype.png") + .expect("ui prototype asset"); + ui.source.generation_route = Some("/api/external/v1/editor/images/generations".to_string()); + let spritesheet = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spritesheet.png") + .expect("art spritesheet asset"); + spritesheet.source.generation_kind = Some("spec".to_string()); + assert!( + validate_manifest_required_visual_asset(&root, &manifest, "art-asset-plan") + .expect_err("wrong spritesheet kind must fail") + .contains("legacy") + ); + + let spritesheet = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spritesheet.png") + .expect("art spritesheet asset"); + spritesheet.source.generation_kind = Some("icon-spritesheet".to_string()); + let art_spec = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spec.png") + .expect("art spec asset"); + art_spec.source.resource_id = Some("resource-icon-spec-v2".to_string()); + for task_id in ["design-foundation", "art-asset-plan"] { + assert!( + validate_manifest_required_visual_asset(&root, &manifest, task_id) + .unwrap_err() + .contains("未精确引用当前统一视觉规范图"), + "{task_id} must become stale after art-spec resource changes" + ); + } + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_can_start_local_preview() { let root = unique_project_path(); @@ -549,6 +631,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { let (canvas_sender, canvas_receiver) = mpsc::channel(); let canvas_base_url = spawn_mock_external_canvas_generation_api_server(Some(canvas_sender)); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); write_project_permission_policy_at( &root, ProjectPermissionPolicy { @@ -640,6 +723,9 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { .observations .iter() .any(|item| item.contains("canvas.asset_generate:ok · 已生成美术素材:"))); + assert!(runtime.observations.iter().any(|item| { + item.contains("透明图集可用,但自动切片未完成:测试图集保持整图,未生成独立切片。") + })); let revision = read_game_creator_agent_runtime_project_revision(&root) .expect("read generated art revision") .revision; @@ -663,19 +749,36 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { let manifest: Value = serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) .expect("manifest json"); - let asset = &manifest["assets"][0]; + let asset = manifest["assets"] + .as_array() + .and_then(|assets| { + assets + .iter() + .find(|asset| asset["localPath"].as_str() == Some("assets/art-spritesheet.png")) + }) + .expect("generated art spritesheet asset"); assert_eq!(asset["source"]["kind"], "canvas"); assert_eq!(asset["source"]["resourceId"], "resource-1"); + assert_eq!( + asset["source"]["generationRoute"], + "/api/external/v1/editor/icon-spritesheets/generations" + ); + assert_eq!(asset["source"]["generationKind"], "icon-spritesheet"); + assert_eq!( + asset["source"]["referenceResourceIds"], + serde_json::json!(["resource-icon-spec"]) + ); assert_eq!(asset["kind"], "art-spritesheet"); assert_eq!(asset["localPath"], "assets/art-spritesheet.png"); assert_eq!( fs::read(root.join(asset["localPath"].as_str().unwrap())).unwrap(), - valid_test_png_bytes() + transparent_test_png_bytes() ); let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); assert!(agent_db.contains("\"recordType\":\"canvas.asset_generate\"")); assert!(agent_db.contains("\"recordType\":\"agent.runtime.canvas.asset_generate\"")); assert!(agent_db.contains("\"agentId\":\"art-asset-plan\"")); + assert!(agent_db.contains("测试图集保持整图,未生成独立切片。")); assert!(!agent_db.contains("editor-runtime-key")); let canvas_requests = (0..5) .map(|_| { @@ -686,13 +789,18 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { .collect::>(); let generation_request = canvas_requests .iter() - .find(|request| request.starts_with("POST /api/external/v1/editor/images/generations ")) + .find(|request| { + request.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ") + }) .expect("canvas generation request"); for expected in [ + r#""referenceImageSrc":"resource-icon-spec""#, + r#""iconDescriptions":"#, + r#""screenColor":"auto""#, r#""projectId":"canvas-project-1""#, r#""assetFolderId":"folder-1""#, r#""canvasCompletion":"#, - r#""assetKind":"art-spritesheet""#, + r#""assetLabel":"游戏首版核心美术素材""#, r#""aspectRatio":"1:1""#, ] { assert!( @@ -705,6 +813,94 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { fs::remove_dir_all(config_dir).ok(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { + async fn capture_generation_request( + root: &Path, + config_dir: &Path, + options: PlatformArtAssetGenerationOptions, + ) -> String { + let (request_sender, request_receiver) = mpsc::channel(); + let canvas_base_url = + spawn_mock_external_canvas_generation_api_server(Some(request_sender)); + fs::create_dir_all(config_dir).expect("create runtime config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { + "baseUrl": canvas_base_url, + "apiKey": "editor-runtime-key" + } + }) + .to_string(), + ) + .expect("write runtime config"); + let _config_guard = use_test_runtime_config_dir(config_dir.to_path_buf()); + request_platform_art_asset_with_options_for_test(root, "原创贪吃蛇视觉", &options) + .await + .expect("prepare canonical visual request"); + (0..5) + .map(|_| { + request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("canvas api request") + }) + .find(|request| request.starts_with("POST ")) + .expect("generation request") + } + + let root = unique_project_path(); + let spec_config_dir = unique_project_path(); + init_local_game_project_at(&root, "project-visual-chain", "月光厨房").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow visual generation"); + let spec_request = capture_generation_request( + &root, + &spec_config_dir, + PlatformArtAssetGenerationOptions { + output_path: Some("assets/art-spec.png".to_string()), + asset_kind: "icon-spec".to_string(), + asset_label: "游戏统一视觉规范图".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }, + ) + .await; + assert!(spec_request.starts_with("POST /api/external/v1/editor/images/generations ")); + assert!(spec_request.contains(r#""kind":"spec""#)); + assert!(spec_request.contains(r#""assetKind":"icon-spec""#)); + assert!(spec_request.contains(r#""referenceImageSrcs":[]"#)); + + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + let ui_config_dir = unique_project_path(); + let ui_request = capture_generation_request( + &root, + &ui_config_dir, + PlatformArtAssetGenerationOptions { + output_path: Some("assets/ui-prototype.png".to_string()), + aspect_ratio: "16:9".to_string(), + image_size: "2K".to_string(), + asset_kind: "ui-prototype".to_string(), + asset_label: "游戏横屏界面原型图".to_string(), + replace_existing: false, + }, + ) + .await; + assert!(ui_request.starts_with("POST /api/external/v1/editor/images/generations ")); + assert!(ui_request.contains(r#""kind":"ui-design""#)); + assert!(ui_request.contains(r#""referenceImageSrcs":["resource-icon-spec"]"#)); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(spec_config_dir).ok(); + fs::remove_dir_all(ui_config_dir).ok(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_manifest() { let root = unique_project_path(); @@ -717,6 +913,7 @@ async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_m ); init_local_game_project_at(&root, "project-canvas-request-lock", "月光厨房") .expect("project init"); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); write_project_permission_policy_at( &root, ProjectPermissionPolicy { @@ -763,7 +960,7 @@ async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_m let captured = request_receiver .recv_timeout(Duration::from_secs(15)) .expect("canvas request before generation response"); - if captured.starts_with("POST /api/external/v1/editor/images/generations ") { + if captured.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ") { saw_generation_request = true; break; } @@ -1224,6 +1421,9 @@ fn register_local_asset_records_existing_asset_with_canvas_source() { task_id: Some("task-1".to_string()), prompt: Some("像素主角".to_string()), model: Some("image-model".to_string()), + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), }, ) .expect("asset register"); @@ -1263,6 +1463,9 @@ fn register_local_asset_records_existing_asset_with_canvas_source() { task_id: None, prompt: None, model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), }, ) .expect("asset update"); @@ -1288,6 +1491,9 @@ fn register_local_asset_rejects_missing_or_unsafe_path() { task_id: None, prompt: None, model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), }; assert!(register_local_asset_at( @@ -2536,6 +2742,109 @@ fn workspace_window_url_carries_encoded_project_path() { ); } +#[test] +fn game_chat_launch_args_are_strict_and_keep_normal_start_compatible() { + assert_eq!( + parse_game_chat_launch_args(&[]).expect("parse normal GUI start"), + None + ); + assert_eq!( + parse_game_chat_launch_args(&["--llm-status".to_string()]) + .expect("leave existing CLI command untouched"), + None + ); + assert_eq!( + parse_game_chat_launch_args(&["--game-chat".to_string()]) + .expect("parse game chat") + .expect("game chat options"), + GameChatLaunchOptions { + project_path: None, + initial_message: None, + } + ); + assert_eq!( + parse_game_chat_launch_args(&[ + "--game-chat".to_string(), + "--project-path".to_string(), + " /tmp/AI Game 项目 ".to_string(), + ]) + .expect("parse game chat project") + .expect("game chat project options"), + GameChatLaunchOptions { + project_path: Some("/tmp/AI Game 项目".to_string()), + initial_message: None, + } + ); + assert_eq!( + parse_game_chat_launch_args(&[ + "--game-chat".to_string(), + "--project-path".to_string(), + "/tmp/game".to_string(), + "--initial-message".to_string(), + "继续完成贪吃蛇".to_string(), + ]) + .expect("parse game chat initial message") + .expect("game chat initial message options"), + GameChatLaunchOptions { + project_path: Some("/tmp/game".to_string()), + initial_message: Some("继续完成贪吃蛇".to_string()), + } + ); + + for invalid in [ + vec!["--project-path", "/tmp/game"], + vec!["--game-chat", "--project-path"], + vec!["--game-chat", "--project-path", "relative-game"], + vec!["--game-chat", "--project-path", "/tmp/game\nnext"], + vec!["--game-chat", "--game-chat"], + vec!["--game-chat", "--initial-message", ""], + vec!["--game-chat", "--initial-message", "bad\nmessage"], + vec![ + "--game-chat", + "--initial-message", + "first", + "--initial-message", + "second", + ], + vec![ + "--game-chat", + "--project-path", + "/tmp/game", + "--project-path", + "/tmp/other", + ], + vec!["--game-chat", "--llm-status"], + vec!["--agent-run", "--game-chat"], + vec!["--game-chat", "--config-dir", "/tmp/appdata"], + ] { + let invalid = invalid.into_iter().map(str::to_string).collect::>(); + assert!( + parse_game_chat_launch_args(&invalid).is_err(), + "unexpected valid game chat args: {invalid:?}" + ); + } +} + +#[test] +fn game_chat_window_url_encodes_optional_project_path() { + assert_eq!( + game_chat_window_url(None, None).to_string(), + "index.html?game-chat" + ); + assert_eq!( + game_chat_window_url(Some("/tmp/AI Game 项目"), None).to_string(), + "index.html?game-chat&projectPath=%2Ftmp%2FAI%20Game%20%E9%A1%B9%E7%9B%AE" + ); + assert_eq!( + game_chat_window_url(Some("/tmp/a&b?#%+c"), None).to_string(), + "index.html?game-chat&projectPath=%2Ftmp%2Fa%26b%3F%23%25%2Bc" + ); + assert_eq!( + game_chat_window_url(Some("/tmp/game"), Some("继续 & 验证")).to_string(), + "index.html?game-chat&projectPath=%2Ftmp%2Fgame&initialMessage=%E7%BB%A7%E7%BB%AD%20%26%20%E9%AA%8C%E8%AF%81" + ); +} + #[test] fn workspace_window_project_path_requires_absolute_path() { assert!(validate_workspace_window_project_path(" /tmp/game ").is_ok()); @@ -2832,22 +3141,21 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { replace_existing: false, }; let prompt = build_platform_art_asset_prompt( - "原创花园防守玩法,需要清楚的资源、波次和操作信息", + "原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开", &[], &options, ); for expected in [ "真正的游戏 UI/UX 原型图", - "资源数值与波次", - "单位卡牌", - "战场网格", - "敌人来袭方向", - "开始、暂停、重开控件", + "不得自行假设它属于塔防", + "状态 HUD", + "主要可玩区域", + "目标/收集物/危险物", + "失败状态与重新开始", + "键盘和触控提示", "禁止只画", - "玩法类型和机制词只用于理解功能", - "转换为新的原创命名", - "Peashooter", - "原创花园防守玩法", + "玩法机制只用于理解功能", + "原创网格贪吃蛇", ] { assert!( prompt.contains(expected), @@ -2860,12 +3168,13 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { assert_eq!(art_spec["format"], "16:9 2K"); for expected in [ "HUD", - "单位卡槽", - "波次", - "暂停", + "主要可玩区域", + "目标或收集物说明", + "失败与重开状态", + "不得假设项目属于塔防", "无 HUD 的场景插画", "原创标题", - "单位名", + "角色轮廓", ] { assert!( art_spec.to_string().contains(expected), @@ -2875,21 +3184,24 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { } #[test] -fn art_spritesheet_generation_prompt_enforces_original_visual_language() { +fn art_spritesheet_generation_prompt_uses_current_game_instead_of_fixed_tower_defense() { let options = PlatformArtAssetGenerationOptions { output_path: Some("assets/art-spritesheet.png".to_string()), asset_kind: "art-spritesheet".to_string(), asset_label: "游戏首版核心美术素材".to_string(), ..PlatformArtAssetGenerationOptions::default() }; - let prompt = build_platform_art_asset_prompt("制作原创植物塔防小游戏", &[], &options); + let prompt = build_platform_art_asset_prompt( + "原创网格贪吃蛇,需要蛇头、直身、转角、尾部和四类食物", + &[], + &options, + ); for expected in [ - "原创核心美术素材图集", - "带脸向日葵", - "嘴状豌豆炮管", - "僵尸人形", + "原创透明核心美术素材图集", + "严格从用户需求和美术 brief 提取", + "不得自行假设为塔防", + "蛇头、直身、转角、尾部和四类食物", "不得把知名角色改色", - "晶体、菌丝、雾气、陶质器物、潮汐生态构装体", ] { assert!( prompt.contains(expected), @@ -2900,10 +3212,10 @@ fn art_spritesheet_generation_prompt_enforces_original_visual_language() { let art_spec = platform_art_asset_art_spec(&options).to_string(); for expected in [ "原创 Web 游戏素材图集", - "带脸向日葵", - "嘴状豌豆炮管", - "草坪横排", - "植物或生态主题不得直接画成", + "不预设塔防或其他固定玩法", + "玩家主体及状态", + "目标或收集物", + "不得擅自加入单位卡牌", ] { assert!(art_spec.contains(expected), "art spec missing {expected}"); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs index 97a8d81cb..4ff135ddf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs @@ -5624,6 +5624,68 @@ fn project_write_lock_rejects_parallel_writer_and_releases_on_drop() { fs::remove_dir_all(root).ok(); } +#[test] +fn steer_consumption_waits_across_a_legitimate_longer_project_write() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "steer lock wait").expect("project init"); + let lock = acquire_project_write_lock(&root, "test.steer.concurrent-writer") + .expect("acquire competing project lock"); + let release = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(1_250)); + drop(lock); + }); + + let acquired = acquire_game_creator_agent_runtime_steer_project_write_lock_with_wait(&root) + .expect("steer consumption should outwait a legitimate project projection"); + drop(acquired); + release.join().expect("release competing project lock"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn provider_plan_waits_across_an_autonomous_manifest_wave_project_write() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "provider plan manifest lock wait") + .expect("project init"); + let lock = acquire_project_write_lock(&root, "runtime.autonomous.schedule_ready") + .expect("acquire manifest-wave project lock"); + let release = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(1_250)); + drop(lock); + }); + + let acquired = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( + &root, + "runtime.provider_request.build.tool_plan", + ) + .expect("provider plan should outwait the manifest wave reservation"); + drop(acquired); + release.join().expect("release manifest-wave project lock"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn runtime_parallel_read_projection_waits_across_a_manifest_wave_project_write() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "parallel read projection lock wait") + .expect("project init"); + let lock = acquire_project_write_lock(&root, "runtime.autonomous.schedule_ready") + .expect("acquire manifest-wave project lock"); + let release = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(1_250)); + drop(lock); + }); + + let acquired = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "runtime.parallel_read.project", + ) + .expect("parallel read projection should outwait the manifest wave reservation"); + drop(acquired); + release.join().expect("release manifest-wave project lock"); + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn agent_runtime_file_write_waits_for_short_parallel_project_writer() { let root = unique_project_path(); @@ -5963,6 +6025,9 @@ fn local_project_image_preview_obeys_auto_file_read_policy() { task_id: None, prompt: None, model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), }, ) .expect("register preview asset"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index 231012c47..936a45962 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -7213,7 +7213,13 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { ); assert_eq!( canvas_asset.parameters["properties"]["input"]["properties"]["assetKind"]["enum"], - serde_json::json!(["game-art", "ui-prototype", "art-spritesheet", null]) + serde_json::json!([ + "game-art", + "icon-spec", + "ui-prototype", + "art-spritesheet", + null + ]) ); let delegate_name = native_runtime_function_name("agent.delegate").expect("delegate name"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs index e8bdf8e91..3a9a597fc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs @@ -969,6 +969,7 @@ fn visual_specialist_finalization_requires_existing_registered_canvas_image() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "视觉 Runtime 完成门禁测试") .expect("project init"); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); let run_id = format!("visual-finalization-{agent_id}"); let state = start_game_creator_agent_runtime_task_at( &root, @@ -997,7 +998,7 @@ fn visual_specialist_finalization_requires_existing_registered_canvas_image() { _ => panic!("missing image must block finalization"), }; assert_eq!(blocker.tool, "runtime.visual_asset"); - assert!(blocker.summary.contains("尚未生成并登记")); + assert!(blocker.summary.contains("不能完成任务")); assert!(blocker .detail .as_deref() @@ -1006,7 +1007,15 @@ fn visual_specialist_finalization_requires_existing_registered_canvas_image() { let absolute_path = root.join(local_path); fs::create_dir_all(absolute_path.parent().expect("visual parent")) .expect("create unregistered visual directory"); - fs::write(&absolute_path, valid_test_png_bytes()).expect("write unregistered visual image"); + fs::write( + &absolute_path, + if kind == "art-spritesheet" { + transparent_test_png_bytes() + } else { + valid_test_png_bytes() + }, + ) + .expect("write unregistered visual image"); let unregistered = finish_game_creator_agent_background_runtime_turn_at( &root, state.clone(), @@ -1035,9 +1044,60 @@ fn visual_specialist_finalization_requires_existing_registered_canvas_image() { task_id: Some(format!("task-{kind}")), prompt: Some("测试视觉资产".to_string()), model: Some("gpt-image-2".to_string()), + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), }, ) - .expect("register required canvas image"); + .expect("register legacy canvas image"); + let legacy = finish_game_creator_agent_background_runtime_turn_at( + &root, + state.clone(), + "旧文件不能冒充正式视觉产物", + revision, + &[], + ) + .expect("legacy image is a recoverable blocker"); + assert!(matches!( + legacy, + AgentBackgroundFinalizationOutcome::Stale(ref blocker) + if blocker.tool == "runtime.visual_asset" + && blocker.detail.as_deref().is_some_and(|detail| detail.contains("legacy")) + )); + register_local_asset_at( + &root, + local_path, + kind, + "image/png", + "canvas", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("canvas-project-1".to_string()), + resource_id: Some(format!("resource-{kind}")), + asset_object_id: Some(format!("asset-object-{kind}")), + task_id: Some(format!("task-{kind}")), + prompt: Some("测试视觉资产".to_string()), + model: Some("gpt-image-2".to_string()), + generation_route: Some( + if kind == "ui-prototype" { + "/api/external/v1/editor/images/generations" + } else { + "/api/external/v1/editor/icon-spritesheets/generations" + } + .to_string(), + ), + generation_kind: Some( + if kind == "ui-prototype" { + "ui-design" + } else { + "icon-spritesheet" + } + .to_string(), + ), + reference_resource_ids: vec!["resource-icon-spec".to_string()], + }, + ) + .expect("register provenanced canvas image"); if agent_id == "design-foundation" { append_ui_prototype_inspection_fixture(&root, &run_id, true); } 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 118a39c7b..e4cf0c63e 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 @@ -748,7 +748,10 @@ async fn background_agent_runtime_can_schedule_ready_manifest_tasks() { async fn background_agent_runtime_can_schedule_ready_tasks_from_tool() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); + update_manifest_task_status_at(&root, "art-director", GameCreationAppTaskStatus::Completed) + .expect("complete art spec dependency fixture"); write_project_permission_policy_at( &root, ProjectPermissionPolicy { @@ -795,7 +798,7 @@ async fn background_agent_runtime_can_schedule_ready_tasks_from_tool() { "reason": "完成前核对固定路径图片是否是真正的 UI 原型", "input": { "paths": ["assets/ui-prototype.png"], - "question": "执行 ui-prototype.v1 八项完成检查" + "question": "执行 ui-prototype.v2 八项完成检查" } } ], @@ -863,7 +866,7 @@ async fn background_agent_runtime_can_schedule_ready_tasks_from_tool() { let foundation_inspection_request = foundation_receiver .recv_timeout(Duration::from_secs(2)) .expect("foundation UI prototype inspection request"); - assert!(foundation_inspection_request.contains("resourceBar")); + assert!(foundation_inspection_request.contains("informationHud")); assert!(foundation_inspection_request.contains("assets/ui-prototype.png")); let foundation_update_request = foundation_receiver .recv_timeout(Duration::from_secs(2)) @@ -1880,7 +1883,17 @@ async fn design_ui_image_inspect_fails_scene_and_persists_canonical_checks() { let provider_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("UI inspection provider request"); - assert!(provider_request.contains("resourceBar")); + assert!(provider_request.contains("informationHud")); + assert!(provider_request.contains("不要假设游戏一定属于塔防")); + for legacy_key in [ + "resourceBar", + "unitCardTray", + "battlefieldGrid", + "enemyEntryDirection", + "waveStatus", + ] { + assert!(!provider_request.contains(legacy_key)); + } assert!(provider_request.contains("不能依据文件名")); let records = read_agent_db_records_for_test(&root); @@ -1895,8 +1908,8 @@ async fn design_ui_image_inspect_fails_scene_and_persists_canonical_checks() { AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE ); assert_eq!(audit["passed"], false); - assert_eq!(audit["checks"]["resourceBar"], false); - assert_eq!(audit["checks"]["battlefieldGrid"], true); + assert_eq!(audit["checks"]["informationHud"], false); + assert_eq!(audit["checks"]["gameplaySurface"], true); assert!(audit["issues"] .as_array() .is_some_and(|issues| !issues.is_empty())); @@ -1914,6 +1927,61 @@ async fn design_ui_image_inspect_fails_scene_and_persists_canonical_checks() { fs::remove_dir_all(root).ok(); } +#[test] +fn image_inspect_safe_receipt_keeps_legacy_v1_audit_readable() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "旧 UI 审计兼容测试").expect("project init"); + let observation = AgentRuntimeToolObservation { + tool: "image.inspect".to_string(), + status: "ok".to_string(), + summary: "旧 UI 原型视觉检查已通过".to_string(), + detail: Some( + serde_json::json!({ + "images": [{ + "path": "assets/ui-prototype.png", + "sha256": "a".repeat(64), + "bytes": 1024, + }], + "responseId": "resp_legacy_ui_audit", + "conclusionChars": 20, + "conclusion": "历史 v1 审计只用于读取。", + "inspectionKind": AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, + "validationProfile": AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, + "passed": true, + "checks": { + "resourceBar": true, + "unitCardTray": true, + "battlefieldGrid": true, + "enemyEntryDirection": true, + "waveStatus": true, + "primaryControls": true, + "implementationClarity": true, + "originalTheme": true, + }, + "issues": [], + }) + .to_string(), + ), + }; + + let safe = agent_runtime_action_receipt_safe_detail_for_owner_for_test( + &root, + "design-foundation", + "legacy-ui-audit-run", + &observation, + ) + .expect("legacy v1 receipt remains safely readable"); + let safe = serde_json::from_str::(&safe).expect("parse safe legacy detail"); + assert_eq!( + safe["validationProfile"], + AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE + ); + assert_eq!(safe["checks"]["resourceBar"], true); + assert!(safe.get("conclusion").is_none()); + + fs::remove_dir_all(root).ok(); +} + #[test] fn seed_refresh_downgrades_completed_visual_tasks_when_registered_file_is_missing() { let _config_guard = crate::tests::write_test_local_config( @@ -1921,11 +1989,12 @@ fn seed_refresh_downgrades_completed_visual_tasks_when_registered_file_is_missin ); let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "旧视觉任务升级测试").expect("project init"); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); let mut manifest = read_manifest_for_project(&root).expect("manifest with visual assets"); - for task_id in ["design-foundation", "art-asset-plan"] { + for task_id in ["art-director", "design-foundation", "art-asset-plan"] { manifest .tasks .iter_mut() diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs index ba3a41b23..909dccd90 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs @@ -446,13 +446,18 @@ fn autonomous_visual_gate_degrades_to_text_without_key_and_requires_images_with_ br#"{"assets":[],"degradedToText":true}"#, ) .expect("write text art manifest"); - for task_id in ["design-foundation", "art-asset-plan"] { + for task_id in ["art-director", "design-foundation", "art-asset-plan"] { update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Completed) .expect("complete text-only visual owner task"); } + assert!(!root.join("assets/art-spec.png").exists()); assert!(!root.join("assets/ui-prototype.png").exists()); assert!(!root.join("assets/art-spritesheet.png").exists()); let without_key = autonomous_seed_task_statuses_for_test(&root); + assert_eq!( + without_key.get("art-director"), + Some(&GameCreationAppTaskStatus::Completed) + ); assert_eq!( without_key.get("design-foundation"), Some(&GameCreationAppTaskStatus::Completed) @@ -464,6 +469,10 @@ fn autonomous_visual_gate_degrades_to_text_without_key_and_requires_images_with_ write_autonomous_editor_api_config_for_test(&config_dir, "editor-runtime-test-key"); let with_key_missing_images = autonomous_seed_task_statuses_for_test(&root); + assert_eq!( + with_key_missing_images.get("art-director"), + Some(&GameCreationAppTaskStatus::Pending) + ); assert_eq!( with_key_missing_images.get("design-foundation"), Some(&GameCreationAppTaskStatus::Pending) @@ -473,13 +482,18 @@ fn autonomous_visual_gate_degrades_to_text_without_key_and_requires_images_with_ Some(&GameCreationAppTaskStatus::Pending) ); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); - for task_id in ["design-foundation", "art-asset-plan"] { + for task_id in ["art-director", "design-foundation", "art-asset-plan"] { update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Completed) .expect("complete visual owner task with registered image"); } let with_key_and_images = autonomous_seed_task_statuses_for_test(&root); + assert_eq!( + with_key_and_images.get("art-director"), + Some(&GameCreationAppTaskStatus::Completed) + ); assert_eq!( with_key_and_images.get("design-foundation"), Some(&GameCreationAppTaskStatus::Completed) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index 5af24a99a..0f172d891 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -31,10 +31,12 @@ pub(super) use super::super::{ }; pub(super) use crate::{ - advance_game_creator_agent_runtime_turn_at, agent_runtime_contains_secret_key_prefix, - agent_runtime_executable_tools, agent_runtime_read_only_delivery_completion_plan_update, - agent_runtime_run_profile_identity_at, agent_runtime_tool_action_fingerprint, - agent_runtime_tool_action_id, agent_runtime_tool_policy_snapshot_for_run_at, + advance_game_creator_agent_runtime_turn_at, + agent_runtime_action_receipt_safe_detail_for_owner_for_test, + agent_runtime_contains_secret_key_prefix, agent_runtime_executable_tools, + agent_runtime_read_only_delivery_completion_plan_update, agent_runtime_run_profile_identity_at, + agent_runtime_tool_action_fingerprint, agent_runtime_tool_action_id, + agent_runtime_tool_policy_snapshot_for_run_at, agent_runtime_tool_requires_pending_revision_gate, agent_runtime_tool_requires_repository_context_fingerprint_gate, agent_runtime_verified_delivery_completion_plan_update, append_agent_db_record, @@ -104,7 +106,9 @@ pub(super) use crate::{ AGENT_RUNTIME_RESPOND_FUNCTION_NAME, AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_RUN_PROFILE_STANDARD, AGENT_RUNTIME_SCHEMA_VERSION, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION, AGENT_RUNTIME_UI_PROTOTYPE_PATH, + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION, + AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, + AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, AGENT_RUNTIME_UI_PROTOTYPE_PATH, AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_CONFIG_FILE_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, GAME_CREATOR_USER_INPUT_REQUEST_TOOL, PROJECT_BLACKBOARD_MEMORY_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 b7ed4d4cc..78bd26122 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 @@ -2987,17 +2987,23 @@ async fn task_update_requires_registered_visual_asset_before_completion() { r#"{"editorApi":{"apiKey":"visual-task-update-test-key"}}"#.to_string(), ); for (task_id, local_path, kind, missing_summary) in [ + ( + "art-director", + "assets/art-spec.png", + "icon-spec", + "统一视觉规范图尚未按正式视觉流程生成并登记", + ), ( "design-foundation", "assets/ui-prototype.png", "ui-prototype", - "策划界面原型图尚未生成并登记", + "策划界面原型图尚未按正式视觉流程生成并登记", ), ( "art-asset-plan", "assets/art-spritesheet.png", "art-spritesheet", - "首版美术素材图尚未生成并登记", + "首版美术素材图尚未按正式视觉流程生成并登记", ), ] { let root = unique_project_path(); @@ -3023,7 +3029,7 @@ async fn task_update_requires_registered_visual_asset_before_completion() { }; let missing = execute_game_creator_agent_runtime_tool_action_with_action_id( &root, - "art-director", + task_id, &run_id, "完成视觉任务", &action, @@ -3043,12 +3049,18 @@ async fn task_update_requires_registered_visual_asset_before_completion() { GameCreationAppTaskStatus::Pending ); + if task_id != "art-director" { + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + } + if task_id == "art-asset-plan" { + register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); + } register_canvas_visual_asset_fixture(&root, local_path, kind); if task_id == "design-foundation" { append_ui_prototype_inspection_fixture(&root, &run_id, false); let scene_rejected = execute_game_creator_agent_runtime_tool_action_with_action_id( &root, - "art-director", + task_id, &run_id, "拒绝用场景图完成 UI 原型任务", &action, @@ -3072,7 +3084,7 @@ async fn task_update_requires_registered_visual_asset_before_completion() { } let completed = execute_game_creator_agent_runtime_tool_action_with_action_id( &root, - "art-director", + task_id, &run_id, "完成视觉任务", &action, diff --git a/apps/ai-game-creator-shell/src-tauri/src/windows.rs b/apps/ai-game-creator-shell/src-tauri/src/windows.rs index c917b66e0..70428d311 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/windows.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/windows.rs @@ -1,5 +1,61 @@ use super::*; +const GAME_CHAT_LAUNCH_USAGE: &str = + "用法:--game-chat [--project-path <本地项目绝对路径>] [--initial-message <首条消息>]"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct GameChatLaunchOptions { + pub(crate) project_path: Option, + pub(crate) initial_message: Option, +} + +pub(crate) fn parse_game_chat_launch_args( + args: &[String], +) -> Result, String> { + let has_game_chat_arg = args.iter().any(|arg| { + matches!( + arg.as_str(), + "--game-chat" | "--project-path" | "--initial-message" + ) + }); + if !has_game_chat_arg { + return Ok(None); + } + if args.first().map(String::as_str) != Some("--game-chat") { + return Err(GAME_CHAT_LAUNCH_USAGE.to_string()); + } + let mut project_path = None; + let mut initial_message = None; + let mut index = 1; + while index < args.len() { + let flag = args[index].as_str(); + let value = args + .get(index + 1) + .ok_or_else(|| GAME_CHAT_LAUNCH_USAGE.to_string())?; + match flag { + "--project-path" if project_path.is_none() => { + project_path = Some(validate_workspace_window_project_path(value)?.to_string()); + } + "--initial-message" if initial_message.is_none() => { + let value = value.trim(); + if value.is_empty() + || value.chars().count() > 4_000 + || value.chars().any(char::is_control) + { + return Err(GAME_CHAT_LAUNCH_USAGE.to_string()); + } + initial_message = Some(value.to_string()); + } + _ => return Err(GAME_CHAT_LAUNCH_USAGE.to_string()), + } + index += 2; + } + Ok(Some(GameChatLaunchOptions { + project_path, + initial_message, + })) +} + pub(crate) fn workspace_window_url(project_path: &str) -> tauri::WebviewUrl { tauri::WebviewUrl::App(PathBuf::from(format!( "index.html?main&projectPath={}", @@ -22,6 +78,29 @@ pub(crate) fn supervisor_chat_window_url(project_path: &str) -> tauri::WebviewUr ))) } +#[cfg(test)] +pub(crate) fn game_chat_window_url( + project_path: Option<&str>, + initial_message: Option<&str>, +) -> tauri::WebviewUrl { + let query = game_chat_window_query(project_path, initial_message); + tauri::WebviewUrl::App(PathBuf::from(format!("index.html?{query}"))) +} + +#[cfg(any(debug_assertions, test))] +fn game_chat_window_query(project_path: Option<&str>, initial_message: Option<&str>) -> String { + let mut query = "game-chat".to_string(); + if let Some(project_path) = project_path { + query.push_str("&projectPath="); + query.push_str(&percent_encode_query_value(project_path)); + } + if let Some(initial_message) = initial_message { + query.push_str("&initialMessage="); + query.push_str(&percent_encode_query_value(initial_message)); + } + query +} + pub(crate) fn validate_workspace_window_project_path(project_path: &str) -> Result<&str, String> { let project_path = project_path.trim(); if project_path.is_empty() { @@ -150,3 +229,22 @@ pub(crate) fn open_developer_window(app: &tauri::AppHandle) -> Result<(), String .map_err(|error| error.to_string())?; Ok(()) } + +#[cfg(all(debug_assertions, not(test)))] +pub(crate) fn navigate_client_to_game_chat( + app: &tauri::AppHandle, + options: &GameChatLaunchOptions, +) -> Result<(), String> { + let client = app + .get_webview_window("client") + .ok_or_else(|| "找不到 AI 游戏创作客户端窗口".to_string())?; + let mut url = client.url().map_err(|error| error.to_string())?; + url.set_path("/index.html"); + url.set_query(Some(&game_chat_window_query( + options.project_path.as_deref(), + options.initial_message.as_deref(), + ))); + url.set_fragment(None); + client.navigate(url).map_err(|error| error.to_string())?; + Ok(()) +} diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index cb13791ee..06dca00fb 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -64,6 +64,7 @@ import type { LocalGameMemoryResult, LocalPreviewResult, LocalPreviewStatus, + LocalProjectDirectoryStatus, LocalProjectCheckpointResult, LocalProjectCheckpointSummary, LocalProjectDiffResult, @@ -220,13 +221,106 @@ import { import { handleProjectSummaryChatCommand } from './features/project-workspace/projectSummaryCommands'; import { ProjectSupervisorView } from './features/project-workspace/ProjectSupervisorView'; import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWorkspaceChatPane'; -import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView'; +import { resolveEmbeddedPreviewUrl } from './features/project-workspace/LocalGamePreviewFrame'; +import { + buildGameChatProgressEvidence, + collectGameChatResultImages, + formatGameChatStageRecord, + SupervisorChatOnlyView, +} from './features/project-workspace/SupervisorChatOnlyView'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; import { type ProjectAgentResultSummary, type ProjectAgentRuntimeSummary, } from './view/project-development'; +const initialSupervisorMessageClaimsByPage = new WeakMap>(); +const GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY = + 'genarrative.game-chat.auto-preview-authorization.v1'; + +type GameChatAutoPreviewAuthorization = { + projectPath: string; + runId: string; +}; + +function readStoredGameChatAutoPreviewAuthorization(): GameChatAutoPreviewAuthorization | null { + try { + const raw = window.localStorage.getItem( + GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY, + ); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw) as Partial; + const projectPath = parsed.projectPath?.trim() ?? ''; + const runId = parsed.runId?.trim() ?? ''; + if ( + !projectPath || + !runId || + !isAbsoluteProjectPath(projectPath) || + projectPathHasControlCharacter(projectPath) || + projectPathHasControlCharacter(runId) + ) { + window.localStorage.removeItem( + GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY, + ); + return null; + } + return { projectPath, runId }; + } catch { + return null; + } +} + +function storeGameChatAutoPreviewAuthorization( + authorization: GameChatAutoPreviewAuthorization | null, +) { + try { + if (authorization) { + window.localStorage.setItem( + GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY, + JSON.stringify(authorization), + ); + } else { + window.localStorage.removeItem( + GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY, + ); + } + } catch { + // A volatile in-memory authorization remains sufficient when storage is unavailable. + } +} + +function claimInitialSupervisorMessageForPage(projectPath: string) { + let claimedProjectPaths = initialSupervisorMessageClaimsByPage.get(window); + if (!claimedProjectPaths) { + claimedProjectPaths = new Set(); + initialSupervisorMessageClaimsByPage.set(window, claimedProjectPaths); + } + if (claimedProjectPaths.has(projectPath)) { + return false; + } + claimedProjectPaths.add(projectPath); + return true; +} + +export function consumeInitialGameChatMessage( + searchParams: URLSearchParams, + location: Pick, + replaceUrl: (url: string) => void, +) { + const message = searchParams.get('initialMessage')?.trim() ?? ''; + if (!message) { + return ''; + } + searchParams.delete('initialMessage'); + const remainingSearch = searchParams.toString(); + replaceUrl( + `${location.pathname}${remainingSearch ? `?${remainingSearch}` : ''}${location.hash}`, + ); + return message; +} + export { AuthenticatedClient } from './app/AuthenticatedClient'; export type { PendingCommand } from './app/types'; export { @@ -252,6 +346,8 @@ type AppProps = { initialProjectManifest?: GameCreationAppManifest; projectSupervisorOnly?: boolean; supervisorChatOnly?: boolean; + gameChatOnly?: boolean; + initialSupervisorMessage?: string; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], @@ -264,6 +360,8 @@ export function App({ initialProjectManifest, projectSupervisorOnly = false, supervisorChatOnly = false, + gameChatOnly = false, + initialSupervisorMessage = '', onPreviewChange, onAgentRuntimeSummariesChange, onAgentResultsChange, @@ -274,12 +372,14 @@ export function App({ const [initialProjectPath] = useState( () => initialProjectPathOverride || readInitialProjectPath(), ); + const eagerSupervisorProject = + projectSupervisorOnly && Boolean(initialProjectPath) && !gameChatOnly; const [projectPath, setProjectPath] = useState( initialProjectPath || defaultProjectPath, ); const [localProject, setLocalProject] = useState(() => - projectSupervisorOnly && initialProjectPath + eagerSupervisorProject ? { projectPath: initialProjectPath, manifestPath: `${initialProjectPath.replace(/[\\/]+$/, '')}/.agent/manifest.json`, @@ -293,10 +393,28 @@ export function App({ initialProjectManifest ?? seedManifest, ); const [projectStatus, setProjectStatus] = useState( - projectSupervisorOnly && initialProjectPath ? '已初始化' : '未初始化', + eagerSupervisorProject ? '已初始化' : '未初始化', ); const [preview, setPreview] = useState(null); + const gameChatPreviewRef = useRef(null); + gameChatPreviewRef.current = preview; const [previewStatus, setPreviewStatus] = useState('未启动'); + const previewStatusRef = useRef(previewStatus); + previewStatusRef.current = previewStatus; + const [gameChatProjectSelectionBusy, setGameChatProjectSelectionBusy] = + useState(false); + const gameChatProjectSelectionVersionRef = useRef(0); + const gameChatAutoPreviewAuthorizationRef = + useRef( + readStoredGameChatAutoPreviewAuthorization(), + ); + const gameChatAutoPreviewAttemptedRef = useRef(new Set()); + const gameChatObservedRunKeysRef = useRef(new Set()); + const gameChatArchivedRunKeysRef = useRef(new Set()); + const initialSupervisorMessageLatchRef = useRef({ + projectPath: initialProjectPath, + prompt: initialSupervisorMessage.trim(), + }); function updateClientPreview( nextPreview: LocalPreviewResult | null, @@ -314,7 +432,7 @@ export function App({ ); } const [chatInput, setChatInput] = useState(() => - supervisorChatOnly && initialProjectPath + (supervisorChatOnly || gameChatOnly) && initialProjectPath ? readSupervisorChatDraft(initialProjectPath) : '', ); @@ -328,7 +446,7 @@ export function App({ useState(null); const [projectSupervisorRuntimeError, setProjectSupervisorRuntimeError] = useState(''); - const [projectSupervisorRetryRunId, setProjectSupervisorRetryRunId] = + const [projectSupervisorExpectedRunId, setProjectSupervisorExpectedRunId] = useState(null); const chatInputRef = useRef(null); const supervisorChatMessagesRef = useRef(null); @@ -390,9 +508,7 @@ export function App({ const [llmConfigStatus, setLlmConfigStatus] = useState(null); const [workspaceStatus, setWorkspaceStatus] = useState( - projectSupervisorOnly && initialProjectPath - ? `已打开:${initialProjectPath}` - : '请选择工作区', + eagerSupervisorProject ? `已打开:${initialProjectPath}` : '请选择工作区', ); const [selectedAgent, setSelectedAgent] = useState( null, @@ -444,12 +560,10 @@ export function App({ const [projectLogContent, setProjectLogContent] = useState(''); const [conversationWriteVersion, setConversationWriteVersion] = useState(0); const savedConversationCountRef = useRef( - projectSupervisorOnly && initialProjectPath - ? createDefaultChatMessages().length - : 0, + eagerSupervisorProject ? createDefaultChatMessages().length : 0, ); const savedConversationProjectPathRef = useRef( - projectSupervisorOnly && initialProjectPath ? initialProjectPath : null, + eagerSupervisorProject ? initialProjectPath : null, ); const projectConversationWriteConfirmedRef = useRef(null); const projectConversationWriteCancelledRef = useRef<{ @@ -467,8 +581,8 @@ export function App({ projectSupervisorSessionIdRef.current = projectSupervisorSessionId; const projectSupervisorRuntimeRef = useRef(null); projectSupervisorRuntimeRef.current = projectSupervisorRuntime; - const projectSupervisorRetryRunIdRef = useRef(null); - projectSupervisorRetryRunIdRef.current = projectSupervisorRetryRunId; + const projectSupervisorExpectedRunIdRef = useRef(null); + projectSupervisorExpectedRunIdRef.current = projectSupervisorExpectedRunId; const projectSupervisorResponseStreamRef = useRef(null); projectSupervisorResponseStreamRef.current = projectSupervisorResponseStream; @@ -493,12 +607,19 @@ export function App({ const pendingUiConfirmationActionRef = useRef<(() => void) | null>(null); function requestRuntimeConfigOpen() { - if (projectSupervisorOnly && !supervisorChatOnly) { + if (projectSupervisorOnly && !supervisorChatOnly && !gameChatOnly) { return; } setRuntimeConfigOpen(true); } + function setGameChatAutoPreviewAuthorization( + authorization: GameChatAutoPreviewAuthorization | null, + ) { + gameChatAutoPreviewAuthorizationRef.current = authorization; + storeGameChatAutoPreviewAuthorization(authorization); + } + function updateProjectSupervisorRuntime( runtime: AgentRuntimeState | null, previous = projectSupervisorRuntimeRef.current, @@ -506,6 +627,17 @@ export function App({ const nextRuntime = runtime ? normalizeAgentRuntimeState(runtime, previous) : null; + const nextProjectPath = localProjectPathRef.current; + if ( + gameChatOnly && + nextProjectPath && + nextRuntime?.runId && + !isAgentRuntimeTerminalState(nextRuntime) + ) { + gameChatObservedRunKeysRef.current.add( + `${nextProjectPath}\n${nextRuntime.runId}`, + ); + } projectSupervisorRuntimeRef.current = nextRuntime; setProjectSupervisorRuntime(nextRuntime); } @@ -542,12 +674,12 @@ export function App({ projectSupervisorRuntimeResumeProjectPathRef.current = null; projectSupervisorSessionIdRef.current = null; projectSupervisorRuntimeRef.current = null; - projectSupervisorRetryRunIdRef.current = null; + projectSupervisorExpectedRunIdRef.current = null; projectSupervisorResponseStreamRef.current = null; projectSupervisorRuntimeSyncingRef.current.clear(); setProjectSupervisorSessionId(null); setProjectSupervisorRuntime(null); - setProjectSupervisorRetryRunId(null); + setProjectSupervisorExpectedRunId(null); setProjectSupervisorResponseStream(null); setProjectSupervisorRuntimeError(''); } @@ -589,6 +721,43 @@ export function App({ }); } + function appendGameChatStageRecord( + nextProjectPath: string, + runtime: AgentRuntimeState, + ) { + if (!gameChatOnly || !isAgentRuntimeTerminalState(runtime)) { + return; + } + const archiveKey = `${nextProjectPath}\n${runtime.runId}`; + if ( + !gameChatObservedRunKeysRef.current.has(archiveKey) || + 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 }], + ); + } + useEscapeToClose(closeAgentConversation, selectedAgent !== null); useEscapeToClose(cancelUiCommandConfirmation, pendingUiConfirmation !== null); useEscapeToClose( @@ -614,6 +783,10 @@ export function App({ } initialProjectOpenedRef.current = true; if (projectSupervisorOnly) { + if (gameChatOnly) { + void openGameChatProjectPath(initialProjectPath); + return; + } if (!isAbsoluteProjectPath(initialProjectPath)) { setWorkspaceStatus('请提供工作区绝对路径'); return; @@ -633,7 +806,7 @@ export function App({ void openWorkspace(initialProjectPath, false); // Initial project opening is guarded by initialProjectOpenedRef. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialProjectPath, projectSupervisorOnly]); + }, [gameChatOnly, initialProjectPath, projectSupervisorOnly]); useEffect(() => { if (projectSupervisorOnly) { @@ -696,20 +869,19 @@ export function App({ if (payload.agentId === PROJECT_SUPERVISOR_AGENT_ID) { const expectedSessionId = projectSupervisorSessionIdRef.current; const currentRuntime = projectSupervisorRuntimeRef.current; - const expectedRetryRunId = projectSupervisorRetryRunIdRef.current; + const expectedRunId = projectSupervisorExpectedRunIdRef.current; const sameRun = currentRuntime !== null && sameAgentRuntimeRun(nextRuntime, currentRuntime); - const isExpectedRetryRun = - expectedRetryRunId !== null && - nextRuntime.runId === expectedRetryRunId; + const isExpectedRun = + expectedRunId !== null && nextRuntime.runId === expectedRunId; if ( nextRuntime.agentId !== PROJECT_SUPERVISOR_AGENT_ID || nextRuntime.runId !== payload.runId || (expectedSessionId !== null && nextRuntime.sessionId !== expectedSessionId) || (currentRuntime !== null && - ((!sameRun && !isExpectedRetryRun) || + ((!sameRun && !isExpectedRun) || (sameRun && nextRuntime.updatedAt < currentRuntime.updatedAt))) ) { return; @@ -719,9 +891,9 @@ export function App({ setProjectSupervisorSessionId(nextRuntime.sessionId); } updateProjectSupervisorRuntime(nextRuntime); - if (isExpectedRetryRun) { - projectSupervisorRetryRunIdRef.current = null; - setProjectSupervisorRetryRunId(null); + if (isExpectedRun) { + projectSupervisorExpectedRunIdRef.current = null; + setProjectSupervisorExpectedRunId(null); } updateProjectSupervisorResponseStream( payload.runtime.responseStream, @@ -781,15 +953,16 @@ export function App({ const nextProjectPath = localProject?.projectPath ?? null; const sessionId = projectSupervisorSessionId; const trackedRunId = - projectSupervisorRetryRunId ?? projectSupervisorRuntime?.runId ?? null; + projectSupervisorExpectedRunId ?? projectSupervisorRuntime?.runId ?? null; if ( !invoke || !nextProjectPath || !sessionId || !trackedRunId || - !projectSupervisorRuntime || - (isAgentRuntimeTerminalState(projectSupervisorRuntime) && - !projectSupervisorRetryRunId) + (!projectSupervisorRuntime && !projectSupervisorExpectedRunId) || + (projectSupervisorRuntime !== null && + isAgentRuntimeTerminalState(projectSupervisorRuntime) && + !projectSupervisorExpectedRunId) ) { return; } @@ -834,9 +1007,9 @@ export function App({ return; } updateProjectSupervisorRuntime(nextRuntime); - if (nextRuntime.runId === projectSupervisorRetryRunIdRef.current) { - projectSupervisorRetryRunIdRef.current = null; - setProjectSupervisorRetryRunId(null); + if (nextRuntime.runId === projectSupervisorExpectedRunIdRef.current) { + projectSupervisorExpectedRunIdRef.current = null; + setProjectSupervisorExpectedRunId(null); } updateProjectSupervisorResponseStream( result.responseStream, @@ -881,7 +1054,7 @@ export function App({ projectSupervisorRuntime?.phase, projectSupervisorRuntime?.runId, projectSupervisorRuntime?.status, - projectSupervisorRetryRunId, + projectSupervisorExpectedRunId, ]); useEffect(() => { @@ -975,8 +1148,191 @@ export function App({ projectSupervisorRuntime?.status, ]); + useEffect(() => { + const invoke = resolveTauriInvoke(); + const nextProjectPath = localProject?.projectPath ?? null; + if (!gameChatOnly || !invoke || !nextProjectPath) { + return; + } + let disposed = false; + let inFlight = false; + const syncPreview = async () => { + if (disposed || inFlight) { + return; + } + inFlight = true; + try { + const status = await invoke( + 'get_local_game_preview_status', + { projectPath: nextProjectPath }, + ); + if (disposed || localProjectPathRef.current !== nextProjectPath) { + return; + } + const runningPreview = + status.status === 'running' && + status.url && + status.port && + status.root && + resolveEmbeddedPreviewUrl({ + status: status.status, + url: status.url, + }) + ? { + url: status.url, + port: status.port, + root: status.root, + } + : null; + if (runningPreview) { + updateClientPreview(runningPreview); + setPreviewStatus(`运行中:127.0.0.1:${runningPreview.port}`); + setGameChatAutoPreviewAuthorization(null); + return; + } + const hadRunningPreview = Boolean(gameChatPreviewRef.current); + updateClientPreview(null); + if (hadRunningPreview) { + setPreviewStatus('未启动'); + } + + const currentSupervisor = projectSupervisorRuntimeRef.current; + let authorization = gameChatAutoPreviewAuthorizationRef.current; + if ( + !authorization && + currentSupervisor?.runId && + isAgentRuntimeTerminalState(currentSupervisor) && + previewStatusRef.current.startsWith( + '项目正在被其他写操作占用:', + ) + ) { + const interruptedAttemptKey = `${nextProjectPath}\n${currentSupervisor.runId}`; + if ( + gameChatObservedRunKeysRef.current.has(interruptedAttemptKey) && + gameChatAutoPreviewAttemptedRef.current.delete( + interruptedAttemptKey, + ) + ) { + authorization = { + projectPath: nextProjectPath, + runId: currentSupervisor.runId, + }; + setGameChatAutoPreviewAuthorization(authorization); + } + } + if ( + !authorization || + authorization.projectPath !== nextProjectPath || + currentSupervisor?.runId !== authorization.runId + ) { + return; + } + const nextManifest = await invoke( + 'get_local_game_manifest', + { projectPath: nextProjectPath }, + ); + if ( + disposed || + localProjectPathRef.current !== nextProjectPath || + projectSupervisorRuntimeRef.current?.runId !== authorization.runId + ) { + return; + } + setManifest(nextManifest); + const firstPrototypeReady = nextManifest.tasks.some( + (task) => task.id === 'code-prototype' && task.status === 'completed', + ); + if (!firstPrototypeReady) { + if ( + currentSupervisor && + isAgentRuntimeTerminalState(currentSupervisor) + ) { + setGameChatAutoPreviewAuthorization(null); + } + return; + } + const attemptKey = `${nextProjectPath}\n${authorization.runId}`; + if (gameChatAutoPreviewAttemptedRef.current.has(attemptKey)) { + return; + } + const policyView = await invoke( + 'read_project_permission_policy', + { projectPath: nextProjectPath }, + ); + const previewDenied = + policyView.policy.deniedCommands.includes('preview.start') || + Object.values(policyView.policy.agentPolicies ?? {}).some((policy) => + policy.deniedCommands.includes('preview.start'), + ); + if (previewDenied) { + gameChatAutoPreviewAttemptedRef.current.add(attemptKey); + setGameChatAutoPreviewAuthorization(null); + const message = '项目权限策略拒绝执行:preview.start'; + setCommandLog((current) => [ + ...current, + 'permission.deny preview.start', + ]); + setPreviewStatus(message); + return; + } + appendLocalPermissionLog( + nextProjectPath, + 'permission.confirm', + 'preview.start', + ); + let startedPreview: LocalPreviewResult; + try { + startedPreview = await invoke( + 'start_local_game_preview', + { projectPath: nextProjectPath }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.startsWith('项目正在被其他写操作占用:')) { + gameChatAutoPreviewAttemptedRef.current.add(attemptKey); + setGameChatAutoPreviewAuthorization(null); + } + throw error; + } + gameChatAutoPreviewAttemptedRef.current.add(attemptKey); + setGameChatAutoPreviewAuthorization(null); + if (disposed || localProjectPathRef.current !== nextProjectPath) { + return; + } + updateClientPreview(startedPreview); + setPreviewStatus(`运行中:127.0.0.1:${startedPreview.port}`); + setCommandLog((current) => [...current, 'preview.start']); + } catch (error) { + if (!disposed && localProjectPathRef.current === nextProjectPath) { + setPreviewStatus( + error instanceof Error ? error.message : String(error), + ); + } + } finally { + inFlight = false; + } + }; + void syncPreview(); + const timer = window.setInterval(() => { + void syncPreview(); + }, 1000); + return () => { + disposed = true; + window.clearInterval(timer); + }; + // The interval reads the latest Runtime through refs and is recreated only for identity changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + gameChatOnly, + localProject?.projectPath, + projectSupervisorRuntime?.runId, + ]); + useLayoutEffect(() => { - if (!supervisorChatOnly || !supervisorChatShouldFollowLatestRef.current) { + if ( + (!supervisorChatOnly && !gameChatOnly) || + !supervisorChatShouldFollowLatestRef.current + ) { return; } const messageList = supervisorChatMessagesRef.current; @@ -988,15 +1344,25 @@ export function App({ projectSupervisorResponseStream?.sequence, projectSupervisorRuntime?.updatedAt, projectSupervisorRuntimeError, + gameChatOnly, supervisorChatOnly, ]); useEffect(() => { - if (!supervisorChatOnly) { + if (!supervisorChatOnly && !gameChatOnly) { return; } - persistSupervisorChatDraft(initialProjectPath, chatInput); - }, [chatInput, initialProjectPath, supervisorChatOnly]); + const draftProjectPath = gameChatOnly + ? (localProject?.projectPath ?? initialProjectPath) + : initialProjectPath; + persistSupervisorChatDraft(draftProjectPath, chatInput); + }, [ + chatInput, + gameChatOnly, + initialProjectPath, + localProject?.projectPath, + supervisorChatOnly, + ]); useEffect(() => { latestMessagesRef.current = messages; @@ -1782,7 +2148,7 @@ export function App({ invoke: TauriInvoke, nextProjectPath: string, sessionId: string, - _runId?: string, + runId?: string, ) { const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1; projectSupervisorHistoryLoadVersionRef.current = loadVersion; @@ -1823,6 +2189,14 @@ export function App({ latestMessagesRef.current = conversationMessages; setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); setMessages(conversationMessages); + const terminalRuntime = projectSupervisorRuntimeRef.current; + if ( + runId && + terminalRuntime?.runId === runId && + isAgentRuntimeTerminalState(terminalRuntime) + ) { + appendGameChatStageRecord(nextProjectPath, terminalRuntime); + } setProjectSupervisorRuntimeError(''); } projectSupervisorRefreshConversationRef.current = @@ -2014,6 +2388,14 @@ export function App({ const projectScopeVersion = projectScopeVersionRef.current + 1; projectScopeVersionRef.current = projectScopeVersion; resetProjectSupervisorState(); + if ( + gameChatAutoPreviewAuthorizationRef.current?.projectPath !== + nextProjectPath + ) { + setGameChatAutoPreviewAuthorization(null); + } + updateClientPreview(null); + setPreviewStatus('未启动'); setWorkspaceStatus('正在打开'); setProjectStatus('正在初始化'); setSelectedAgent(null); @@ -2036,7 +2418,9 @@ export function App({ { projectPath: trimmedProjectPath, projectId: seedManifest.projectId, - name: seedManifest.name, + name: gameChatOnly + ? projectNameFromPath(trimmedProjectPath) + : seedManifest.name, }, ); if (projectScopeVersionRef.current !== projectScopeVersion) { @@ -2071,6 +2455,9 @@ export function App({ localProjectPathRef.current = openedProject.projectPath; setProjectPath(openedProject.projectPath); setLocalProject(openedProject); + if (gameChatOnly) { + setChatInput(readSupervisorChatDraft(openedProject.projectPath)); + } setManifest(openedProject.manifest); setProjectFiles([]); setProjectCheckpoints([]); @@ -2116,6 +2503,90 @@ export function App({ } } + async function openGameChatProjectPath( + nextProjectPath: string, + requestedSelectionVersion?: number, + ) { + const selectionVersion = + requestedSelectionVersion ?? + gameChatProjectSelectionVersionRef.current + 1; + gameChatProjectSelectionVersionRef.current = selectionVersion; + setPendingNonEmptyProjectCreate(null); + const trimmedProjectPath = nextProjectPath.trim(); + if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) { + setWorkspaceStatus('请提供工作区绝对路径'); + return; + } + if (projectPathHasControlCharacter(trimmedProjectPath)) { + setWorkspaceStatus('工作区路径不能包含控制字符'); + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setWorkspaceStatus('需要在 Tauri App 内运行'); + return; + } + setGameChatProjectSelectionBusy(true); + try { + const directoryStatus = await invoke( + 'inspect_local_project_directory', + { projectPath: trimmedProjectPath }, + ); + if (gameChatProjectSelectionVersionRef.current !== selectionVersion) { + return; + } + if (directoryStatus.exists && !directoryStatus.isDirectory) { + setWorkspaceStatus('项目路径不是文件夹'); + return; + } + if (directoryStatus.isGameCreatorProject) { + await openWorkspace(trimmedProjectPath, false); + return; + } + await executeProjectCreate(trimmedProjectPath, false, selectionVersion); + } catch (error) { + setWorkspaceStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + if (gameChatProjectSelectionVersionRef.current === selectionVersion) { + setGameChatProjectSelectionBusy(false); + } + } + } + + async function handleGameChatProjectPick() { + const invoke = resolveTauriInvoke(); + if (!invoke || gameChatProjectSelectionBusy) { + return; + } + const selectionVersion = gameChatProjectSelectionVersionRef.current + 1; + gameChatProjectSelectionVersionRef.current = selectionVersion; + setPendingNonEmptyProjectCreate(null); + setGameChatProjectSelectionBusy(true); + try { + const selectedPath = await invoke( + 'pick_local_project_directory', + ); + if (gameChatProjectSelectionVersionRef.current !== selectionVersion) { + return; + } + if (!selectedPath) { + setWorkspaceStatus('已取消'); + return; + } + await openGameChatProjectPath(selectedPath, selectionVersion); + } catch (error) { + setWorkspaceStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + if (gameChatProjectSelectionVersionRef.current === selectionVersion) { + setGameChatProjectSelectionBusy(false); + } + } + } + async function openAgentConversation( agent: AgentStatusCard, skipConversationPolicyConfirm = false, @@ -4574,9 +5045,16 @@ export function App({ sessionId, prompt, runtime: projectSupervisorRuntimeRef.current, - runProfile: supervisorChatOnly ? 'standard' : 'autonomous-game-build', + runProfile: + supervisorChatOnly && !gameChatOnly + ? 'standard' + : 'autonomous-game-build', }); const runtimeResult = submission.runtimeResult; + const acceptedRunId = submission.acceptedRunId.trim(); + if (!acceptedRunId) { + throw new Error('项目总控 Agent 请求未进入后台队列'); + } setCommandLog((current) => [ ...current, `agent.runtime.${submission.mode} project-supervisor`, @@ -4597,11 +5075,29 @@ export function App({ ) { throw new Error('项目总控 Agent Runtime Session 身份不匹配'); } - updateProjectSupervisorRuntime(runtime); - updateProjectSupervisorResponseStream( - runtimeResult.responseStream, - runtime, - ); + const acceptedRuntimeReady = runtime.runId === acceptedRunId; + if (!acceptedRuntimeReady) { + projectSupervisorExpectedRunIdRef.current = acceptedRunId; + setProjectSupervisorExpectedRunId(acceptedRunId); + } + if (gameChatOnly) { + gameChatObservedRunKeysRef.current.add( + `${nextProjectPath}\n${acceptedRunId}`, + ); + setGameChatAutoPreviewAuthorization({ + projectPath: nextProjectPath, + runId: acceptedRunId, + }); + } + if (acceptedRuntimeReady) { + projectSupervisorExpectedRunIdRef.current = null; + setProjectSupervisorExpectedRunId(null); + updateProjectSupervisorRuntime(runtime); + updateProjectSupervisorResponseStream( + runtimeResult.responseStream, + runtime, + ); + } setProjectSupervisorRuntimeError(''); const refreshConversation = projectSupervisorRefreshConversationRef.current; @@ -4610,7 +5106,7 @@ export function App({ invoke, nextProjectPath, sessionId, - runtime.runId, + acceptedRunId, ).catch((error) => { if ( localProjectPathRef.current === nextProjectPath && @@ -4624,11 +5120,13 @@ export function App({ } }); } - syncTerminalProjectSupervisorConversation( - invoke, - nextProjectPath, - runtime, - ); + if (acceptedRuntimeReady) { + syncTerminalProjectSupervisorConversation( + invoke, + nextProjectPath, + runtime, + ); + } } catch (error) { if (localProjectPathRef.current !== nextProjectPath) { return; @@ -4651,6 +5149,29 @@ export function App({ } } + useEffect(() => { + const latch = initialSupervisorMessageLatchRef.current; + if (!gameChatOnly || !latch.prompt || !localProject) { + return; + } + if (localProject.projectPath !== latch.projectPath) { + claimInitialSupervisorMessageForPage(latch.projectPath); + return; + } + if ( + chatAgentBusy || + !claimInitialSupervisorMessageForPage(latch.projectPath) + ) { + return; + } + supervisorChatShouldFollowLatestRef.current = true; + setMessages((current) => [ + ...current, + { role: 'user', text: latch.prompt, runtimeOwned: true }, + ]); + void executeChatAgentReply(latch.prompt); + }, [chatAgentBusy, gameChatOnly, initialSupervisorMessage, localProject]); + async function handleProjectSupervisorToolAction( decision: 'confirm' | 'reject', ) { @@ -4921,8 +5442,8 @@ export function App({ if (!acceptedRunId) { throw new Error('项目总控重试请求未进入后台队列'); } - projectSupervisorRetryRunIdRef.current = acceptedRunId; - setProjectSupervisorRetryRunId(acceptedRunId); + projectSupervisorExpectedRunIdRef.current = acceptedRunId; + setProjectSupervisorExpectedRunId(acceptedRunId); const nextRuntime = agentRuntimeStateFromResult(result, currentRuntime); if (nextRuntime.runId === acceptedRunId) { if ( @@ -4936,8 +5457,8 @@ export function App({ result.responseStream, nextRuntime, ); - projectSupervisorRetryRunIdRef.current = null; - setProjectSupervisorRetryRunId(null); + projectSupervisorExpectedRunIdRef.current = null; + setProjectSupervisorExpectedRunId(null); } setCommandLog((current) => [ ...current, @@ -5402,6 +5923,7 @@ export function App({ async function executeProjectCreate( nextProjectPath: string, announceToChat: boolean, + expectedGameChatSelectionVersion?: number, ) { const trimmedProjectPath = nextProjectPath.trim(); const invoke = resolveTauriInvoke(); @@ -5416,6 +5938,13 @@ export function App({ 'is_local_project_directory_non_empty', { projectPath: trimmedProjectPath }, ); + if ( + expectedGameChatSelectionVersion !== undefined && + gameChatProjectSelectionVersionRef.current !== + expectedGameChatSelectionVersion + ) { + return; + } if (nonEmpty) { setPendingNonEmptyProjectCreate({ projectPath: trimmedProjectPath, @@ -5429,6 +5958,13 @@ export function App({ // ponytail: stale test doubles and older shells may miss this helper; init still validates. } } + if ( + expectedGameChatSelectionVersion !== undefined && + gameChatProjectSelectionVersionRef.current !== + expectedGameChatSelectionVersion + ) { + return; + } await openWorkspace(nextProjectPath, announceToChat); } @@ -9465,6 +10001,10 @@ export function App({ ) { event.preventDefault(); const prompt = chatInput.trim(); + if ((gameChatOnly || supervisorChatOnly) && prompt.startsWith('/')) { + void handleChatSubmit(event); + return; + } if (agentRuntimeNeedsUserInput(projectSupervisorRuntimeRef.current)) { setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题'); return; @@ -9472,7 +10012,7 @@ export function App({ if (!prompt || chatAgentBusy) { return; } - if (supervisorChatOnly) { + if (supervisorChatOnly || gameChatOnly) { supervisorChatShouldFollowLatestRef.current = true; } setChatInput(''); @@ -9489,6 +10029,53 @@ export function App({ (agent.runtimeStatus !== null || agent.hasRecentEvidence), ); + if (projectSupervisorOnly && gameChatOnly) { + const gameChatProjectPath = localProject?.projectPath ?? ''; + return ( + setRuntimeConfigOpen(false)} + onConfirmConfirmation={confirmUiCommand} + onOpenRuntimeConfig={() => setRuntimeConfigOpen(true)} + onProjectPick={() => void handleGameChatProjectPick()} + onScroll={handleSupervisorChatScroll} + onShowEarlierMessages={showEarlierConversationMessages} + onSubmit={handleProjectSupervisorOnlySubmit} + onToolAction={handleProjectSupervisorToolAction} + onUserInput={handleProjectSupervisorUserInput} + pendingConfirmation={pendingUiConfirmation} + pendingCommand={pendingCommand} + onCancelPendingCommand={handlePendingCommandCancel} + onConfirmPendingCommand={() => void handlePendingCommandConfirm()} + pendingNonEmptyProjectCreate={pendingNonEmptyProjectCreate} + onCancelNonEmptyProjectCreate={cancelProjectCreateInNonEmptyFolder} + onConfirmNonEmptyProjectCreate={confirmProjectCreateInNonEmptyFolder} + preview={preview} + previewStatus={previewStatus} + projectPath={gameChatProjectPath} + projectReady={Boolean(localProject)} + projectSelectionBusy={gameChatProjectSelectionBusy} + runtime={projectSupervisorRuntime} + runtimeByAgentId={agentRuntimeById} + manifest={manifest} + runtimeConfigOpen={runtimeConfigOpen} + runtimeError={projectSupervisorRuntimeError} + transientReply={projectSupervisorTransientReply} + hasConversationControls={projectSupervisorHasConversationControls} + hiddenConversationCount={hiddenConversationCount} + needsUserInput={projectSupervisorNeedsUserInput} + visibleMessages={visibleMessages} + workspaceStatus={workspaceStatus} + expectedRunId={projectSupervisorExpectedRunId} + /> + ); + } + if (projectSupervisorOnly && supervisorChatOnly) { const supervisorProjectPath = localProject?.projectPath || initialProjectPath || projectPath; @@ -9508,6 +10095,9 @@ export function App({ onToolAction={handleProjectSupervisorToolAction} onUserInput={handleProjectSupervisorUserInput} pendingConfirmation={pendingUiConfirmation} + pendingCommand={pendingCommand} + onCancelPendingCommand={handlePendingCommandCancel} + onConfirmPendingCommand={() => void handlePendingCommandConfirm()} projectPath={supervisorProjectPath} runtime={projectSupervisorRuntime} runtimeConfigOpen={runtimeConfigOpen} @@ -9518,6 +10108,7 @@ export function App({ needsUserInput={projectSupervisorNeedsUserInput} visibleMessages={visibleMessages} workspaceStatus={workspaceStatus} + expectedRunId={projectSupervisorExpectedRunId} /> ); } 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 fb76f67b7..2e8d5de12 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 @@ -792,19 +792,28 @@ export async function submitProjectSupervisorRuntimeTask({ runProfile, }, ); - return { mode: 'steer' as const, runtimeResult: steer.runtime }; + return { + mode: 'steer' as const, + runtimeResult: steer.runtime, + acceptedRunId: steerRuntime.runId, + }; } + const requestedRunId = createAgentChatRunId('project-supervisor-task'); const runtimeResult = await invoke( 'start_game_creator_supervisor_runtime_task', { projectPath, sessionId, task: prompt, - runId: createAgentChatRunId('project-supervisor-task'), + runId: requestedRunId, runProfile, }, ); - return { mode: 'start' as const, runtimeResult }; + return { + mode: 'start' as const, + runtimeResult, + acceptedRunId: agentRuntimeStartedRunId(runtimeResult, requestedRunId), + }; } export function agentRuntimeSteerStatus(result: AgentRuntimeSteerResult) { diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/GameChatImageViewer.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/GameChatImageViewer.tsx new file mode 100644 index 000000000..855ea2d46 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/GameChatImageViewer.tsx @@ -0,0 +1,192 @@ +import { Minus, Plus, RotateCcw, X } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +import { + closeDialogOnBackdropMouseDown, + useEscapeToClose, +} from '../../app/dialogs'; + +const MIN_SCALE = 0.5; +const MAX_SCALE = 4; +const SCALE_STEP = 0.25; + +type ImageViewTransform = { + scale: number; + x: number; + y: number; +}; + +type DragState = { + pointerId: number; + x: number; + y: number; +}; + +function clampScale(scale: number) { + return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale)); +} + +export function GameChatImageViewer({ + alt, + label, + path, + src, + onClose, +}: { + alt: string; + label: string; + path: string; + src: string; + onClose: () => void; +}) { + const [view, setView] = useState({ + scale: 1, + x: 0, + y: 0, + }); + const [dragging, setDragging] = useState(false); + const dragRef = useRef(null); + + useEscapeToClose(onClose); + useEffect(() => { + setView({ scale: 1, x: 0, y: 0 }); + dragRef.current = null; + setDragging(false); + }, [src]); + + function updateScale(nextScale: number) { + setView((current) => ({ + ...current, + scale: clampScale(nextScale), + })); + } + + function resetView() { + setView({ scale: 1, x: 0, y: 0 }); + } + + return ( +
closeDialogOnBackdropMouseDown(event, onClose)} + > +
+
+
+ {label} + {path} +
+
+ + + {`${Math.round(view.scale * 100)}%`} + + + + +
+
+
{ + event.preventDefault(); + updateScale( + view.scale + (event.deltaY < 0 ? SCALE_STEP : -SCALE_STEP), + ); + }} + onDoubleClick={resetView} + onPointerDown={(event) => { + if (event.button !== 0) { + return; + } + event.currentTarget.setPointerCapture?.(event.pointerId); + dragRef.current = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + }; + setDragging(true); + }} + onPointerMove={(event) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + const deltaX = event.clientX - drag.x; + const deltaY = event.clientY - drag.y; + dragRef.current = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + }; + setView((current) => ({ + ...current, + x: current.x + deltaX, + y: current.y + deltaY, + })); + }} + onPointerUp={(event) => { + if (dragRef.current?.pointerId !== event.pointerId) { + return; + } + dragRef.current = null; + setDragging(false); + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture?.(event.pointerId); + } + }} + onPointerCancel={() => { + dragRef.current = null; + setDragging(false); + }} + > + {alt} +
+
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx new file mode 100644 index 000000000..40a425b5a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx @@ -0,0 +1,48 @@ +export type LocalGamePreviewLike = { + status?: string | null; + url?: string | null; +}; + +export function resolveEmbeddedPreviewUrl( + preview: LocalGamePreviewLike | null | undefined, +) { + if ( + !preview?.url || + (preview.status !== undefined && preview.status !== 'running') + ) { + return null; + } + try { + const url = new URL(preview.url); + if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1') { + return null; + } + return url.toString(); + } catch { + return null; + } +} + +export function LocalGamePreviewFrame({ + preview, + title, + className, +}: { + preview: LocalGamePreviewLike | null | undefined; + title: string; + className?: string; +}) { + const embeddedUrl = resolveEmbeddedPreviewUrl(preview); + if (!embeddedUrl) { + return null; + } + return ( +