diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index 2de097ee1..0b20e28e2 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -69,23 +69,8 @@ jobs: - name: Install npm dependencies run: bash scripts/ci-npm-ci-with-retry.sh - - name: Run repository lint gates - run: npm run lint - - - name: Build web applications - run: npm run build - - - name: Validate content data - run: npm run check:content - - - name: Check committed whitespace - shell: bash - run: | - set -euo pipefail - base_ref="${SPACETIME_SCHEMA_BASE_REF:-}" - test -n "${base_ref}" - git cat-file -e "${base_ref}^{commit}" - git diff --check "${base_ref}"...HEAD + - name: Run repository checks + run: npm run check:repository-ci frontend-tests: name: Frontend tests diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 000000000..fdb72ecc2 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +npm run check:pre-push-master -- "$@" diff --git a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs index d10cfdcac..756e17422 100644 --- a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs @@ -253,12 +253,24 @@ export function defaultRuntimeConfigDirCandidates({ ), ); } else { + const configuredRoot = environment.XDG_CONFIG_HOME; + const posixAbsoluteConfiguredRoot = + configuredRoot && path.posix.isAbsolute(configuredRoot); + const hostAbsoluteConfiguredRoot = + configuredRoot && + !posixAbsoluteConfiguredRoot && + path.isAbsolute(configuredRoot); const configRoot = - environment.XDG_CONFIG_HOME && - path.posix.isAbsolute(environment.XDG_CONFIG_HOME) - ? environment.XDG_CONFIG_HOME + configuredRoot && + (posixAbsoluteConfiguredRoot || hostAbsoluteConfiguredRoot) + ? configuredRoot : path.posix.join(homeDirectory, '.config'); - pushUnique(candidates, path.posix.join(configRoot, appIdentifier)); + pushUnique( + candidates, + hostAbsoluteConfiguredRoot + ? path.join(configRoot, appIdentifier) + : path.posix.join(configRoot, appIdentifier), + ); } return candidates; } diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 0d913a01b..a541abce3 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1594,7 +1594,8 @@ for (const snippet of [ 'const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json"', 'const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json")', 'fn configure_game_creator_runtime_config_dir(', - 'app.path().app_config_dir()?', + 'game_creator_runtime_config_dir()', + '.unwrap_or_else(|| app.path().app_config_dir())?', 'fn load_game_creator_app_config()', 'fn read_game_creator_app_config()', 'fn write_game_creator_app_config(', @@ -1688,8 +1689,8 @@ for (const snippet of [ "'write_game_creator_app_config'", 'aria-label="运行时配置"', 'LLM API Key', - 'showDeveloperEditorApi', - '开发者 External Editor API Key', + 'External Editor Base URL', + 'External Editor API Key', 'runtime_config.save', "'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'", "'activate_local_game_preview'", diff --git a/apps/ai-game-creator-shell/scripts/process-session-real-e2e-fixture.mjs b/apps/ai-game-creator-shell/scripts/process-session-real-e2e-fixture.mjs index 2caf00aea..7eedae4cb 100644 --- a/apps/ai-game-creator-shell/scripts/process-session-real-e2e-fixture.mjs +++ b/apps/ai-game-creator-shell/scripts/process-session-real-e2e-fixture.mjs @@ -46,6 +46,8 @@ export function buildProcessSessionFixtureSource({ ' if (!echoed && line === challenge) {', ' echoed = true;', " console.log(echoPrefix + ' ' + challenge);", + " } else if (line === challenge + ':stop') {", + ' stop();', ' }', ' }', '});', diff --git a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs index 6b03ae39c..2f7026531 100644 --- a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs @@ -4,8 +4,9 @@ import fs from 'node:fs/promises'; import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -const appRoot = path.resolve(new URL('..', import.meta.url).pathname); +const appRoot = fileURLToPath(new URL('..', import.meta.url)); const localConfigPath = path.join(appRoot, 'game-creator.config.local.json'); const projectRoot = path.join( os.tmpdir(), @@ -884,7 +885,28 @@ function readBrowserDom(url) { } function resolveChromeBin() { + const windowsRoot = path.parse(os.homedir()).root; for (const candidate of [ + path.join( + windowsRoot, + 'Program Files/Google/Chrome/Application/chrome.exe', + ), + path.join( + windowsRoot, + 'Program Files (x86)/Google/Chrome/Application/chrome.exe', + ), + path.join( + os.homedir(), + 'AppData/Local/Google/Chrome/Application/chrome.exe', + ), + path.join( + windowsRoot, + 'Program Files/Microsoft/Edge/Application/msedge.exe', + ), + path.join( + windowsRoot, + 'Program Files (x86)/Microsoft/Edge/Application/msedge.exe', + ), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index c0bf687f5..87452d7b5 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -45,12 +45,16 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) { if (separatorIndex < 0) { return ['dev', ...args, '--config', configOverride]; } + const separatedArguments = args.slice(separatorIndex); + if (separatedArguments[1] !== '--') { + separatedArguments.unshift('--'); + } return [ 'dev', ...args.slice(0, separatorIndex), '--config', configOverride, - ...args.slice(separatorIndex), + ...separatedArguments, ]; } diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md index f2a7af165..3403f655f 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md @@ -16,4 +16,4 @@ git.inspect 会返回 commitSnapshotFingerprint;只有当前非零 revision 用户输入请求协议:user.input_request 使用 {"questions":[{"id":"唯一 snake_case","header":"最多 12 字符","question":"单句问题","options":[{"label":"短选项","description":"一条影响说明"},{"label":"另一选项","description":"一条影响说明"}]}]},一次 1-3 题、每题 2-3 个选项且始终允许自由输入。它必须是本轮唯一函数调用,不得同批调用 update_agent_plan、其他动作函数或 respond_to_user。只有 Project Supervisor 或没有父委派身份的静态 Agent 开发试聊可直接调用;委派专业 Agent 和动态隔离 child 必须把澄清需要回传父 Agent。 -静态委派协议:新 agent.delegate 必须提交 1-8 条 acceptanceCriteria、0-16 个精确项目内非私有 expectedArtifacts,以及 nullable repairOfDelegationId/runId。专业 Agent 收到的 task 会携带完整合同。Supervisor 认领回执后必须区分 evidence-ready 与 needs-repair;前者仍需语义验收,后者不能作为成功。 +静态委派协议:新 agent.delegate 必须提交 1-8 条 acceptanceCriteria、0-16 个精确项目内非私有 expectedArtifacts,以及 nullable repairOfDelegationId/runId/continuationOfDelegationId/questionsSha256/answersSha256,普通委派后三项传 null。专业 Agent 收到的 task 会携带完整合同。Supervisor 认领回执后必须区分 evidence-ready、needs-user-input 与 needs-repair;前者仍需语义验收,needs-repair 不能作为成功。专业 Agent 若缺少会实质改变结果的用户事实,不能调用 user.input_request,必须以最终回复首行 `AGC_NEEDS_USER_INPUT_V1`,下一行短 JSON `{"questions":[...]}` 返回 1-3 个结构化问题;Runtime 会把它作为内部回执交给 Supervisor。Supervisor 对每个原 delivery 逐一用现有 user.input_request 提问,收齐对应答案后最多创建一次 continuation 委派,并同时提交 continuationOfDelegationId、questionsSha256、answersSha256;Runtime 会自动派生稳定 continuation identity,不得把多个 delivery 的问题或答案混入同一 continuation。 diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/playbook.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/playbook.md index 204a3d97b..534b488d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/playbook.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/playbook.md @@ -1,5 +1,5 @@ 互不重叠的临时并行检查通过 agent.spawn_isolated 分派;当同一目标同时需要边界清晰的专业委派和互不重叠的临时检查时,必须把两类协作放进同一个 native planning 批次一次性提交,不能拆成先后轮次。提交首个协作批次前,先分别完整枚举当前目标中已经生效的长期专业交付和临时隔离检查;两类都非空时,遗漏任一类的批次都不得提交。仓库合同明确把临时检查分为先行和后续独立阶段时,首批只提交当前已经生效的检查;先行组 ready 后优先创建刚生效的后续组,所有必要组创建前不得调用 agent.run_status 认领先行组,全部 ready 后用一次 agent.run_status 收齐。已有委派未收束时不要重复委派。 -需要等待专业 Agent 时不得调用 respond_to_user;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;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。 +需要等待专业 Agent 时不得调用 respond_to_user;Runtime 会通过 delegate/all-join 完成屏障保持同一父 run,取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。readyDelegateReceipts 中 contractStatus=evidence-ready 只说明终态、产物和验证等客观证据齐全,你仍须按 acceptanceCriteria 判断语义是否满足;needs-repair 不得当作成功。contractStatus=needs-user-input 时,Runtime 会按原 delivery 逐一发起 user.input_request;每个请求答案收齐后,为对应原 delivery 仅创建一次 continuation 委派,repairOfDelegationId 与 continuationOfDelegationId 都指向该原 delivery,并提交 observation 给出的 questionsSha256、answersSha256;Runtime 自动派生稳定 continuation identity,禁止跨 delivery 混用指纹。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。 只在所有必要回执已认领、manifest 正式任务图已经完成、所有必要返工也已认领、项目副作用已验证且没有待确认动作或待回答请求时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 7354d0283..8bda8dd61 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -22,6 +22,9 @@ mod runtime_state; mod runtime_tools; use codex_app_server::*; use codex_cli::*; +pub(crate) use codex_cli::{ + game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity, +}; pub(crate) use generation::*; pub(crate) use interaction::*; pub(crate) use prompt::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index eeedc2dfd..ede836cf6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -7,7 +7,6 @@ use std::sync::{Arc, OnceLock, Weak}; use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::sync::{mpsc, oneshot, Mutex}; -const GAME_CREATOR_CODEX_APP_SERVER_EXECUTABLE: &str = "codex"; const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc"; const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY"; const GAME_CREATOR_CODEX_APP_SERVER_PROTOCOL: &str = "genarrative-codex-app-server.v2"; @@ -20,6 +19,8 @@ const GAME_CREATOR_CODEX_APP_SERVER_THREAD_MAX: usize = 128; const GAME_CREATOR_CODEX_APP_SERVER_RPC_TIMEOUT_MS: u64 = 30_000; pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX: &str = "codex-app-server-terminal-unknown:"; +pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX: &str = + "codex-app-server-error:"; type RpcResult = Result; @@ -177,6 +178,92 @@ fn game_creator_codex_app_server_terminal_unknown( )) } +fn game_creator_codex_app_server_error_kind(kind: &str) -> platform_llm::LlmError { + platform_llm::LlmError::InvalidRequest(format!( + "{GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX}{kind}" + )) +} + +fn game_creator_codex_app_server_error_http_status( + info: &serde_json::Value, + field: &str, +) -> Option { + info.get(field)? + .get("httpStatusCode")? + .as_u64() + .and_then(|status| u16::try_from(status).ok()) + .filter(|status| (100..=599).contains(status)) +} + +fn game_creator_codex_app_server_connection_error( + info: &serde_json::Value, + field: &str, +) -> platform_llm::LlmError { + match game_creator_codex_app_server_error_http_status(info, field) { + Some(401 | 403) => game_creator_codex_app_server_error_kind("unauthorized"), + Some(status_code) => platform_llm::LlmError::Upstream { + status_code, + message: "Codex app-server 连接上游失败".to_string(), + }, + None => platform_llm::LlmError::Connectivity { + attempts: 1, + message: "Codex app-server 连接失败".to_string(), + }, + } +} + +fn game_creator_codex_app_server_failed_turn_error( + turn: &serde_json::Value, +) -> platform_llm::LlmError { + let Some(info) = turn + .get("error") + .and_then(|error| error.get("codexErrorInfo")) + .filter(|info| !info.is_null()) + else { + return game_creator_codex_app_server_error_kind("other"); + }; + if let Some(kind) = info.as_str() { + return match kind { + "contextWindowExceeded" => { + game_creator_codex_app_server_error_kind("context-window-exceeded") + } + "sessionBudgetExceeded" => { + game_creator_codex_app_server_error_kind("session-budget-exceeded") + } + "usageLimitExceeded" => { + game_creator_codex_app_server_error_kind("usage-limit-exceeded") + } + "serverOverloaded" | "internalServerError" => platform_llm::LlmError::Upstream { + status_code: 503, + message: "Codex app-server 上游服务暂时不可用".to_string(), + }, + "cyberPolicy" => game_creator_codex_app_server_error_kind("cyber-policy"), + "unauthorized" => game_creator_codex_app_server_error_kind("unauthorized"), + "badRequest" => game_creator_codex_app_server_error_kind("bad-request"), + "threadRollbackFailed" => { + game_creator_codex_app_server_error_kind("thread-rollback-failed") + } + "sandboxError" => game_creator_codex_app_server_error_kind("sandbox-error"), + "other" => game_creator_codex_app_server_error_kind("other"), + _ => game_creator_codex_app_server_error_kind("other"), + }; + } + for field in [ + "httpConnectionFailed", + "responseStreamConnectionFailed", + "responseStreamDisconnected", + "responseTooManyFailedAttempts", + ] { + if info.get(field).is_some() { + return game_creator_codex_app_server_connection_error(info, field); + } + } + if info.get("activeTurnNotSteerable").is_some() { + return game_creator_codex_app_server_error_kind("active-turn-not-steerable"); + } + game_creator_codex_app_server_error_kind("other") +} + async fn isolate_game_creator_codex_app_server_terminal_unknown( inner: &Arc, detail: impl Into, @@ -394,10 +481,8 @@ fn configure_game_creator_codex_app_server_command( "plugins", "remote_plugin", "shell_tool", - "skill_search", "tool_suggest", "unified_exec", - "view_image", "workspace_dependencies", ] { command.arg("--disable").arg(feature); @@ -520,12 +605,9 @@ impl CodexAppServerConnection { llm: &GameCreatorLlmConfig, credential: &CodexAppServerCredential, ) -> Result { - Self::spawn_with_executable_and_credential( - llm, - credential, - std::ffi::OsStr::new(GAME_CREATOR_CODEX_APP_SERVER_EXECUTABLE), - ) - .await + let executable = game_creator_codex_cli_executable_path() + .map_err(platform_llm::LlmError::InvalidConfig)?; + Self::spawn_with_executable_and_credential(llm, credential, executable.as_os_str()).await } async fn spawn_with_executable( @@ -1012,9 +1094,7 @@ impl CodexAppServerConnection { )) } "failed" => { - return Err(platform_llm::LlmError::InvalidRequest( - "Codex app-server turn 执行失败".to_string(), - )) + return Err(game_creator_codex_app_server_failed_turn_error(turn)) } status => { return Err(platform_llm::LlmError::Deserialize(format!( @@ -1577,6 +1657,81 @@ mod tests { assert!(response.text.is_empty()); } + #[test] + fn codex_app_server_failed_turn_uses_structured_error_info_without_raw_details() { + let secret = ["sk", "turn-secret"].join("-"); + let failed_turn = serde_json::json!({ + "status": "failed", + "error": { + "message": format!("private message {secret}"), + "additionalDetails": "https://provider.example/private C:\\Users\\victim\\project", + "codexErrorInfo": "contextWindowExceeded" + } + }); + let error = game_creator_codex_app_server_failed_turn_error(&failed_turn); + assert_eq!( + error, + platform_llm::LlmError::InvalidRequest( + "codex-app-server-error:context-window-exceeded".to_string() + ) + ); + let visible = error.to_string(); + assert!(!visible.contains(&secret)); + assert!(!visible.contains("provider.example")); + assert!(!visible.contains("victim")); + } + + #[test] + fn codex_app_server_failed_turn_maps_stable_categories_and_http_status() { + for (info, expected) in [ + ( + serde_json::json!("usageLimitExceeded"), + platform_llm::LlmError::InvalidRequest( + "codex-app-server-error:usage-limit-exceeded".to_string(), + ), + ), + ( + serde_json::json!("unauthorized"), + platform_llm::LlmError::InvalidRequest( + "codex-app-server-error:unauthorized".to_string(), + ), + ), + ( + serde_json::json!({"httpConnectionFailed":{"httpStatusCode":429}}), + platform_llm::LlmError::Upstream { + status_code: 429, + message: "Codex app-server 连接上游失败".to_string(), + }, + ), + ( + serde_json::json!({"responseStreamDisconnected":{"httpStatusCode":null}}), + platform_llm::LlmError::Connectivity { + attempts: 1, + message: "Codex app-server 连接失败".to_string(), + }, + ), + ] { + let turn = serde_json::json!({ + "status": "failed", + "error": { + "message": "private upstream body", + "additionalDetails": "private diagnostics", + "codexErrorInfo": info + } + }); + assert_eq!( + game_creator_codex_app_server_failed_turn_error(&turn), + expected + ); + } + assert_eq!( + game_creator_codex_app_server_failed_turn_error( + &serde_json::json!({"status":"failed","error":null}) + ), + platform_llm::LlmError::InvalidRequest("codex-app-server-error:other".to_string()) + ); + } + #[test] fn codex_app_server_rejects_non_responses_key_mapping() { let mut llm = test_llm(); @@ -1587,6 +1742,41 @@ mod tests { assert!(game_creator_codex_app_server_validate_llm_config(&llm).is_err()); } + #[test] + fn codex_app_server_command_uses_only_current_cli_feature_flags() { + let mut command = tokio::process::Command::new("codex"); + configure_game_creator_codex_app_server_command(&mut command, &test_llm()) + .expect("configure app-server command"); + let arguments = command + .as_std() + .get_args() + .map(|argument| argument.to_string_lossy().into_owned()) + .collect::>(); + assert!(arguments + .windows(2) + .any(|pair| pair == ["--disable", "shell_tool"])); + assert!(!arguments.iter().any(|argument| argument == "skill_search")); + assert!(!arguments.iter().any(|argument| argument == "view_image")); + } + + #[cfg(windows)] + #[test] + fn codex_app_server_current_cli_accepts_configured_arguments() { + let executable = game_creator_codex_cli_executable_path().expect("Codex CLI executable"); + let mut command = std::process::Command::new(executable); + let mut configured = tokio::process::Command::new("codex"); + configure_game_creator_codex_app_server_command(&mut configured, &test_llm()) + .expect("configure app-server command"); + command.args(configured.as_std().get_args()); + command.arg("--help").stdin(Stdio::null()); + let output = command.output().expect("run Codex app-server help"); + assert!( + output.status.success(), + "configured app-server arguments must be accepted: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + #[test] fn codex_app_server_pool_key_isolated_by_credentials_and_route() { let mut base = test_llm(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index d26107510..d0c2877d2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -1,4 +1,5 @@ use super::*; +use std::path::{Path, PathBuf}; use std::process::Stdio; use sha2::{Digest, Sha256}; @@ -9,29 +10,128 @@ const GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024; const GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES: usize = 4 * 1024 * 1024; const GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES: usize = 256 * 1024; +fn game_creator_codex_cli_executable_candidates_for( + app_data: Option<&Path>, + local_app_data: Option<&Path>, + runtime_config_dir: Option<&Path>, + path: Option<&std::ffi::OsStr>, +) -> Vec { + let mut candidates = Vec::new(); + #[cfg(windows)] + { + fn append_native_npm_candidates(candidates: &mut Vec, npm_root: &Path) { + let vendor_root = npm_root + .join("node_modules") + .join("@openai") + .join("codex") + .join("node_modules") + .join("@openai") + .join("codex-win32-x64") + .join("vendor"); + if let Ok(entries) = std::fs::read_dir(vendor_root) { + let mut targets = entries + .filter_map(Result::ok) + .map(|entry| entry.path().join("bin").join("codex.exe")) + .collect::>(); + targets.sort(); + candidates.extend(targets); + } + } + + fn append_desktop_codex_candidates(candidates: &mut Vec, local_app_data: &Path) { + let bin_root = local_app_data.join("OpenAI").join("Codex").join("bin"); + if let Ok(entries) = std::fs::read_dir(bin_root) { + let mut targets = entries + .filter_map(Result::ok) + .map(|entry| entry.path().join("codex.exe")) + .collect::>(); + targets.sort(); + targets.reverse(); + candidates.extend(targets); + } + } + + if let Some(app_data) = app_data { + append_native_npm_candidates(&mut candidates, &app_data.join("npm")); + } + if let Some(local_app_data) = local_app_data { + append_desktop_codex_candidates(&mut candidates, local_app_data); + } + if let Some(app_data) = runtime_config_dir.and_then(Path::parent) { + append_native_npm_candidates(&mut candidates, &app_data.join("npm")); + if let Some(user_profile) = app_data.parent() { + append_desktop_codex_candidates(&mut candidates, &user_profile.join("Local")); + } + } + if let Some(path) = path { + for entry in std::env::split_paths(&path) { + append_native_npm_candidates(&mut candidates, &entry); + candidates.push(entry.join("codex.exe")); + } + } + } + candidates.push(PathBuf::from(GAME_CREATOR_CODEX_CLI_EXECUTABLE)); + candidates +} + +fn game_creator_codex_cli_executable_candidates() -> Vec { + game_creator_codex_cli_executable_candidates_for( + std::env::var_os("APPDATA").as_deref().map(Path::new), + std::env::var_os("LOCALAPPDATA").as_deref().map(Path::new), + game_creator_runtime_config_dir().as_deref(), + std::env::var_os("PATH").as_deref(), + ) +} + +fn game_creator_codex_cli_version_at(executable: &Path) -> Result { + let output = std::process::Command::new(executable) + .arg("--version") + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err(format!("版本检查退出状态为 {}", output.status)); + } + let version = std::str::from_utf8(&output.stdout) + .map_err(|_| "版本信息不是 UTF-8".to_string())? + .trim(); + if !version.starts_with("codex-cli ") || version.len() > 120 { + return Err("返回了无法识别的版本信息".to_string()); + } + Ok(version.to_string()) +} + +pub(crate) fn game_creator_codex_cli_executable_path() -> Result { + let mut last_error = None; + let mut seen = std::collections::HashSet::new(); + for candidate in game_creator_codex_cli_executable_candidates() { + let identity = candidate.to_string_lossy().to_ascii_lowercase(); + if !seen.insert(identity) { + continue; + } + match game_creator_codex_cli_version_at(&candidate) { + Ok(_) => return Ok(candidate), + Err(error) => last_error = Some(error), + } + } + Err(format!( + "Codex CLI 未安装或当前 Agent Runner 无法启动;已检查 PATH 和 npm 全局安装目录{}", + last_error + .map(|error| format!("(最后错误:{error})")) + .unwrap_or_default() + )) +} + struct CodexCliStderrSummary { byte_len: usize, sha256: String, classification: &'static str, } -pub(in crate::agent) fn game_creator_codex_cli_version_identity() -> Result { - let output = std::process::Command::new(GAME_CREATOR_CODEX_CLI_EXECUTABLE) - .arg("--version") - .stdin(Stdio::null()) - .stderr(Stdio::null()) - .output() - .map_err(|_| "Codex CLI 未安装或不在当前 Agent Runner PATH 中".to_string())?; - if !output.status.success() { - return Err("Codex CLI 版本检查失败".to_string()); - } - let version = std::str::from_utf8(&output.stdout) - .map_err(|_| "Codex CLI 版本信息不是 UTF-8".to_string())? - .trim(); - if !version.starts_with("codex-cli ") || version.len() > 120 { - return Err("Codex CLI 返回了无法识别的版本信息".to_string()); - } - Ok(version.to_string()) +pub(crate) fn game_creator_codex_cli_version_identity() -> Result { + let executable = game_creator_codex_cli_executable_path()?; + game_creator_codex_cli_version_at(&executable) } pub(in crate::agent) fn game_creator_codex_cli_reasoning_effort( @@ -546,11 +646,9 @@ async fn request_game_creator_agent_codex_cli_with_executable( pub(in crate::agent) async fn request_game_creator_agent_codex_cli( request: LlmRunRequest, ) -> Result { - request_game_creator_agent_codex_cli_with_executable( - std::ffi::OsStr::new(GAME_CREATOR_CODEX_CLI_EXECUTABLE), - request, - ) - .await + let executable = + game_creator_codex_cli_executable_path().map_err(platform_llm::LlmError::InvalidConfig)?; + request_game_creator_agent_codex_cli_with_executable(executable.as_os_str(), request).await } #[cfg(test)] @@ -567,6 +665,103 @@ mod tests { ]) } + #[cfg(windows)] + #[test] + fn codex_cli_candidates_prefer_sorted_native_npm_targets_before_path() { + let temp = tempfile::tempdir().expect("temp dir"); + let app_data = temp.path().join("app-data"); + let vendor = app_data + .join("npm/node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor"); + std::fs::create_dir_all(vendor.join("z-target/bin")).expect("z target"); + std::fs::create_dir_all(vendor.join("a-target/bin")).expect("a target"); + let path_dir = temp.path().join("path"); + std::fs::create_dir_all(&path_dir).expect("path dir"); + + let candidates = game_creator_codex_cli_executable_candidates_for( + Some(&app_data), + None, + None, + Some(path_dir.as_os_str()), + ); + assert_eq!( + candidates[0], + vendor.join("a-target/bin/codex.exe"), + "native npm targets must be deterministic and precede PATH" + ); + assert_eq!(candidates[1], vendor.join("z-target/bin/codex.exe")); + assert_eq!(candidates[2], path_dir.join("codex.exe")); + assert_eq!(candidates.last(), Some(&PathBuf::from("codex"))); + } + + #[cfg(windows)] + #[test] + fn codex_cli_candidates_discover_native_npm_target_from_path_without_appdata() { + let temp = tempfile::tempdir().expect("temp dir"); + let npm_root = temp.path().join("npm"); + let native = npm_root + .join("node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor") + .join("x86_64-pc-windows-msvc/bin/codex.exe"); + std::fs::create_dir_all(native.parent().expect("native parent")) + .expect("native target directory"); + + let candidates = game_creator_codex_cli_executable_candidates_for( + None, + None, + None, + Some(npm_root.as_os_str()), + ); + assert_eq!(candidates[0], native); + assert_eq!(candidates[1], npm_root.join("codex.exe")); + } + + #[cfg(windows)] + #[test] + fn codex_cli_candidates_discover_native_npm_target_from_runtime_config_dir() { + let temp = tempfile::tempdir().expect("temp dir"); + let app_data = temp.path().join("roaming"); + let config_dir = app_data.join("world.genarrative.ai-game-creator"); + let native = app_data + .join("npm/node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor") + .join("x86_64-pc-windows-msvc/bin/codex.exe"); + std::fs::create_dir_all(native.parent().expect("native parent")) + .expect("native target directory"); + + let candidates = + game_creator_codex_cli_executable_candidates_for(None, None, Some(&config_dir), None); + assert_eq!(candidates[0], native); + } + + #[cfg(windows)] + #[test] + fn codex_cli_candidates_discover_desktop_native_target() { + let temp = tempfile::tempdir().expect("temp dir"); + let local_app_data = temp.path().join("local"); + let older = local_app_data.join("OpenAI/Codex/bin/111/codex.exe"); + let newer = local_app_data.join("OpenAI/Codex/bin/222/codex.exe"); + std::fs::create_dir_all(older.parent().expect("older parent")).expect("older dir"); + std::fs::create_dir_all(newer.parent().expect("newer parent")).expect("newer dir"); + + let candidates = game_creator_codex_cli_executable_candidates_for( + None, + Some(&local_app_data), + None, + None, + ); + assert_eq!(candidates[0], newer); + assert_eq!(candidates[1], older); + } + + #[cfg(windows)] + #[test] + fn codex_cli_resolver_finds_current_native_install() { + let executable = game_creator_codex_cli_executable_path().expect("Codex CLI executable"); + assert!(executable.is_absolute()); + assert_eq!( + game_creator_codex_cli_version_identity().expect("Codex CLI version"), + game_creator_codex_cli_version_at(&executable).expect("same executable version") + ); + } + #[test] fn codex_cli_mode_renders_runtime_messages_and_structured_tool_contract() { let prompt = render_game_creator_codex_cli_prompt(&tool_request()).expect("render prompt"); 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 8c1375ad2..0fe8bab76 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 @@ -2265,7 +2265,19 @@ struct TrustedPlatformArtTransactionDirectory { impl TrustedPlatformArtTransactionDirectory { fn open_anchored(root: &Path, path: &Path) -> Result { - let parent = TrustedPlatformArtRecoveryParent::open(root, path, false)?; + // The transaction leaf is a directory. Anchor and validate its parent using a + // missing sibling path so the regular-file recovery preflight does not reject the + // directory itself before the dedicated directory validation below. + let anchor_target = path.with_file_name(".art-spritesheet-contract-transaction.anchor"); + let parent = TrustedPlatformArtRecoveryParent::open(root, &anchor_target, false)?; + #[cfg(unix)] + let parent = TrustedPlatformArtRecoveryParent { + leaf: path + .file_name() + .ok_or_else(|| "平台图集事务目录缺少叶子文件名".to_string())? + .to_os_string(), + ..parent + }; #[cfg(unix)] { use std::os::unix::ffi::OsStrExt; @@ -3658,11 +3670,14 @@ fn sync_strict_platform_art_contract_state_at( path.display() )); } - Ok(_) => fs::File::open(&path) - .and_then(|file| file.sync_all()) - .map_err(|error| { - format!("同步平台图集合同文件失败:{}: {error}", path.display()) - })?, + Ok(_) => { + #[cfg(unix)] + fs::File::open(&path) + .and_then(|file| file.sync_all()) + .map_err(|error| { + format!("同步平台图集合同文件失败:{}: {error}", path.display()) + })?; + } Err(error) if error.kind() == std::io::ErrorKind::NotFound && !require_complete => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => { return Err(format!("平台图集提交缺少完整合同文件:{}", path.display())); @@ -4698,6 +4713,7 @@ impl PlatformArtSliceContractRollback { &journal, "平台图集事务 journal", )?; + #[cfg(unix)] trusted_transaction_directory .handle .sync_all() @@ -4794,6 +4810,7 @@ impl PlatformArtSliceContractRollback { )); } } + #[cfg(unix)] trusted_transaction_directory.handle.sync_all().map_err(|error| { format!( "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集合同已提交,但同步 prepared marker 清理失败:{error}" @@ -7852,6 +7869,7 @@ mod canvas_generation_tests { drop(project_lock); } + #[cfg(unix)] #[test] fn durable_strict_contract_transaction_rejects_same_length_snapshot_rewrite_during_read() { let temporary = tempfile::tempdir().expect("create concurrent snapshot project"); @@ -8322,6 +8340,7 @@ mod canvas_generation_tests { assert!(!root.join(STRICT_PLATFORM_ART_TRANSACTION_PATH).exists()); } + #[cfg(unix)] #[test] fn durable_strict_contract_transaction_rejects_replaced_transaction_directory() { let temporary = tempfile::tempdir().expect("create replaced transaction directory fixture"); @@ -8689,6 +8708,7 @@ mod canvas_generation_tests { .expect("open sparse main sheet"); main.set_len(STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES + 1) .expect("create oversized sparse main sheet"); + drop(main); let error = match PlatformArtSliceContractRollback::capture(root, "oversized-snapshot") { Ok(_) => panic!("oversized sparse snapshot must fail before an unbounded read"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs index dd84942a3..43a6aa0ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs @@ -547,7 +547,13 @@ pub(crate) fn game_creator_agent_llm_error_public_summary( (format!("upstream-{status_code}"), Some(*status_code)) } platform_llm::LlmError::InvalidConfig(_) => ("invalid-config".to_string(), None), - platform_llm::LlmError::InvalidRequest(_) => ("invalid-request".to_string(), None), + platform_llm::LlmError::InvalidRequest(message) => ( + message + .strip_prefix(GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX) + .map(|kind| format!("codex-app-server-{kind}")) + .unwrap_or_else(|| "invalid-request".to_string()), + None, + ), platform_llm::LlmError::StreamUnavailable => ("stream-unavailable".to_string(), None), platform_llm::LlmError::EmptyResponse => ("empty-response".to_string(), None), platform_llm::LlmError::Deserialize(_) => ("deserialize".to_string(), None), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/run_lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/run_lifecycle.rs index d70ede095..697fd2c81 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/run_lifecycle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/run_lifecycle.rs @@ -93,9 +93,10 @@ pub(crate) async fn control_agent_run_at( let prompt = resumed_agent_run_prompt(&previous_trace.goal, action, detail); let generated = generate_local_game_draft_at(root, &prompt, progress).await?; let trace = read_latest_agent_run_trace(root)?; + let game_index_path = generated.game_index_path.replace('\\', "/"); let message = format!( "{},已重新运行为 {}:{}", - control_result.message, trace.run_id, generated.game_index_path + control_result.message, trace.run_id, game_index_path ); let event = if action == "retry" { "agent.retry.run" 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 cc6d337e5..731d40ef0 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 @@ -214,7 +214,9 @@ pub(in crate::agent) fn validate_root_goal_contract_control_plan_at( || !plan.plan.is_empty() || !plan.response.trim().is_empty() { - return Err("根 Project Supervisor 必须先把自己对当前用户最终意图的理解作为本轮唯一动作提交 agent.goal_contract;固定规则只提供上下文,不能先调度、委派、修改项目或回复完成".to_string()); + return Err(format!( + "{AGENT_RUNTIME_ROOT_GOAL_CONTRACT_REQUIRED_ERROR_PREFIX};固定规则只提供上下文,不能先调度、委派、修改项目或回复完成" + )); } return Ok(()); } @@ -236,6 +238,29 @@ pub(in crate::agent) fn validate_root_goal_contract_control_plan_at( Ok(()) } +pub(super) const AGENT_RUNTIME_ROOT_GOAL_CONTRACT_REQUIRED_ERROR_PREFIX: &str = + "根 Project Supervisor 必须先把自己对当前用户最终意图的理解作为本轮唯一动作提交 agent.goal_contract"; + +pub(in crate::agent) fn restrict_agent_runtime_root_goal_contract_tools( + request: &mut LlmRunRequest, +) -> Result<(), String> { + let goal_contract_function = native_runtime_function_name("agent.goal_contract") + .ok_or_else(|| "无法生成根 Goal Contract 工具函数名".to_string())?; + request + .function_tools + .retain(|tool| tool.name == goal_contract_function); + if request.function_tools.len() != 1 { + return Err("根 Goal Contract 工具目录缺少 agent.goal_contract".to_string()); + } + request.max_output_tokens = Some( + request + .max_output_tokens + .unwrap_or(AGENT_RUNTIME_AUTONOMOUS_FORCED_ACTION_MAX_OUTPUT_TOKENS) + .min(AGENT_RUNTIME_AUTONOMOUS_FORCED_ACTION_MAX_OUTPUT_TOKENS), + ); + Ok(()) +} + pub(super) const AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX: &str = "自主构建 Project Supervisor 必须先收束已有专业 Agent 委派"; pub(super) const AGENT_RUNTIME_AUTONOMOUS_PREVIEW_AFTER_STATIC_LIVENESS_ERROR_PREFIX: &str = @@ -702,6 +727,30 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness( .actions .iter() .any(|action| action.tool.trim() == "agent.route_manifest"); + let latest_playtest_index = observations + .iter() + .rposition(|observation| observation.tool == "preview.validate"); + let latest_playtest_is_failed = latest_playtest_index.is_some_and(|index| { + observations[index].status == "failed" + && observations[index].summary == "浏览器验证未通过,请根据诊断修复后重试" + }); + // A Supervisor without a mutation must still hit the same first-mutation + // liveness gate as every other autonomous Agent. A concrete failed + // playtest is more specific and must reach its repair gate below. + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && loop_index > AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + && verification_gate.mutation_revision.is_none() + && verification_gate.failed_playtest_revision.is_none() + && !has_mutation + && !has_code_asset_route + && plan.response.trim().is_empty() + && !has_specialist_delegation + && !latest_playtest_is_failed + { + return Err(format!( + "{AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX};Supervisor 尚未提交首次项目 mutation 或有效协作动作,禁止继续只规划、读取、验证或空转" + )); + } if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { if let Some(failed_playtest_revision) = verification_gate.failed_playtest_revision { if project_revision < failed_playtest_revision { @@ -743,10 +792,7 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness( "{AGENT_RUNTIME_AUTONOMOUS_PREVIEW_AFTER_STATIC_LIVENESS_ERROR_PREFIX};当前 revision {project_revision} 已通过 game.static_smoke,下一步必须只调用 preview.validate 取得当前 revision 的桌面与移动真实试玩凭证;不得继续委派、更新计划、读取、搜索、查询状态、修改项目或返回最终回复" )); } - let Some(latest_playtest_index) = observations - .iter() - .rposition(|observation| observation.tool == "preview.validate") - else { + let Some(latest_playtest_index) = latest_playtest_index else { return Ok(()); }; let latest_playtest = &observations[latest_playtest_index]; @@ -1609,6 +1655,35 @@ pub(in crate::agent) fn agent_runtime_protocol_error_requires_supervisor_collabo mod tests { use super::*; + #[test] + fn root_goal_contract_repair_catalog_contains_only_goal_contract() { + let catalog = GameCreatorMcpCatalog { + fingerprint: String::new(), + servers: Vec::new(), + tools: Vec::new(), + }; + let mut request = LlmRunRequest::new(Vec::new()) + .with_function_tools( + build_agent_runtime_native_function_tools(&catalog) + .expect("build native function tools"), + ) + .with_tool_choice(platform_llm::LlmToolChoice::Required); + + restrict_agent_runtime_root_goal_contract_tools(&mut request) + .expect("restrict root Goal Contract tools"); + + assert_eq!(request.function_tools.len(), 1); + assert_eq!( + request.function_tools[0].name, + native_runtime_function_name("agent.goal_contract") + .expect("goal contract function name") + ); + assert_eq!( + request.tool_choice, + Some(platform_llm::LlmToolChoice::Required) + ); + } + fn autonomous_initial_delegate( agent_id: &str, expected_artifacts: &[&str], @@ -1911,4 +1986,88 @@ mod tests { .is_err()); } } + + #[test] + fn autonomous_supervisor_without_playtest_still_hits_pre_mutation_liveness_gate() { + let verification_gate = AgentRuntimeVerificationGate { + schema_version: "test".to_string(), + project_id: "test".to_string(), + agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + run_id: "supervisor-pre-mutation".to_string(), + requires_verification: false, + mutation_revision: None, + verified_revision: None, + last_mutation_tool: None, + last_verification_tool: None, + last_verification_status: None, + static_smoke_verified_revision: None, + failed_playtest_revision: None, + updated_at: 0, + }; + let plan = AgentRuntimeToolPlan { + thinking_summary: "继续只读规划".to_string(), + ..AgentRuntimeToolPlan::default() + }; + let error = validate_agent_runtime_autonomous_plan_liveness( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1, + 0, + &verification_gate, + &[], + &plan, + false, + false, + ) + .expect_err("Supervisor without a playtest must not bypass pre-mutation liveness"); + assert!(error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX)); + } + + #[test] + fn autonomous_supervisor_latest_successful_playtest_does_not_bypass_pre_mutation_gate() { + let verification_gate = AgentRuntimeVerificationGate { + schema_version: "test".to_string(), + project_id: "test".to_string(), + agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + run_id: "supervisor-latest-playtest".to_string(), + requires_verification: false, + mutation_revision: None, + verified_revision: None, + last_mutation_tool: None, + last_verification_tool: None, + last_verification_status: None, + static_smoke_verified_revision: None, + failed_playtest_revision: None, + updated_at: 0, + }; + let observations = [ + AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器验证未通过,请根据诊断修复后重试".to_string(), + detail: None, + }, + AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "ok".to_string(), + summary: "浏览器验证通过".to_string(), + detail: None, + }, + ]; + let plan = AgentRuntimeToolPlan { + thinking_summary: "继续只读规划".to_string(), + ..AgentRuntimeToolPlan::default() + }; + let error = validate_agent_runtime_autonomous_plan_liveness( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1, + 0, + &verification_gate, + &observations, + &plan, + false, + false, + ) + .expect_err("latest successful playtest must not retain an earlier failure exemption"); + assert!(error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX)); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index 3f2a0951d..0ed74be4c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -105,6 +105,8 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( let root_goal_contract_context = render_game_creator_agent_runtime_goal_contract_for_prompt_at(root, agent_id, run_id)? .unwrap_or_else(|| "null".to_string()); + let root_goal_contract_required = + root_control_authority && root_goal_contract_context == "null"; let acceptance_graph_context = render_game_creator_agent_runtime_acceptance_graph_for_prompt_at(root, agent_id, run_id)? .unwrap_or_else(|| "null".to_string()); @@ -316,6 +318,55 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( tool.name != goal_contract_function && tool.name != acceptance_update_function }); } + // A rejected structured-plan update is a request-scoped liveness signal. + // The next Provider turn must perform the concrete mutation (or deliver a + // read-only result) instead of entering another planning loop. + let latest_plan_rejection = observations.iter().rposition(|observation| { + observation.tool == "runtime.plan_update" && observation.status == "rejected" + }); + let plan_rejection_needs_repair = latest_plan_rejection.is_some_and(|index| { + !observations[index + 1..] + .iter() + .any(is_agent_runtime_project_mutation_observation) + }); + if autonomous_game_build && plan_rejection_needs_repair { + let supervisor_orchestrator_repair = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + { + let policy = + resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id)?.policy; + let state = read_supervisor_collaboration_state_at(root, agent_id, run_id)?; + policy.orchestrator_only_after_delegation && state.has_collaboration() + } else { + false + }; + let repair_tools: &[&str] = if supervisor_orchestrator_repair { + &["agent.delegate", "agent.run_status"] + } else { + &[ + "file.write", + "file.patch", + "file.delete", + "project.patchset", + "project.restore", + "canvas.asset_generate", + ] + }; + let mut allowed_function_names = + BTreeSet::from([AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string()]); + for tool in repair_tools { + if let Some(name) = native_runtime_function_name(tool) { + allowed_function_names.insert(name); + } + } + request + .function_tools + .retain(|tool| allowed_function_names.contains(&tool.name)); + request.messages.push(LlmMessage::user(if supervisor_orchestrator_repair { + "上一轮 runtime.plan_update 被拒绝。本轮 Supervisor 已进入协作编排模式,只能调用 agent.run_status 或 agent.delegate 继续收束,或在证据足够时 respond_to_user;禁止再次规划、读取、搜索、验证或直接修改项目。" + } else { + "上一轮 runtime.plan_update 被拒绝。本轮必须立即提交当前 in_progress 步骤对应的实际项目 mutation,或在只读合同已满足时 respond_to_user;禁止再次规划、读取、搜索、验证、委派或普通文本解释。" + })); + } if autonomous_game_build && !editor_api_key_is_configured() { let canvas_function = native_runtime_function_name("canvas.asset_generate") .ok_or_else(|| "无法生成画布素材工具函数名".to_string())?; @@ -330,6 +381,12 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( .function_tools .retain(|tool| tool.name != project_verify_function); } + if root_goal_contract_required { + restrict_agent_runtime_root_goal_contract_tools(&mut request)?; + request.messages.push(LlmMessage::user( + "当前根 Run 尚未冻结 Goal Contract。本轮唯一可用工具是 agent.goal_contract;必须且只能调用一次,用 outcome 具体概括当前用户最终意图,acceptanceNodes 至少提交一项可核对标准。每个 requiredEvidence 必须选择在该标准所有合法结果下都能成功产生回执的工具;环境探测可能以 rejected/failed 表示正常否定结果时,不得把该探测工具写成必需成功回执(例如非 Git 项目不得要求 git.inspect 成功,应使用 project.index 的成功回执证明 isRepository=false)。nonNegotiables、preferences、forbiddenAssumptions、openQuestions 没有内容时传空数组。不得调用 update_agent_plan、respond_to_user 或任何其他动作,不得输出普通文本。", + )); + } request = apply_game_creator_llm_web_search( apply_game_creator_llm_reasoning_effort(request, &llm)?, &llm, @@ -530,12 +587,14 @@ mod tests { game_creator_project_supervisor_chat_system_prompt, init_local_game_project_at, provider_command_exec_contract, provider_command_start_contract, required_runtime_prompt_section, resolve_agent_conversation_session_id_at, - start_game_creator_agent_runtime_task_at, AgentRuntimeTaskLink, AgentRuntimeToolPlan, - GameCreatorMcpCatalog, GameCreatorMcpCatalogTool, - AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, + start_game_creator_agent_runtime_task_at, AgentRuntimeGoalContractAcceptanceNodeDraft, + AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation, + AgentRuntimeToolPlan, GameCreatorMcpCatalog, GameCreatorMcpCatalogTool, + AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, AGENT_RUNTIME_RESPOND_FUNCTION_NAME, AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION, + AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION, }; fn native_input_required_fields( @@ -556,6 +615,92 @@ mod tests { .collect() } + #[test] + fn rejected_plan_update_forces_request_scoped_mutation_catalog() { + let directory = crate::tests::canonical_test_tempdir("provider-plan-rejection-repair-"); + let root = directory.path().join("project"); + init_local_game_project_at(&root, "plan-rejection-repair", "修复现有游戏") + .expect("project init"); + let binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "plan-rejection-repair-root", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind root"); + let state = start_game_creator_agent_runtime_task_at( + &root, + &binding.agent_id, + "修复现有游戏", + &binding.run_id, + &binding.source, + "执行当前计划中的项目修改", + vec!["立即修改 game/index.html".to_string()], + ) + .expect("start task"); + crate::agent::create_game_creator_agent_runtime_goal_contract_at( + &root, + &binding.agent_id, + &binding.run_id, + &state.current_task, + &AgentRuntimeGoalContractDraft { + outcome: "修复现有游戏".to_string(), + non_negotiables: Vec::new(), + preferences: Vec::new(), + forbidden_assumptions: Vec::new(), + open_questions: Vec::new(), + acceptance_nodes: vec![AgentRuntimeGoalContractAcceptanceNodeDraft { + criterion_id: "repair-game".to_string(), + criterion: "完成项目修改".to_string(), + required: true, + required_evidence: vec!["file.patch".to_string()], + dependencies: Vec::new(), + }], + }, + ) + .expect("create goal contract"); + let catalog = GameCreatorMcpCatalog { + fingerprint: String::new(), + servers: Vec::new(), + tools: Vec::new(), + }; + let rejected = AgentRuntimeToolObservation { + tool: "runtime.plan_update".to_string(), + status: "rejected".to_string(), + summary: "结构化计划更新被 Runtime 拒绝".to_string(), + detail: Some("计划状态回退".to_string()), + }; + let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + &state.current_task, + &[rejected], + 1, + &catalog, + ) + .expect("build request"); + let names = request + .function_tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(); + assert!(names.contains( + crate::agent_native_tools::native_runtime_function_name("file.patch") + .expect("file.patch function") + .as_str() + )); + assert!(names.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); + assert!(!names.contains(AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME)); + assert!(request + .messages + .iter() + .any(|message| message.content.contains("runtime.plan_update 被拒绝"))); + } + fn build_request_system_prompt_for_root_source( agent_id: &str, root_source: &str, @@ -667,7 +812,7 @@ mod tests { } #[test] - fn trusted_root_supervisor_receives_dynamic_goal_control_tools() { + fn trusted_root_supervisor_first_turn_only_receives_goal_contract_tool() { let directory = crate::tests::canonical_test_tempdir("provider-goal-control-"); let root = directory.path().join("project"); init_local_game_project_at(&root, "goal-control-project", "完成可验证游戏") @@ -711,6 +856,7 @@ mod tests { assert!(prompt.contains("动态目标协议:agent.goal_contract")); assert!(prompt.contains("固定规则、关键词、资产探测和专家建议只能作为上下文")); assert!(prompt.contains("未提交的 passed 节点保持不变")); + assert_eq!(request.function_tools.len(), 1); assert_eq!( native_input_required_fields(&request, "agent.goal_contract"), [ @@ -722,10 +868,9 @@ mod tests { "acceptanceNodes" ] ); - assert_eq!( - native_input_required_fields(&request, "agent.acceptance_update"), - ["contractFingerprint", "evaluations"] - ); + assert!(request.messages.iter().any(|message| message + .content + .contains("本轮唯一可用工具是 agent.goal_contract"))); } #[test] 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 50719d29b..3754f1f80 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 @@ -856,11 +856,14 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at && protocol_error .starts_with(AGENT_RUNTIME_AUTONOMOUS_TRUNCATED_SCAFFOLD_ERROR_PREFIX) && !request.function_tools.is_empty(); + let force_root_goal_contract = protocol_error + .starts_with(AGENT_RUNTIME_ROOT_GOAL_CONTRACT_REQUIRED_ERROR_PREFIX); let force_supervisor_initial_collaboration = agent_runtime_protocol_error_requires_supervisor_collaboration_repair( &protocol_error, ) && !request.function_tools.is_empty(); - if force_supervisor_initial_collaboration + if force_root_goal_contract + || force_supervisor_initial_collaboration || force_autonomous_specialist_mutation_only || force_autonomous_specialist_verification_only || force_autonomous_response_plan_completion @@ -887,7 +890,12 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at .retain(|tool| tool.name != project_verify_function); } } - if force_supervisor_initial_collaboration { + if force_root_goal_contract { + restrict_agent_runtime_root_goal_contract_tools(&mut request)?; + request.messages.push(LlmMessage::user(format!( + "上一条输出不符合工具计划协议:{protocol_error}\n当前根 Run 尚未冻结 Goal Contract。本次修复的原生工具目录只保留 agent.goal_contract;必须且只能调用一次,用 outcome 具体概括当前用户最终意图,acceptanceNodes 至少提交一项可核对标准。每个 requiredEvidence 必须选择在该标准所有合法结果下都能成功产生回执的工具;环境探测可能以 rejected/failed 表示正常否定结果时,不得把该探测工具写成必需成功回执(例如非 Git 项目不得要求 git.inspect 成功,应使用 project.index 的成功回执证明 isRepository=false)。nonNegotiables、preferences、forbiddenAssumptions、openQuestions 没有内容时传空数组。不得调用 update_agent_plan、respond_to_user 或任何其他动作,不得输出普通文本、解释、markdown 或代码围栏。" + ))); + } else if force_supervisor_initial_collaboration { supervisor_collaboration_repair_active = true; if let Some(actions) = supervisor_collaboration_candidate_actions.take() { supervisor_collaboration_repair_actions = 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 9604b7f27..b979bc23c 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 @@ -301,7 +301,7 @@ pub(crate) use provider_recovery::{ #[cfg(test)] pub(crate) use provider_recovery::{ drive_waiting_autonomous_manifest_parent_wake_budget_for_test, - ensure_waiting_provider_retry_records_for_test, + ensure_static_delegate_user_input_wait_at, ensure_waiting_provider_retry_records_for_test, mark_autonomous_manifest_parent_wake_needs_reconciliation_for_test, prepare_waiting_autonomous_manifest_parent_for_test, probe_static_delegate_parent_wake_singleflight_coalescing, 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 4d02b7aba..fa6ecd3fc 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 @@ -60,6 +60,25 @@ pub(super) fn game_creator_agent_background_final_reply_fallback( } } +pub(super) fn game_creator_agent_final_reply_error_allows_fallback(error: &str) -> bool { + const KIND_PREFIX: &str = "kind="; + let Some(start) = error.rfind(KIND_PREFIX) else { + return false; + }; + if error[..start] + .chars() + .next_back() + .is_some_and(|boundary| !boundary.is_whitespace() && boundary != ':' && boundary != ':') + { + return false; + } + let kind = error[start + KIND_PREFIX.len()..] + .chars() + .take_while(|character| character.is_ascii_lowercase() || *character == '-') + .collect::(); + matches!(kind.as_str(), "empty-response" | "deserialize") +} + fn requested_game_chat_fast_path_plan_at( root: &Path, plan: AgentRuntimeToolPlan, @@ -1879,8 +1898,46 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( .detail .as_deref() .is_some_and(static_delegate_barrier_requires_repair); + let user_input_required = blocker.detail.as_deref().is_some_and(|detail| { + detail + .split_whitespace() + .find_map(|part| part.strip_prefix("userInputRequired=")) + .and_then(|value| value.parse::().ok()) + .is_some_and(|count| count > 0) + }); runtime.status = "running".to_string(); - if repair_required { + if user_input_required { + let deliveries = match claimed_static_delegate_deliveries_at( + &root, + &runtime.agent_id, + &runtime.run_id, + ) { + Ok(deliveries) => deliveries, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("读取 needs-user-input 回执失败:{error}"), + ); + } + }; + if let Err(error) = ensure_static_delegate_user_input_wait_at( + &root, + &mut runtime, + &deliveries, + ) { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("Supervisor 用户澄清请求无法安全进入等待态:{error}"), + ); + } + return AgentBackgroundTaskOutcome::WaitingForUserInput; + } else if repair_required { runtime.phase = "planning".to_string(); runtime.current_action = "等待 Project Supervisor 发起唯一返工".to_string(); runtime.waiting_on = @@ -3761,7 +3818,10 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( { return AgentBackgroundTaskOutcome::NeedsReconciliation; } - Err(_) if final_reply_fallback.is_some() => { + Err(error) + if final_reply_fallback.is_some() + && game_creator_agent_final_reply_error_allows_fallback(&error) => + { final_reply_fallback.expect("checked final reply fallback") } Err(error) => { 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 df9c4e0a5..e02eacbd6 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 @@ -387,12 +387,13 @@ fn game_chat_main_without_asset_audit_fixture(root: &Path) -> String { async fn game_chat_main_agent_delegates_only_real_missing_art_and_limits_children_to_assets() { let temporary = tempfile::tempdir().expect("create game-chat art child root"); let root = temporary.path().join("project"); - let (_main, mut child, _delegation_id, _child_lane) = game_chat_main_art_child_fixture_with_lane( - &root, - "art-asset-plan", - &["core-spritesheet"], - true, - ); + let (_main, mut child, _delegation_id, _child_lane) = + game_chat_main_art_child_fixture_with_lane( + &root, + "art-asset-plan", + &["core-spritesheet"], + true, + ); assert_eq!(child.agent_id, "art-asset-plan"); assert_eq!(child.source, "agent-delegate"); assert_eq!(child.parent_agent_id.as_deref(), Some("code-prototype")); @@ -3294,6 +3295,36 @@ fn plan_response_precedes_autonomous_supervisor_deterministic_fallback() { ); } +#[test] +fn autonomous_final_reply_fallback_only_accepts_safe_response_shape_failures() { + let fingerprint = "a".repeat(64); + for allowed in ["empty-response", "deserialize"] { + assert!(game_creator_agent_final_reply_error_allows_fallback( + &format!("后台 Agent 最终回复调用 LLM 失败:kind={allowed} fingerprint={fingerprint} chars=12") + )); + } + for rejected in [ + "codex-app-server-unauthorized", + "codex-app-server-usage-limit-exceeded", + "codex-app-server-context-window-exceeded", + "codex-app-server-cyber-policy", + "codex-app-server-sandbox-error", + "invalid-config", + "transport", + "upstream-503", + ] { + assert!( + !game_creator_agent_final_reply_error_allows_fallback(&format!( + "后台 Agent 最终回复调用 LLM 失败:kind={rejected} fingerprint={fingerprint} chars=12" + )), + "final reply fallback must reject {rejected}" + ); + } + assert!(!game_creator_agent_final_reply_error_allows_fallback( + "上游自由文本kind=deserialize fingerprint=private" + )); +} + #[tokio::test] async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallback_once() { const RUN_ID: &str = "autonomous-final-reply-fallback-run"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs index 805214caa..7e130e4a5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs @@ -787,7 +787,9 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( can_repair_terminal_receipt = true; } if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT { - if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && !static_delegate_clarification_pending_matches_delivery_at(root, &pending)? + { let _ = cancel_game_creator_agent_user_input_request_for_pending_at(root, &pending); mark_game_creator_agent_runtime_needs_reconciliation_at( root, 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 1c2f55a5e..3b6af0f9d 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 @@ -321,6 +321,92 @@ pub(crate) fn schedule_waiting_static_delegate_parent_wake_after_lane_release( }); } +/// Convert a claimed `needs-user-input` delivery into the Supervisor's own +/// durable user-input action. The child never owns this action: it is tied to +/// the parent run and therefore passes the normal user-input owner gate. +pub(crate) fn ensure_static_delegate_user_input_wait_at( + root: &Path, + runtime: &mut AgentRuntimeState, + deliveries: &[StaticDelegateDeliveryRecord], +) -> Result { + let mut pending_deliveries = deliveries.iter().filter(|delivery| { + delivery.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsUserInput + }) && delivery.clarification_answers_sha256.is_none() + }); + let Some(delivery) = pending_deliveries.next() else { + return Ok(false); + }; + // Each durable request belongs to exactly one original delivery. Other + // deliveries remain behind the completion barrier and are asked next. + let result = delivery + .structured_result + .as_ref() + .ok_or_else(|| "needs-user-input delivery 缺少 structured result".to_string())?; + let questions = result.user_input_questions.clone(); + let action = AgentRuntimeToolAction { + tool: GAME_CREATOR_USER_INPUT_REQUEST_TOOL.to_string(), + reason: Some("代 Supervisor 汇总子 Agent 的澄清问题".to_string()), + input: serde_json::json!({"questions": questions}), + }; + let question_binding = game_creator_agent_user_input_action_input_summary(&action.input) + .unwrap_or_else(|| "questionsSha256=unavailable".to_string()); + let task = format!( + "子 Agent 需要用户澄清后才能继续。delegationId={};{question_binding}。请回答以下问题;回答完成后只创建一次 agent.delegate continuation,并将 repairOfDelegationId 与 continuationOfDelegationId 指向该原 delegation,同时提交 questionsSha256/answersSha256。", + delivery.delegation_id + ); + // Re-entry after a wake or restart may only reuse the exact request that + // belongs to this delivery; an unrelated Supervisor question must not mask it. + if let Ok(existing) = read_game_creator_agent_runtime_pending_tool_action( + root, + &runtime.agent_id, + &runtime.run_id, + ) { + if existing.action.tool == GAME_CREATOR_USER_INPUT_REQUEST_TOOL + && matches!( + existing.status.as_str(), + AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT + | AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED + ) + { + if existing.action.input != action.input || existing.task != task { + return Err( + "当前 Supervisor 用户输入 pending 与 needs-user-input delivery 身份冲突" + .to_string(), + ); + } + return Ok( + existing.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT + ); + } + } + let plan = AgentRuntimeToolPlan { + thinking_summary: "汇总子 Agent 澄清问题并等待用户回答".to_string(), + plan: vec!["等待用户回答后创建唯一 continuation 委派".to_string()], + actions: Vec::new(), + response: String::new(), + plan_update: None, + }; + let repository_context_fingerprint = build_repository_startup_context_at(root)?.fingerprint; + let project_revision = read_game_creator_agent_runtime_project_revision(root)?; + let mut pending = build_game_creator_agent_runtime_pending_tool_action( + root, + runtime, + &task, + &plan, + &[], + &project_revision, + &repository_context_fingerprint, + &action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT, + None, + )?; + persist_game_creator_agent_user_input_wait_at(root, runtime, &mut pending)?; + Ok(true) +} + pub(in crate::agent) async fn drive_waiting_static_delegate_parent_wake_pass( root: &Path, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs index 4abdc0db3..e59e42a88 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs @@ -1350,6 +1350,50 @@ mod tests { &contract_plan, ) .expect("sole goal contract action is allowed"); + let contract_with_extra_action = AgentRuntimeToolPlan { + actions: vec![action("agent.goal_contract"), action("file.list")], + ..AgentRuntimeToolPlan::default() + }; + assert!(validate_root_goal_contract_control_plan_at( + &root, + &binding.agent_id, + &binding.run_id, + &contract_with_extra_action, + ) + .expect_err("extra action before Goal Contract must fail") + .contains("必须先")); + let contract_with_plan_update = AgentRuntimeToolPlan { + actions: vec![action("agent.goal_contract")], + plan_update: Some(AgentRuntimePlanUpdate { + explanation: "不应与合同同轮".to_string(), + steps: vec![AgentRuntimePlanUpdateStep { + step: "不应先规划".to_string(), + status: "in_progress".to_string(), + }], + }), + ..AgentRuntimeToolPlan::default() + }; + assert!(validate_root_goal_contract_control_plan_at( + &root, + &binding.agent_id, + &binding.run_id, + &contract_with_plan_update, + ) + .expect_err("plan update before Goal Contract must fail") + .contains("必须先")); + let contract_with_response = AgentRuntimeToolPlan { + actions: vec![action("agent.goal_contract")], + response: "不应先回复".to_string(), + ..AgentRuntimeToolPlan::default() + }; + assert!(validate_root_goal_contract_control_plan_at( + &root, + &binding.agent_id, + &binding.run_id, + &contract_with_response, + ) + .expect_err("response before Goal Contract must fail") + .contains("必须先")); let contract_with_legacy_plan = AgentRuntimeToolPlan { actions: vec![action("agent.goal_contract")], plan: vec!["先执行旧式计划".to_string()], diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs index 0a15a6860..ef3faa7bc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs @@ -204,6 +204,40 @@ pub(in crate::agent) fn game_creator_agent_runtime_failure_conversation_message( } else { "专业 Agent" }; + let public_kind_prefix = "kind=codex-app-server-"; + let codex_error_kind = error + .rfind(public_kind_prefix) + .map(|start| &error[start + public_kind_prefix.len()..]) + .or_else(|| { + error + .rfind(GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX) + .map(|start| { + &error[start + GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX.len()..] + }) + }) + .map(|kind| { + kind.chars() + .take_while(|character| character.is_ascii_lowercase() || *character == '-') + .collect::() + }) + .filter(|kind| !kind.is_empty()); + if let Some(kind) = codex_error_kind { + let detail = match kind + .trim_matches(|character: char| !character.is_ascii_lowercase() && character != '-') + { + "context-window-exceeded" => "模型上下文已超限,请缩小任务范围后重试", + "session-budget-exceeded" => "本次会话预算已耗尽,请缩小任务范围或新建任务", + "usage-limit-exceeded" => "Codex 用量已达上限,请检查账户额度后重试", + "unauthorized" => "Codex 鉴权失败,请重新登录或检查 API Key", + "bad-request" => "Codex 请求无效,请检查模型与运行时配置", + "cyber-policy" => "Codex 安全策略拒绝了本次请求,请调整任务内容", + "sandbox-error" => "Codex 隔离环境启动失败,请重试或检查本机环境", + "thread-rollback-failed" => "Codex 会话恢复失败,请新建任务后重试", + "active-turn-not-steerable" => "当前 Codex 任务无法追加指令,请等待结束后重试", + _ => "Codex 执行失败,请查看运行详情后重试", + }; + return format!("{subject} {detail}"); + } if let Some((http_status, retry_attempt, max_retries)) = game_creator_agent_runtime_exhausted_upstream_retry_fields(error) { @@ -1956,4 +1990,43 @@ mod tests { assert!(!unrelated_visible.contains("absolute-path")); assert!(!unrelated_visible.contains("redacted-secret")); } + + #[test] + fn codex_app_server_failure_kind_has_actionable_safe_public_summary() { + let private_error = format!( + "agentLlm.code-prototype 调用 LLM 失败:kind=codex-app-server-context-window-exceeded fingerprint={} chars=999", + "a".repeat(64) + ); + assert_eq!( + game_creator_agent_runtime_failure_conversation_message( + "code-prototype", + &private_error, + ), + "专业 Agent 模型上下文已超限,请缩小任务范围后重试" + ); + let unauthorized = format!( + "kind=codex-app-server-unauthorized fingerprint={} chars=32", + "b".repeat(64) + ); + assert_eq!( + game_creator_agent_runtime_failure_conversation_message( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &unauthorized, + ), + "项目总控 Agent Codex 鉴权失败,请重新登录或检查 API Key" + ); + for visible in [ + game_creator_agent_runtime_failure_conversation_message( + "code-prototype", + &private_error, + ), + game_creator_agent_runtime_failure_conversation_message( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &unauthorized, + ), + ] { + assert!(!visible.contains("fingerprint")); + assert!(!visible.contains("chars=")); + } + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 820ea4827..52aa4a66d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -2336,12 +2336,18 @@ pub(super) fn try_open_game_creator_agent_runtime_task_lock_file( if let Some(component) = component { current.push(component); if !current.exists() { - fs::create_dir(¤t).map_err(|error| { - format!( - "创建 Agent Runtime 锁目录失败:{}: {error}", - current.display() - ) - })?; + if let Err(error) = fs::create_dir(¤t) { + // 另一并发锁请求可能在 exists 与 create_dir 之间创建同一目录; + // 下方元数据检查仍是权威校验,并会拒绝普通文件或 reparse point。 + if error.kind() != std::io::ErrorKind::AlreadyExists + && error.raw_os_error() != Some(183) + { + return Err(format!( + "创建 Agent Runtime 锁目录失败:{}: {error}", + current.display() + )); + } + } } } let metadata = fs::symlink_metadata(¤t).map_err(|error| { @@ -2599,6 +2605,21 @@ pub(super) fn append_game_creator_agent_runtime_event_with_action( ) })?; } + let failure_detail = matches!( + event_type, + "error" | "turn.failed" | "turn.budget_exhausted" + ) + .then(|| { + detail.map(|value| game_creator_agent_runtime_public_failure_detail(&state.agent_id, value)) + }) + .flatten(); + let public_text = if matches!(event_type, "turn.failed" | "turn.budget_exhausted") { + failure_detail + .clone() + .or_else(|| game_creator_agent_runtime_public_event_text(root, event_type, summary)) + } else { + game_creator_agent_runtime_public_event_text(root, event_type, summary) + }; let event = AgentRuntimeEvent { schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: state.agent_id.clone(), @@ -2612,7 +2633,7 @@ pub(super) fn append_game_creator_agent_runtime_event_with_action( status: status.to_string(), phase: phase.to_string(), summary: summary.to_string(), - public_text: game_creator_agent_runtime_public_event_text(root, event_type, summary), + public_text, detail: detail .filter(|_| { !(event_type == "observation" @@ -2624,10 +2645,9 @@ pub(super) fn append_game_creator_agent_runtime_event_with_action( event_type, "error" | "turn.failed" | "turn.budget_exhausted" ) { - return game_creator_agent_runtime_public_failure_detail( - &state.agent_id, - value, - ); + return failure_detail.clone().unwrap_or_else(|| { + game_creator_agent_runtime_public_failure_detail(&state.agent_id, value) + }); } let max_chars = if event_type == "observation" && summary.starts_with("agent.action_history:") @@ -4115,6 +4135,10 @@ pub(super) fn redact_agent_runtime_project_paths_raw(root: &Path, value: &str) - let root_display = root.to_string_lossy(); if !root_display.is_empty() { redacted = redacted.replace(root_display.as_ref(), "$PROJECT_ROOT"); + #[cfg(windows)] + if let Some(non_verbatim_root) = root_display.strip_prefix(r"\\?\") { + redacted = redacted.replace(non_verbatim_root, "$PROJECT_ROOT"); + } } if let Ok(canonical_root) = root.canonicalize() { let canonical_display = canonical_root.to_string_lossy(); @@ -4134,6 +4158,10 @@ pub(super) fn redact_agent_runtime_project_paths_preserving_tail( let root_display = root.to_string_lossy(); if !root_display.is_empty() { redacted = redacted.replace(root_display.as_ref(), "$PROJECT_ROOT"); + #[cfg(windows)] + if let Some(non_verbatim_root) = root_display.strip_prefix(r"\\?\") { + redacted = redacted.replace(non_verbatim_root, "$PROJECT_ROOT"); + } } if let Ok(canonical_root) = root.canonicalize() { let canonical_display = canonical_root.to_string_lossy(); 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 ed846969b..c8327c490 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 @@ -34,6 +34,8 @@ pub(in crate::agent) use project_ops::*; pub(in crate::agent) use run_status::*; pub(in crate::agent) use task_ops::*; +#[cfg(test)] +pub(crate) use delivery::build_static_delegate_result_for_child_at; #[cfg(test)] pub(crate) use media::validate_agent_runtime_canvas_replacement_authorization_at; 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 9b4f7256f..165830cec 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 @@ -545,6 +545,24 @@ pub(crate) fn observe_agent_runtime_agent_delegate( detail: None, }; } + let clarification_continuation_identity = + match validate_static_delegate_clarification_continuation_at( + root, + agent_id, + parent_run_id, + input, + repair_of_delegation_id.as_deref(), + ) { + Ok(identity) => identity, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: sanitize_agent_runtime_text(&error, 240), + detail: None, + } + } + }; if let Err(error) = validate_publish_delegate_run_profile_at(root, agent_id, parent_run_id, &target_agent_id) { @@ -582,8 +600,15 @@ pub(crate) fn observe_agent_runtime_agent_delegate( detail: None, }; } - let delegation_id = - agent_runtime_delegation_id(agent_id, parent_run_id, &target_agent_id, &action_identity); + let delegation_action_identity = clarification_continuation_identity + .as_deref() + .unwrap_or(action_identity.as_str()); + let delegation_id = agent_runtime_delegation_id( + agent_id, + parent_run_id, + &target_agent_id, + delegation_action_identity, + ); let delegated_task = match render_static_delegate_task_contract( &task, agent_id, @@ -736,7 +761,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate( agent_id, parent_session_id, parent_run_id, - &action_identity, + delegation_action_identity, &delegation_id, &target_agent_id, &existing.target_session_id, @@ -826,7 +851,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate( agent_id, parent_session_id, parent_run_id, - &action_identity, + delegation_action_identity, &delegation_id, &target_agent_id, &existing.session_id, @@ -992,7 +1017,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate( agent_id, parent_session_id, parent_run_id, - &action_identity, + delegation_action_identity, &delegation_id, &target_agent_id, &target_session_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs index 8f6a80e06..a4bbf91db 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs @@ -94,7 +94,7 @@ pub(in crate::agent) fn validate_static_delegate_delivery_for_child_result( Ok(()) } -pub(in crate::agent) fn build_static_delegate_result_for_child_at( +pub(crate) fn build_static_delegate_result_for_child_at( root: &Path, delivery: &StaticDelegateDeliveryRecord, child_task: &AgentRuntimeTaskRecord, @@ -110,11 +110,11 @@ pub(in crate::agent) fn build_static_delegate_result_for_child_at( let verified_revision = (verification_status == Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED)) .then_some(gate.verified_revision) .flatten(); - let error = child_task + let result_detail = child_task .error .as_deref() - .or((terminal_status != "completed").then_some(result_detail)); - let error = error.map(|value| redact_agent_runtime_error(root, value, 500)); + .or((!result_detail.trim().is_empty()).then_some(result_detail)); + let result_detail = result_detail.map(|value| redact_agent_runtime_error(root, value, 500)); let mut result = build_static_delegate_structured_result_at( root, terminal_status, @@ -123,7 +123,7 @@ pub(in crate::agent) fn build_static_delegate_result_for_child_at( verification_status, gate.last_verification_tool.as_deref(), verified_revision, - error.as_deref(), + result_detail.as_deref(), )?; if verification_status == Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED) { if let Some(evidence) = result.evidence.first_mut() { @@ -327,6 +327,16 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( { return Err("静态委派 parent-wake 的父 run 状态身份不一致".to_string()); } + if barrier.user_input_required_count > 0 { + let deliveries = claimed_static_delegate_deliveries_at( + root, + ¤t_task.agent_id, + ¤t_task.run_id, + )?; + let mut state = state; + ensure_static_delegate_user_input_wait_at(root, &mut state, &deliveries)?; + return Ok(true); + } let state = advance_game_creator_agent_runtime_turn_at( root, state, @@ -885,7 +895,7 @@ pub(crate) fn publish_game_creator_agent_delegate_result( &existing_delivery, child_task, terminal_status, - &safe_result_summary, + &result_detail, ) { Ok(result) => result, Err(error) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs index d31c814da..b58d27a2b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs @@ -237,6 +237,14 @@ pub(crate) fn observe_agent_runtime_run_status( .structured_result .as_ref() .map(|result| result.contract_status), + "needsUserInput": delivery + .structured_result + .as_ref() + .is_some_and(|result| result.contract_status == StaticDelegateContractStatus::NeedsUserInput), + "userInputQuestionCount": delivery + .structured_result + .as_ref() + .map(|result| result.user_input_questions.len()), "acceptanceCriteriaCount": delivery.acceptance_criteria.len(), "expectedArtifactsCount": delivery.expected_artifacts.len(), }) 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 0c9adc269..6a9e44e4e 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 @@ -482,6 +482,17 @@ fn validate_native_agent_delegate_input( "repairOfDelegationId", "runId", ]; + const ALLOWED_FIELDS: [&str; 9] = [ + "agentId", + "task", + "acceptanceCriteria", + "expectedArtifacts", + "repairOfDelegationId", + "runId", + "continuationOfDelegationId", + "questionsSha256", + "answersSha256", + ]; let object = input.as_object().ok_or_else(|| { protocol_error( AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema, @@ -498,7 +509,7 @@ fn validate_native_agent_delegate_input( } if object .keys() - .any(|field| !REQUIRED_FIELDS.contains(&field.as_str())) + .any(|field| !ALLOWED_FIELDS.contains(&field.as_str())) { return Err(protocol_error( AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema, @@ -528,6 +539,25 @@ fn validate_native_agent_delegate_input( true, )?; validate_native_delegate_string(object.get("runId"), "runId", 160, true)?; + if object.contains_key("continuationOfDelegationId") { + validate_native_delegate_string( + object.get("continuationOfDelegationId"), + "continuationOfDelegationId", + 160, + true, + )?; + } + if object.contains_key("questionsSha256") { + validate_native_delegate_string( + object.get("questionsSha256"), + "questionsSha256", + 64, + true, + )?; + } + if object.contains_key("answersSha256") { + validate_native_delegate_string(object.get("answersSha256"), "answersSha256", 64, true)?; + } if object .get("repairOfDelegationId") .is_some_and(Value::is_string) @@ -538,6 +568,47 @@ fn validate_native_agent_delegate_input( "Agent 原生工具协议错误:agent.delegate 返工委派时 runId 必须为 JSON null", )); } + let continuation_fields = [ + "continuationOfDelegationId", + "questionsSha256", + "answersSha256", + ] + .iter() + .filter(|field| object.get(**field).is_some_and(Value::is_string)) + .count(); + let continuation_present = [ + "continuationOfDelegationId", + "questionsSha256", + "answersSha256", + ] + .iter() + .filter(|field| object.contains_key(**field)) + .count(); + if (continuation_present != 0 && continuation_present != 3) + || (continuation_fields != 0 && continuation_fields != 3) + { + return Err(protocol_error( + AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema, + "Agent 原生工具协议错误:agent.delegate 澄清 continuation 字段必须同时提供", + )); + } + for field in ["questionsSha256", "answersSha256"] { + if object.get(field).is_some_and(Value::is_string) + && object + .get(field) + .and_then(Value::as_str) + .is_none_or(|value| { + value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + { + return Err(protocol_error( + AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema, + format!( + "Agent 原生工具协议错误:agent.delegate {field} 必须是 64 位十六进制 SHA-256" + ), + )); + } + } Ok(()) } @@ -1216,14 +1287,17 @@ fn runtime_tool_input_schema(tool: &str) -> Value { "blackboard.write" => two_string_input_schema("title", "content"), "agent.message" => two_string_input_schema("agentId", "content"), "agent.delegate" => json!({ - "type": "object", "required": ["agentId", "task", "acceptanceCriteria", "expectedArtifacts", "repairOfDelegationId", "runId"], "additionalProperties": false, + "type": "object", "required": ["agentId", "task", "acceptanceCriteria", "expectedArtifacts", "repairOfDelegationId", "runId", "continuationOfDelegationId", "questionsSha256", "answersSha256"], "additionalProperties": false, "properties": { "agentId": { "type": "string", "minLength": 1 }, "task": { "type": "string", "minLength": 1, "maxLength": 2400 }, "acceptanceCriteria": { "type": "array", "minItems": 1, "maxItems": 8, "items": { "type": "string", "minLength": 1, "maxLength": 240 } }, "expectedArtifacts": { "type": "array", "maxItems": 16, "items": { "type": "string", "minLength": 1, "maxLength": 240 } }, "repairOfDelegationId": { "type": ["string", "null"] }, - "runId": { "type": ["string", "null"] } + "runId": { "type": ["string", "null"] }, + "continuationOfDelegationId": { "type": ["string", "null"] }, + "questionsSha256": { "type": ["string", "null"] }, + "answersSha256": { "type": ["string", "null"] } } }), "agent.spawn_isolated" => json!({ @@ -1505,9 +1579,68 @@ mod tests { "expectedArtifacts": [], "repairOfDelegationId": repair_of_delegation_id, "runId": run_id, + "continuationOfDelegationId": null, + "questionsSha256": null, + "answersSha256": null, }) } + #[test] + fn native_agent_delegate_accepts_complete_clarification_continuation_binding() { + let mut input = valid_delegate_input(json!("delegation-id"), Value::Null); + let object = input.as_object_mut().expect("delegate input object"); + object.insert( + "continuationOfDelegationId".to_string(), + json!("delegation-id"), + ); + object.insert("questionsSha256".to_string(), json!("a".repeat(64))); + object.insert("answersSha256".to_string(), json!("b".repeat(64))); + + validate_native_agent_delegate_input(&input) + .expect("complete clarification continuation binding"); + } + + #[test] + fn native_agent_delegate_accepts_legacy_input_without_clarification_fields() { + let mut input = valid_delegate_input(Value::Null, Value::Null); + let object = input.as_object_mut().expect("delegate input object"); + object.remove("continuationOfDelegationId"); + object.remove("questionsSha256"); + object.remove("answersSha256"); + + validate_native_agent_delegate_input(&input) + .expect("legacy delegate input without clarification fields"); + } + + #[test] + fn native_agent_delegate_rejects_partial_or_invalid_clarification_binding() { + let mut partial = valid_delegate_input(json!("delegation-id"), Value::Null); + partial + .as_object_mut() + .expect("delegate input object") + .insert( + "continuationOfDelegationId".to_string(), + json!("delegation-id"), + ); + assert!(validate_native_agent_delegate_input(&partial) + .expect_err("partial continuation binding must fail") + .to_string() + .contains("必须同时提供")); + + let mut invalid_sha = valid_delegate_input(json!("delegation-id"), Value::Null); + let object = invalid_sha.as_object_mut().expect("delegate input object"); + object.insert( + "continuationOfDelegationId".to_string(), + json!("delegation-id"), + ); + object.insert("questionsSha256".to_string(), json!("z".repeat(64))); + object.insert("answersSha256".to_string(), json!("b".repeat(64))); + assert!(validate_native_agent_delegate_input(&invalid_sha) + .expect_err("invalid continuation sha must fail") + .to_string() + .contains("SHA-256")); + } + #[test] fn native_agent_delegate_repair_rejects_string_run_id() { let repair_id = "delegation-value-must-not-leak"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs index 309a9456b..086f410d2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs @@ -842,7 +842,7 @@ fn result_serializes_with_camel_case_evidence_paths() { #[test] fn persisted_report_uses_only_relative_evidence_paths() { - let evidence_root = PathBuf::from("/tmp/browser-evidence"); + let evidence_root = std::env::temp_dir().join("browser-evidence"); let result = BrowserValidationResult { schema_version: RESULT_SCHEMA_VERSION.to_string(), url: "http://127.0.0.1:34567/".to_string(), 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 eec0c244f..076a4f0aa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -807,6 +807,26 @@ fn strip_agent_runtime_cli_private_paths(value: &mut serde_json::Value) { } } +pub(crate) fn start_cli_agent_task_at( + project_path: &Path, + agent_id: &str, + task: &str, + run_id: &str, +) -> Result { + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + start_game_creator_supervisor_background_task_for_session_at( + project_path, + None, + task, + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + ) + } else { + start_game_creator_agent_background_task_at(project_path, agent_id, task, run_id) + } +} + fn serialize_agent_runtime_cli_payload(payload: &T) -> Result { let mut value = serde_json::to_value(payload) .map_err(|error| format!("序列化 Agent Runtime 状态失败:{error}"))?; @@ -906,12 +926,7 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { .map_err(|error| format!("创建 CLI runtime 失败:{error}"))?; let run_id = format!("cli-{agent_id}-{}", unix_millis()); let terminal = runtime.block_on(async { - let started = start_game_creator_agent_background_task_at( - &project_path, - &agent_id, - &task, - &run_id, - )?; + let started = start_cli_agent_task_at(&project_path, &agent_id, &task, &run_id)?; let canonical_run_id = started.state.run_id.clone(); let deadline = std::time::Instant::now() + Duration::from_secs(600); loop { diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index adeec51c4..873662bca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -631,6 +631,14 @@ fn resolve_project_command_executable_from_path( } let executable = executable.ok_or_else(|| format!("command.exec 找不到受信任的 {program} 可执行文件"))?; + #[cfg(windows)] + let safe_directories = safe_directories + .into_iter() + .map(|directory| { + let directory = directory.to_string_lossy(); + PathBuf::from(directory.strip_prefix(r"\\?\").unwrap_or(&directory)) + }) + .collect::>(); let safe_path = std::env::join_paths(safe_directories) .map_err(|error| format!("构造 command.exec 安全 PATH 失败:{error}"))?; Ok((executable, safe_path)) @@ -1149,6 +1157,10 @@ pub(crate) fn prepare_project_command_launch_spec( ), (OsString::from("CARGO_NET_OFFLINE"), OsString::from("true")), (OsString::from("CARGO_TERM_COLOR"), OsString::from("never")), + // 受控命令不得继承用户级 Cargo rustc-wrapper(例如 sccache); + // 隔离 HOME/CARGO_HOME 下这类包装器既不可复现,也可能无法启动。 + (OsString::from("RUSTC_WRAPPER"), OsString::new()), + (OsString::from("RUSTC_WORKSPACE_WRAPPER"), OsString::new()), (OsString::from("npm_config_audit"), OsString::from("false")), (OsString::from("npm_config_fund"), OsString::from("false")), ( @@ -1213,10 +1225,18 @@ pub(crate) fn prepare_project_command_launch_spec( environment.push(( OsString::from("ComSpec"), PathBuf::from(system_root) - .join("System32/cmd.exe") + .join("System32") + .join("cmd.exe") .into_os_string(), )); } + #[cfg(windows)] + for (_, value) in &mut environment { + let rendered = value.to_string_lossy(); + if let Some(without_prefix) = rendered.strip_prefix(r"\\?\") { + *value = OsString::from(without_prefix); + } + } let arguments = project_command_actual_arguments(spec); #[cfg(target_os = "linux")] @@ -1247,10 +1267,71 @@ pub(crate) fn prepare_project_command_launch_spec( } #[cfg(not(target_os = "linux"))] { + #[cfg(windows)] + let (executable, arguments, cwd) = { + fn without_windows_verbatim_prefix(path: PathBuf) -> PathBuf { + let value = path.to_string_lossy(); + PathBuf::from(value.strip_prefix(r"\\?\").unwrap_or(&value)) + } + let is_npm_batch = spec + .executable + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("npm.cmd")); + if is_npm_batch { + let npm_directory = spec.executable.parent().ok_or_else(|| { + ProjectCommandError::new( + ProjectCommandErrorStage::Preflight, + "command.exec 无法定位 Windows npm 安装目录", + ) + })?; + let node_executable = npm_directory.join("node.exe"); + let npm_cli = npm_directory.join("node_modules/npm/bin/npm-cli.js"); + if !node_executable.is_file() || !npm_cli.is_file() { + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::Preflight, + "command.exec Windows npm 安装缺少 node.exe 或 npm-cli.js", + )); + } + let node_executable = fs::canonicalize(node_executable).map_err(|error| { + ProjectCommandError::new( + ProjectCommandErrorStage::Preflight, + format!("command.exec 定位 Windows node.exe 失败:{error}"), + ) + })?; + let npm_cli = fs::canonicalize(npm_cli).map_err(|error| { + ProjectCommandError::new( + ProjectCommandErrorStage::Preflight, + format!("command.exec 定位 Windows npm-cli.js 失败:{error}"), + ) + })?; + let node_executable = without_windows_verbatim_prefix(node_executable); + let npm_cli = without_windows_verbatim_prefix(npm_cli); + let mut node_arguments = vec![npm_cli.into_os_string()]; + node_arguments.extend(arguments.into_iter().map(OsString::from)); + ( + node_executable, + node_arguments, + without_windows_verbatim_prefix(spec.cwd.clone()), + ) + } else { + ( + without_windows_verbatim_prefix(spec.executable.clone()), + arguments.into_iter().map(OsString::from).collect(), + without_windows_verbatim_prefix(spec.cwd.clone()), + ) + } + }; + #[cfg(not(windows))] + let (executable, arguments, cwd) = ( + spec.executable.clone(), + arguments.into_iter().map(OsString::from).collect(), + spec.cwd.clone(), + ); Ok(ProjectCommandLaunchSpec { - executable: spec.executable.clone(), - arguments: arguments.into_iter().map(OsString::from).collect(), - cwd: spec.cwd.clone(), + executable, + arguments, + cwd, environment, sandbox_backend: "legacy-host-restricted".to_string(), sandbox_mode: "fixed-command".to_string(), @@ -1308,7 +1389,10 @@ pub(crate) fn stage_project_command_launch_spec( } } -fn configure_project_command_process_group(command: &mut tokio::process::Command) { +fn configure_project_command_process_group( + command: &mut tokio::process::Command, + launch: &ProjectCommandLaunchSpec, +) { #[cfg(unix)] { use std::os::unix::process::CommandExt; @@ -1316,6 +1400,13 @@ fn configure_project_command_process_group(command: &mut tokio::process::Command } #[cfg(windows)] { + let npm_cli_host = launch + .arguments + .first() + .and_then(|argument| Path::new(argument).file_name()) + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("npm-cli.js")); + let _ = npm_cli_host; crate::configure_windows_background_tokio_command(command, true); } } @@ -1347,7 +1438,7 @@ where for (name, value) in &staged.launch.environment { command.env(name, value); } - configure_project_command_process_group(&mut command); + configure_project_command_process_group(&mut command, &staged.launch); #[cfg(target_os = "linux")] staged .gate @@ -1803,6 +1894,12 @@ where Err(_) => { let termination = match terminate_project_command_process_group(&mut child).await { Ok(termination) => termination, + #[cfg(windows)] + Err(error) if child.try_wait().ok().flatten().is_some() => { + format!( + "请求终止受控进程组后主进程已回收(taskkill 未找到已退出进程:{error})" + ) + } Err(error) => { stdout_task.abort(); stderr_task.abort(); @@ -2144,7 +2241,13 @@ mod tests { "expected rejection for {program} {args:?}" ); } - let absolute = vec!["test".to_string(), "/tmp/outside.rs".to_string()]; + let absolute = vec![ + "test".to_string(), + std::env::temp_dir() + .join("outside.rs") + .to_string_lossy() + .into_owned(), + ]; assert!(resolve_project_command_spec_at(root, "cargo", &absolute, ".", 30).is_err()); let sensitive = vec!["status".to_string(), ".agent/agent.db".to_string()]; assert!(resolve_project_command_spec_at(root, "git", &sensitive, ".", 30).is_err()); @@ -2512,6 +2615,18 @@ raise SystemExit(code)' ); } + #[cfg(windows)] + #[test] + fn project_command_safe_path_uses_win32_compatible_directories() { + let dir = command_project("windows-safe-path"); + let raw_path = std::env::var_os("PATH").expect("PATH"); + let (_, safe_path) = + resolve_project_command_executable_from_path(dir.path(), "node", &raw_path) + .expect("resolve node executable"); + assert!(std::env::split_paths(&safe_path) + .all(|directory| { !directory.as_os_str().to_string_lossy().starts_with(r"\\?\") })); + } + #[cfg(not(target_os = "linux"))] #[test] fn project_command_injects_git_safety_options_before_pathspec_separator() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 27f8ad432..04ce73338 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -368,23 +368,12 @@ pub(crate) fn game_creator_codex_app_server_llm_route_error( } pub(crate) fn check_game_creator_codex_cli_available() -> Result<(), String> { - let output = std::process::Command::new("codex") - .arg("--version") - .stdin(std::process::Stdio::null()) - .output() - .map_err(|_| "Codex CLI 未安装或不在当前客户端 PATH 中".to_string())?; - if !output.status.success() { - return Err("Codex CLI 版本检查失败".to_string()); - } - let version = String::from_utf8_lossy(&output.stdout); - if !version.trim().starts_with("codex-cli ") { - return Err("Codex CLI 返回了无法识别的版本信息".to_string()); - } - Ok(()) + crate::agent::game_creator_codex_cli_version_identity().map(|_| ()) } fn check_game_creator_codex_app_server_available() -> Result<(), String> { - let output = std::process::Command::new("codex") + let executable = crate::agent::game_creator_codex_cli_executable_path()?; + let output = std::process::Command::new(executable) .args(["app-server", "--help"]) .stdin(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) @@ -860,6 +849,11 @@ pub(crate) fn windows_private_dacl_security_information( } else { 0 } + | if initialize_owner && !owner_matches { + OWNER_SECURITY_INFORMATION + } else { + 0 + } } #[cfg(windows)] @@ -1251,7 +1245,10 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( pub(crate) fn configure_game_creator_runtime_config_dir( app: &tauri::AppHandle, ) -> Result<(), Box> { - let config_dir = prepare_game_creator_runtime_config_dir(&app.path().app_config_dir()?) + let requested_config_dir = game_creator_runtime_config_dir() + .map(Ok) + .unwrap_or_else(|| app.path().app_config_dir())?; + let config_dir = prepare_game_creator_runtime_config_dir(&requested_config_dir) .map_err(std::io::Error::other)?; let config_path = config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME); if !config_path.exists() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index 365b5ae72..d726b825b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -13,6 +13,8 @@ const STATIC_DELEGATE_ACCEPTANCE_CRITERION_MAX_CHARS: usize = 240; const STATIC_DELEGATE_MAX_EXPECTED_ARTIFACTS: usize = 16; const STATIC_DELEGATE_EXPECTED_ARTIFACT_MAX_CHARS: usize = 240; const STATIC_DELEGATE_MAX_EVIDENCE: usize = 16; +const STATIC_DELEGATE_USER_INPUT_PREFIX: &str = "AGC_NEEDS_USER_INPUT_V1\n"; +const STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS: usize = 500; #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] @@ -28,6 +30,7 @@ pub(crate) enum StaticDelegateDeliveryStatus { pub(crate) enum StaticDelegateContractStatus { EvidenceReady, NeedsRepair, + NeedsUserInput, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -64,6 +67,26 @@ pub(crate) struct StaticDelegateStructuredResult { pub(crate) evidence: Vec, #[serde(default)] pub(crate) error: Option, + #[serde(default)] + pub(crate) user_input_questions: Vec, + #[serde(default)] + pub(crate) user_input_questions_sha256: Option, +} + +impl Default for StaticDelegateStructuredResult { + fn default() -> Self { + Self { + contract_status: StaticDelegateContractStatus::NeedsRepair, + artifacts: Vec::new(), + missing_expected_artifacts: Vec::new(), + verification_required: false, + verified_revision: None, + evidence: Vec::new(), + error: None, + user_input_questions: Vec::new(), + user_input_questions_sha256: None, + } + } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -84,6 +107,10 @@ pub(crate) struct StaticDelegateDeliveryRecord { pub(crate) expected_artifacts: Vec, #[serde(default)] pub(crate) repair_of_delegation_id: Option, + #[serde(default)] + pub(crate) clarification_request_id: Option, + #[serde(default)] + pub(crate) clarification_answers_sha256: Option, pub(crate) status: StaticDelegateDeliveryStatus, pub(crate) terminal_status: Option, pub(crate) result_summary: Option, @@ -136,6 +163,7 @@ pub(crate) struct StaticDelegateCompletionBarrier { pub(crate) ready_unclaimed_count: usize, pub(crate) unobserved_claim_count: usize, pub(crate) repair_required_count: usize, + pub(crate) user_input_required_count: usize, } impl StaticDelegateCompletionBarrier { @@ -144,6 +172,7 @@ impl StaticDelegateCompletionBarrier { && self.ready_unclaimed_count == 0 && self.unobserved_claim_count == 0 && self.repair_required_count == 0 + && self.user_input_required_count == 0 } pub(crate) fn has_waiting(self) -> bool { @@ -152,11 +181,12 @@ impl StaticDelegateCompletionBarrier { pub(crate) fn detail(self) -> String { format!( - "waitingDelegations={} · readyUnclaimedReceipts={} · unobservedReceiptClaims={} · repairRequired={} · 必须认领专业 Agent 回执,并对 needs-repair 原委派发起唯一返工后再继续", + "waitingDelegations={} · readyUnclaimedReceipts={} · unobservedReceiptClaims={} · repairRequired={} · userInputRequired={} · 必须认领专业 Agent 回执,并处理 needs-user-input 或对 needs-repair 原委派发起唯一返工后再继续", self.waiting_count, self.ready_unclaimed_count, self.unobserved_claim_count, - self.repair_required_count + self.repair_required_count, + self.user_input_required_count ) } } @@ -213,6 +243,8 @@ pub(crate) fn new_static_delegate_delivery_with_contract( acceptance_criteria: acceptance_criteria.to_vec(), expected_artifacts: expected_artifacts.to_vec(), repair_of_delegation_id: repair_of_delegation_id.map(str::to_string), + clarification_request_id: None, + clarification_answers_sha256: None, status: StaticDelegateDeliveryStatus::Dispatched, terminal_status: None, result_summary: None, @@ -286,6 +318,8 @@ pub(crate) fn mark_static_delegate_delivery_ready_at( verified_revision: None, evidence: Vec::new(), error: (terminal_status != "completed").then(|| result_summary.to_string()), + user_input_questions: Vec::new(), + user_input_questions_sha256: None, }; mark_static_delegate_delivery_ready_with_result_at( root, @@ -434,6 +468,8 @@ pub(crate) fn static_delegate_completion_barrier_at( && !legacy_empty_contract && delivery.structured_result.as_ref().is_some_and(|result| { result.contract_status == StaticDelegateContractStatus::NeedsRepair + || (result.contract_status == StaticDelegateContractStatus::NeedsUserInput + && delivery.clarification_answers_sha256.is_some()) }) && !deliveries.iter().any(|candidate| { candidate.repair_of_delegation_id.as_deref() @@ -447,6 +483,26 @@ pub(crate) fn static_delegate_completion_barrier_at( }) }) .count(); + barrier.user_input_required_count = deliveries + .iter() + .filter(|delivery| { + delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent + && delivery.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsUserInput + }) + && delivery.clarification_answers_sha256.is_none() + && !deliveries.iter().any(|candidate| { + candidate.repair_of_delegation_id.as_deref() + == Some(delivery.delegation_id.as_str()) + && matches!( + candidate.status, + StaticDelegateDeliveryStatus::Dispatched + | StaticDelegateDeliveryStatus::Ready + | StaticDelegateDeliveryStatus::ClaimedByParent + ) + }) + }) + .count(); Ok(barrier) } @@ -1076,6 +1132,207 @@ pub(crate) fn validate_static_delegate_repair_request_at( Ok(()) } +pub(crate) fn validate_static_delegate_clarification_continuation_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + input: &serde_json::Value, + repair_of_delegation_id: Option<&str>, +) -> Result, String> { + let input_text = |keys: &[&str]| -> String { + keys.iter() + .find_map(|key| input.get(*key).and_then(serde_json::Value::as_str)) + .unwrap_or_default() + .trim() + .to_string() + }; + let Some(repair_of_delegation_id) = repair_of_delegation_id + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + if !input_text(&[ + "continuationOfDelegationId", + "continuation_of_delegation_id", + ]) + .is_empty() + || !input_text(&["questionsSha256", "questions_sha256"]).is_empty() + || !input_text(&["answersSha256", "answers_sha256"]).is_empty() + { + return Err( + "澄清 continuation 必须同时提交 repairOfDelegationId 并指向原 delivery".to_string(), + ); + } + return Ok(None); + }; + let continuation_of = input_text(&[ + "continuationOfDelegationId", + "continuation_of_delegation_id", + ]); + let input_questions_sha = input_text(&["questionsSha256", "questions_sha256"]); + let input_answers_sha = input_text(&["answersSha256", "answers_sha256"]); + let original = + read_static_delegate_delivery_at(root, repair_of_delegation_id)?.ok_or_else(|| { + format!("澄清 continuation 引用的原 delivery 不存在:{repair_of_delegation_id}") + })?; + let needs_user_input = original.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsUserInput + }); + if !needs_user_input { + if !continuation_of.is_empty() + || !input_questions_sha.is_empty() + || !input_answers_sha.is_empty() + { + return Err("普通返工不能携带澄清 continuation 绑定".to_string()); + } + return Ok(None); + } + if parent_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || original.parent_agent_id != parent_agent_id + || original.parent_run_id != parent_run_id + || original.status != StaticDelegateDeliveryStatus::ClaimedByParent + { + return Err( + "澄清 continuation 必须由认领原回执的 Project Supervisor 在同一父 run 创建".to_string(), + ); + } + let questions_sha256 = original + .structured_result + .as_ref() + .and_then(|result| result.user_input_questions_sha256.as_deref()) + .filter(|value| valid_static_delegate_sha256(value)) + .ok_or_else(|| "澄清 continuation 的原 delivery 缺少问题指纹".to_string())?; + let answers_sha256 = original + .clarification_answers_sha256 + .as_deref() + .filter(|value| valid_static_delegate_sha256(value)) + .ok_or_else(|| "澄清 continuation 尚未取得该原 delivery 对应的用户回答".to_string())?; + let continuation_of = input_text(&[ + "continuationOfDelegationId", + "continuation_of_delegation_id", + ]); + if continuation_of != repair_of_delegation_id { + return Err("澄清 continuation 必须绑定原 delegationId".to_string()); + } + if input_questions_sha != questions_sha256 || input_answers_sha != answers_sha256 { + return Err("澄清 continuation 的问题或答案指纹与已回答请求不一致".to_string()); + } + let continuation_identity = format!( + "clarification-continuation-{:x}", + Sha256::digest(format!( + "{parent_run_id}\n{repair_of_delegation_id}\n{questions_sha256}\n{answers_sha256}" + )) + ); + Ok(Some(continuation_identity)) +} + +pub(crate) fn bind_static_delegate_clarification_answer_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + delegation_id: &str, + request_id: &str, + questions_sha256: &str, + answers_sha256: &str, +) -> Result<(), String> { + validate_static_delegate_id(delegation_id, "delegationId", 160)?; + validate_static_delegate_id(request_id, "requestId", 160)?; + if !valid_static_delegate_sha256(questions_sha256) + || !valid_static_delegate_sha256(answers_sha256) + { + return Err("子 Agent 澄清回答缺少有效 SHA-256 绑定".to_string()); + } + let _lock = try_acquire_game_creator_agent_delegation_lock_with_wait( + root, + delegation_id, + "static-clarification-answer", + )? + .ok_or_else(|| format!("静态委派澄清绑定正在更新:{delegation_id}"))?; + let mut delivery = read_static_delegate_delivery_at(root, delegation_id)? + .ok_or_else(|| format!("静态委派澄清原 delivery 不存在:{delegation_id}"))?; + let expected_questions_sha = delivery + .structured_result + .as_ref() + .filter(|result| result.contract_status == StaticDelegateContractStatus::NeedsUserInput) + .and_then(|result| result.user_input_questions_sha256.as_deref()) + .ok_or_else(|| "静态委派澄清原 delivery 不是 needs-user-input".to_string())?; + if delivery.parent_agent_id != parent_agent_id + || delivery.parent_run_id != parent_run_id + || delivery.status != StaticDelegateDeliveryStatus::ClaimedByParent + || expected_questions_sha != questions_sha256 + { + return Err("静态委派澄清回答与原 delivery 身份或问题指纹冲突".to_string()); + } + match ( + delivery.clarification_request_id.as_deref(), + delivery.clarification_answers_sha256.as_deref(), + ) { + (None, None) => { + delivery.clarification_request_id = Some(request_id.to_string()); + delivery.clarification_answers_sha256 = Some(answers_sha256.to_string()); + delivery.updated_at = unix_timestamp(); + write_static_delegate_delivery_at(root, &delivery)?; + Ok(()) + } + (Some(existing_request), Some(existing_answers)) + if existing_request == request_id && existing_answers == answers_sha256 => + { + Ok(()) + } + _ => Err("静态委派澄清回答已绑定到不同请求或答案".to_string()), + } +} + +pub(crate) fn static_delegate_clarification_pending_matches_delivery_at( + root: &Path, + pending: &AgentRuntimePendingToolAction, +) -> Result { + if pending.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || pending.action.tool != GAME_CREATOR_USER_INPUT_REQUEST_TOOL + { + return Ok(false); + } + let Some(delegation_id) = pending + .task + .strip_prefix("子 Agent 需要用户澄清后才能继续。delegationId=") + .and_then(|value| value.split(';').next()) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(false); + }; + let Some(delivery) = read_static_delegate_delivery_at(root, delegation_id)? else { + return Ok(false); + }; + let Some(result) = delivery.structured_result.as_ref().filter(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsUserInput + && result.user_input_questions_sha256.is_some() + }) else { + return Ok(false); + }; + let questions = parse_game_creator_agent_user_input_questions(&pending.action.input)?; + let questions_sha256 = format!( + "{:x}", + Sha256::digest( + serde_json::to_vec(&questions) + .map_err(|error| format!("序列化 Supervisor 澄清问题失败:{error}"))? + ) + ); + Ok(delivery.parent_agent_id == pending.agent_id + && delivery.parent_session_id == pending.session_id + && delivery.parent_run_id == pending.run_id + && delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent + && delivery.clarification_answers_sha256.is_none() + && result.user_input_questions == questions + && result.user_input_questions_sha256.as_deref() == Some(questions_sha256.as_str()) + && pending + .task + .contains(&format!("questionsSha256={questions_sha256}"))) +} + +fn valid_static_delegate_sha256(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + #[allow(clippy::too_many_arguments)] pub(crate) fn build_static_delegate_structured_result_at( root: &Path, @@ -1133,12 +1390,16 @@ pub(crate) fn build_static_delegate_structured_result_at( let verification_passed = !verification_required || (verification_status == Some("passed") && verified_revision.is_some()); let completed = terminal_status == "completed"; - let contract_status = - if completed && missing_expected_artifacts.is_empty() && verification_passed { - StaticDelegateContractStatus::EvidenceReady - } else { - StaticDelegateContractStatus::NeedsRepair - }; + let (user_input_questions, user_input_questions_sha256) = + parse_static_delegate_user_input_request(error)?; + let needs_user_input = completed && user_input_questions.is_some(); + let contract_status = if needs_user_input { + StaticDelegateContractStatus::NeedsUserInput + } else if completed && missing_expected_artifacts.is_empty() && verification_passed { + StaticDelegateContractStatus::EvidenceReady + } else { + StaticDelegateContractStatus::NeedsRepair + }; let mut evidence = Vec::new(); if verification_status == Some("passed") { let kind = verification_tool.unwrap_or("project.verify").to_string(); @@ -1181,9 +1442,33 @@ pub(crate) fn build_static_delegate_structured_result_at( verified_revision: verification_passed.then_some(verified_revision).flatten(), evidence, error: derived_error, + user_input_questions: user_input_questions.unwrap_or_default(), + user_input_questions_sha256, }) } +fn parse_static_delegate_user_input_request( + response: Option<&str>, +) -> Result<(Option>, Option), String> { + let Some(response) = response.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok((None, None)); + }; + if !response.starts_with(STATIC_DELEGATE_USER_INPUT_PREFIX) { + return Ok((None, None)); + } + if response.chars().count() > STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS { + return Err("子 Agent 用户澄清请求超过回执长度上限".to_string()); + } + let payload = response[STATIC_DELEGATE_USER_INPUT_PREFIX.len()..].trim(); + let value = serde_json::from_str::(payload) + .map_err(|error| format!("子 Agent 用户澄清请求 JSON 无效:{error}"))?; + let questions = parse_game_creator_agent_user_input_questions(&value)?; + let serialized = serde_json::to_vec(&questions) + .map_err(|error| format!("序列化子 Agent 用户澄清问题失败:{error}"))?; + let sha256 = format!("{:x}", Sha256::digest(serialized)); + Ok((Some(questions), Some(sha256))) +} + pub(crate) fn suppress_static_delegate_deliveries_for_parent_terminal_at( root: &Path, parent_agent_id: &str, @@ -1496,6 +1781,24 @@ fn validate_static_delegate_delivery_record( return Err("静态委派 repairOfDelegationId 不能指向自己".to_string()); } } + if record.clarification_request_id.is_some() != record.clarification_answers_sha256.is_some() + || record + .clarification_answers_sha256 + .as_deref() + .is_some_and(|value| !valid_static_delegate_sha256(value)) + { + return Err("静态委派 delivery 澄清回答绑定无效".to_string()); + } + if let Some(request_id) = record.clarification_request_id.as_deref() { + validate_static_delegate_id(request_id, "clarificationRequestId", 160)?; + if record.status != StaticDelegateDeliveryStatus::ClaimedByParent + || record.structured_result.as_ref().is_none_or(|result| { + result.contract_status != StaticDelegateContractStatus::NeedsUserInput + }) + { + return Err("只有已认领 needs-user-input delivery 可以绑定澄清回答".to_string()); + } + } if record.status == StaticDelegateDeliveryStatus::Dispatched && (record.terminal_status.is_some() || record.result_summary.is_some() @@ -1750,6 +2053,24 @@ fn validate_static_delegate_structured_result( { return Err("静态委派 evidence-ready 与客观证据冲突".to_string()); } + if result.contract_status == StaticDelegateContractStatus::NeedsUserInput { + if terminal_status != "completed" + || result.user_input_questions.is_empty() + || result.user_input_questions.len() > 3 + { + return Err("静态委派 needs-user-input 与终态或问题数量冲突".to_string()); + } + let expected_sha = serde_json::to_vec(&result.user_input_questions) + .map(|bytes| format!("{:x}", Sha256::digest(bytes))) + .map_err(|error| format!("序列化静态委派用户问题失败:{error}"))?; + if result.user_input_questions_sha256.as_deref() != Some(expected_sha.as_str()) { + return Err("静态委派 needs-user-input 问题指纹无效".to_string()); + } + } else if !result.user_input_questions.is_empty() + || result.user_input_questions_sha256.is_some() + { + return Err("非 needs-user-input 静态委派不能携带用户问题".to_string()); + } if result .error .as_ref() @@ -1900,6 +2221,56 @@ mod tests { fs::remove_dir_all(root).ok(); } + #[test] + fn static_delegate_user_input_envelope_is_structured_and_fingerprinted() { + let response = format!( + "{STATIC_DELEGATE_USER_INPUT_PREFIX}{}", + serde_json::json!({ + "questions": [{ + "id": "target_platform", + "header": "平台", + "question": "主要运行在哪里?", + "options": [ + {"label": "Web", "description": "浏览器运行"}, + {"label": "移动端", "description": "手机或平板运行"} + ] + }] + }) + ); + let (questions, sha256) = parse_static_delegate_user_input_request(Some(&response)) + .expect("valid child clarification envelope"); + let questions = questions.expect("questions present"); + assert_eq!(questions.len(), 1); + assert_eq!(sha256.as_deref().map(str::len), Some(64)); + + let result = build_static_delegate_structured_result_at( + &std::env::temp_dir(), + "completed", + &[], + false, + None, + None, + None, + Some(&response), + ) + .expect("build needs-user-input result"); + assert_eq!( + result.contract_status, + StaticDelegateContractStatus::NeedsUserInput + ); + validate_static_delegate_structured_result(&result, "completed", &[]) + .expect("needs-user-input result validates"); + } + + #[test] + fn static_delegate_user_input_envelope_fails_closed_when_malformed() { + let error = parse_static_delegate_user_input_request(Some( + "AGC_NEEDS_USER_INPUT_V1\n{\"questions\":[]}", + )) + .expect_err("empty question envelope must fail"); + assert!(error.contains("user.input_request")); + } + #[test] fn stale_prepared_claim_snapshot_cannot_downgrade_observed_claim() { let root = std::env::temp_dir().join(format!( 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 049030c67..2d533e865 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2008,6 +2008,10 @@ fn main() { } } + if let Some(config_dir) = runtime_config_dir { + set_game_creator_runtime_config_dir(config_dir); + } + let mut tauri_context = tauri::generate_context!(); let startup_log = if cfg!(all(not(debug_assertions), feature = "game-chat-release")) { let path = initialize_game_chat_startup_log(&tauri_context.config().identifier); diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/io.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/io.rs index ad9c6b2d9..04a74476d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/io.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/io.rs @@ -206,6 +206,9 @@ where } let mut bytes = data.as_bytes().to_vec(); if append_newline { + #[cfg(windows)] + bytes.extend_from_slice(b"\r\n"); + #[cfg(not(windows))] bytes.push(b'\n'); } if bytes.len() > PROCESS_SESSION_MAX_STDIN_BYTES { @@ -265,7 +268,7 @@ where } Ok(ProcessSessionStdinResult { process_id: process_id.to_string(), - bytes_written: bytes.len(), + bytes_written: data.len() + usize::from(append_newline), content_sha256, stdin_open: output.stdin_open, eof, diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs index d5e5f166d..8a5e21147 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs @@ -50,10 +50,22 @@ pub(crate) fn validate_process_session_command_spec( Ok(()) } -fn process_session_command_builder( +pub(super) fn process_session_command_builder( launch: &ProjectCommandLaunchSpec, #[cfg(target_os = "linux")] bridge: &ProcessSessionBridgeServer, ) -> Result { + #[cfg(windows)] + let is_npm_launch = launch + .executable + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("npm.cmd")) + || launch.arguments.first().is_some_and(|argument| { + std::path::Path::new(argument) + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("npm-cli.js")) + }); #[cfg(target_os = "linux")] let mut command = { let current_executable = std::env::current_exe() @@ -76,16 +88,72 @@ fn process_session_command_builder( }; #[cfg(not(target_os = "linux"))] let mut command = { - let mut command = CommandBuilder::new(&launch.executable); - command.args(&launch.arguments); - command + #[cfg(windows)] + { + if launch + .executable + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("npm.cmd")) + { + let npm_directory = launch + .executable + .parent() + .ok_or_else(|| "command.start 无法定位 Windows npm 安装目录".to_string())?; + let node_executable = npm_directory.join("node.exe"); + let npm_cli = npm_directory.join("node_modules/npm/bin/npm-cli.js"); + if !node_executable.is_file() || !npm_cli.is_file() { + return Err( + "command.start Windows npm 安装缺少 node.exe 或 npm-cli.js".to_string() + ); + } + let node_executable = node_executable.to_string_lossy(); + let npm_cli = npm_cli.to_string_lossy(); + let mut command = CommandBuilder::new( + node_executable + .strip_prefix(r"\\?\") + .unwrap_or(&node_executable), + ); + command.arg(npm_cli.strip_prefix(r"\\?\").unwrap_or(&npm_cli)); + command.args(&launch.arguments); + command + } else { + let mut command = CommandBuilder::new(&launch.executable); + command.args(&launch.arguments); + command + } + } + #[cfg(not(windows))] + { + let mut command = CommandBuilder::new(&launch.executable); + command.args(&launch.arguments); + command + } }; + #[cfg(windows)] + { + let cwd = launch.cwd.to_string_lossy(); + command.cwd(cwd.strip_prefix(r"\\?\").unwrap_or(&cwd)); + } + #[cfg(not(windows))] command.cwd(&launch.cwd); command.env_clear(); #[cfg(not(target_os = "linux"))] for (name, value) in &launch.environment { command.env(name, value); } + #[cfg(windows)] + if is_npm_launch { + let node_executable = launch.executable.to_string_lossy(); + let node_executable = node_executable.strip_prefix(r"\\?\").unwrap_or(&node_executable); + command.env("npm_node_execpath", node_executable); + command.env("NODE", node_executable); + command.env("npm_config_node_gyp", ""); + // Windows 环境变量名不区分大小写。先移除继承的拼写,避免 + // CommandBuilder 更新值后仍保留 `ComSpec` 而隐藏 npm 的小写键。 + command.env_remove("ComSpec"); + command.env("npm_config_script_shell", r"C:\Windows\System32\cmd.exe"); + } #[cfg(target_os = "linux")] { command.env( @@ -770,28 +838,6 @@ where ) }) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; - let reader = pair - .master - .try_clone_reader() - .map_err(|error| { - process_session_launch_failed( - root, - &mut durable_record, - format!("克隆 command.start PTY reader 失败:{error}"), - ) - }) - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; - let writer = pair - .master - .take_writer() - .map_err(|error| { - process_session_launch_failed( - root, - &mut durable_record, - format!("取得 command.start PTY writer 失败:{error}"), - ) - }) - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; let command = process_session_command_builder(launch) .map_err(|error| process_session_launch_failed(root, &mut durable_record, error)) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; @@ -807,6 +853,30 @@ where }) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; drop(pair.slave); + let reader = pair.master.try_clone_reader().map_err(|error| { + let _ = child.kill(); + let _ = child.wait(); + ProjectCommandError::new( + ProjectCommandErrorStage::Execution, + process_session_launch_failed( + root, + &mut durable_record, + format!("克隆 command.start PTY reader 失败:{error}"), + ), + ) + })?; + let writer = pair.master.take_writer().map_err(|error| { + let _ = child.kill(); + let _ = child.wait(); + ProjectCommandError::new( + ProjectCommandErrorStage::Execution, + process_session_launch_failed( + root, + &mut durable_record, + format!("取得 command.start PTY writer 失败:{error}"), + ), + ) + })?; #[cfg(windows)] let windows_job = match WindowsProcessJob::assign(child.as_ref()) { Ok(job) => job, @@ -1014,21 +1084,129 @@ impl AnsiStripper { } } +#[cfg(windows)] +#[derive(Default)] +pub(super) struct AnsiTerminalRepositionDetector { + state: u8, +} + +#[cfg(windows)] +impl AnsiTerminalRepositionDetector { + pub(super) fn push(&mut self, byte: u8) -> bool { + match self.state { + 0 if byte == 0x1b => self.state = 1, + 1 if byte == b'[' => self.state = 2, + 1 => self.state = 0, + 2 if (0x40..=0x7e).contains(&byte) => { + self.state = 0; + return matches!(byte, b'A'..=b'H' | b'f'); + } + 2 => {} + _ => self.state = 0, + } + false + } +} + fn drain_process_session_output( live: Arc, mut reader: Box, ) { let mut buffer = [0u8; 4096]; let mut pending = Vec::new(); + let mut pending_logical_line_bytes = 0usize; let mut ansi = AnsiStripper::default(); let mut output_limit = false; + #[cfg(windows)] + let mut conpty_cursor_query_match = 0usize; + #[cfg(windows)] + let mut conpty_cursor_replied = false; + #[cfg(windows)] + let mut terminal_reposition = AnsiTerminalRepositionDetector::default(); + #[cfg(windows)] + let mut conpty_soft_wrap = false; loop { match reader.read(&mut buffer) { Ok(0) => break, Ok(read) => { for byte in &buffer[..read] { + #[cfg(windows)] + { + const CONPTY_CURSOR_QUERY: &[u8] = b"\x1b[6n"; + if !conpty_cursor_replied + && *byte == CONPTY_CURSOR_QUERY[conpty_cursor_query_match] + { + conpty_cursor_query_match += 1; + if conpty_cursor_query_match == CONPTY_CURSOR_QUERY.len() { + conpty_cursor_query_match = 0; + let reply_result = live + .writer + .lock() + .map_err(|_| "process session stdin 锁已损坏".to_string()) + .and_then(|mut writer| { + let Some(writer) = writer.as_mut() else { + // 终止线程会先关闭 stdin;此时 ConPTY 可能仍把启动期 + // 光标查询交给 reader。进程树已经进入收束阶段,无需再 + // 把无法回复查询升级成 needs-reconciliation。 + return Ok(()); + }; + writer + .write_all(b"\x1b[1;1R") + .and_then(|()| writer.flush()) + .map_err(|error| { + format!("回复 Windows ConPTY 光标查询失败:{error}") + }) + }); + if let Err(error) = reply_result { + if let Ok(mut output) = live.output.lock() { + output.status = "failed".to_string(); + output.needs_reconciliation = true; + output.stdin_open = false; + let detail = format!( + "\n\n" + ); + if output.text.len().saturating_add(detail.len()) + <= PROCESS_SESSION_MAX_OUTPUT_BYTES + { + output.text.push_str(&detail); + } + live.output_changed.notify_all(); + } + let _ = live.control.send(ProcessControl::Terminate); + return; + } + conpty_cursor_replied = true; + } + } else if !conpty_cursor_replied { + conpty_cursor_query_match = + usize::from(*byte == CONPTY_CURSOR_QUERY[0]); + } + } + #[cfg(windows)] + let ends_terminal_reposition = terminal_reposition.push(*byte); let before = pending.len(); ansi.push(*byte, &mut pending); + let visible_bytes = pending.len().saturating_sub(before); + if visible_bytes > 0 + && !matches!(pending.last(), Some(b'\n' | b'\r')) + { + pending_logical_line_bytes = + pending_logical_line_bytes.saturating_add(visible_bytes); + if pending_logical_line_bytes > PROCESS_SESSION_MAX_PENDING_LINE_BYTES { + output_limit = true; + break; + } + } + #[cfg(windows)] + if ends_terminal_reposition && !pending.is_empty() { + pending.push(b'\n'); + if !append_process_output_line(&live, &pending) { + output_limit = true; + break; + } + pending.clear(); + continue; + } if pending.len() == before { continue; } @@ -1037,10 +1215,36 @@ fn drain_process_session_output( output_limit = true; break; } + #[cfg(windows)] + { + // ConPTY materializes an automatic terminal-width wrap as CR/LF. + // It is a display boundary, not an application line terminator, so + // it must not reset the logical-line safety limit. A real short line + // still resets at CR; the immediately following LF preserves that + // decision. + const PROCESS_SESSION_PTY_COLS: usize = 120; + match pending.last() { + Some(b'\r') => { + conpty_soft_wrap = pending_logical_line_bytes + >= PROCESS_SESSION_PTY_COLS; + if !conpty_soft_wrap { + pending_logical_line_bytes = 0; + } + } + Some(b'\n') => { + if !conpty_soft_wrap { + pending_logical_line_bytes = 0; + } + conpty_soft_wrap = false; + } + _ => {} + } + } + #[cfg(not(windows))] + { + pending_logical_line_bytes = 0; + } pending.clear(); - } else if pending.len() > PROCESS_SESSION_MAX_PENDING_LINE_BYTES { - output_limit = true; - break; } } if output_limit { diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/recovery.rs index 24d62ab1f..da3de5e8e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/recovery.rs @@ -121,8 +121,8 @@ pub(crate) fn terminate_process_sessions_for_run_at( let terminal = terminate_process_session_at(root, &identity, &record.process_id, None)?; if terminal.status == "running" || terminal.needs_reconciliation { return Err(format!( - "进程会话 {} 尚未形成可信终态,不能把 run 标记为已取消", - record.process_id + "进程会话 {} 尚未形成可信终态(status={},needsReconciliation={}),不能把 run 标记为已取消", + record.process_id, terminal.status, terminal.needs_reconciliation )); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs index 5230b3cb2..6b778c1c6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs @@ -366,6 +366,69 @@ fn process_session_ansi_stripper_handles_split_csi_and_osc() { assert_eq!(String::from_utf8(visible).expect("utf8"), "ABC\n"); } +#[cfg(windows)] +#[test] +fn process_session_terminal_reposition_detector_ignores_color_sequences() { + let mut detector = AnsiTerminalRepositionDetector::default(); + let color = b"\x1b[31m"; + assert!(!color.iter().any(|byte| detector.push(*byte))); + + let mut reposition = AnsiTerminalRepositionDetector::default(); + let sequence = b"\x1b[5;1H"; + assert_eq!( + sequence + .iter() + .filter(|byte| reposition.push(**byte)) + .count(), + 1 + ); +} + +#[cfg(windows)] +#[test] +fn process_session_windows_npm_builder_uses_node_cli_and_native_script_shell() { + let directory = tempfile::tempdir().expect("temp project"); + let root = directory.path(); + init_local_game_project_at(root, "npm-builder-project", "Npm Builder Project") + .expect("initialize project"); + fs::write( + root.join("package.json"), + r#"{"scripts":{"dev":"node fixture.js"}}"#, + ) + .expect("write package.json"); + let spec = resolve_project_command_spec_at( + root, + "npm", + &["run".to_string(), "dev".to_string()], + ".", + 30, + ) + .expect("resolve npm command"); + let launch = prepare_project_command_launch_spec(root, &spec).expect("prepare npm launch"); + let command = process_session_command_builder(&launch).expect("build npm PTY command"); + let argv = command.get_argv(); + + assert!(argv[0].to_string_lossy().ends_with("node.exe")); + assert!(argv[1] + .to_string_lossy() + .replace('\\', "/") + .ends_with("node_modules/npm/bin/npm-cli.js")); + assert!(argv + .iter() + .all(|argument| !argument.to_string_lossy().starts_with(r"\\?\"))); + assert_eq!( + command.get_env("npm_config_script_shell"), + Some(std::ffi::OsStr::new(r"C:\Windows\System32\cmd.exe")) + ); + assert_eq!( + command.get_env("NODE"), + command.get_env("npm_node_execpath") + ); + assert!(command + .get_cwd() + .is_some_and(|cwd| !cwd.to_string_lossy().starts_with(r"\\?\"))); +} + #[test] fn process_session_real_pty_streams_stdin_and_terminates() { let _guard = process_session_test_guard(); @@ -515,6 +578,7 @@ setInterval(() => {}, 1000); "{:?}", transcript.output ); + #[cfg(unix)] assert!( transcript_lines.contains(&"STOPPED"), "{:?}", @@ -1337,7 +1401,11 @@ process.stdin.resume(); } } assert_eq!(poll.status, "exited", "tail: {tail}"); + #[cfg(not(windows))] assert!(tail.contains("EOF"), "tail: {tail}"); + // Closing a ConPTY input pipe closes the attached Windows console. Unlike a Unix PTY, + // Node's console stdin does not emit its stream-level `end` callback before that terminal + // close, so the portable contract here is the trusted `exited` terminal state above. clear_process_session_registry_for_tests(); } @@ -1429,7 +1497,8 @@ fn process_session_overlong_unterminated_line_is_stopped() { let fingerprint = project_command_source_fingerprint(root).expect("source fingerprint"); let mut poll = start_process_session_at(root, identity.clone(), &spec, fingerprint).expect("start"); - for _ in 0..30 { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while std::time::Instant::now() < deadline { if poll.status != "running" { break; } @@ -1439,7 +1508,7 @@ fn process_session_overlong_unterminated_line_is_stopped() { &poll.process_id, Some(&poll.next_cursor), Some(8_000), - Some(250), + Some(100), ) .expect("poll output limit"); } 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 3f12dbdd4..56a9cc65e 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 @@ -767,11 +767,7 @@ fn nt_open_windows_agent_db_relative( &mut io_status, std::ptr::null_mut(), FILE_ATTRIBUTE_NORMAL, - if directory { - FILE_SHARE_READ | FILE_SHARE_WRITE - } else { - 0 - }, + FILE_SHARE_READ | FILE_SHARE_WRITE, if create { FILE_OPEN_IF } else { FILE_OPEN }, create_options, std::ptr::null_mut(), @@ -3447,7 +3443,7 @@ fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result + ) || matches!(error.raw_os_error(), Some(32 | 33)) => { Ok(None) } @@ -3479,12 +3475,12 @@ pub(super) fn append_jsonl_line_unlocked( .create(true) .read(true) .write(true) - .append(true) .open(path) .map_err(|error| format!("打开{error_label}失败:{}: {error}", path.display()))?; repair_truncated_jsonl_tail_unlocked(&mut file, path, error_label)?; let framed = format!("{line}\n"); - file.write_all(framed.as_bytes()) + file.seek(SeekFrom::End(0)) + .and_then(|_| file.write_all(framed.as_bytes())) .and_then(|_| file.flush()) .and_then(|_| file.sync_data()) .map_err(|error| format!("写入{error_label}失败:{}: {error}", path.display())) 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 20d51c937..6d5d560f1 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 @@ -2127,6 +2127,33 @@ fn incomplete_provider_request_query_uses_the_agent_db_append_lock() { fs::remove_dir_all(root).ok(); } +#[cfg(windows)] +#[test] +fn windows_agent_db_read_handle_can_coexist_with_an_open_writer() { + let root = unique_agent_db_test_root("windows-shared-read-write"); + append_agent_db_record_fixture( + &root, + serde_json::json!({"recordType": "test.windows-shared-read-write"}), + ) + .expect("append shared-handle fixture"); + let directory = open_agent_db_directory(&root, false) + .expect("open Agent DB directory") + .expect("Agent DB directory exists"); + let writer = open_agent_db_storage(directory, true, false) + .expect("open Agent DB writer") + .expect("Agent DB exists"); + + let (records, _) = read_agent_db_records_bounded(&root, u64::MAX) + .expect("read Agent DB while writer handle remains open"); + assert!(records.iter().any(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some("test.windows-shared-read-write") + })); + + drop(writer); + fs::remove_dir_all(root).ok(); +} + #[test] fn incomplete_provider_request_query_rejects_non_target_duplicate_reversed_and_multi_terminal_sequences( ) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs index 83e61551c..28b5f56bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs @@ -1025,29 +1025,50 @@ fn try_acquire_asset_canvas_draft_lock( .parent() .ok_or_else(|| "素材画布锁缺少父目录".to_string())?; fs::create_dir_all(parent).map_err(|_| "创建素材画布锁目录失败".to_string())?; - match fs::OpenOptions::new() - .create(true) - .read(true) - .write(true) - .share_mode(0) - .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) - .open(&path) - { - Ok(file) => { - validate_windows_regular_file_handle(&file, "素材画布锁")?; - crate::secure_windows_game_creator_path_for_current_user(&path, false, true)?; - Ok(Some(AssetCanvasDraftLock { _file: file })) + let open_lock = |create_new| { + let mut options = fs::OpenOptions::new(); + options + .read(true) + .write(true) + .share_mode(0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + if create_new { + options.create_new(true); } - Err(error) - if matches!( - error.kind(), - std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock - ) => - { - Ok(None) - } - Err(_) => Err("获取素材画布系统文件锁失败".to_string()), + options.open(&path) + }; + let (file, created) = match open_lock(true) { + Ok(file) => (file, true), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => match open_lock(false) { + Ok(file) => (file, false), + Err(error) if windows_file_lock_is_contended(&error) => { + return Ok(None); + } + Err(_) => return Err("获取素材画布系统文件锁失败".to_string()), + }, + Err(error) if windows_file_lock_is_contended(&error) => return Ok(None), + Err(_) => return Err("获取素材画布系统文件锁失败".to_string()), + }; + validate_windows_regular_file_handle(&file, "素材画布锁")?; + if created { + crate::initialize_windows_game_creator_file_owner_for_current_user(&path)?; + } else { + crate::secure_windows_game_creator_path_for_current_user(&path, false, true)?; } + Ok(Some(AssetCanvasDraftLock { _file: file })) +} + +#[cfg(windows)] +fn windows_file_lock_is_contended(error: &std::io::Error) -> bool { + const ERROR_SHARING_VIOLATION: i32 = 32; + const ERROR_LOCK_VIOLATION: i32 = 33; + matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock + ) || matches!( + error.raw_os_error(), + Some(ERROR_SHARING_VIOLATION | ERROR_LOCK_VIOLATION) + ) } #[cfg(not(any(unix, windows)))] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index 7bbc799b0..a49f17b04 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -82,6 +82,21 @@ fn project_write_lock_can_be_reclaimed(path: &Path) -> bool { project_write_lock_age_seconds(path, &metadata) > PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS } +fn project_write_lock_open_error_is_contention(error: &std::io::Error) -> bool { + if error.kind() == std::io::ErrorKind::AlreadyExists { + return true; + } + #[cfg(windows)] + { + // Windows can report an existing or delete-pending create_new target as + // ACCESS_DENIED instead of ALREADY_EXISTS while another thread drops it. + return error.kind() == std::io::ErrorKind::PermissionDenied + || matches!(error.raw_os_error(), Some(5 | 32 | 33)); + } + #[cfg(not(windows))] + false +} + pub(crate) fn acquire_project_write_lock( root: &Path, command_id: &str, @@ -118,7 +133,7 @@ pub(crate) fn acquire_project_write_lock( }); } Err(error) - if error.kind() == std::io::ErrorKind::AlreadyExists + if project_write_lock_open_error_is_contention(&error) && !retried_after_reclaim && project_write_lock_can_be_reclaimed(&path) => { @@ -127,7 +142,7 @@ pub(crate) fn acquire_project_write_lock( })?; retried_after_reclaim = true; } - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + Err(error) if project_write_lock_open_error_is_contention(&error) => { return Err(format!("项目正在被其他写操作占用:{}", path.display())); } Err(error) => { 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 4f1e01b7e..c078f5b02 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 @@ -150,7 +150,7 @@ fn try_open_manifest_write_lock_file(path: &Path) -> Result, String if matches!( error.kind(), std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock - ) => + ) || matches!(error.raw_os_error(), Some(32 | 33)) => { Ok(None) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs index d4c23f8e9..8c587703c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs @@ -246,6 +246,7 @@ fn try_open_resource_layout_write_lock_file(root: &Path) -> Result, )); } } + let existed = path.exists(); match fs::OpenOptions::new() .create(true) .read(true) @@ -256,14 +257,18 @@ fn try_open_resource_layout_write_lock_file(root: &Path) -> Result, { Ok(file) => { validate_windows_regular_file_handle(&file, "资源布局锁")?; - crate::secure_windows_game_creator_path_for_current_user(&path, false, true)?; + if existed { + crate::secure_windows_game_creator_path_for_current_user(&path, false, true)?; + } else { + crate::initialize_windows_game_creator_file_owner_for_current_user(&path)?; + } Ok(Some(file)) } Err(error) if matches!( error.kind(), std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock - ) => + ) || matches!(error.raw_os_error(), Some(32 | 33)) => { Ok(None) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs index 30835b4d9..d8cd83355 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs @@ -511,6 +511,8 @@ where } Err(_) => { terminate_project_verification_process_tree(&mut child).await; + stdout_task.abort(); + stderr_task.abort(); (None, true) } }; @@ -518,10 +520,26 @@ where collect_project_verification_output_task(stdout_task, "stdout"), collect_project_verification_output_task(stderr_task, "stderr"), ); - let stdout = stdout - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; - let stderr = stderr - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; + let stdout = match stdout { + Ok(output) => output, + Err(_) if timed_out => String::new(), + Err(error) => { + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::Execution, + error, + )); + } + }; + let stderr = match stderr { + Ok(output) => output, + Err(_) if timed_out => String::new(), + Err(error) => { + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::Execution, + error, + )); + } + }; let mut sections = Vec::new(); if !stdout.trim().is_empty() { sections.push(format!("stdout:\n{}", stdout.trim())); diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs index ccfe7b7af..4b140ab53 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs @@ -596,10 +596,11 @@ pub(super) fn open_external_agent_runner_endpoint_file(path: &Path) -> Result request, + other => panic!("unexpected delegated clarification recovery: {other:?}"), + }; + let (_, observation) = answer_game_creator_agent_user_input_request_for_pending_at( + &root, + &first, + &request.request_id, + "needs-user-input-response", + BTreeMap::from([("target_platform".to_string(), "Web".to_string())]), + ) + .expect("answer delegated clarification request"); + let detail = + serde_json::from_str::(observation.detail.as_deref().expect("answer detail")) + .expect("parse answer detail"); + let questions_sha = detail["questionsSha256"].as_str().expect("questions sha"); + let answers_sha = detail["answersSha256"].as_str().expect("answers sha"); + let pending_path = game_creator_agent_runtime_pending_tool_action_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ); + fs::remove_file(pending_path).expect("clear answered pending before next planning"); + + let continuation_input = serde_json::json!({ + "continuationOfDelegationId": delegation_id, + "questionsSha256": questions_sha, + "answersSha256": answers_sha, + }); + let identity = validate_static_delegate_clarification_continuation_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + &continuation_input, + Some(delegation_id), + ) + .expect("validate continuation after pending cleanup") + .expect("derived continuation identity"); + assert_eq!( + validate_static_delegate_clarification_continuation_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + &continuation_input, + Some(delegation_id), + ) + .expect("replay continuation validation"), + Some(identity.clone()) + ); + let barrier = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read answered clarification barrier"); + assert_eq!(barrier.user_input_required_count, 0); + assert_eq!(barrier.repair_required_count, 1); + + let wrong_binding = serde_json::json!({ + "continuationOfDelegationId": delegation_id, + "questionsSha256": "a".repeat(64), + "answersSha256": answers_sha, + }); + assert!(validate_static_delegate_clarification_continuation_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + &wrong_binding, + Some(delegation_id), + ) + .expect_err("wrong clarification binding must fail") + .contains("指纹")); + + let target_lock = + try_acquire_game_creator_agent_runtime_task_lock(&root, &delivery.target_agent_id) + .expect("acquire clarification continuation target lane") + .expect("clarification continuation target lane available"); + let delegate_input = serde_json::json!({ + "agentId": delivery.target_agent_id, + "task": "根据用户确认的 Web 首发平台继续完成原方案", + "acceptanceCriteria": delivery.acceptance_criteria, + "expectedArtifacts": delivery.expected_artifacts, + "repairOfDelegationId": delegation_id, + "runId": null, + "continuationOfDelegationId": delegation_id, + "questionsSha256": questions_sha, + "answersSha256": answers_sha, + }); + let first_continuation = observe_agent_runtime_agent_delegate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + Some("provider-continuation-action-one"), + &delegate_input, + ); + assert_eq!(first_continuation.status, "ok", "{first_continuation:?}"); + let replayed_continuation = observe_agent_runtime_agent_delegate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + Some("provider-continuation-action-two"), + &delegate_input, + ); + assert_eq!( + replayed_continuation.status, "ok", + "{replayed_continuation:?}" + ); + let continuation_delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + &delivery.target_agent_id, + &identity, + ); + let continuation = read_static_delegate_delivery_at(&root, &continuation_delegation_id) + .expect("read clarification continuation delivery") + .expect("clarification continuation delivery exists"); + assert_eq!(continuation.parent_action_id, identity); + assert_eq!( + continuation.repair_of_delegation_id.as_deref(), + Some(delegation_id) + ); + let continuation_barrier = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read continuation barrier"); + assert_eq!(continuation_barrier.user_input_required_count, 0); + assert_eq!(continuation_barrier.repair_required_count, 0); + assert_eq!(continuation_barrier.waiting_count, 1); + drop(target_lock); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn completed_child_final_response_becomes_needs_user_input_delivery_result() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-child-final-clarification", + "真实 child 澄清终态测试", + ) + .expect("project init"); + let delivery = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "parent-session", + "parent-run", + "parent-action", + "child-final-clarification-delegation", + "design-director", + "child-session", + "child-run", + &["明确首发平台".to_string()], + &[], + None, + ); + let response = concat!( + "AGC_NEEDS_USER_INPUT_V1\n", + r#"{"questions":[{"id":"target_platform","header":"首发平台","question":"首版优先发布到哪个平台?","options":[{"label":"Web","description":"优先浏览器交付。"},{"label":"桌面端","description":"优先桌面客户端交付。"}]}]}"# + ); + let child_task = AgentRuntimeTaskRecord { + schema_version: "game-creator-agent-runtime-task.v1".to_string(), + task_id: "child-task".to_string(), + agent_id: delivery.target_agent_id.clone(), + session_id: delivery.target_session_id.clone(), + run_id: delivery.target_run_id.clone(), + source: "agent-delegate".to_string(), + parent_agent_id: Some(delivery.parent_agent_id.clone()), + parent_run_id: Some(delivery.parent_run_id.clone()), + delegation_id: Some(delivery.delegation_id.clone()), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + goal_id: None, + goal_revision: 0, + goal_status: None, + task: "明确首发平台".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "已完成澄清请求".to_string(), + terminal_detail: Some(response.to_string()), + error: None, + updated_at: unix_timestamp(), + }; + let result = build_static_delegate_result_for_child_at( + &root, + &delivery, + &child_task, + "completed", + response, + ) + .expect("build real completed child result"); + assert_eq!( + result.contract_status, + StaticDelegateContractStatus::NeedsUserInput + ); + assert_eq!(result.user_input_questions.len(), 1); + assert!(result.user_input_questions_sha256.is_some()); + + fs::remove_dir_all(root).ok(); +} + #[test] fn project_supervisor_run_status_replays_receipts_for_same_action() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index 3983af83f..2b70787dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -1212,17 +1212,20 @@ fn llm_config_status_preserves_global_web_search_error_when_required_agents_over #[test] fn cli_runtime_config_dir_is_explicit_absolute_and_removed_before_command_parse() { + let temp_root = std::env::temp_dir(); + let config_dir = temp_root.join("genarrative-appdata"); + let project_dir = temp_root.join("genarrative-cli-game"); let mut args = vec![ "--agent-task".to_string(), "--config-dir".to_string(), - "/tmp/genarrative-appdata".to_string(), - "/tmp/genarrative-cli-game".to_string(), + config_dir.to_string_lossy().into_owned(), + project_dir.to_string_lossy().into_owned(), "code-prototype".to_string(), "修复失败测试".to_string(), ]; assert_eq!( take_cli_runtime_config_dir(&mut args).expect("take config dir"), - Some(PathBuf::from("/tmp/genarrative-appdata")) + Some(config_dir) ); assert!(!args.iter().any(|arg| arg == "--config-dir")); assert!(matches!( @@ -1236,9 +1239,9 @@ fn cli_runtime_config_dir_is_explicit_absolute_and_removed_before_command_parse( .contains("绝对路径")); let mut duplicate = vec![ "--config-dir".to_string(), - "/tmp/a".to_string(), + temp_root.join("a").to_string_lossy().into_owned(), "--config-dir".to_string(), - "/tmp/b".to_string(), + temp_root.join("b").to_string_lossy().into_owned(), ]; assert!(take_cli_runtime_config_dir(&mut duplicate) .expect_err("duplicate config dir must fail") 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 d0fe0840b..0d66111ed 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 @@ -536,7 +536,11 @@ pub(crate) async fn wait_for_agent_runtime_manifest_projection_async( ) -> AgentRuntimeResult { let deadline = Instant::now() + Duration::from_secs(10); let mut terminal = wait_for_agent_runtime_terminal_and_lane_release_async( - root, agent_id, run_id, runtime_status, phase, + root, + agent_id, + run_id, + runtime_status, + phase, ) .await; let mut stable_samples = 0_u8; @@ -1628,17 +1632,23 @@ pub(crate) fn spawn_mock_llm_tool_plan_then_invalid_final_reply( .write_all(planning_response.as_bytes()) .expect("mock tool plan response"); - let (mut final_stream, _) = listener.accept().expect("mock final reply accept"); - drop(read_mock_http_request(&mut final_stream)); - let invalid_body = "{invalid-json"; - let final_response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - invalid_body.len(), - invalid_body - ); - final_stream - .write_all(final_response.as_bytes()) - .expect("mock invalid final reply response"); + // Autonomous runs enforce a 12-retry floor. Return the same malformed + // response for the initial final-reply request and every retry so this + // fixture tests deserialize exhaustion rather than an accidental + // connection-refused fallback after the first malformed response. + for _ in 0..=12 { + let (mut final_stream, _) = listener.accept().expect("mock final reply accept"); + drop(read_mock_http_request(&mut final_stream)); + let invalid_body = "{invalid-json"; + let final_response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + invalid_body.len(), + invalid_body + ); + final_stream + .write_all(final_response.as_bytes()) + .expect("mock invalid final reply response"); + } }); base_url } @@ -4111,8 +4121,14 @@ fn wait_for_tool_plan_handoff_test_stop( expected_entries: usize, ) -> crate::tool_plan_handoff::AgentRuntimeToolPlanHandoffLedger { for _ in 0..250 { - let handoff = crate::tool_plan_handoff::read_for_run_at(root, agent_id, run_id) - .expect("read tool-plan handoff after test stop"); + let handoff = match crate::tool_plan_handoff::read_for_run_at(root, agent_id, run_id) { + Ok(handoff) => handoff, + Err(error) if error.contains("仍由活跃写入句柄持有") => { + std::thread::sleep(Duration::from_millis(20)); + continue; + } + Err(error) => panic!("read tool-plan handoff after test stop: {error}"), + }; if let Some(handoff) = handoff { if handoff.entries.len() == expected_entries && game_creator_agent_runtime_task_lock_is_available(root, agent_id) @@ -5604,7 +5620,7 @@ async fn background_agent_runtime_marks_response_plan_step_failed_when_final_rep "response": "" }) .to_string(); - let base_url = spawn_mock_llm_server_responses(vec![plan_json]); + let base_url = spawn_mock_llm_tool_plan_then_invalid_final_reply(plan_json); let _config_guard = write_test_local_config(format!( r#"{{ "agentLlm": {{ 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 bed788c5a..7a8b75405 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 @@ -585,6 +585,20 @@ async fn background_agent_runtime_preview_start_respects_project_policy() { ))); assert!(!root.join(".agent/logs/preview.log").exists()); + cancel_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "code-preview-policy-run", + ) + .expect("cancel waiting preview policy task"); + wait_for_agent_runtime_terminal_and_lane_release( + &root, + "code-prototype", + "code-preview-policy-run", + "cancelled", + "cancelled", + ); + fs::remove_dir_all(root).ok(); } @@ -1123,11 +1137,19 @@ async fn generate_local_game_draft_fails_after_max_passes_without_final_artifact trace["passPlans"].as_array().unwrap().len() == usize::from(GAME_CREATOR_AGENT_LOOP_MAX_PASSES) ); - assert!(trace["artifacts"] + let artifact_paths = trace["artifacts"] .as_array() .unwrap() .iter() - .any(|artifact| artifact["path"] == ".agent/passes/pass-3/game.html")); + .filter_map(|artifact| artifact["path"].as_str()) + .map(|path| path.replace('\\', "/")) + .collect::>(); + assert!( + artifact_paths + .iter() + .any(|path| path == ".agent/passes/pass-3/game.html"), + "unexpected max-pass artifact paths: {artifact_paths:?}" + ); assert!(!trace["steps"] .as_array() .unwrap() @@ -3017,16 +3039,21 @@ fn game_chat_initial_window_url_is_applied_before_tauri_creates_the_client() { #[test] fn workspace_window_project_path_requires_absolute_path() { - assert!(validate_workspace_window_project_path(" /tmp/game ").is_ok()); + let absolute = std::env::temp_dir().join("game"); + let padded_absolute = format!(" {} ", absolute.display()); + assert!(validate_workspace_window_project_path(&padded_absolute).is_ok()); assert!(validate_workspace_window_project_path("relative-game") .expect_err("relative path should be rejected") .contains("绝对路径")); assert!(validate_workspace_window_project_path(" ") .expect_err("empty path should be rejected") .contains("绝对路径")); - assert!(validate_workspace_window_project_path("/tmp/game\nnext") - .expect_err("control character path should be rejected") - .contains("控制字符")); + let control_character_path = format!("{}\nnext", absolute.display()); + assert!( + validate_workspace_window_project_path(&control_character_path) + .expect_err("control character path should be rejected") + .contains("控制字符") + ); } #[test] @@ -3090,6 +3117,36 @@ fn cli_agent_run_requires_project_and_prompt() { initialize: true, } ); + + let root = unique_project_path(); + init_local_game_project_at(&root, "cli-supervisor-source", "CLI Supervisor 来源") + .expect("init CLI Supervisor project"); + let started = start_cli_agent_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "检查项目", + "cli-supervisor-source-run", + ) + .expect("start CLI Supervisor task"); + assert_eq!(started.state.source, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE); + assert_eq!( + started.state.run_profile, + AGENT_RUNTIME_RUN_PROFILE_STANDARD + ); + cancel_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "cli-supervisor-source-run", + ) + .expect("cancel CLI Supervisor source task"); + wait_for_agent_runtime_terminal_and_lane_release( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "cli-supervisor-source-run", + "cancelled", + "cancelled", + ); + fs::remove_dir_all(&root).ok(); let agent_enqueue = parse_cli_command(&[ "--agent-enqueue".to_string(), "--init".to_string(), 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 d328906ab..fa253f251 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 @@ -494,10 +494,16 @@ async fn mcp_runtime_write_tool_waits_for_confirmation_and_executes_once() { .recv_timeout(Duration::from_secs(5)) .expect("receive MCP observation followup"); assert!(followup_request.contains(&format!("mutated:{mutation_value}"))); - let terminal = wait_for_agent_runtime_idle(&root, "code-prototype"); - assert_eq!(terminal.phase, "completed"); + let terminal = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "code-prototype", + "mcp-runtime-confirm-run", + "idle", + "completed", + ); + assert_eq!(terminal.state.phase, "completed"); assert_eq!( - terminal.last_response.as_deref(), + terminal.state.last_response.as_deref(), Some("MCP 写工具已确认并且只执行了一次。") ); assert_eq!( @@ -720,10 +726,16 @@ async fn mcp_executing_sidecar_recovers_after_client_loss_without_replay() { .recv_timeout(Duration::from_secs(5)) .expect("receive recovered MCP observation"); assert!(followup_request.contains(&format!("mutated:{mutation_value}"))); - let terminal = wait_for_agent_runtime_idle(&root, "code-prototype"); - assert_eq!(terminal.phase, "completed"); + let terminal = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "code-prototype", + "mcp-sidecar-recovery-run", + "idle", + "completed", + ); + assert_eq!(terminal.state.phase, "completed"); assert_eq!( - terminal.last_response.as_deref(), + terminal.state.last_response.as_deref(), Some("MCP 已从私有 sidecar 恢复,没有重放远端写工具。") ); assert_eq!( @@ -3618,10 +3630,16 @@ async fn background_agent_runtime_resumes_approved_auto_action_once_without_llm_ .expect("replan after recovered auto observation"); assert!(replan_request.contains("已写入 Agent 记忆 design-director")); assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(runtime.run_id, "design-auto-approved-recovery-run"); + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + "design-auto-approved-recovery-run", + "idle", + "completed", + ); + assert_eq!(runtime.state.run_id, "design-auto-approved-recovery-run"); assert_eq!( - runtime.last_response.as_deref(), + runtime.state.last_response.as_deref(), Some("恢复后只写入了一次私有记忆。") ); let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); @@ -3956,9 +3974,17 @@ async fn provider_transient_retry_transport_failure_closes_then_stable_retry_suc .recv_timeout(Duration::from_millis(100)) .is_err()); - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(runtime.phase, "completed"); - assert_eq!(runtime.last_response.as_deref(), Some("瞬态失败后已完成")); + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + run_id, + "idle", + "completed", + ); + assert_eq!( + runtime.state.last_response.as_deref(), + Some("瞬态失败后已完成") + ); let records = read_agent_db_records_for_test(&root); let lifecycle = records @@ -5048,9 +5074,15 @@ async fn provider_transient_retry_zero_max_retries_stops_after_first_failure() { request_notice_receiver .recv_timeout(Duration::from_secs(5)) .expect("first physical Provider request"); - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(runtime.phase, "failed"); + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + run_id, + "failed", + "failed", + ); assert!(runtime + .state .error .as_deref() .is_some_and(|error| error.contains("kind=transport"))); @@ -7723,7 +7755,10 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { "acceptanceCriteria", "expectedArtifacts", "repairOfDelegationId", - "runId" + "runId", + "continuationOfDelegationId", + "questionsSha256", + "answersSha256" ]) ); assert_eq!( 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 b9d345fde..b0dadfb3f 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 @@ -617,7 +617,8 @@ async fn response_stream_private_process_output_is_never_published_or_committed_ } #[tokio::test] -async fn response_stream_final_failure_with_retry_disabled_commits_planning_fallback() { +async fn response_stream_final_disconnect_with_retry_disabled_fails_without_committing_planning_fallback( +) { let root = unique_project_path(); init_local_game_project_at( &root, @@ -686,32 +687,28 @@ async fn response_stream_final_failure_with_retry_disabled_commits_planning_fall ); drop(finalization_lock); - let completed = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(completed.phase, "completed"); - assert_eq!(completed.last_response.as_deref(), Some(planning_fallback)); - let committed = wait_for_response_stream_status( - &root, - "design-director", - run_id, - "committed", - ready.sequence.saturating_add(1), - ); - assert_eq!(committed.accumulated_text, planning_fallback); + let failed = wait_for_agent_runtime_phase(&root, "design-director", "failed"); + assert_eq!(failed.status, "failed"); + assert!(failed + .error + .as_deref() + .is_some_and(|error| error.contains("kind=transport"))); + assert_eq!(failed.last_response, None); let conversation = read_local_conversation_for_session_at( &root, Some("design-director"), Some(&started.state.session_id), ) - .expect("read committed fallback conversation"); - assert_eq!( - conversation - .messages - .iter() - .filter(|message| message.role == "assistant") - .map(|message| message.content.as_str()) - .collect::>(), - vec![planning_fallback] - ); + .expect("read failed final stream conversation"); + let assistant_messages = conversation + .messages + .iter() + .filter(|message| message.role == "assistant") + .map(|message| message.content.as_str()) + .collect::>(); + assert_eq!(assistant_messages.len(), 1); + assert_ne!(assistant_messages[0], planning_fallback); + assert!(assistant_messages[0].contains("失败")); let requests = mock.stop_and_collect(); assert_eq!( @@ -760,11 +757,21 @@ async fn response_stream_final_failure_with_retry_disabled_commits_planning_fall final_lifecycle[0]["requestSlot"], final_lifecycle[1]["requestSlot"] ); - assert_response_stream_completion_event_details( - &root, - "design-director", - run_id, - planning_fallback, + let mut final_reply_failed_audit = false; + for _ in 0..250 { + final_reply_failed_audit = read_agent_db_records_for_test(&root).iter().any(|record| { + record["recordType"] == "agent.runtime.background_task.failed" + && record["runId"] == run_id + && record["failureKind"] == "final-reply-failed" + }); + if final_reply_failed_audit { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + assert!( + final_reply_failed_audit, + "final reply transport failure audit must eventually persist" ); assert_response_stream_public_surfaces_exclude(&root, "design-director", &[planning_fallback]); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/tool_planning.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/tool_planning.rs index d8978756a..1ce915e4e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/tool_planning.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/tool_planning.rs @@ -1365,6 +1365,7 @@ async fn background_agent_runtime_task_executes_plan_tool_observation_loop() { } assert!(runtime_result .task_path + .replace('\\', "/") .ends_with(".agent/runtime/tasks/design-director.jsonl")); assert!(runtime_result .recent_tasks diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs index f65168688..09b5f0df0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs @@ -384,6 +384,19 @@ async fn background_agent_runtime_can_confirm_and_continue_waiting_tool_actions( let runtime_lock_path = root.join(".agent/runtime/locks/design-director.lock"); fs::create_dir_all(runtime_lock_path.parent().expect("runtime lock parent")) .expect("runtime lock dir"); + for _ in 0..250 { + if game_creator_agent_runtime_task_lock_is_available(&root, "design-director") + .expect("probe released confirmation lane") + { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + assert!( + game_creator_agent_runtime_task_lock_is_available(&root, "design-director") + .expect("confirm released confirmation lane"), + "confirmation state was visible before its runtime lane released" + ); fs::write( &runtime_lock_path, serde_json::json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs index 1fa8efa64..d4c274484 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs @@ -1007,10 +1007,15 @@ async fn background_agent_runtime_read_only_action_survives_cross_agent_revision .recv_timeout(Duration::from_secs(2)) .expect("replan after latest read observation"); assert!(replan_request.contains("read-only action observes the latest revision")); - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(runtime.phase, "completed"); - assert_eq!(runtime.error, None); - assert!(runtime.recent_tool_calls.iter().any(|call| { + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + "design-read-only-revision-drift-run", + "idle", + "completed", + ); + assert_eq!(runtime.state.error, None); + assert!(runtime.state.recent_tool_calls.iter().any(|call| { call.tool == "file.read" && call.status == "ok" && call.action_id.is_some() })); assert_eq!( @@ -1106,10 +1111,15 @@ async fn background_agent_runtime_reconciliation_blocks_queue_until_manual_cance .expect("queued task starts after reconciliation cancel"); assert!(followup_request.contains("人工核对解除后执行的任务")); assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(runtime.run_id, "design-reconciliation-after-cancel-run"); + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + "design-reconciliation-after-cancel-run", + "idle", + "completed", + ); assert_eq!( - runtime.last_response.as_deref(), + runtime.state.last_response.as_deref(), Some("核对解除后,后续任务已按顺序完成。") ); let runtime_result = @@ -1323,10 +1333,15 @@ async fn background_agent_runtime_recovers_pending_task_after_cancelled_canonica .recv_timeout(Duration::from_secs(2)) .expect("pending task plan request"); assert!(plan_request.contains("恢复排队后台任务")); - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(runtime.status, "idle"); + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + "design-pending-recover-run", + "idle", + "completed", + ); assert_eq!( - runtime.last_response.as_deref(), + runtime.state.last_response.as_deref(), Some("已恢复并完成排队后台任务。") ); let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); @@ -1446,11 +1461,15 @@ async fn background_agent_runtime_recovers_stale_running_before_pending_task() { .recv_timeout(Duration::from_secs(2)) .expect("pending plan request after recovered running"); assert!(second_request.contains("后续排队任务")); - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(runtime.run_id, "design-pending-after-stale-run"); - assert_eq!(runtime.status, "idle"); + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + "design-pending-after-stale-run", + "idle", + "completed", + ); assert_eq!( - runtime.last_response.as_deref(), + runtime.state.last_response.as_deref(), Some("后续排队任务已完成。") ); let runtime_result = @@ -1551,11 +1570,17 @@ async fn background_agent_runtime_recovers_stale_running_task() { .recv_timeout(Duration::from_secs(2)) .expect("recovered plan request"); assert!(plan_request.contains("恢复上一进程遗留任务")); - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(runtime.status, "idle"); - assert_eq!(runtime.phase, "completed"); + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + "design-recover-run", + "idle", + "completed", + ); + assert_eq!(runtime.state.status, "idle"); + assert_eq!(runtime.state.phase, "completed"); assert_eq!( - runtime.last_response.as_deref(), + runtime.state.last_response.as_deref(), Some("已恢复并完成上一进程遗留的后台任务。") ); let runtime_result = @@ -1787,10 +1812,16 @@ async fn background_agent_runtime_repairs_terminal_receipt_through_reconciliatio .recv_timeout(Duration::from_secs(5)) .expect("replan after receipt repair"); assert!(request.contains("已读取项目文件摘要,恢复时不得重放")); - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(runtime.phase, "completed"); + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + &state.run_id, + "idle", + "completed", + ); assert_eq!( runtime + .state .recent_tool_calls .iter() .filter(|record| record.action_id.as_deref() == Some(pending.action_id.as_str())) @@ -2059,10 +2090,15 @@ async fn background_agent_runtime_resume_commands_distinguish_auto_and_confirmed confirm_resume_game_creator_agent_runtime_tasks(root.to_string_lossy().into_owned()) .expect("explicit confirmation may resume under confirm policy"); assert_eq!(resumed.len(), 1); - let completed = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(completed.run_id, "design-confirm-run"); + let completed = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + "design-confirm-run", + "idle", + "completed", + ); assert_eq!( - completed.last_response.as_deref(), + completed.state.last_response.as_deref(), Some("已确认恢复默认策略下的后台任务。") ); @@ -2231,9 +2267,15 @@ async fn background_agent_runtime_resumes_observed_auto_action_without_reexecuti .expect("replan from durable observation"); assert!(replan_request.contains("已写入 Agent 记忆 design-director")); assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + "design-auto-observed-recovery-run", + "idle", + "completed", + ); assert_eq!( - runtime.last_response.as_deref(), + runtime.state.last_response.as_deref(), Some("已从观察继续,没有重放工具。") ); let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); 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 d44047373..f7b91b02b 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 @@ -1338,6 +1338,7 @@ async fn role_agent_legacy_alias_maps_to_canonical_task_runtime_and_route() { assert_eq!(alias_read.task_queue.running, 1); assert!(alias_read .session_path + .replace('\\', "/") .ends_with(".agent/runtime/agents/art-asset-plan.json")); let runtimes = read_game_creator_agent_runtimes_at(&root).expect("read all runtimes"); assert!(runtimes 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 4d013debf..e6908bf47 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 @@ -147,12 +147,23 @@ fn runtime_events_expose_stable_ids_and_backend_owned_public_text_only() { "public-event-action-1", ) .expect("repeat public action idempotently"); + append_game_creator_agent_runtime_action_event( + &root, + &state, + "turn.failed", + "failed", + "failed", + "Agent Runtime 本轮处理失败。", + Some("kind=codex-app-server-context-window-exceeded fingerprint=private"), + "public-event-failure-1", + ) + .expect("append classified public failure"); let events = read_recent_game_creator_agent_runtime_events( &game_creator_agent_runtime_event_path(&root, "code-prototype"), ) .expect("read public runtime events"); - assert_eq!(events.len(), 4); + assert_eq!(events.len(), 5); assert!(events.iter().all(|event| !event.event_id.trim().is_empty())); let mut event_ids = events .iter() @@ -176,6 +187,16 @@ fn runtime_events_expose_stable_ids_and_backend_owned_public_text_only() { .as_deref() .unwrap_or_default() .contains("raw tool input must stay private")); + assert_eq!( + events[4].public_text.as_deref(), + Some("专业 Agent 模型上下文已超限,请缩小任务范围后重试") + ); + assert_eq!(events[4].detail, events[4].public_text); + assert!(!events[4] + .public_text + .as_deref() + .unwrap_or_default() + .contains("fingerprint")); fs::remove_dir_all(root).ok(); } @@ -2572,7 +2593,7 @@ async fn runtime_v11_closure_repository_context_drift_replans_before_auto_mutati } assert_eq!( fs::read_to_string(root.join("AGENTS.md")).expect("read drifted rules"), - "drifted rules\n" + "drifted rules\\n" ); fs::remove_dir_all(root).ok(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs index 27936aee5..2b6dc6663 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs @@ -58,9 +58,11 @@ async fn role_agent_runtime_turn_persists_session_events_and_index() { read_game_creator_agent_runtime_at(&root, "art-director").expect("read runtime state"); assert!(result .session_path + .replace('\\', "/") .ends_with(".agent/runtime/agents/art-director.json")); assert!(result .event_path + .replace('\\', "/") .ends_with(".agent/runtime/events/art-director.jsonl")); assert_eq!( result.state.last_response.as_deref(), @@ -836,13 +838,19 @@ fn local_agent_memory_reads_private_memory_by_task_id() { let read = read_local_agent_memory_at(&root, "design-director").expect("read agent memory"); assert_eq!(read.task_id, "design-director"); - assert!(read.path.ends_with("memory/agents/design/director.md")); + assert!(read + .path + .replace('\\', "/") + .ends_with("memory/agents/design/director.md")); assert_eq!(read.content, "# 策划 Director 私有记忆\n"); assert!(read.exists); let missing = read_local_agent_memory_at(&root, "art-asset-plan").expect("read missing memory"); assert_eq!(missing.task_id, "art-asset-plan"); - assert!(missing.path.ends_with("memory/agents/art/asset.md")); + assert!(missing + .path + .replace('\\', "/") + .ends_with("memory/agents/art/asset.md")); assert!(!missing.exists); fs::remove_dir_all(root).ok(); @@ -859,7 +867,10 @@ fn local_agent_memory_writes_private_memory_by_task_id() { ) .expect("write agent memory"); assert_eq!(written.task_id, "design-director"); - assert!(written.path.ends_with("memory/agents/design/director.md")); + assert!(written + .path + .replace('\\', "/") + .ends_with("memory/agents/design/director.md")); assert!(written.exists); assert_eq!( written.content, @@ -970,7 +981,10 @@ fn local_conversation_can_read_and_append_project_and_agent_messages() { }, ) .expect("append project conversation"); - assert!(project.path.ends_with(".agent/conversations/project.jsonl")); + assert!(project + .path + .replace('\\', "/") + .ends_with(".agent/conversations/project.jsonl")); assert_eq!(project.agent_id, None); assert_eq!(project.messages[0].content, "做一个像素动作游戏"); @@ -986,6 +1000,7 @@ fn local_conversation_can_read_and_append_project_and_agent_messages() { .expect("append agent conversation"); assert!(agent .path + .replace('\\', "/") .ends_with(".agent/conversations/agents/design-director.jsonl")); assert_eq!(agent.agent_id.as_deref(), Some("design-director")); assert_eq!( @@ -1229,10 +1244,12 @@ fn agent_conversation_sessions_preserve_legacy_and_isolate_new_history() { assert_eq!(legacy_history.messages[0].content, "legacy history"); assert_eq!(new_history.messages.len(), 1); assert_eq!(new_history.messages[0].content, "new session only"); - assert!(legacy_history.path.ends_with("design-director.jsonl")); - assert!(new_history - .path - .ends_with(&format!("design-director/sessions/{new_session_id}.jsonl"))); + let legacy_history_path = legacy_history.path.replace('\\', "/"); + let new_history_path = new_history.path.replace('\\', "/"); + assert!(legacy_history_path.ends_with("design-director.jsonl")); + assert!(new_history_path.ends_with(&format!( + "design-director/sessions/{new_session_id}.jsonl" + ))); let legacy_context = render_local_conversation_prompt_context_for_session( &root, Some("design-director"), 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 b7cef4e2d..de3454007 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 @@ -906,6 +906,11 @@ pub(super) fn write_ledger_at_windows( temporary_file .sync_all() .map_err(|error| format!("同步 Windows tool-plan 成功响应交接账本失败:{error}"))?; + // The temporary file was opened exclusively so recovery can distinguish an active + // atomic write from a stale temp file. After the rename it is already the primary + // ledger, therefore retaining that exclusive handle makes a fully committed ledger + // briefly unreadable to another runtime thread on Windows. + drop(temporary_file); storage.verify()?; Ok(()) } @@ -1012,11 +1017,19 @@ pub(super) fn list_at_windows( "tool-plan 成功响应交接临时文件 run hash 无效:{file_name}" )); } - remove_windows_tool_plan_file_at( + // A concurrent writer owns atomic temp files exclusively. Seeing one + // during a recovery scan is normal: leave it alone and let that writer + // rename it, while still cleaning stale temps that can be acquired. + let cleanup = remove_windows_tool_plan_file_at( &agent_directory, &file_name, "tool-plan 成功响应交接原子临时文件", - )?; + ); + if let Err(error) = cleanup { + if !error.contains("仍由活跃写入句柄持有") { + return Err(error); + } + } } None => { return Err(format!( 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 c2162ab9e..a8eb1ab98 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 @@ -770,7 +770,11 @@ fn tool_plan_handoff_reports_file_uri_and_flattened_path_shapes() { "newText": "new", }), "#/path", - "exact-absolute", + if cfg!(windows) { + "exact-platform-absolute" + } else { + "exact-absolute" + }, ), ( serde_json::json!({ @@ -778,7 +782,11 @@ fn tool_plan_handoff_reports_file_uri_and_flattened_path_shapes() { "opaqueProviderField": "/tmp/private.html", }), "#/field", - "exact-absolute", + if cfg!(windows) { + "exact-platform-absolute" + } else { + "exact-absolute" + }, ), ( serde_json::json!({ @@ -786,7 +794,11 @@ fn tool_plan_handoff_reports_file_uri_and_flattened_path_shapes() { "12345678901234567890": "/tmp/private.html", }), "#/field", - "exact-absolute", + if cfg!(windows) { + "exact-platform-absolute" + } else { + "exact-absolute" + }, ), ] .into_iter() @@ -2017,10 +2029,11 @@ fn tool_plan_handoff_list_preserves_exclusively_open_windows_temp_file() { let temp_path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id) .with_file_name(&temp_name); - let error = list_at(project.path()).expect_err("exclusive temp must keep recovery busy"); + let ledgers = list_at(project.path()).expect("active exclusive temp must not block recovery"); + assert_eq!(ledgers.len(), 1); assert!( temp_path.exists(), - "active Windows temp must remain: {error}" + "active Windows temp must remain while its writer is alive" ); drop(temp_file); diff --git a/apps/ai-game-creator-shell/src-tauri/src/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/user_input.rs index da9959786..9e94618a5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/user_input.rs @@ -611,6 +611,7 @@ fn build_user_input_observation( "questionCount": record.question_count, "answerCount": record.answer_count, "answerChars": record.answer_chars, + "questionsSha256": record.questions_sha256, "answersSha256": answers_sha256, })) .map_err(|error| format!("序列化用户输入 observation 失败:{error}"))?; @@ -775,9 +776,38 @@ fn finish_prepared_user_input_answer( record.answered_at = Some(now); record.updated_at = now; write_user_input_record(root, pending, &record)?; + bind_user_input_record_to_static_delegate_at(root, pending, &record)?; Ok(record) } +fn bind_user_input_record_to_static_delegate_at( + root: &Path, + pending: &AgentRuntimePendingToolAction, + record: &AgentRuntimeUserInputRecord, +) -> Result<(), String> { + let Some(delegation_id) = pending + .task + .strip_prefix("子 Agent 需要用户澄清后才能继续。delegationId=") + .and_then(|value| value.split(';').next()) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(()); + }; + bind_static_delegate_clarification_answer_at( + root, + &pending.agent_id, + &pending.run_id, + delegation_id, + &record.request_id, + &record.questions_sha256, + record + .answers_sha256 + .as_deref() + .ok_or_else(|| "用户输入请求 answered 状态缺少答案指纹".to_string())?, + ) +} + pub(crate) fn prepare_game_creator_agent_user_input_request_at( root: &Path, pending: &AgentRuntimePendingToolAction, @@ -805,6 +835,7 @@ pub(crate) fn prepare_game_creator_agent_user_input_request_at( if record.observation.as_ref() != Some(&observation) { return Err("用户输入请求 observation 重算冲突".to_string()); } + bind_user_input_record_to_static_delegate_at(root, pending, &record)?; Ok(AgentRuntimeUserInputRecovery::Answered { request: user_input_record_view(&record), observation, @@ -895,6 +926,7 @@ pub(crate) fn answer_game_creator_agent_user_input_request_for_pending_at( if record.observation.as_ref() != Some(&observation) { return Err("用户输入请求 answered observation 冲突".to_string()); } + bind_user_input_record_to_static_delegate_at(root, pending, &record)?; Ok((user_input_record_view(&record), observation)) } diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 875d09ec6..2dc8e129b 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -11187,7 +11187,6 @@ export function App({ {runtimeConfigOpen ? ( setRuntimeConfigOpen(false)} onLog={(entry) => setCommandLog((current) => [...current, entry])} /> diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 6cf04d7c7..d97a3ca63 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -846,6 +846,7 @@ export interface AgentStatusCard { runtimeWaitingOn: string | null; runtimeNextStep: string | null; runtimeTask: string | null; + runtimeError?: string | null; runtimeRunId: string | null; runtimeLoopIteration: number | null; runtimeMaxLoopIterations: number | null; 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 1135bc779..88d45c55c 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 @@ -703,8 +703,16 @@ export function agentRuntimeStartedRunId( export function isAgentRuntimeTerminalState(runtime: AgentRuntimeState) { return ( - ['completed', 'failed', 'cancelled'].includes(runtime.phase) || - ['completed', 'failed', 'cancelled', 'idle'].includes(runtime.status) + ['completed', 'failed', 'cancelled', 'needs-reconciliation'].includes( + runtime.phase, + ) || + [ + 'completed', + 'failed', + 'cancelled', + 'idle', + 'needs-reconciliation', + ].includes(runtime.status) ); } @@ -973,6 +981,12 @@ function agentRuntimeProviderRetryStatus(runtime: AgentRuntimeState) { export function agentRuntimeConversationStatus(runtime: AgentRuntimeState) { if (isAgentRuntimeTerminalState(runtime)) { + if ( + runtime.status === 'needs-reconciliation' || + runtime.phase === 'needs-reconciliation' + ) { + return 'Agent 运行状态需要核对,正在同步记录'; + } if (runtime.status === 'failed' || runtime.phase === 'failed') { return 'Agent 运行失败,正在同步错误记录'; } @@ -1017,6 +1031,14 @@ export function projectSupervisorChatRuntimeStatus(runtime: AgentRuntimeState) { if (runtime.status === 'idle' || runtime.phase === 'idle') { return '等待输入'; } + if ( + runtime.status === 'needs-reconciliation' || + runtime.phase === 'needs-reconciliation' + ) { + return runtime.error + ? projectRuntimeVisibleError(runtime.error, '项目总控 Agent', true) + : '项目总控 Agent 运行状态需要核对,请打开运行详情后重试'; + } if (isAgentRuntimeTerminalState(runtime)) { if (runtime.status === 'failed' || runtime.phase === 'failed') { return runtime.error @@ -1037,15 +1059,13 @@ export function formatAgentRuntimeEvent(event: AgentRuntimeEventRecord) { 'turn.failed', 'turn.budget_exhausted', ].includes(event.eventType); - const containsInternalFailureDiagnostics = Boolean( - event.detail && - /(?:errorSha256|errorChars|fingerprint|chars|retryAttempt|retryState)=|<(?:absolute-path|redacted-url)>|\[redacted(?:[- ]secret| sensitive context)\]/i.test( - event.detail, - ), - ); - const visibleDetail = - isFailureEvent && containsInternalFailureDiagnostics ? null : event.detail; + const visibleDetail = isFailureEvent ? null : event.detail; + const publicFailureText = + isFailureEvent && typeof event.publicText === 'string' + ? event.publicText.trim() + : ''; const summary = + publicFailureText || event.summary || visibleDetail || (isFailureEvent ? 'Agent Runtime 本轮处理失败。' : event.runId); @@ -1365,11 +1385,16 @@ export function projectSupervisorRuntimeStatusLabel( runtime: AgentRuntimeState | null, runtimeError: string, ) { + if ( + runtime?.status === 'needs-reconciliation' || + runtime?.phase === 'needs-reconciliation' + ) { + return '待核对'; + } if ( runtimeError || runtime?.status === 'failed' || - runtime?.phase === 'failed' || - runtime?.phase === 'needs-reconciliation' + runtime?.phase === 'failed' ) { return '失败'; } @@ -1627,6 +1652,27 @@ export function projectRuntimeVisibleError( if (isMudPointInsufficientRuntimeError(message)) { return MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE; } + const codexAppServerKind = visibleMessage.match( + /(?:^|[\s::])kind=codex-app-server-([a-z-]+)(?=\s|$)/, + )?.[1]; + if (codexAppServerKind) { + const detail = { + 'context-window-exceeded': '模型上下文已超限,请缩小任务范围后重试', + 'session-budget-exceeded': '本次会话预算已耗尽,请缩小任务范围或新建任务', + 'usage-limit-exceeded': 'Codex 用量已达上限,请检查账户额度后重试', + unauthorized: 'Codex 鉴权失败,请重新登录或检查 API Key', + 'bad-request': 'Codex 请求无效,请检查模型与运行时配置', + 'cyber-policy': 'Codex 安全策略拒绝了本次请求,请调整任务内容', + 'sandbox-error': 'Codex 隔离环境启动失败,请重试或检查本机环境', + 'thread-rollback-failed': 'Codex 会话恢复失败,请新建任务后重试', + 'active-turn-not-steerable': + '当前 Codex 任务无法追加指令,请等待结束后重试', + other: 'Codex 执行失败,请查看运行详情后重试', + }[codexAppServerKind]; + if (detail) { + return `${subject} ${detail}`; + } + } const exhaustedUpstreamRetry = visibleMessage.match( /(?:^|[\s::])kind=upstream-(\d{3}) httpStatus=(\d{3}) fingerprint=[0-9a-f]{64} chars=\d+ retryAttempt=(\d+) maxRetries=(\d+) retryState=exhausted\s*$/, ); @@ -1684,6 +1730,54 @@ export function projectRuntimeVisibleError( ) { return `${subject} 服务繁忙,请稍后重试`; } + if ( + normalized.includes('needs-reconciliation') || + normalized.includes('result-unknown') || + normalized.includes('终态未知') || + normalized.includes('需要人工核对') + ) { + return `${subject} 运行状态需要核对,请打开运行详情后重试`; + } + if ( + normalized.includes('budget-exhausted') || + normalized.includes('预算耗尽') || + normalized.includes('预算已耗尽') + ) { + return `${subject} 本轮预算已耗尽,请缩小任务范围后重试`; + } + if ( + normalized.includes('missing expected artifact') || + normalized.includes('expected artifact') || + normalized.includes('缺少预期产物') || + normalized.includes('缺少 expected artifact') + ) { + return `${subject} 未生成要求的产物,请查看任务要求后重试`; + } + if ( + normalized.includes('verification') || + normalized.includes('project.verify') || + normalized.includes('preview.validate') || + normalized.includes('验证未通过') || + normalized.includes('验证失败') + ) { + return `${subject} 项目验证未通过,请查看运行详情并修复后重试`; + } + if ( + normalized.includes('policy') || + normalized.includes('permission') || + normalized.includes('拒绝') || + normalized.includes('禁止') || + normalized.includes('不允许') + ) { + return `${subject} 被项目权限或安全策略阻止,请检查审批配置`; + } + if ( + normalized.includes('落盘失败') || + normalized.includes('持久化失败') || + normalized.includes('写入失败') + ) { + return `${subject} 保存运行记录失败,请检查项目目录后重试`; + } const containsInternalDiagnostics = normalized.includes('agentllm.') || /(?:^|[\s::])kind=/.test(normalized) || @@ -1711,7 +1805,8 @@ export function projectRuntimeVisibleError( export function projectSupervisorVisibleConversationText( message: string, - role: ChatMessage['role'] = 'assistant', + role: ChatMessage['role'] | 'tool' = 'assistant', + subject = '项目总控 Agent', ) { const failurePrefix = '后台任务失败:'; if (role !== 'assistant' || !message.startsWith(failurePrefix)) { @@ -1719,7 +1814,7 @@ export function projectSupervisorVisibleConversationText( } return projectRuntimeVisibleError( message.slice(failurePrefix.length), - '项目总控 Agent', + subject, true, ); } @@ -1834,9 +1929,22 @@ export function formatAgentRecentRuntimeTask(task: AgentRuntimeTaskRecord) { const goalSource = task.goalId ? `Goal ${task.goalStatus ?? '-'} · revision ${task.goalRevision ?? 0}` : null; + const failed = + task.status === 'failed' || + ['failed', 'budget-exhausted', 'needs-reconciliation'].includes(task.phase); + const failureDetail = failed + ? task.terminalDetail?.trim() || task.error?.trim() || null + : null; + const failureSummary = failureDetail + ? projectRuntimeVisibleError( + failureDetail, + projectProfessionalAgentLabel(task.agentId), + true, + ) + : null; return `${task.status} / ${task.phase} · ${ task.task || task.currentAction || task.runId }${goalSource ? ` · ${goalSource}` : ''}${ delegationSource ? ` · ${delegationSource}` : '' - }`; + }${failureSummary ? ` · 失败原因:${failureSummary}` : ''}`; } diff --git a/apps/ai-game-creator-shell/src/features/project-summary/agentPresentation.ts b/apps/ai-game-creator-shell/src/features/project-summary/agentPresentation.ts index 88f6c7361..ad4cb34df 100644 --- a/apps/ai-game-creator-shell/src/features/project-summary/agentPresentation.ts +++ b/apps/ai-game-creator-shell/src/features/project-summary/agentPresentation.ts @@ -37,7 +37,9 @@ import { formatAgentRuntimePlanStep, formatAgentRuntimeTaskQueue, isAgentRuntimeTerminalState, + projectProfessionalAgentLabel, projectRuntimeVisibleCurrentWork, + projectRuntimeVisibleError, taskRowsFromManifest, } from '../agent-runtime'; import { @@ -153,6 +155,26 @@ export function taskRowsForAgentStatus( return mergedTasks; } +function agentRuntimeFailureDetail(runtime: AgentRuntimeState) { + const runtimeError = runtime.error?.trim(); + if (runtimeError) { + return runtimeError; + } + const terminalTask = [...(runtime.recentTasks ?? [])] + .reverse() + .find( + (task) => + task.runId === runtime.runId && + (task.status === 'failed' || + ['failed', 'budget-exhausted', 'needs-reconciliation'].includes( + task.phase, + )), + ); + return ( + terminalTask?.terminalDetail?.trim() || terminalTask?.error?.trim() || null + ); +} + export function deriveAgentStatusCards( nextManifest: GameCreationAppManifest, trace: GameCreationAgentRunTrace | null, @@ -204,6 +226,7 @@ export function deriveAgentStatusCards( ? (runtime.nextStep ?? agentRuntimeNextStepFromPhase(runtime.phase)) : null, runtimeTask: runtime?.currentTask ?? null, + runtimeError: runtime ? agentRuntimeFailureDetail(runtime) : null, runtimeRunId: runtime?.runId ?? null, runtimeLoopIteration: runtime?.loopIteration ?? null, runtimeMaxLoopIterations: runtime?.maxLoopIterations ?? null, @@ -247,7 +270,12 @@ export function projectAgentRuntimeSummaries( ) { return 4; } - if (runtime.status === 'failed' || runtime.phase === 'failed') { + if ( + runtime.status === 'failed' || + runtime.phase === 'failed' || + runtime.status === 'needs-reconciliation' || + runtime.phase === 'needs-reconciliation' + ) { return 3; } if (!isAgentRuntimeTerminalState(runtime)) { @@ -296,6 +324,9 @@ export function projectAgentRuntimeSummaries( runtime.phase === 'waiting-for-confirmation', ); const failed = runtime.status === 'failed' || runtime.phase === 'failed'; + const needsReconciliation = + runtime.status === 'needs-reconciliation' || + runtime.phase === 'needs-reconciliation'; const completed = runtime.status === 'completed' || runtime.phase === 'completed'; const cancelled = @@ -304,7 +335,7 @@ export function projectAgentRuntimeSummaries( const status: ProjectAgentRuntimeSummary['status'] = waitingForInput || waitingForConfirmation ? 'waiting' - : failed + : failed || needsReconciliation ? 'failed' : completed ? 'completed' @@ -322,17 +353,28 @@ export function projectAgentRuntimeSummaries( ? '待回答' : waitingForConfirmation ? '待确认' - : failed - ? '失败' - : completed - ? '已完成' - : cancelled - ? '已取消' - : idle - ? '等待中' - : runtime.phase === 'planning' - ? '分析中' - : '工作中'; + : needsReconciliation + ? '待核对' + : failed + ? '失败' + : completed + ? '已完成' + : cancelled + ? '已取消' + : idle + ? '等待中' + : runtime.phase === 'planning' + ? '分析中' + : '工作中'; + const failureDetail = agentRuntimeFailureDetail(runtime); + const failureSummary = + (failed || needsReconciliation) && failureDetail + ? projectRuntimeVisibleError( + failureDetail, + projectProfessionalAgentLabel(runtime.agentId), + true, + ) + : null; return [ { @@ -340,6 +382,7 @@ export function projectAgentRuntimeSummaries( label, status, statusLabel, + failureSummary, currentTask: (activePlanStep && agentRuntimePlanStepText(activePlanStep)) || projectRuntimeVisibleCurrentWork(runtime), @@ -387,6 +430,7 @@ export function sameAgentStatusCard( left.runtimeWaitingOn === right.runtimeWaitingOn && left.runtimeNextStep === right.runtimeNextStep && left.runtimeTask === right.runtimeTask && + left.runtimeError === right.runtimeError && left.runtimeRunId === right.runtimeRunId && left.runtimeLoopIteration === right.runtimeLoopIteration && left.runtimeMaxLoopIterations === right.runtimeMaxLoopIterations && @@ -455,6 +499,7 @@ export function sameAgentRuntimeTasks( task.task === other.task && task.currentAction === other.currentAction && task.terminalDetail === other.terminalDetail && + task.error === other.error && task.updatedAt === other.updatedAt ); }) @@ -901,8 +946,22 @@ export function formatAgentCardRuntimeStatus(agent: AgentStatusCard) { agent.runtimeMaxLoopIterations ?? 3 }` : null; + const needsAttention = + agent.runtimeStatus === 'failed' || + agent.runtimePhase === 'failed' || + agent.runtimeStatus === 'needs-reconciliation' || + agent.runtimePhase === 'needs-reconciliation'; + const failureSummary = + needsAttention && agent.runtimeError + ? projectRuntimeVisibleError( + agent.runtimeError, + projectProfessionalAgentLabel(agent.id), + true, + ) + : null; return [ `Runtime:${agent.runtimeStatus} / ${agent.runtimePhase ?? '-'}`, + failureSummary, loopProgress, agent.runtimeAction, agent.runtimeWaitingOn ? `等待 ${agent.runtimeWaitingOn}` : null, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/AgentConversationOverlay.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/AgentConversationOverlay.tsx index d823404ca..5b6db7c21 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/AgentConversationOverlay.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/AgentConversationOverlay.tsx @@ -16,7 +16,11 @@ import type { AgentStatusCard, LocalConversationMessageRecord, } from '../../app/types'; -import { AgentRuntimeStatusPanel } from '../agent-runtime'; +import { + AgentRuntimeStatusPanel, + projectProfessionalAgentLabel, + projectSupervisorVisibleConversationText, +} from '../agent-runtime'; import { commandDraftFromSuggestedToolCall } from '../project-summary/agentPresentation'; import { agentTaskGraphStateLabels, @@ -268,7 +272,11 @@ export function AgentConversationOverlay({ key={`${message.updatedAt}-${index}`} className={`message message--${message.role}`} > - {message.content} + {projectSupervisorVisibleConversationText( + message.content, + message.role, + projectProfessionalAgentLabel(selectedAgent.id), + )}

))} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 7cc9d262d..220a31edf 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -13,7 +13,10 @@ import type { PendingUiConfirmation, } from '../../app/types'; import { + projectProfessionalAgentLabel, + projectRuntimeVisibleError, ProjectSupervisorRuntimePanel, + projectSupervisorVisibleConversationText, projectWorkspaceStatusForDisplay, } from '../agent-runtime'; import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation'; @@ -77,7 +80,10 @@ export function ProjectSupervisorView({ key={message.messageId ?? `${message.role}-${index}`} className={`message message--${message.role}`} > - {message.text} + {projectSupervisorVisibleConversationText( + message.text, + message.role, + )}

))} {transientReply ? ( @@ -151,6 +157,15 @@ export function ProjectSupervisorView({ taskStatusLabels[agent.status]} {agent.runtimeTask ? {agent.runtimeTask} : null} + {agent.runtimeError ? ( + + {projectRuntimeVisibleError( + agent.runtimeError, + projectProfessionalAgentLabel(agent.id), + true, + )} + + ) : null} ))} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx index 0057e4c6a..141a298e4 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx @@ -303,7 +303,23 @@ export function formatGameChatStageRecord( ) { const terminalStatus = runtime.status === 'failed' || runtime.phase === 'failed' - ? progress.interruptionText || '本轮失败' + ? progress.interruptionText || + (runtime.error + ? projectRuntimeVisibleError( + runtime.error, + '项目总控 Agent', + true, + ) + : '本轮失败') + : runtime.status === 'needs-reconciliation' || + runtime.phase === 'needs-reconciliation' + ? runtime.error + ? projectRuntimeVisibleError( + runtime.error, + '项目总控 Agent', + true, + ) + : '项目总控 Agent 运行状态需要核对,请打开运行详情后重试' : runtime.status === 'cancelled' || runtime.phase === 'cancelled' ? '本轮已取消' : runtime.status === 'completed' || runtime.phase === 'completed' @@ -971,7 +987,10 @@ export function SupervisorChatOnlyView({ const running = Boolean( chatAgentBusy || synchronizingAcceptedRun || - (runtime && !isAgentRuntimeTerminalState(runtime)), + (runtime && + runtime.status !== 'needs-reconciliation' && + runtime.phase !== 'needs-reconciliation' && + !isAgentRuntimeTerminalState(runtime)), ); const gameChatInterruptionText = gameChatMode && runtime @@ -1099,6 +1118,12 @@ export function SupervisorChatOnlyView({ if (runtimeError) { return '运行异常'; } + if ( + runtime?.status === 'needs-reconciliation' || + runtime?.phase === 'needs-reconciliation' + ) { + return '待人工核对'; + } if (synchronizingAcceptedRun) { return '正在启动'; } diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index ce52e5f90..df6e2469e 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -1,5 +1,19 @@ -import { Plus, Trash2, Zap } from 'lucide-react'; -import { type FormEvent, Fragment, useEffect, useRef, useState } from 'react'; +import { + Bot, + Cable, + CheckCircle2, + CircleAlert, + LoaderCircle, + Plus, + RotateCcw, + Save, + Settings2, + SlidersHorizontal, + Trash2, + X, + Zap, +} from 'lucide-react'; +import { type FormEvent, useEffect, useRef, useState } from 'react'; import { createGameCreationAppSeedTasks } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { @@ -82,6 +96,40 @@ const runtimeAgentModes = new Set([ 'provider', ]); +type RuntimeSettingsSection = 'general' | 'agents' | 'connections' | 'advanced'; + +const runtimeSettingsSections = [ + { + id: 'general', + label: '常用设置', + description: '运行方式与默认模型', + icon: Settings2, + }, + { + id: 'agents', + label: 'Agent 模型', + description: '按角色覆盖默认模型', + icon: Bot, + }, + { + id: 'connections', + label: '连接与工具', + description: 'MCP 与外部服务', + icon: Cable, + }, + { + id: 'advanced', + label: '高级参数', + description: '上下文、超时与重试', + icon: SlidersHorizontal, + }, +] as const satisfies ReadonlyArray<{ + id: RuntimeSettingsSection; + label: string; + description: string; + icon: typeof Settings2; +}>; + const defaultRuntimeMcpServerConfig: GameCreatorMcpServerConfig = { enabled: true, required: false, @@ -522,12 +570,10 @@ function normalizeRuntimeConfigDraft( export function RuntimeConfigDialog({ projectPath, - showDeveloperEditorApi = false, onClose, onLog, }: { projectPath?: string; - showDeveloperEditorApi?: boolean; onClose: () => void; onLog?: (entry: string) => void; }) { @@ -536,6 +582,9 @@ export function RuntimeConfigDialog({ const [runtimeConfigDraft, setRuntimeConfigDraft] = useState(defaultRuntimeConfigDraft); const [runtimeConfigBusy, setRuntimeConfigBusy] = useState(false); + const [activeSection, setActiveSection] = + useState('general'); + const [expandedAgentIds, setExpandedAgentIds] = useState([]); const [newMcpServerId, setNewMcpServerId] = useState(''); const [mcpStructuredDrafts, setMcpStructuredDrafts] = useState< Record @@ -860,6 +909,24 @@ export function RuntimeConfigDialog({ } } + const selectedSection = + runtimeSettingsSections.find((section) => section.id === activeSection) ?? + runtimeSettingsSections[0]; + const configuredAgentCount = Object.values( + runtimeConfigDraft.agentLlm ?? {}, + ).filter((config) => + Object.values(config).some((value) => value !== undefined && value !== ''), + ).length; + const runtimeConfigStatusTone = runtimeConfigBusy + ? 'busy' + : /^(已保存|已读取|已恢复默认|已添加|已移除|MCP 已连接)/.test( + runtimeConfigStatus, + ) + ? 'success' + : runtimeConfigStatus === '未读取' + ? 'neutral' + : 'warning'; + return (
closeDialogOnBackdropMouseDown(event, onClose)} >
closeDialogOnEscape(event, onClose)} > -
-

运行时配置

-
+
+
+ AI GAME CREATOR +

Agent 设置

+
+ +
+
+ +
+
+
+

{selectedSection.label}

+

{selectedSection.description}

+
+ {activeSection === 'agents' ? ( + {configuredAgentCount} 个角色已覆盖 + ) : activeSection === 'connections' ? ( + + {Object.keys(runtimeConfigDraft.mcpServers).length} 个 MCP + + ) : null} +
+
+ {activeSection === 'general' ? ( + <> + + {runtimeConfigDraft.agentMode !== 'codex_cli' ? ( + <> + + + + + + + + + + ) : null} + + ) : null} + {activeSection === 'advanced' && + runtimeConfigDraft.agentMode !== 'codex_cli' ? ( + <> + + + + + + + + ) : null} + {activeSection === 'agents' && + runtimeConfigDraft.agentMode !== 'codex_cli' ? ( +
+ {runtimeAgentLlmRows.map((agent) => { + const agentLlm = + runtimeConfigDraft.agentLlm?.[agent.id] ?? {}; + const defaultReasoningEffort = + runtimeAgentReasoningEffortDefaults[ + agent.id as keyof typeof runtimeAgentReasoningEffortDefaults + ]; + return ( +
+ + {expandedAgentIds.includes(agent.id) ? ( +
+ + + + + + + + + + + +
+ ) : null} +
+ ); + })} +
+ ) : null} + {activeSection === 'connections' ? ( + <> + + + + ) : null} + {activeSection === 'connections' ? ( +
+
+
+

MCP servers

+ {`${Object.keys(runtimeConfigDraft.mcpServers).length} 个配置`} +
+ +
+
+ + +
+ {Object.entries(runtimeConfigDraft.mcpServers).length === + 0 ? ( +

尚未配置 MCP server

+ ) : ( +
+ {Object.entries(runtimeConfigDraft.mcpServers).map( + ([serverId, server]) => { + const structuredDraft = + mcpStructuredDrafts[serverId] ?? + runtimeMcpStructuredDraft(server); + const serverStatus = mcpCatalog?.servers.find( + (candidate) => candidate.serverId === serverId, + ); + return ( +
+
+
+ {serverId} + + {!server.enabled + ? '已停用' + : serverStatus + ? serverStatus.connected + ? `已连接 · ${serverStatus.toolCount} 个工具` + : '连接失败' + : server.transport === 'stdio' + ? 'STDIO · 未测试' + : 'HTTP · 未测试'} + +
+ +
+
+ 配置 +
+ + + + + {server.transport === 'stdio' ? ( + <> + + +