diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index 350dca833..44eda9820 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -32,6 +32,7 @@ Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses on - Use stable references such as `objectKey`, project resource ID, or asset ID in generation requests. Use `/assets/read-url` only for temporary preview/download access. - Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either. - Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent. +- For successful `style="pixelArt"`, treat completed-result and nested resource/asset dimensions as the final logical-grid PNG dimensions. They may differ from `size`, `imageSize`, the provider image, and `canvasCompletion.placeholder`; do not rescale or reject the artifact to match those inputs. - Keep generated artifacts in the canvas and asset library together. Character animation accepts `assetFolderId` and `assetLabel`; its completed result directly returns the final `assetKind="character-animation"` resource and asset with formal sequence fields. Do not create a duplicate first-frame record. ## Documentation Navigation diff --git a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md index 03c69a94f..f855a94fa 100644 --- a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md +++ b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md @@ -96,7 +96,7 @@ A minimal `canvasCompletion` is: } ``` -`dialogId` is optional. Do not reconstruct canvas state from completion results. Reload the project and asset library when complete authoritative snapshots are needed. +`dialogId` is optional. The placeholder supplies canvas placement and completion coordinates; it is not a final media pixel-size constraint. For successful pixel-art snapping, the result layer uses the final logical-grid PNG dimensions even when they differ from the placeholder. Do not reconstruct canvas state from completion results. Reload the project and asset library when complete authoritative snapshots are needed. Character animation accepts `assetFolderId` and `assetLabel` and persists the final transparent sequence directly. Its completed compact result includes the authoritative `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`. Use those records directly and never synthesize a duplicate asset from the first frame. @@ -145,7 +145,7 @@ Carry the current art spec in `generationInputs.artSpec` and reflect important c The top-level `style` field is not the art spec's visual-style prose. It appends a short server-side clause to the prompt sent to the provider and enables deterministic post-processing: - Omitted, `null`, empty string, or `"none"`: no clause is appended and no post-processing runs, without warning. -- `"pixelArt"`: append one short pixel-art line to the end of the prompt sent to the provider, and enable pixel-art snapping, for ordinary image generation, `kind: "character"`, and icon spritesheet generation. The line is appended, not substituted — the rest of your prompt is unchanged. For the exact per-kind wording, read the `style` field description in the OpenAPI document; it is the contract, and this guide deliberately does not copy it. +- `"pixelArt"`: append one short pixel-art line to the end of the prompt sent to the provider, and enable pixel-art snapping, for ordinary image generation, `kind: "character"`, and icon spritesheet generation. On successful snapping, each detected grid cell becomes one output pixel and the logical-grid PNG is persisted directly; it is not resized back to `size`, `imageSize`, the provider image, or `canvasCompletion.placeholder`. The line is appended, not substituted — the rest of your prompt is unchanged. For the exact per-kind wording, read the `style` field description in the OpenAPI document; it is the contract, and this guide deliberately does not copy it. - Unknown strings, or `"pixelArt"` on unsupported kinds such as `spec`, `quick-edit`, `ui-design`, or `publication-material`: continue without style processing and return `warning.code: "unsupported-image-style"`. - Non-string JSON values: malformed request, HTTP `400`. @@ -196,7 +196,7 @@ Do not guess dimensions or pass a temporary signed read URL. See `authentication The completed `result` may contain stable artifact fields such as: -- `objectKey`, media type, dimensions, or task ID. +- `objectKey`, media type, dimensions, or task ID. For successful `pixelArt`, image `width`/`height`, icon `spritesheetWidth`/`spritesheetHeight`, and nested resource/asset dimensions are the actual final logical-grid PNG dimensions rather than requested, provider, or placeholder dimensions. - Sound-effect `durationSeconds` is the probed MP3 duration and `loop` is the frozen request boolean; neither is inferred from Prompt text. - `resource`, `resourceId`, or equivalent canvas reference. - `asset`, `assetId`, or equivalent library reference. @@ -209,6 +209,10 @@ It deliberately excludes a complete project/canvas/library snapshot, Data URL, B Interpret warnings only after the query reaches `status=completed`. The query-level `warning` is display-ready text. Compact `result.warning` and `result.sliceWarning` preserve structured artifact semantics. +### Delivery-size normalization result before pixel-art snapping + +`result.warning.code: "dimension-restore-fallback"` records only the delivery-size normalization result established before any subsequent `pixelArt` snapping: the provider image could not be safely normalized, so its dimensions were preserved at that processing boundary. It does not describe or constrain the dimensions after `pixelArt`; if snapping succeeds, use the completed result's actual logical-grid dimensions as authoritative. + ### Source-preserved post-processing failure When `result.warning.code` is `postprocess-failed-source-preserved`: diff --git a/AGENTS.md b/AGENTS.md index 694ec26fe..f7969ee42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,12 @@ - 后续新增 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`;历史文档不要求批量重命名,除非本次任务明确涉及。 - 工程修改要同步更新对应 `docs/` 文档;产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则时,同步更新 `docs/project-memory/shared-memory/`。 - 默认保持系统简洁:优先复用、修改、扩展现有系统、页面和公共组件,不新建平行系统或平行页面。 +- 对已明确退役且不存在现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,坚持“四不写”: + 1. 不写历史兼容代码。 + 2. 不写用于维持退役行为的防御性兼容测试。 + 3. 不写仅说明其曾存在或已删除的墓碑注释。 + 4. 不写仅记录其已删除的墓碑文档;直接将权威文档更新为当前状态。 +- 公开 API、持久化数据、SpacetimeDB schema、跨版本重放、活跃实例和正式迁移不适用“四不写”;必要兼容应最小化、白名单化并配套契约或迁移测试,迁移完成后同步删除兼容实现与对应测试。 - UI 面板中不要默认写功能说明、规则描述或开发解释文案;移动端优先,同时保证网页端可正常显示和操作。 - 点击按钮弹出独立面板的设计,不要实现成在当前面板下面追加内容。 diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index 6e225ac31..ae9d1bedf 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -1,4 +1,5 @@ { + "agentMode": "codex_app_server", "llm": { "apiKey": "", "baseUrl": "https://api.openai.com/v1", diff --git a/apps/ai-game-creator-shell/package-lock.json b/apps/ai-game-creator-shell/package-lock.json index 048c92537..f0fb8cac3 100644 --- a/apps/ai-game-creator-shell/package-lock.json +++ b/apps/ai-game-creator-shell/package-lock.json @@ -11,6 +11,7 @@ "@lexical/react": "^0.47.0", "@lexical/utils": "^0.47.0", "@tauri-apps/plugin-clipboard-manager": "2.3.2", + "@tauri-apps/plugin-http": "^2.5.9", "@tauri-apps/plugin-opener": "~2", "@vitejs/plugin-react": "^5.0.4", "lexical": "^0.47.0", @@ -2208,6 +2209,15 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-http": { + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz", + "integrity": "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.5.4", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index e2982bbc3..dfb31f3c2 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -37,6 +37,7 @@ "@lexical/react": "^0.47.0", "@lexical/utils": "^0.47.0", "@tauri-apps/plugin-clipboard-manager": "2.3.2", + "@tauri-apps/plugin-http": "^2.5.9", "@tauri-apps/plugin-opener": "~2", "@vitejs/plugin-react": "^5.0.4", "lexical": "^0.47.0", diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index be97f7404..41114ddbd 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -651,10 +651,12 @@ async function runConfigWizardRegressionChecks() { apiKind: 'openai_responses', }, ); + assert.equal(updatedPrimary.agentMode, 'provider'); await writeGameCreatorWizardConfig(overlayState, updatedPrimary); const reloadedState = await readGameCreatorWizardConfigState(overlayConfigDir); assert.equal(reloadedState.effectiveConfig.llm.apiKey, 'fixture-new-key'); + assert.equal(reloadedState.effectiveConfig.agentMode, 'provider'); assert.equal(reloadedState.effectiveConfig.llm.model, 'new-model'); assert.equal( reloadedState.effectiveConfig.llm.baseUrl, @@ -1160,6 +1162,12 @@ if (defaultAppConfig.llm?.apiKey !== '') { throw new Error('AI game creator shell default llm.apiKey must stay empty'); } +if (defaultAppConfig.agentMode !== 'codex_app_server') { + throw new Error( + 'AI game creator shell default agentMode must be codex_app_server', + ); +} + const allowedLlmReasoningEfforts = new Set([ 'default', 'low', @@ -1530,7 +1538,6 @@ for (const snippet of [ '$verified = $targetItem.GetAccessControl()', '$rules.Count -ne 1', '[System.Security.AccessControl.FileSystemRights]::FullControl', - "runChildCapture('powershell.exe'", "'-NoProfile'", "'-Command'", 'windowsPrivateAclScript', @@ -1543,6 +1550,11 @@ for (const snippet of [ ); } } +if (!/runChildCapture\(\s*['"]powershell\.exe['"]/u.test(configWizardSource)) { + throw new Error( + 'AI game creator config wizard guardrail drifted: runChildCapture(powershell.exe)', + ); +} if (/\bGet-Acl\b/u.test(configWizardSource)) { throw new Error( 'AI game creator config wizard must not rely on Get-Acl module auto-loading', diff --git a/apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs b/apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs index dee615c1e..49b12d9fa 100644 --- a/apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs +++ b/apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs @@ -211,6 +211,7 @@ export function buildGameCreatorWizardConfig(existingConfig, llmInput) { } return { ...source, + agentMode: 'provider', llm: { ...previousLlm, apiKey, @@ -290,21 +291,25 @@ export async function secureWindowsGameCreatorPathForCurrentUser( targetPath, { isDirectory }, ) { - const result = await runChildCapture('powershell.exe', [ - '-NoLogo', - '-NoProfile', - '-NonInteractive', - '-ExecutionPolicy', - 'Bypass', - '-Command', - windowsPrivateAclScript, - ], { - env: { - ...process.env, - GENARRATIVE_AGC_PRIVATE_PATH: targetPath, - GENARRATIVE_AGC_PRIVATE_IS_DIRECTORY: String(isDirectory), + const result = await runChildCapture( + 'powershell.exe', + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + windowsPrivateAclScript, + ], + { + env: { + ...process.env, + GENARRATIVE_AGC_PRIVATE_PATH: targetPath, + GENARRATIVE_AGC_PRIVATE_IS_DIRECTORY: String(isDirectory), + }, }, - }); + ); if (result.code !== 0 || result.signal) { const detail = result.stderr.trim() || result.stdout.trim(); throw new Error( diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index ec50f66c7..89a23f4f5 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -702,10 +702,39 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ + "percent-encoding", "time", "version_check", ] +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -729,7 +758,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types 0.5.0", "libc", @@ -742,7 +771,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -889,6 +918,12 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "dbus" version = "0.9.11" @@ -1018,6 +1053,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -1131,6 +1175,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -1616,6 +1669,7 @@ dependencies = [ "tauri-build", "tauri-plugin-clipboard-manager", "tauri-plugin-dialog", + "tauri-plugin-http", "tauri-plugin-opener", "tempfile", "tokio", @@ -1835,6 +1889,25 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -1955,6 +2028,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1979,6 +2053,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2015,9 +2090,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2488,6 +2565,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -3734,6 +3817,22 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "pxfm" version = "0.1.30" @@ -3987,8 +4086,12 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "cookie", + "cookie_store", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", @@ -3998,6 +4101,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime", "native-tls", "percent-encoding", "pin-project-lite", @@ -4021,6 +4125,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams 0.4.2", "web-sys", + "webpki-roots", ] [[package]] @@ -4291,7 +4396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.13.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4847,6 +4952,27 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -4868,7 +4994,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.13.0", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -5103,6 +5229,30 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-http" +version = "2.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067" +dependencies = [ + "bytes", + "cookie_store", + "data-url", + "http", + "regex", + "reqwest 0.12.28", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "tokio", + "url", + "urlpattern", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -6176,6 +6326,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 59f16c386..06aad7379 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -41,6 +41,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "nat shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false } tauri = { version = "2.11.2", features = [] } tauri-plugin-dialog = "2.7.1" +tauri-plugin-http = "2.5.9" tauri-plugin-opener = "2" tempfile = "3" tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] } diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json index 7f4653d2f..50d312f47 100644 --- a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json @@ -9,6 +9,10 @@ "core:image:allow-rgba", "core:image:allow-size", "core:resources:allow-close", + { + "identifier": "http:default", + "allow": [{ "url": "https://dev.genarrative.world/api/*" }] + }, "opener:default" ] } 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 946f634ff..7354d0283 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -9,6 +9,8 @@ use std::collections::BTreeSet; use std::io::{Seek, SeekFrom}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +mod codex_app_server; +mod codex_cli; mod generation; mod interaction; mod prompt; @@ -18,6 +20,8 @@ mod runtime_driver; mod runtime_protocol; mod runtime_state; mod runtime_tools; +use codex_app_server::*; +use codex_cli::*; pub(crate) use generation::*; pub(crate) use interaction::*; pub(crate) use prompt::*; @@ -27,3 +31,7 @@ pub(crate) use runtime_driver::*; pub(crate) use runtime_protocol::*; pub(crate) use runtime_state::*; pub(crate) use runtime_tools::*; + +pub(crate) fn shutdown_game_creator_codex_app_servers() -> Result<(), String> { + shutdown_game_creator_codex_app_servers_impl() +} 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 new file mode 100644 index 000000000..eeedc2dfd --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -0,0 +1,1975 @@ +use super::*; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +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"; +const GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES: usize = 4 * 1024 * 1024; +const GAME_CREATOR_CODEX_APP_SERVER_STDERR_RECORD_MAX_BYTES: usize = 256 * 1024; +const GAME_CREATOR_CODEX_APP_SERVER_AUTH_MAX_BYTES: usize = 1024 * 1024; +const GAME_CREATOR_CODEX_APP_SERVER_BACKLOG_TURN_MAX: usize = 128; +const GAME_CREATOR_CODEX_APP_SERVER_POOL_MAX: usize = 32; +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:"; + +type RpcResult = Result; + +struct CodexPendingRpc { + sender: oneshot::Sender, + turn_start_cancellation: Option>, +} + +enum CodexAppServerCredential { + AppDataKey { + fingerprint: String, + }, + AuthBridge { + auth_json: Vec, + fingerprint: String, + }, +} + +impl CodexAppServerCredential { + fn fingerprint(&self) -> &str { + match self { + Self::AppDataKey { fingerprint } | Self::AuthBridge { fingerprint, .. } => fingerprint, + } + } + + fn uses_app_data_key(&self) -> bool { + matches!(self, Self::AppDataKey { .. }) + } +} + +struct CodexAppServerStderrSummary { + byte_len: u64, + sha256: Sha256, + current_record_bytes: usize, + classification: &'static str, +} + +impl CodexAppServerStderrSummary { + fn new() -> Self { + Self { + byte_len: 0, + sha256: Sha256::new(), + current_record_bytes: 0, + classification: "empty", + } + } + + fn observe(&mut self, bytes: &[u8]) -> bool { + self.byte_len = self.byte_len.saturating_add(bytes.len() as u64); + self.sha256.update(bytes); + if self.classification == "empty" && !bytes.is_empty() { + self.classification = "nonempty"; + } + for byte in bytes { + if *byte == b'\n' { + self.current_record_bytes = 0; + } else { + self.current_record_bytes = self.current_record_bytes.saturating_add(1); + if self.current_record_bytes > GAME_CREATOR_CODEX_APP_SERVER_STDERR_RECORD_MAX_BYTES + { + self.classification = "oversized-record"; + return true; + } + } + } + false + } + + fn mark_read_failure(&mut self) { + self.classification = "read-failed"; + } + + fn diagnostic(&self) -> String { + format!( + "stderrClass={};stderrBytes={};stderrSha256={:x}", + self.classification, + self.byte_len, + self.sha256.clone().finalize() + ) + } +} + +struct CodexTurnStartCancellation { + inner: Weak, + thread_id: String, + turn_id: std::sync::Mutex>, + cancelled: AtomicBool, + interrupt_sent: AtomicBool, +} + +impl CodexTurnStartCancellation { + fn new(inner: &Arc, thread_id: &str) -> Self { + Self { + inner: Arc::downgrade(inner), + thread_id: thread_id.to_string(), + turn_id: std::sync::Mutex::new(None), + cancelled: AtomicBool::new(false), + interrupt_sent: AtomicBool::new(false), + } + } + + fn observe_turn_id(&self, turn_id: &str) { + if let Ok(mut slot) = self.turn_id.lock() { + *slot = Some(turn_id.to_string()); + } + self.maybe_interrupt(); + } + + fn cancel(&self) { + self.cancelled.store(true, Ordering::Release); + self.maybe_interrupt(); + } + + fn maybe_interrupt(&self) { + if !self.cancelled.load(Ordering::Acquire) { + return; + } + let turn_id = self + .turn_id + .lock() + .ok() + .and_then(|slot| slot.as_ref().cloned()); + let Some(turn_id) = turn_id else { + return; + }; + if self + .interrupt_sent + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + let Some(inner) = self.inner.upgrade() else { + return; + }; + let connection = CodexAppServerConnection { inner }; + let thread_id = self.thread_id.clone(); + tokio::spawn(async move { + let _ = connection + .request( + "turn/interrupt", + serde_json::json!({ "threadId": thread_id, "turnId": turn_id }), + ) + .await; + }); + } +} + +fn game_creator_codex_app_server_terminal_unknown( + detail: impl AsRef, +) -> platform_llm::LlmError { + platform_llm::LlmError::Transport(format!( + "{GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX} {}", + detail.as_ref() + )) +} + +async fn isolate_game_creator_codex_app_server_terminal_unknown( + inner: &Arc, + detail: impl Into, +) -> platform_llm::LlmError { + let detail = detail.into(); + game_creator_codex_app_server_pool() + .lock() + .await + .retain(|_, candidate| !Arc::ptr_eq(candidate, inner)); + shutdown_game_creator_codex_app_server_inner(inner, &detail).await; + game_creator_codex_app_server_terminal_unknown(detail) +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct CodexNodeThreadKey { + project_id: String, + agent_id: String, + session_id: String, + run_id: String, +} + +impl From<&AgentRuntimeProviderRequestSnapshot> for CodexNodeThreadKey { + fn from(snapshot: &AgentRuntimeProviderRequestSnapshot) -> Self { + Self { + project_id: snapshot.project_id.clone(), + agent_id: snapshot.agent_id.clone(), + session_id: snapshot.session_id.clone(), + run_id: snapshot.run_id.clone(), + } + } +} + +#[derive(Clone, Debug)] +enum CodexTurnEvent { + AgentMessageDelta(String), + Item(serde_json::Value), + Terminal(serde_json::Value), + TransportClosed(String), +} + +struct CodexAppServerInner { + writer: Mutex, + child: Mutex>, + pending: Mutex>, + turns: Mutex>>, + turn_backlog: Mutex>>, + threads: Mutex>, + stderr_summary: Mutex, + turn_gate: Mutex<()>, + next_request_id: AtomicU64, + last_used: AtomicU64, + closed: AtomicBool, + _working_dir: tempfile::TempDir, +} + +#[derive(Clone)] +struct CodexAppServerConnection { + inner: Arc, +} + +static GAME_CREATOR_CODEX_APP_SERVER_POOL: OnceLock< + Mutex>>, +> = OnceLock::new(); +static GAME_CREATOR_CODEX_APP_SERVER_USAGE_CLOCK: AtomicU64 = AtomicU64::new(1); + +#[derive(Clone, Debug)] +struct CodexThreadEntry { + thread_id: String, + last_used: u64, + active_uses: usize, +} + +fn next_game_creator_codex_app_server_usage_tick() -> u64 { + GAME_CREATOR_CODEX_APP_SERVER_USAGE_CLOCK.fetch_add(1, Ordering::Relaxed) +} + +fn game_creator_codex_app_server_pool() -> &'static Mutex>> +{ + GAME_CREATOR_CODEX_APP_SERVER_POOL.get_or_init(|| Mutex::new(HashMap::new())) +} + +pub(in crate::agent) fn game_creator_codex_app_server_protocol_identity() -> &'static str { + GAME_CREATOR_CODEX_APP_SERVER_PROTOCOL +} + +fn game_creator_codex_app_server_pool_key( + llm: &GameCreatorLlmConfig, + codex_cli_version: &str, + snapshot: &AgentRuntimeProviderRequestSnapshot, + credential_fingerprint: &str, +) -> String { + let stable = serde_json::json!({ + "credentialFingerprint": credential_fingerprint, + "baseUrl": llm.base_url, + "apiKind": llm.api_kind, + "codexCliVersion": codex_cli_version, + "node": { + "projectId": snapshot.project_id, + "agentId": snapshot.agent_id, + "sessionId": snapshot.session_id, + "runId": snapshot.run_id, + }, + }); + format!("{:x}", Sha256::digest(stable.to_string().as_bytes())) +} + +fn find_game_creator_codex_auth_path() -> Option { + std::env::var_os("CODEX_HOME") + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var_os("HOME").map(|home| std::path::PathBuf::from(home).join(".codex")) + }) + .or_else(|| { + std::env::var_os("USERPROFILE") + .map(|home| std::path::PathBuf::from(home).join(".codex")) + }) + .map(|home| home.join("auth.json")) + .filter(|path| path.is_file()) +} + +fn read_game_creator_codex_auth_bridge( + source_auth: &std::path::Path, +) -> Result { + let mut auth_json = Vec::new(); + { + use std::io::Read; + std::fs::File::open(source_auth) + .map_err(|_| { + platform_llm::LlmError::InvalidConfig( + "读取 Codex CLI 登录态失败;请重新登录 Codex CLI 后重试".to_string(), + ) + })? + .take(GAME_CREATOR_CODEX_APP_SERVER_AUTH_MAX_BYTES.saturating_add(1) as u64) + .read_to_end(&mut auth_json) + .map_err(|_| { + platform_llm::LlmError::InvalidConfig( + "读取 Codex CLI 登录态失败;请重新登录 Codex CLI 后重试".to_string(), + ) + })?; + } + if auth_json.is_empty() || auth_json.len() > GAME_CREATOR_CODEX_APP_SERVER_AUTH_MAX_BYTES { + return Err(platform_llm::LlmError::InvalidConfig( + "Codex CLI 登录态文件为空或超过大小上限;请重新登录 Codex CLI 后重试".to_string(), + )); + } + Ok(CodexAppServerCredential::AuthBridge { + fingerprint: format!("auth-bridge:{:x}", Sha256::digest(&auth_json)), + auth_json, + }) +} + +fn resolve_game_creator_codex_app_server_credential( + llm: &GameCreatorLlmConfig, +) -> Result { + if !llm.api_key.trim().is_empty() { + return Ok(CodexAppServerCredential::AppDataKey { + fingerprint: format!("app-data-key:{:x}", Sha256::digest(llm.api_key.as_bytes())), + }); + } + let source_auth = find_game_creator_codex_auth_path().ok_or_else(|| { + platform_llm::LlmError::InvalidConfig( + "codex_app_server 未配置 API Key,且未找到可桥接的 Codex CLI 登录态;请先登录 Codex CLI 或填写 Agent LLM API Key" + .to_string(), + ) + })?; + read_game_creator_codex_auth_bridge(&source_auth) +} + +fn game_creator_codex_app_server_validate_llm_config( + llm: &GameCreatorLlmConfig, +) -> Result<(), platform_llm::LlmError> { + if llm.api_kind != "openai_responses" { + return Err(platform_llm::LlmError::InvalidConfig(format!( + "codex_app_server 仅支持 apiKind=openai_responses;当前 apiKind={},请改用 provider 模式", + llm.api_kind + ))); + } + if llm.web_search_enabled { + return Err(platform_llm::LlmError::InvalidConfig( + "codex_app_server 模式下 webSearchEnabled 必须为 false;AGC Runtime 是唯一 ToolHost" + .to_string(), + )); + } + Ok(()) +} + +fn quoted_toml_string(value: &str) -> Result { + serde_json::to_string(value).map_err(|error| { + platform_llm::LlmError::InvalidConfig(format!("序列化 Codex app-server 配置失败:{error}")) + }) +} + +fn configure_game_creator_codex_app_server_command( + command: &mut tokio::process::Command, + llm: &GameCreatorLlmConfig, +) -> Result<(), platform_llm::LlmError> { + command + .arg("app-server") + .arg("--stdio") + .arg("-c") + .arg("mcp_servers={}") + .arg("-c") + .arg("web_search=\"disabled\"") + .arg("-c") + .arg("agents.enabled=false"); + for feature in [ + "apps", + "browser_use", + "browser_use_external", + "browser_use_full_cdp_access", + "computer_use", + "goals", + "image_generation", + "in_app_browser", + "plugins", + "remote_plugin", + "shell_tool", + "skill_search", + "tool_suggest", + "unified_exec", + "view_image", + "workspace_dependencies", + ] { + command.arg("--disable").arg(feature); + } + if !llm.api_key.trim().is_empty() { + command + .arg("-c") + .arg(format!( + "model_provider={}", + quoted_toml_string(GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID)? + )) + .arg("-c") + .arg(format!( + "model_providers.{GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID}.name={}", + quoted_toml_string("Genarrative AGC")? + )) + .arg("-c") + .arg(format!( + "model_providers.{GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID}.base_url={}", + quoted_toml_string(llm.base_url.trim_end_matches('/'))? + )) + .arg("-c") + .arg(format!( + "model_providers.{GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID}.env_key={}", + quoted_toml_string(GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV)? + )) + .arg("-c") + .arg(format!( + "model_providers.{GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID}.wire_api={}", + quoted_toml_string("responses")? + )) + .env(GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV, &llm.api_key); + } + Ok(()) +} + +fn prepare_isolated_game_creator_codex_home( + root: &std::path::Path, + credential: &CodexAppServerCredential, +) -> Result { + let isolated_home = root.join("codex-home"); + std::fs::create_dir(&isolated_home).map_err(|error| { + platform_llm::LlmError::Transport(format!("创建隔离 Codex app-server HOME 失败:{error}")) + })?; + let CodexAppServerCredential::AuthBridge { auth_json, .. } = credential else { + return Ok(isolated_home); + }; + let target_auth = isolated_home.join("auth.json"); + std::fs::write(&target_auth, auth_json).map_err(|error| { + platform_llm::LlmError::Transport(format!( + "桥接 Codex 登录态到隔离 app-server 失败:{error}" + )) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&target_auth, std::fs::Permissions::from_mode(0o600)).map_err( + |error| { + platform_llm::LlmError::Transport(format!("收紧隔离 Codex 登录态权限失败:{error}")) + }, + )?; + } + Ok(isolated_home) +} + +impl CodexAppServerConnection { + async fn acquire( + snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + ) -> Result { + game_creator_codex_app_server_validate_llm_config(llm)?; + let codex_cli_version = game_creator_codex_cli_version_identity() + .map_err(platform_llm::LlmError::InvalidConfig)?; + let credential = resolve_game_creator_codex_app_server_credential(llm)?; + let key = game_creator_codex_app_server_pool_key( + llm, + &codex_cli_version, + snapshot, + credential.fingerprint(), + ); + let mut pool = game_creator_codex_app_server_pool().lock().await; + if let Some(inner) = pool.get(&key) { + if !inner.closed.load(Ordering::Acquire) { + inner.last_used.store( + next_game_creator_codex_app_server_usage_tick(), + Ordering::Release, + ); + return Ok(Self { + inner: Arc::clone(inner), + }); + } + } + pool.retain(|_, inner| !inner.closed.load(Ordering::Acquire)); + if pool.len() >= GAME_CREATOR_CODEX_APP_SERVER_POOL_MAX { + let evict_key = pool + .iter() + .filter(|(_, inner)| Arc::strong_count(inner) == 1) + .min_by_key(|(_, inner)| inner.last_used.load(Ordering::Acquire)) + .map(|(key, _)| key.clone()); + let Some(evict_key) = evict_key else { + return Err(platform_llm::LlmError::InvalidConfig(format!( + "Codex app-server 同时活动的 LLM 路由超过 {} 个,请等待现有节点完成或重启 Agent Runner", + GAME_CREATOR_CODEX_APP_SERVER_POOL_MAX + ))); + }; + if let Some(inner) = pool.remove(&evict_key) { + shutdown_game_creator_codex_app_server_inner( + &inner, + "Codex app-server LRU 路由已淘汰", + ) + .await; + } + } + let connection = Self::spawn(llm, &credential).await?; + pool.insert(key, Arc::clone(&connection.inner)); + Ok(connection) + } + + async fn spawn( + llm: &GameCreatorLlmConfig, + credential: &CodexAppServerCredential, + ) -> Result { + Self::spawn_with_executable_and_credential( + llm, + credential, + std::ffi::OsStr::new(GAME_CREATOR_CODEX_APP_SERVER_EXECUTABLE), + ) + .await + } + + async fn spawn_with_executable( + llm: &GameCreatorLlmConfig, + executable: &std::ffi::OsStr, + ) -> Result { + let credential = resolve_game_creator_codex_app_server_credential(llm)?; + Self::spawn_with_executable_and_credential(llm, &credential, executable).await + } + + async fn spawn_with_executable_and_credential( + llm: &GameCreatorLlmConfig, + credential: &CodexAppServerCredential, + executable: &std::ffi::OsStr, + ) -> Result { + let working_dir = tempfile::Builder::new() + .prefix("genarrative-agc-codex-app-server-") + .tempdir() + .map_err(|error| { + platform_llm::LlmError::Transport(format!( + "创建 Codex app-server 临时目录失败:{error}" + )) + })?; + let isolated_codex_home = + prepare_isolated_game_creator_codex_home(working_dir.path(), credential)?; + let isolated_workspace = working_dir.path().join("workspace"); + std::fs::create_dir(&isolated_workspace).map_err(|error| { + platform_llm::LlmError::Transport(format!( + "创建 Codex app-server 隔离工作目录失败:{error}" + )) + })?; + std::fs::create_dir(isolated_workspace.join(".git")).map_err(|error| { + platform_llm::LlmError::Transport(format!( + "创建 Codex app-server 隔离仓库边界失败:{error}" + )) + })?; + let isolated_os_home = working_dir.path().join("home"); + let isolated_app_data = isolated_os_home.join("appdata"); + let isolated_local_app_data = isolated_os_home.join("local-appdata"); + for path in [ + &isolated_os_home, + &isolated_app_data, + &isolated_local_app_data, + ] { + std::fs::create_dir_all(path).map_err(|error| { + platform_llm::LlmError::Transport(format!( + "创建 Codex app-server 隔离用户目录失败:{error}" + )) + })?; + } + let mut command = tokio::process::Command::new(executable); + configure_game_creator_codex_app_server_command(&mut command, llm)?; + command + .current_dir(&isolated_workspace) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + game_creator_codex_cli_minimal_environment(&mut command); + command + .env("CODEX_HOME", &isolated_codex_home) + .env("HOME", &isolated_os_home) + .env("USERPROFILE", &isolated_os_home) + .env("APPDATA", &isolated_app_data) + .env("LOCALAPPDATA", &isolated_local_app_data) + .env_remove("CODEX_API_KEY"); + if credential.uses_app_data_key() { + command.env(GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV, &llm.api_key); + } + configure_game_creator_codex_cli_process(&mut command); + let mut child = command.spawn().map_err(|error| { + platform_llm::LlmError::InvalidConfig(format!( + "Codex app-server 不可用,请安装并登录 Codex CLI:{error}" + )) + })?; + let stdin = child.stdin.take().ok_or_else(|| { + platform_llm::LlmError::Transport("Codex app-server stdin 未建立".to_string()) + })?; + let stdout = child.stdout.take().ok_or_else(|| { + platform_llm::LlmError::Transport("Codex app-server stdout 未建立".to_string()) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + platform_llm::LlmError::Transport("Codex app-server stderr 未建立".to_string()) + })?; + let inner = Arc::new(CodexAppServerInner { + writer: Mutex::new(stdin), + child: Mutex::new(Some(child)), + pending: Mutex::new(HashMap::new()), + turns: Mutex::new(HashMap::new()), + turn_backlog: Mutex::new(HashMap::new()), + threads: Mutex::new(HashMap::new()), + stderr_summary: Mutex::new(CodexAppServerStderrSummary::new()), + turn_gate: Mutex::new(()), + next_request_id: AtomicU64::new(1), + last_used: AtomicU64::new(next_game_creator_codex_app_server_usage_tick()), + closed: AtomicBool::new(false), + _working_dir: working_dir, + }); + tokio::spawn(read_game_creator_codex_app_server_stdout( + Arc::downgrade(&inner), + stdout, + )); + tokio::spawn(read_game_creator_codex_app_server_stderr( + Arc::downgrade(&inner), + stderr, + )); + let connection = Self { inner }; + connection + .request( + "initialize", + serde_json::json!({ + "clientInfo": { + "name": "genarrative-ai-game-creator", + "title": "Genarrative AI Game Creator", + "version": env!("CARGO_PKG_VERSION") + }, + "capabilities": { "experimentalApi": true } + }), + ) + .await + .map_err(platform_llm::LlmError::Transport)?; + connection + .notify("initialized", serde_json::json!({})) + .await + .map_err(platform_llm::LlmError::Transport)?; + Ok(connection) + } + + async fn write_message(&self, value: &serde_json::Value) -> Result<(), String> { + if self.inner.closed.load(Ordering::Acquire) { + return Err("Codex app-server 连接已关闭".to_string()); + } + let mut bytes = serde_json::to_vec(value) + .map_err(|error| format!("序列化 Codex app-server JSON-RPC 失败:{error}"))?; + bytes.push(b'\n'); + let mut writer = self.inner.writer.lock().await; + writer + .write_all(&bytes) + .await + .map_err(|error| format!("写入 Codex app-server 失败:{error}"))?; + writer + .flush() + .await + .map_err(|error| format!("刷新 Codex app-server stdin 失败:{error}")) + } + + async fn notify(&self, method: &str, params: serde_json::Value) -> Result<(), String> { + self.write_message(&serde_json::json!({ + "method": method, + "params": params, + })) + .await + } + + async fn request(&self, method: &str, params: serde_json::Value) -> RpcResult { + self.request_with_turn_start_cancellation(method, params, None) + .await + } + + async fn request_with_turn_start_cancellation( + &self, + method: &str, + params: serde_json::Value, + turn_start_cancellation: Option>, + ) -> RpcResult { + let id = self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); + let (sender, receiver) = oneshot::channel(); + self.inner.pending.lock().await.insert( + id, + CodexPendingRpc { + sender, + turn_start_cancellation, + }, + ); + let weak_inner = Arc::downgrade(&self.inner); + let method_for_timeout = method.to_string(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis( + GAME_CREATOR_CODEX_APP_SERVER_RPC_TIMEOUT_MS, + )) + .await; + let Some(inner) = weak_inner.upgrade() else { + return; + }; + let pending = inner.pending.lock().await.remove(&id); + if let Some(pending) = pending { + let _ = pending.sender.send(Err(format!( + "Codex app-server RPC {method_for_timeout} 超时" + ))); + } + }); + if let Err(error) = self + .write_message(&serde_json::json!({ + "id": id, + "method": method, + "params": params, + })) + .await + { + self.inner.pending.lock().await.remove(&id); + return Err(error); + } + match tokio::time::timeout( + std::time::Duration::from_millis(GAME_CREATOR_CODEX_APP_SERVER_RPC_TIMEOUT_MS), + receiver, + ) + .await + { + Ok(Ok(result)) => result, + Ok(Err(_)) => Err("Codex app-server RPC 响应通道已关闭".to_string()), + Err(_) => { + self.inner.pending.lock().await.remove(&id); + Err(format!("Codex app-server RPC {method} 超时")) + } + } + } + + async fn thread_for( + &self, + snapshot: &AgentRuntimeProviderRequestSnapshot, + request: &LlmRunRequest, + llm: &GameCreatorLlmConfig, + ) -> Result { + let key = CodexNodeThreadKey::from(snapshot); + let mut threads = self.inner.threads.lock().await; + if let Some(entry) = threads.get_mut(&key) { + entry.active_uses = entry.active_uses.saturating_add(1); + entry.last_used = next_game_creator_codex_app_server_usage_tick(); + return Ok(CodexThreadLease { + connection: self.clone(), + key, + thread_id: entry.thread_id.clone(), + }); + } + if threads.len() >= GAME_CREATOR_CODEX_APP_SERVER_THREAD_MAX { + let evict_key = threads + .iter() + .filter(|(_, entry)| entry.active_uses == 0) + .min_by_key(|(_, entry)| entry.last_used) + .map(|(key, _)| key.clone()); + let Some(evict_key) = evict_key else { + return Err(platform_llm::LlmError::InvalidRequest(format!( + "Codex app-server 同时活动的节点 thread 超过 {} 个", + GAME_CREATOR_CODEX_APP_SERVER_THREAD_MAX + ))); + }; + if let Some(entry) = threads.remove(&evict_key) { + let _ = self + .request( + "thread/unsubscribe", + serde_json::json!({ "threadId": entry.thread_id }), + ) + .await; + } + } + let model = request + .model + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or(&llm.model); + let mut params = serde_json::json!({ + "model": model, + "cwd": self.inner._working_dir.path().join("workspace"), + "approvalPolicy": "never", + "sandbox": "read-only", + "ephemeral": true, + "baseInstructions": "You are an isolated reasoning engine for Genarrative AGC. Never use native tools, shell, files, network, MCP, skills, plugins, hooks, or sub-agents. Use only the complete message snapshot provided in each turn and return only the requested answer contract." + }); + if !llm.api_key.trim().is_empty() { + params["modelProvider"] = + serde_json::Value::String(GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID.to_string()); + } + let result = self + .request("thread/start", params) + .await + .map_err(platform_llm::LlmError::Transport)?; + let thread_id = result + .pointer("/thread/id") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + platform_llm::LlmError::Deserialize( + "Codex app-server thread/start 缺少 thread.id".to_string(), + ) + })? + .to_string(); + threads.insert( + key.clone(), + CodexThreadEntry { + thread_id: thread_id.clone(), + last_used: next_game_creator_codex_app_server_usage_tick(), + active_uses: 1, + }, + ); + Ok(CodexThreadLease { + connection: self.clone(), + key, + thread_id, + }) + } + + async fn register_turn(&self, turn_id: &str) -> mpsc::UnboundedReceiver { + let (sender, receiver) = mpsc::unbounded_channel(); + self.inner + .turns + .lock() + .await + .insert(turn_id.to_string(), sender.clone()); + if let Some(events) = self.inner.turn_backlog.lock().await.remove(turn_id) { + for event in events { + let _ = sender.send(event); + } + } + receiver + } + + async fn release_thread( + &self, + snapshot: &AgentRuntimeProviderRequestSnapshot, + thread_id: &str, + ) { + let key = CodexNodeThreadKey::from(snapshot); + let removed = { + let mut threads = self.inner.threads.lock().await; + (threads.get(&key).map(|entry| entry.thread_id.as_str()) == Some(thread_id)) + .then(|| threads.remove(&key)) + .flatten() + }; + if removed.is_some() { + let _ = self + .request( + "thread/unsubscribe", + serde_json::json!({ "threadId": thread_id }), + ) + .await; + } + } + + async fn run_turn( + &self, + snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + request: LlmRunRequest, + mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, + ) -> Result { + let _turn_guard = self.inner.turn_gate.lock().await; + let thread_lease = self.thread_for(snapshot, &request, llm).await?; + let thread_id = thread_lease.thread_id.clone(); + let prompt = render_game_creator_codex_cli_prompt(&request) + .map_err(platform_llm::LlmError::InvalidRequest)?; + let model = request + .model + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or(&llm.model); + let mut params = serde_json::json!({ + "threadId": thread_id, + "input": [{ "type": "text", "text": prompt }], + "model": model, + "approvalPolicy": "never", + "sandboxPolicy": { "type": "readOnly", "networkAccess": false }, + }); + if let Some(effort) = game_creator_codex_cli_reasoning_effort(&request) { + params["effort"] = serde_json::Value::String(effort.to_string()); + } + if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) { + params["outputSchema"] = schema; + } + let turn_start_cancellation = + Arc::new(CodexTurnStartCancellation::new(&self.inner, &thread_id)); + let mut turn_start_guard = CodexTurnStartGuard { + cancellation: Arc::clone(&turn_start_cancellation), + armed: true, + }; + let result = match self + .request_with_turn_start_cancellation( + "turn/start", + params, + Some(turn_start_cancellation), + ) + .await + { + Ok(result) => result, + Err(error) => { + return Err(isolate_game_creator_codex_app_server_terminal_unknown( + &self.inner, + format!("turn/start 终态未知:{error}"), + ) + .await) + } + }; + let turn_id = match result + .pointer("/turn/id") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + { + Some(turn_id) => turn_id.to_string(), + None => { + return Err(isolate_game_creator_codex_app_server_terminal_unknown( + &self.inner, + "turn/start 响应缺少 turn.id", + ) + .await) + } + }; + turn_start_guard.armed = false; + let mut receiver = self.register_turn(&turn_id).await; + let mut guard = CodexTurnGuard { + connection: self.clone(), + thread_id: thread_id.clone(), + turn_id: turn_id.clone(), + armed: true, + }; + let timeout_ms = request + .request_timeout_ms + .unwrap_or(GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS) + .max(1); + let collect = async { + let mut final_text = None; + let mut streamed_text = String::new(); + loop { + match receiver.recv().await { + Some(CodexTurnEvent::AgentMessageDelta(delta)) => { + streamed_text.push_str(&delta); + if let Some(callback) = on_agent_message_delta.as_deref_mut() { + callback(&platform_llm::LlmStreamDelta { + accumulated_text: streamed_text.clone(), + delta_text: delta, + finish_reason: None, + }); + } + } + Some(CodexTurnEvent::Item(params)) => { + if let Some(item) = params.get("item") { + let item_type = item + .get("type") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if item_type == "agentMessage" { + if let Some(text) = item + .get("text") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + { + final_text = Some(text.to_string()); + } + } else if !matches!( + item_type, + "userMessage" | "plan" | "reasoning" | "contextCompaction" + ) { + return Err(platform_llm::LlmError::InvalidRequest(format!( + "Codex app-server 违反 AGC ToolHost 边界,产生非被动 item {item_type}" + ))); + } + } + } + Some(CodexTurnEvent::Terminal(params)) => { + let turn = params.get("turn").unwrap_or(¶ms); + if final_text.is_none() { + final_text = turn + .get("items") + .and_then(serde_json::Value::as_array) + .and_then(|items| { + items.iter().rev().find_map(|item| { + (item.get("type")?.as_str()? == "agentMessage") + .then(|| item.get("text")?.as_str().map(str::to_string)) + .flatten() + }) + }); + } + match turn + .get("status") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + { + "completed" => { + return final_text + .filter(|text| !text.trim().is_empty()) + .ok_or(platform_llm::LlmError::EmptyResponse) + } + "interrupted" => { + return Err(platform_llm::LlmError::InvalidRequest( + "Codex app-server turn 已中断".to_string(), + )) + } + "failed" => { + return Err(platform_llm::LlmError::InvalidRequest( + "Codex app-server turn 执行失败".to_string(), + )) + } + status => { + return Err(platform_llm::LlmError::Deserialize(format!( + "Codex app-server turn/completed 状态无效:{status}" + ))) + } + } + } + Some(CodexTurnEvent::TransportClosed(error)) => { + return Err(isolate_game_creator_codex_app_server_terminal_unknown( + &self.inner, + error, + ) + .await) + } + None => { + return Err(isolate_game_creator_codex_app_server_terminal_unknown( + &self.inner, + "Codex app-server turn 事件通道已关闭", + ) + .await) + } + } + } + }; + let text = match tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), collect) + .await + { + Ok(result) => result?, + Err(_) => { + return Err(isolate_game_creator_codex_app_server_terminal_unknown( + &self.inner, + "等待 turn/completed 超时", + ) + .await) + } + }; + guard.armed = false; + self.inner.turns.lock().await.remove(&turn_id); + let response = parse_game_creator_codex_app_server_text(&text, &thread_id, &request)?; + if matches!( + snapshot.request_kind.as_str(), + "final-reply" | "steer-decision" + ) { + self.release_thread(snapshot, &thread_id).await; + } + Ok(response) + } +} + +struct CodexTurnStartGuard { + cancellation: Arc, + armed: bool, +} + +impl Drop for CodexTurnStartGuard { + fn drop(&mut self) { + if self.armed { + self.cancellation.cancel(); + } + } +} + +struct CodexThreadLease { + connection: CodexAppServerConnection, + key: CodexNodeThreadKey, + thread_id: String, +} + +impl Drop for CodexThreadLease { + fn drop(&mut self) { + let connection = self.connection.clone(); + let key = self.key.clone(); + let thread_id = self.thread_id.clone(); + tokio::spawn(async move { + let mut threads = connection.inner.threads.lock().await; + if let Some(entry) = threads.get_mut(&key) { + if entry.thread_id == thread_id { + entry.active_uses = entry.active_uses.saturating_sub(1); + entry.last_used = next_game_creator_codex_app_server_usage_tick(); + } + } + }); + } +} + +struct CodexTurnGuard { + connection: CodexAppServerConnection, + thread_id: String, + turn_id: String, + armed: bool, +} + +impl Drop for CodexTurnGuard { + fn drop(&mut self) { + if !self.armed || self.connection.inner.closed.load(Ordering::Acquire) { + return; + } + let connection = self.connection.clone(); + let thread_id = self.thread_id.clone(); + let turn_id = self.turn_id.clone(); + tokio::spawn(async move { + connection.inner.turns.lock().await.remove(&turn_id); + connection.inner.turn_backlog.lock().await.remove(&turn_id); + let _ = connection + .request( + "turn/interrupt", + serde_json::json!({ "threadId": thread_id, "turnId": turn_id }), + ) + .await; + }); + } +} + +fn parse_game_creator_codex_app_server_text( + text: &str, + thread_id: &str, + request: &LlmRunRequest, +) -> Result { + let tool_calls = if request.function_tools.is_empty() { + Vec::new() + } else { + let envelope = serde_json::from_str::(text).map_err(|_| { + platform_llm::LlmError::Deserialize( + "Codex app-server structured output 不是严格 JSON".to_string(), + ) + })?; + let calls = envelope + .get("toolCalls") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + platform_llm::LlmError::Deserialize( + "Codex app-server structured output 缺少 toolCalls".to_string(), + ) + })?; + calls + .iter() + .enumerate() + .map(|(index, call)| { + let name = call + .get("name") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + platform_llm::LlmError::Deserialize( + "Codex app-server tool call 缺少 name".to_string(), + ) + })?; + let arguments = call + .get("arguments") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + platform_llm::LlmError::Deserialize( + "Codex app-server tool call 缺少 arguments".to_string(), + ) + })?; + if !request.function_tools.iter().any(|tool| tool.name == name) { + return Err(platform_llm::LlmError::Deserialize( + "Codex app-server 返回未广告函数".to_string(), + )); + } + Ok(platform_llm::LlmToolCall { + id: format!("codex-app-server-call-{}", index.saturating_add(1)), + name: name.to_string(), + arguments: arguments.to_string(), + }) + }) + .collect::, _>>()? + }; + Ok(platform_llm::LlmRunResponse { + provider: platform_llm::LlmProvider::OpenAiCompatible, + model: "codex-app-server".to_string(), + text: if tool_calls.is_empty() { + text.to_string() + } else { + String::new() + }, + finish_reason: Some("stop".to_string()), + response_id: Some(thread_id.to_string()), + usage: None, + tool_calls, + }) +} + +async fn read_game_creator_codex_app_server_stdout( + inner: Weak, + stdout: tokio::process::ChildStdout, +) { + let mut reader = BufReader::new(stdout); + loop { + let mut buffer = match read_bounded_game_creator_codex_app_server_line(&mut reader).await { + Ok(Some(buffer)) => buffer, + Ok(None) => { + fail_game_creator_codex_app_server_connection( + &inner, + "Codex app-server 已退出".to_string(), + ) + .await; + return; + } + Err(error) => { + fail_game_creator_codex_app_server_connection(&inner, error).await; + return; + } + }; + while matches!(buffer.last(), Some(b'\n' | b'\r')) { + buffer.pop(); + } + if buffer.is_empty() { + continue; + } + let message = match serde_json::from_slice::(&buffer) { + Ok(message) => message, + Err(_) => { + fail_game_creator_codex_app_server_connection( + &inner, + "Codex app-server 返回无效 JSON-RPC".to_string(), + ) + .await; + return; + } + }; + let Some(inner) = inner.upgrade() else { + return; + }; + if let Some(id) = message.get("id").and_then(serde_json::Value::as_u64) { + if message.get("method").is_some() { + let writer = Arc::clone(&inner); + tokio::spawn(async move { + let response = serde_json::json!({ + "id": id, + "error": { + "code": -32601, + "message": "Genarrative AGC 拒绝 app-server 的交互、审批与工具请求" + } + }); + let mut bytes = match serde_json::to_vec(&response) { + Ok(bytes) => bytes, + Err(_) => return, + }; + bytes.push(b'\n'); + let mut stdin = writer.writer.lock().await; + let _ = stdin.write_all(&bytes).await; + let _ = stdin.flush().await; + }); + continue; + } + if let Some(pending) = inner.pending.lock().await.remove(&id) { + let result = if let Some(error) = message.get("error") { + Err(format!( + "Codex app-server JSON-RPC 失败:{}", + error + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("未知错误") + )) + } else { + Ok(message + .get("result") + .cloned() + .unwrap_or(serde_json::Value::Null)) + }; + if let (Some(cancellation), Ok(value)) = + (pending.turn_start_cancellation.as_ref(), &result) + { + if let Some(turn_id) = value + .pointer("/turn/id") + .and_then(serde_json::Value::as_str) + { + cancellation.observe_turn_id(turn_id); + } + } + let _ = pending.sender.send(result); + } + continue; + } + let method = message + .get("method") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if !matches!( + method, + "item/agentMessage/delta" | "item/started" | "item/completed" | "turn/completed" + ) { + continue; + } + let params = message + .get("params") + .cloned() + .unwrap_or(serde_json::Value::Null); + let turn_id = params + .get("turnId") + .and_then(serde_json::Value::as_str) + .or_else(|| { + params + .pointer("/turn/id") + .and_then(serde_json::Value::as_str) + }) + .map(str::to_string); + let Some(turn_id) = turn_id else { + continue; + }; + let event = match method { + "item/agentMessage/delta" => { + let Some(delta) = params + .get("delta") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + else { + continue; + }; + CodexTurnEvent::AgentMessageDelta(delta.to_string()) + } + "item/started" | "item/completed" => CodexTurnEvent::Item(params), + _ => CodexTurnEvent::Terminal(params), + }; + let sender = if method == "turn/completed" { + inner.turns.lock().await.remove(&turn_id) + } else { + inner.turns.lock().await.get(&turn_id).cloned() + }; + if let Some(sender) = sender { + let _ = sender.send(event); + } else { + let mut backlog = inner.turn_backlog.lock().await; + if !backlog.contains_key(&turn_id) + && backlog.len() >= GAME_CREATOR_CODEX_APP_SERVER_BACKLOG_TURN_MAX + { + continue; + } + let events = backlog.entry(turn_id).or_default(); + if events.len() < 64 { + events.push(event); + } + } + } +} + +async fn read_bounded_game_creator_codex_app_server_line( + reader: &mut R, +) -> Result>, String> +where + R: AsyncBufRead + Unpin, +{ + let mut line = Vec::new(); + loop { + let available = reader + .fill_buf() + .await + .map_err(|error| format!("读取 Codex app-server stdout 失败:{error}"))?; + if available.is_empty() { + return Ok((!line.is_empty()).then_some(line)); + } + let consumed = available + .iter() + .position(|byte| *byte == b'\n') + .map(|index| index.saturating_add(1)) + .unwrap_or(available.len()); + if line.len().saturating_add(consumed) > GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES { + return Err("Codex app-server JSON-RPC 单行超过大小上限".to_string()); + } + let completed = available.get(consumed.saturating_sub(1)) == Some(&b'\n'); + line.extend_from_slice(&available[..consumed]); + reader.consume(consumed); + if completed { + return Ok(Some(line)); + } + } +} + +async fn read_game_creator_codex_app_server_stderr( + inner: Weak, + mut stderr: tokio::process::ChildStderr, +) { + let mut buffer = [0_u8; 8 * 1024]; + loop { + let count = match stderr.read(&mut buffer).await { + Ok(0) => return, + Err(_) => { + if let Some(inner) = inner.upgrade() { + inner.stderr_summary.lock().await.mark_read_failure(); + } + fail_game_creator_codex_app_server_connection( + &inner, + "Codex app-server stderr 读取失败".to_string(), + ) + .await; + return; + } + Ok(count) => count, + }; + let Some(upgraded) = inner.upgrade() else { + return; + }; + let oversized_record = upgraded + .stderr_summary + .lock() + .await + .observe(&buffer[..count]); + drop(upgraded); + if oversized_record { + fail_game_creator_codex_app_server_connection( + &inner, + "Codex app-server stderr 单条记录超过大小上限".to_string(), + ) + .await; + return; + } + } +} + +async fn fail_game_creator_codex_app_server_connection( + inner: &Weak, + error: String, +) { + let Some(inner) = inner.upgrade() else { + return; + }; + if inner.closed.swap(true, Ordering::AcqRel) { + return; + } + let exit_status = inner + .child + .lock() + .await + .as_mut() + .and_then(|child| child.try_wait().ok().flatten()) + .map(|status| status.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let stderr = inner.stderr_summary.lock().await.diagnostic(); + let diagnostic = format!("{error};exitStatus={exit_status};{stderr}"); + eprintln!("agent.runner.failed: Codex app-server 连接终止:{diagnostic}"); + for (_, pending) in inner.pending.lock().await.drain() { + let _ = pending.sender.send(Err(diagnostic.clone())); + } + for (_, sender) in inner.turns.lock().await.drain() { + let _ = sender.send(CodexTurnEvent::TransportClosed(diagnostic.clone())); + } + inner.threads.lock().await.clear(); + inner.turn_backlog.lock().await.clear(); +} + +async fn shutdown_game_creator_codex_app_server_inner( + inner: &Arc, + reason: &str, +) { + inner.closed.store(true, Ordering::Release); + for (_, pending) in inner.pending.lock().await.drain() { + let _ = pending.sender.send(Err(reason.to_string())); + } + for (_, sender) in inner.turns.lock().await.drain() { + let _ = sender.send(CodexTurnEvent::TransportClosed(reason.to_string())); + } + inner.threads.lock().await.clear(); + inner.turn_backlog.lock().await.clear(); + if let Some(mut child) = inner.child.lock().await.take() { + terminate_game_creator_codex_cli_process_tree(&mut child).await; + } +} + +pub(in crate::agent) async fn request_game_creator_agent_codex_app_server( + snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + request: LlmRunRequest, +) -> Result { + CodexAppServerConnection::acquire(snapshot, llm) + .await? + .run_turn(snapshot, llm, request, None) + .await +} + +pub(in crate::agent) async fn stream_game_creator_agent_codex_app_server( + snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + request: LlmRunRequest, + mut on_agent_message_delta: F, +) -> Result +where + F: FnMut(&platform_llm::LlmStreamDelta) + Send, +{ + CodexAppServerConnection::acquire(snapshot, llm) + .await? + .run_turn(snapshot, llm, request, Some(&mut on_agent_message_delta)) + .await +} + +pub(in crate::agent) fn shutdown_game_creator_codex_app_servers_impl() -> Result<(), String> { + tauri::async_runtime::block_on(async { + let connections = game_creator_codex_app_server_pool() + .lock() + .await + .drain() + .map(|(_, connection)| connection) + .collect::>(); + for inner in connections { + shutdown_game_creator_codex_app_server_inner(&inner, "Codex app-server 正在关闭").await; + } + Ok(()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_llm() -> GameCreatorLlmConfig { + GameCreatorLlmConfig { + api_key: "fixture-secret".to_string(), + base_url: "https://example.invalid/v1".to_string(), + model: "fixture-model".to_string(), + api_kind: "openai_responses".to_string(), + reasoning_effort: "high".to_string(), + stream: false, + web_search_enabled: false, + context_window_tokens: 128_000, + auto_compact_token_limit: 64_000, + tool_output_token_limit: 12_000, + request_timeout_ms: 10_000, + max_retries: 0, + retry_backoff_ms: 100, + } + } + + fn test_snapshot() -> AgentRuntimeProviderRequestSnapshot { + AgentRuntimeProviderRequestSnapshot { + project_id: "project-1".to_string(), + agent_id: "planner".to_string(), + task_id: "task-1".to_string(), + session_id: "session-1".to_string(), + run_id: "run-1".to_string(), + source: "test".to_string(), + goal_id: None, + goal_revision: 0, + goal_snapshot_fingerprint: String::new(), + applied_steer_cursor: 0, + request_kind: "tool-plan".to_string(), + request_slot: "slot-1".to_string(), + web_search_enabled: false, + allow_idle_context_compaction: false, + } + } + + fn tool_request() -> LlmRunRequest { + LlmRunRequest::single_turn("系统", "任务").with_function_tools(vec![ + platform_llm::LlmFunctionTool::new( + "runtime_tool_file_read", + "读取文件", + serde_json::json!({"type":"object"}), + ), + ]) + } + + #[test] + fn codex_app_server_maps_structured_output_to_runtime_tool_calls() { + let text = r#"{"toolCalls":[{"name":"runtime_tool_file_read","arguments":"{\"path\":\"game/index.html\"}"}]}"#; + let response = parse_game_creator_codex_app_server_text(text, "thread-1", &tool_request()) + .expect("parse app-server output"); + assert_eq!(response.response_id.as_deref(), Some("thread-1")); + assert_eq!(response.tool_calls.len(), 1); + assert_eq!(response.tool_calls[0].name, "runtime_tool_file_read"); + assert!(response.text.is_empty()); + } + + #[test] + fn codex_app_server_rejects_non_responses_key_mapping() { + let mut llm = test_llm(); + llm.api_kind = "anthropic".to_string(); + assert!(game_creator_codex_app_server_validate_llm_config(&llm).is_err()); + llm.api_kind = "openai_responses".to_string(); + llm.web_search_enabled = true; + assert!(game_creator_codex_app_server_validate_llm_config(&llm).is_err()); + } + + #[test] + fn codex_app_server_pool_key_isolated_by_credentials_and_route() { + let mut base = test_llm(); + let snapshot = test_snapshot(); + base.api_key = "key-a".to_string(); + let base_credential = + resolve_game_creator_codex_app_server_credential(&base).expect("base credential"); + let mut changed = base.clone(); + changed.api_key = "key-b".to_string(); + let changed_credential = + resolve_game_creator_codex_app_server_credential(&changed).expect("changed credential"); + assert_ne!( + game_creator_codex_app_server_pool_key( + &base, + "codex-cli 0.147.0", + &snapshot, + base_credential.fingerprint() + ), + game_creator_codex_app_server_pool_key( + &changed, + "codex-cli 0.147.0", + &snapshot, + changed_credential.fingerprint() + ) + ); + changed = base.clone(); + changed.base_url = "https://other.invalid/v1".to_string(); + assert_ne!( + game_creator_codex_app_server_pool_key( + &base, + "codex-cli 0.147.0", + &snapshot, + base_credential.fingerprint() + ), + game_creator_codex_app_server_pool_key( + &changed, + "codex-cli 0.147.0", + &snapshot, + base_credential.fingerprint() + ) + ); + assert_ne!( + game_creator_codex_app_server_pool_key( + &base, + "codex-cli 0.147.0", + &snapshot, + base_credential.fingerprint() + ), + game_creator_codex_app_server_pool_key( + &base, + "codex-cli 0.148.0", + &snapshot, + base_credential.fingerprint() + ) + ); + let mut other_node = snapshot.clone(); + other_node.agent_id = "other-agent".to_string(); + assert_ne!( + game_creator_codex_app_server_pool_key( + &base, + "codex-cli 0.147.0", + &snapshot, + base_credential.fingerprint() + ), + game_creator_codex_app_server_pool_key( + &base, + "codex-cli 0.147.0", + &other_node, + base_credential.fingerprint() + ) + ); + } + + #[test] + fn codex_app_server_auth_bridge_snapshot_drives_pool_and_isolated_home() { + let temp = tempfile::tempdir().expect("temp dir"); + let source_auth = temp.path().join("source-auth.json"); + std::fs::write( + &source_auth, + br#"{"tokens":{"access_token":"first-secret"}}"#, + ) + .expect("write first auth"); + let first = read_game_creator_codex_auth_bridge(&source_auth).expect("first snapshot"); + std::fs::write( + &source_auth, + br#"{"tokens":{"access_token":"second-secret"}}"#, + ) + .expect("write second auth"); + let second = read_game_creator_codex_auth_bridge(&source_auth).expect("second snapshot"); + let llm = GameCreatorLlmConfig { + api_key: String::new(), + ..test_llm() + }; + let snapshot = test_snapshot(); + assert_ne!( + game_creator_codex_app_server_pool_key( + &llm, + "codex-cli 0.147.0", + &snapshot, + first.fingerprint() + ), + game_creator_codex_app_server_pool_key( + &llm, + "codex-cli 0.147.0", + &snapshot, + second.fingerprint() + ) + ); + + let isolated = temp.path().join("isolated"); + std::fs::create_dir(&isolated).expect("create isolated root"); + let home = prepare_isolated_game_creator_codex_home(&isolated, &first) + .expect("prepare auth bridge"); + assert_eq!( + std::fs::read(home.join("auth.json")).expect("read bridged auth"), + br#"{"tokens":{"access_token":"first-secret"}}"# + ); + } + + #[tokio::test] + async fn codex_app_server_ndjson_and_stderr_records_are_bounded_without_newline() { + let oversized_stdout = vec![b'x'; GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES + 1]; + let mut reader = BufReader::new(oversized_stdout.as_slice()); + let error = read_bounded_game_creator_codex_app_server_line(&mut reader) + .await + .expect_err("oversized unterminated JSON-RPC record must fail"); + assert!(error.contains("单行超过大小上限")); + + let secret = vec![b's'; GAME_CREATOR_CODEX_APP_SERVER_STDERR_RECORD_MAX_BYTES + 1]; + let mut summary = CodexAppServerStderrSummary::new(); + assert!(summary.observe(&secret)); + let diagnostic = summary.diagnostic(); + assert!(diagnostic.contains("stderrClass=oversized-record")); + assert!(diagnostic.contains("stderrBytes=262145")); + assert!(!diagnostic.contains(std::str::from_utf8(&secret).expect("fixture utf8"))); + } + + #[cfg(unix)] + #[tokio::test] + async fn codex_app_server_uses_initialize_thread_and_turn_protocol() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp.path().join("fake-codex-app-server"); + std::fs::write( + &executable, + r#"#!/bin/sh +case " $* " in + *"fixture-secret"*) exit 41 ;; +esac +case " $* " in + *'web_search="disabled"'*'agents.enabled=false'*'--disable shell_tool'*) ;; + *) exit 40 ;; +esac +[ "$GENARRATIVE_AGC_CODEX_API_KEY" = "fixture-secret" ] || exit 42 +[ -z "$CODEX_API_KEY" ] || exit 39 +case "$HOME" in */home) ;; *) exit 38 ;; esac +[ -d .git ] || exit 37 +IFS= read -r initialize +case "$initialize" in *'"method":"initialize"'*) ;; *) exit 43 ;; esac +printf '%s\n' '{"id":1,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}' +IFS= read -r initialized +case "$initialized" in *'"method":"initialized"'*) ;; *) exit 44 ;; esac +IFS= read -r thread_start +case "$thread_start" in *'"method":"thread/start"'*'"modelProvider":"genarrative_agc"'*) ;; *) exit 45 ;; esac +printf '%s\n' '{"id":2,"result":{"thread":{"id":"thread-1"}}}' +IFS= read -r turn_start +case "$turn_start" in *'"method":"turn/start"'*'"outputSchema"'*) ;; *) exit 46 ;; esac +printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-1","items":[],"status":"inProgress"}}}' +printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"item-1","delta":"{\"toolCalls\":"}}' +printf '%s\n' '{"method":"item/completed","params":{"completedAtMs":1,"threadId":"thread-1","turnId":"turn-1","item":{"id":"item-1","type":"agentMessage","text":"{\"toolCalls\":[{\"name\":\"runtime_tool_file_read\",\"arguments\":\"{\\\"path\\\":\\\"game/index.html\\\"}\"}]}"}}}' +printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thread-1","turn":{"id":"turn-1","items":[],"status":"completed"}}}' +while IFS= read -r line; do :; done +"#, + ) + .expect("write fake app-server"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod fake app-server"); + + let llm = test_llm(); + let connection = + CodexAppServerConnection::spawn_with_executable(&llm, executable.as_os_str()) + .await + .expect("spawn fake app-server"); + let mut streamed = String::new(); + let mut on_delta = + |delta: &platform_llm::LlmStreamDelta| streamed.push_str(&delta.delta_text); + let response = connection + .run_turn(&test_snapshot(), &llm, tool_request(), Some(&mut on_delta)) + .await + .expect("run fake app-server turn"); + assert_eq!(streamed, "{\"toolCalls\":"); + assert_eq!(response.response_id.as_deref(), Some("thread-1")); + assert_eq!(response.tool_calls.len(), 1); + assert_eq!(response.tool_calls[0].name, "runtime_tool_file_read"); + assert_eq!(connection.inner.threads.lock().await.len(), 1); + } + + #[cfg(unix)] + #[tokio::test] + async fn codex_app_server_interrupts_turn_when_request_is_dropped_before_start_response() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp.path().join("fake-codex-app-server-cancel"); + std::fs::write( + &executable, + r#"#!/bin/sh +IFS= read -r initialize +printf '%s\n' '{"id":1,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}' +IFS= read -r initialized +IFS= read -r thread_start +printf '%s\n' '{"id":2,"result":{"thread":{"id":"thread-cancel"}}}' +IFS= read -r turn_start +sleep 0.2 +printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-cancel","items":[],"status":"inProgress"}}}' +IFS= read -r interrupt +case "$interrupt" in *'"method":"turn/interrupt"'*'"turnId":"turn-cancel"'*) ;; *) exit 51 ;; esac +: > "$HOME/interrupt-seen" +printf '%s\n' '{"id":4,"result":{}}' +while IFS= read -r line; do :; done +"#, + ) + .expect("write cancel fake app-server"); + let mut permissions = std::fs::metadata(&executable) + .expect("cancel fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod cancel fake app-server"); + + let llm = test_llm(); + let connection = + CodexAppServerConnection::spawn_with_executable(&llm, executable.as_os_str()) + .await + .expect("spawn cancel fake app-server"); + let task_connection = connection.clone(); + let task_llm = llm.clone(); + let task = tokio::spawn(async move { + task_connection + .run_turn(&test_snapshot(), &task_llm, tool_request(), None) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + task.abort(); + let _ = task.await; + + let marker = connection + .inner + ._working_dir + .path() + .join("home/interrupt-seen"); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while !marker.is_file() { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .expect("dropped turn must be interrupted after start response arrives"); + } + + #[cfg(unix)] + #[tokio::test] + async fn codex_app_server_terminal_unknown_isolates_only_timed_out_node() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp.path().join("fake-codex-app-server-no-terminal"); + std::fs::write( + &executable, + r#"#!/bin/sh +IFS= read -r initialize +printf '%s\n' '{"id":1,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}' +IFS= read -r initialized +IFS= read -r thread_start +printf '%s\n' '{"id":2,"result":{"thread":{"id":"thread-timeout"}}}' +IFS= read -r turn_start +printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-timeout","items":[],"status":"inProgress"}}}' +while IFS= read -r line; do :; done +"#, + ) + .expect("write no-terminal fake app-server"); + let mut permissions = std::fs::metadata(&executable) + .expect("no-terminal fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions) + .expect("chmod no-terminal fake app-server"); + + let llm = test_llm(); + let failing = CodexAppServerConnection::spawn_with_executable(&llm, executable.as_os_str()) + .await + .expect("spawn failing fake app-server"); + let sibling = CodexAppServerConnection::spawn_with_executable(&llm, executable.as_os_str()) + .await + .expect("spawn sibling fake app-server"); + let failing_key = format!( + "test-terminal-unknown-failing-{}", + failing + .inner + .child + .lock() + .await + .as_ref() + .and_then(tokio::process::Child::id) + .expect("failing child pid") + ); + let sibling_key = format!( + "test-terminal-unknown-sibling-{}", + sibling + .inner + .child + .lock() + .await + .as_ref() + .and_then(tokio::process::Child::id) + .expect("sibling child pid") + ); + { + let mut pool = game_creator_codex_app_server_pool().lock().await; + pool.insert(failing_key.clone(), Arc::clone(&failing.inner)); + pool.insert(sibling_key.clone(), Arc::clone(&sibling.inner)); + } + + let result = failing + .run_turn( + &test_snapshot(), + &llm, + tool_request().with_request_timeout_ms(50), + None, + ) + .await; + let failing_closed = failing.inner.closed.load(Ordering::Acquire); + let failing_child_reaped = failing.inner.child.lock().await.is_none(); + let sibling_closed = sibling.inner.closed.load(Ordering::Acquire); + let sibling_child_live = sibling.inner.child.lock().await.is_some(); + let (failing_still_pooled, sibling_still_pooled) = { + let mut pool = game_creator_codex_app_server_pool().lock().await; + let failing_still_pooled = pool + .values() + .any(|candidate| Arc::ptr_eq(candidate, &failing.inner)); + let sibling_still_pooled = pool + .values() + .any(|candidate| Arc::ptr_eq(candidate, &sibling.inner)); + pool.remove(&failing_key); + pool.remove(&sibling_key); + (failing_still_pooled, sibling_still_pooled) + }; + shutdown_game_creator_codex_app_server_inner(&failing.inner, "test cleanup").await; + shutdown_game_creator_codex_app_server_inner(&sibling.inner, "test cleanup").await; + + let error = result.expect_err("missing turn/completed must fail closed"); + let platform_llm::LlmError::Transport(message) = error else { + panic!("terminal-unknown must stay a transport error: {error}"); + }; + assert!(message.starts_with(GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX)); + assert!(message.contains("等待 turn/completed 超时")); + assert!(failing_closed); + assert!(failing_child_reaped); + assert!(!failing_still_pooled); + assert!(!sibling_closed); + assert!(sibling_child_live); + assert!(sibling_still_pooled); + } + + #[tokio::test] + #[ignore = "requires an installed and authenticated Codex CLI and consumes one model turn"] + async fn codex_app_server_real_smoke_uses_existing_local_auth() { + let mut llm = GameCreatorLlmConfig::default(); + llm.api_key.clear(); + if let Ok(model) = std::env::var("AGC_CODEX_APP_SERVER_SMOKE_MODEL") { + llm.model = model; + } + let request = LlmRunRequest::single_turn( + "只回复指定文本,不使用任何工具。", + "请只回复 CODEX_APP_SERVER_SMOKE_OK", + ); + let response = request_game_creator_agent_codex_app_server(&test_snapshot(), &llm, request) + .await + .expect("real Codex app-server request"); + assert_eq!(response.text.trim(), "CODEX_APP_SERVER_SMOKE_OK"); + assert!(response.tool_calls.is_empty()); + } +} 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 new file mode 100644 index 000000000..d26107510 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -0,0 +1,783 @@ +use super::*; +use std::process::Stdio; + +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; + +const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex"; +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; + +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(in crate::agent) fn game_creator_codex_cli_reasoning_effort( + request: &LlmRunRequest, +) -> Option<&'static str> { + request + .response_reasoning_effort + .map(|effort| match effort { + platform_llm::LlmResponseReasoningEffort::Low => "low", + platform_llm::LlmResponseReasoningEffort::Medium => "medium", + platform_llm::LlmResponseReasoningEffort::High => "high", + }) +} + +pub(in crate::agent) fn game_creator_codex_cli_tool_output_schema( + request: &LlmRunRequest, +) -> Option { + if request.function_tools.is_empty() { + return None; + } + let names = request + .function_tools + .iter() + .map(|tool| serde_json::Value::String(tool.name.clone())) + .collect::>(); + Some(serde_json::json!({ + "type": "object", + "required": ["toolCalls"], + "additionalProperties": false, + "properties": { + "toolCalls": { + "type": "array", + "minItems": 1, + "maxItems": AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT, + "items": { + "type": "object", + "required": ["name", "arguments"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "enum": names }, + "arguments": { + "type": "string", + "description": "严格 JSON 对象字符串,必须符合对应函数 parameters schema" + } + } + } + } + } + })) +} + +pub(in crate::agent) fn render_game_creator_codex_cli_prompt( + request: &LlmRunRequest, +) -> Result { + let messages = serde_json::to_string_pretty(&request.messages) + .map_err(|error| format!("序列化 Codex CLI Agent 消息失败:{error}"))?; + let functions = serde_json::to_string_pretty(&request.function_tools) + .map_err(|error| format!("序列化 Codex CLI Agent 函数目录失败:{error}"))?; + let output_contract = if request.function_tools.is_empty() { + "本次没有 Runtime 函数目录。请直接返回请求要求的最终正文;不要使用 Markdown 代码围栏。" + .to_string() + } else { + "本次必须只返回 output schema 要求的 toolCalls 对象。每个 name 必须来自函数目录;arguments 必须是一个严格 JSON 对象的字符串形式,并符合该函数 parameters。不要返回普通正文、Markdown、解释或额外字段。" + .to_string() + }; + let prompt = format!( + "你是 Genarrative AI 游戏创作 Runtime 当前节点的推理 Agent。\n\n安全边界:不要调用 Codex 内置 shell、文件、网络、MCP、插件、Skill 或子 Agent;不要读取当前临时目录。项目事实只来自下面的消息,项目行动只能通过返回 Runtime 函数请求完成。不得声称已经执行尚未由 Runtime observation 证明的动作。\n\n{output_contract}\n\n以下消息按 role 保持原顺序,是本次节点的完整请求:\n{messages}\n\n以下是当前 Runtime 实际广告的函数目录:\n{functions}" + ); + if prompt.len() > GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES { + return Err(format!( + "Codex CLI Agent prompt 超过 {} 字节上限", + GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES + )); + } + Ok(prompt) +} + +pub(in crate::agent) fn game_creator_codex_cli_minimal_environment( + command: &mut tokio::process::Command, +) { + command.env_clear(); + for name in [ + "PATH", + "HOME", + "USERPROFILE", + "APPDATA", + "LOCALAPPDATA", + "CODEX_HOME", + "TMPDIR", + "TEMP", + "TMP", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "SystemRoot", + "WINDIR", + "ComSpec", + "PATHEXT", + ] { + if let Some(value) = std::env::var_os(name) { + command.env(name, value); + } + } + command.env("NO_COLOR", "1").env("TERM", "dumb"); +} + +pub(in crate::agent) fn configure_game_creator_codex_cli_process( + command: &mut tokio::process::Command, +) { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.as_std_mut().process_group(0); + #[cfg(target_os = "linux")] + unsafe { + command.as_std_mut().pre_exec(|| { + if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 { + return Err(std::io::Error::last_os_error()); + } + if libc::getppid() == 1 { + return Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "Codex CLI parent exited before child exec", + )); + } + Ok(()) + }); + } + } + #[cfg(windows)] + crate::configure_windows_background_tokio_command(command, true); +} + +pub(in crate::agent) async fn terminate_game_creator_codex_cli_process_tree( + child: &mut tokio::process::Child, +) { + if let Some(process_id) = child.id() { + #[cfg(unix)] + unsafe { + libc::kill(-(process_id as i32), libc::SIGKILL); + } + #[cfg(windows)] + { + let mut command = tokio::process::Command::new("taskkill"); + command + .args(["/PID", &process_id.to_string(), "/T", "/F"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + crate::configure_windows_background_tokio_command(&mut command, false); + let _ = command.status().await; + } + } + let _ = child.kill().await; + let _ = child.wait().await; +} + +async fn read_game_creator_codex_cli_output( + mut reader: R, + max_bytes: usize, +) -> Result, String> +where + R: AsyncRead + Unpin, +{ + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 8 * 1024]; + let mut exceeded = false; + loop { + let count = reader + .read(&mut buffer) + .await + .map_err(|error| format!("读取 Codex CLI Agent 输出失败:{error}"))?; + if count == 0 { + break; + } + let remaining = max_bytes.saturating_sub(bytes.len()); + bytes.extend_from_slice(&buffer[..count.min(remaining)]); + exceeded |= count > remaining; + } + if exceeded { + return Err(format!("Codex CLI Agent 输出超过 {max_bytes} 字节上限")); + } + Ok(bytes) +} + +async fn summarize_game_creator_codex_cli_stderr( + mut reader: R, + max_bytes: usize, +) -> Result +where + R: AsyncRead + Unpin, +{ + let mut byte_len = 0_usize; + let mut sha256 = Sha256::new(); + let mut buffer = [0_u8; 8 * 1024]; + loop { + let count = reader + .read(&mut buffer) + .await + .map_err(|_| "读取 Codex CLI Agent stderr 失败".to_string())?; + if count == 0 { + break; + } + byte_len = byte_len.saturating_add(count); + sha256.update(&buffer[..count]); + } + Ok(CodexCliStderrSummary { + byte_len, + sha256: format!("{:x}", sha256.finalize()), + classification: if byte_len == 0 { + "empty" + } else if byte_len > max_bytes { + "oversized" + } else { + "nonempty" + }, + }) +} + +fn parse_game_creator_codex_cli_response( + stdout: &[u8], + request: &LlmRunRequest, +) -> Result { + let stdout = std::str::from_utf8(stdout).map_err(|_| { + platform_llm::LlmError::Deserialize("Codex CLI Agent JSONL 不是 UTF-8".to_string()) + })?; + let mut response_id = None; + let mut final_message = None; + let mut usage = None; + let mut completed = false; + for (index, line) in stdout.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let event = serde_json::from_str::(line).map_err(|_| { + platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent JSONL 第 {} 行无效", + index.saturating_add(1) + )) + })?; + match event.get("type").and_then(serde_json::Value::as_str) { + Some("thread.started") => { + response_id = event + .get("thread_id") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + } + Some("item.completed") + if event + .pointer("/item/type") + .and_then(serde_json::Value::as_str) + == Some("agent_message") => + { + final_message = event + .pointer("/item/text") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + } + Some("turn.completed") => { + completed = true; + let prompt_tokens = event + .pointer("/usage/input_tokens") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let completion_tokens = event + .pointer("/usage/output_tokens") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let total_tokens = prompt_tokens.saturating_add(completion_tokens); + if total_tokens > 0 { + usage = Some(platform_llm::LlmTokenUsage { + prompt_tokens, + completion_tokens, + total_tokens, + }); + } + } + Some("turn.failed") => { + return Err(platform_llm::LlmError::Transport( + "Codex CLI Agent 返回失败终态".to_string(), + )); + } + _ => {} + } + } + if !completed { + return Err(platform_llm::LlmError::Deserialize( + "Codex CLI Agent 缺少 turn.completed 终态".to_string(), + )); + } + let text = final_message + .filter(|text| !text.trim().is_empty()) + .ok_or(platform_llm::LlmError::EmptyResponse)?; + let tool_calls = if request.function_tools.is_empty() { + Vec::new() + } else { + let envelope = serde_json::from_str::(&text).map_err(|_| { + platform_llm::LlmError::Deserialize( + "Codex CLI Agent structured output 不是严格 JSON".to_string(), + ) + })?; + let calls = envelope + .get("toolCalls") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + platform_llm::LlmError::Deserialize( + "Codex CLI Agent structured output 缺少 toolCalls".to_string(), + ) + })?; + calls + .iter() + .enumerate() + .map(|(index, call)| { + let name = call + .get("name") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + platform_llm::LlmError::Deserialize( + "Codex CLI Agent tool call 缺少 name".to_string(), + ) + })?; + let arguments = call + .get("arguments") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + platform_llm::LlmError::Deserialize( + "Codex CLI Agent tool call 缺少 arguments".to_string(), + ) + })?; + if !request.function_tools.iter().any(|tool| tool.name == name) { + return Err(platform_llm::LlmError::Deserialize( + "Codex CLI Agent 返回未广告函数".to_string(), + )); + } + Ok(platform_llm::LlmToolCall { + id: format!("codex-cli-call-{}", index.saturating_add(1)), + name: name.to_string(), + arguments: arguments.to_string(), + }) + }) + .collect::, _>>()? + }; + Ok(platform_llm::LlmRunResponse { + provider: platform_llm::LlmProvider::OpenAiCompatible, + model: "codex-cli".to_string(), + text: if tool_calls.is_empty() { + text + } else { + String::new() + }, + finish_reason: Some("stop".to_string()), + response_id, + usage, + tool_calls, + }) +} + +async fn request_game_creator_agent_codex_cli_with_executable( + executable: &std::ffi::OsStr, + request: LlmRunRequest, +) -> Result { + let prompt = render_game_creator_codex_cli_prompt(&request) + .map_err(platform_llm::LlmError::InvalidRequest)?; + let temp_dir = tempfile::Builder::new() + .prefix("genarrative-agc-codex-cli-") + .tempdir() + .map_err(|error| { + platform_llm::LlmError::Transport(format!("创建 Codex CLI Agent 临时目录失败:{error}")) + })?; + let schema_path = if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) { + let path = temp_dir.path().join("tool-output.schema.json"); + let content = serde_json::to_vec(&schema).map_err(|error| { + platform_llm::LlmError::InvalidRequest(format!( + "序列化 Codex CLI Agent output schema 失败:{error}" + )) + })?; + std::fs::write(&path, content).map_err(|error| { + platform_llm::LlmError::Transport(format!( + "写入 Codex CLI Agent output schema 失败:{error}" + )) + })?; + Some(path) + } else { + None + }; + + let mut command = tokio::process::Command::new(executable); + command + .arg("exec") + .arg("--json") + .arg("--ephemeral") + .arg("--ignore-user-config") + .arg("--ignore-rules") + .arg("--skip-git-repo-check") + .arg("--sandbox") + .arg("read-only") + .arg("--disable") + .arg("shell_tool") + .arg("--config") + .arg("approval_policy=\"never\"") + .arg("--cd") + .arg(temp_dir.path()); + if let Some(effort) = game_creator_codex_cli_reasoning_effort(&request) { + command + .arg("--config") + .arg(format!("model_reasoning_effort=\"{effort}\"")); + } + if let Some(path) = schema_path.as_ref() { + command.arg("--output-schema").arg(path); + } + command + .arg("-") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + game_creator_codex_cli_minimal_environment(&mut command); + configure_game_creator_codex_cli_process(&mut command); + + let mut child = command.spawn().map_err(|error| { + platform_llm::LlmError::InvalidConfig(format!( + "Codex CLI Agent 不可用,请安装并登录 Codex CLI:{error}" + )) + })?; + let mut stdin = child.stdin.take().ok_or_else(|| { + platform_llm::LlmError::Transport("Codex CLI Agent stdin 未建立".to_string()) + })?; + stdin.write_all(prompt.as_bytes()).await.map_err(|error| { + platform_llm::LlmError::Transport(format!("写入 Codex CLI Agent prompt 失败:{error}")) + })?; + stdin.shutdown().await.map_err(|error| { + platform_llm::LlmError::Transport(format!("关闭 Codex CLI Agent stdin 失败:{error}")) + })?; + drop(stdin); + + let stdout = child.stdout.take().ok_or_else(|| { + platform_llm::LlmError::Transport("Codex CLI Agent stdout 未建立".to_string()) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + platform_llm::LlmError::Transport("Codex CLI Agent stderr 未建立".to_string()) + })?; + let stdout_task = tokio::spawn(read_game_creator_codex_cli_output( + stdout, + GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES, + )); + let stderr_task = tokio::spawn(summarize_game_creator_codex_cli_stderr( + stderr, + GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES, + )); + let timeout_ms = request + .request_timeout_ms + .unwrap_or(GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS) + .max(1); + let wait = + tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), child.wait()).await; + let (status, timed_out) = match wait { + Ok(Ok(status)) => (Some(status), false), + Ok(Err(error)) => { + terminate_game_creator_codex_cli_process_tree(&mut child).await; + return Err(platform_llm::LlmError::Transport(format!( + "等待 Codex CLI Agent 退出失败:{error}" + ))); + } + Err(_) => { + terminate_game_creator_codex_cli_process_tree(&mut child).await; + (None, true) + } + }; + let stdout = stdout_task + .await + .map_err(|_| { + platform_llm::LlmError::Transport("Codex CLI Agent stdout 读取任务失败".to_string()) + })? + .map_err(platform_llm::LlmError::Transport)?; + let stderr = stderr_task + .await + .map_err(|_| { + platform_llm::LlmError::Transport("Codex CLI Agent stderr 读取任务失败".to_string()) + })? + .map_err(platform_llm::LlmError::Transport)?; + if timed_out { + return Err(platform_llm::LlmError::Timeout { attempts: 1 }); + } + let status = status.expect("non-timeout Codex CLI wait has exit status"); + if stderr.classification == "oversized" { + return Err(platform_llm::LlmError::Transport(format!( + "Codex CLI Agent stderr 超过 {} 字节上限;stderrClass={};stderrBytes={};stderrSha256={}", + GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES, + stderr.classification, + stderr.byte_len, + stderr.sha256 + ))); + } + if !status.success() { + return Err(platform_llm::LlmError::Transport(format!( + "Codex CLI Agent 退出失败(code={});stderrClass={};stderrBytes={};stderrSha256={};请检查 Codex CLI 登录状态、网络和本机配置", + status + .code() + .map(|code| code.to_string()) + .unwrap_or_else(|| "none".to_string()), + stderr.classification, + stderr.byte_len, + stderr.sha256 + ))); + } + parse_game_creator_codex_cli_response(&stdout, &request) +} + +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 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tool_request() -> LlmRunRequest { + LlmRunRequest::single_turn("系统", "任务").with_function_tools(vec![ + platform_llm::LlmFunctionTool::new( + "runtime_tool_file_read", + "读取文件", + serde_json::json!({"type":"object"}), + ), + ]) + } + + #[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"); + assert!(prompt.contains("runtime_tool_file_read")); + assert!(prompt.contains("项目行动只能通过返回 Runtime 函数请求完成")); + assert!(!prompt.contains(GAME_CREATOR_CODEX_CLI_EXECUTABLE)); + let schema = game_creator_codex_cli_tool_output_schema(&tool_request()).expect("schema"); + assert_eq!( + schema.pointer("/properties/toolCalls/items/properties/name/enum/0"), + Some(&serde_json::json!("runtime_tool_file_read")) + ); + } + + #[test] + fn codex_cli_mode_removes_ambient_api_key_from_effective_environment() { + let mut command = tokio::process::Command::new("codex"); + command.env("CODEX_API_KEY", "ambient-secret"); + game_creator_codex_cli_minimal_environment(&mut command); + assert!(!command + .as_std() + .get_envs() + .any(|(name, value)| name == "CODEX_API_KEY" && value.is_some())); + } + + #[tokio::test] + async fn codex_cli_mode_bounds_unterminated_output_and_summarizes_stderr_without_raw_text() { + let output = vec![b'x'; 33]; + let error = read_game_creator_codex_cli_output(output.as_slice(), 32) + .await + .expect_err("unterminated output over the cap must fail"); + assert!(error.contains("超过 32 字节上限")); + + let stderr = b"private-auth-detail-without-newline"; + let summary = summarize_game_creator_codex_cli_stderr(stderr.as_slice(), 256) + .await + .expect("summarize stderr"); + assert_eq!(summary.classification, "nonempty"); + assert_eq!(summary.byte_len, stderr.len()); + assert_eq!(summary.sha256, format!("{:x}", Sha256::digest(stderr))); + assert!(!summary.sha256.contains("private-auth-detail")); + } + + #[test] + fn codex_cli_mode_maps_structured_agent_message_to_existing_tool_calls() { + let stdout = concat!( + "{\"type\":\"thread.started\",\"thread_id\":\"thread-1\"}\n", + "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"{\\\"toolCalls\\\":[{\\\"name\\\":\\\"runtime_tool_file_read\\\",\\\"arguments\\\":\\\"{\\\\\\\"reason\\\\\\\":\\\\\\\"检查\\\\\\\",\\\\\\\"input\\\\\\\":{\\\\\\\"path\\\\\\\":\\\\\\\"game/index.html\\\\\\\"}}\\\"}]}\"}}\n", + "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":7}}\n" + ); + let response = parse_game_creator_codex_cli_response(stdout.as_bytes(), &tool_request()) + .expect("parse response"); + assert_eq!(response.response_id.as_deref(), Some("thread-1")); + assert_eq!(response.tool_calls.len(), 1); + assert_eq!(response.tool_calls[0].name, "runtime_tool_file_read"); + assert_eq!(response.usage.expect("usage").total_tokens, 19); + } + + #[test] + fn codex_cli_mode_maps_plain_final_message_and_requires_terminal_event() { + let request = LlmRunRequest::single_turn("系统", "任务"); + let stdout = concat!( + "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"完成\"}}\n", + "{\"type\":\"turn.completed\",\"usage\":{}}\n" + ); + let response = parse_game_creator_codex_cli_response(stdout.as_bytes(), &request) + .expect("parse response"); + assert_eq!(response.text, "完成"); + assert!(response.tool_calls.is_empty()); + assert!(matches!( + parse_game_creator_codex_cli_response( + b"{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"x\"}}\n", + &request + ), + Err(platform_llm::LlmError::Deserialize(_)) + )); + + let recovered_stdout = concat!( + "{\"type\":\"error\",\"message\":\"Reconnecting\"}\n", + "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"恢复成功\"}}\n", + "{\"type\":\"turn.completed\",\"usage\":{}}\n" + ); + assert_eq!( + parse_game_creator_codex_cli_response(recovered_stdout.as_bytes(), &request) + .expect("短暂 error 事件后完成") + .text, + "恢复成功" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn codex_cli_mode_builds_ephemeral_json_exec_with_prompt_on_stdin() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp.path().join("fake-codex"); + std::fs::write( + &executable, + r#"#!/bin/sh +prompt="$(cat)" +case "$prompt" in + *"CODEx_STDIN_MARKER"*) ;; + *) exit 41 ;; +esac +has_exec=0 +has_json=0 +has_ephemeral=0 +has_ignore_user_config=0 +has_read_only=0 +has_shell_disabled=0 +previous="" +for argument in "$@"; do + [ "$argument" = "exec" ] && has_exec=1 + [ "$argument" = "--json" ] && has_json=1 + [ "$argument" = "--ephemeral" ] && has_ephemeral=1 + [ "$argument" = "--ignore-user-config" ] && has_ignore_user_config=1 + [ "$previous:$argument" = "--sandbox:read-only" ] && has_read_only=1 + [ "$previous:$argument" = "--disable:shell_tool" ] && has_shell_disabled=1 + previous="$argument" +done +[ "$has_exec$has_json$has_ephemeral$has_ignore_user_config$has_read_only$has_shell_disabled" = "111111" ] || exit 42 +printf '%s\n' '{"type":"thread.started","thread_id":"fake-thread"}' +printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"fixture-ok"}}' +printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens":2}}' +"#, + ) + .expect("write fake codex"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod fake codex"); + + let request = LlmRunRequest::single_turn("系统", "CODEx_STDIN_MARKER"); + let response = + request_game_creator_agent_codex_cli_with_executable(executable.as_os_str(), request) + .await + .expect("fake codex request"); + assert_eq!(response.text, "fixture-ok"); + assert_eq!(response.response_id.as_deref(), Some("fake-thread")); + assert_eq!(response.usage.expect("usage").total_tokens, 5); + } + + #[cfg(unix)] + #[tokio::test] + async fn codex_cli_mode_does_not_expose_process_stderr_on_failure() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp.path().join("fake-codex-failure"); + std::fs::write( + &executable, + "#!/bin/sh\nprintf '%s\\n' 'secret-auth-detail' >&2\nexit 43\n", + ) + .expect("write fake codex"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod fake codex"); + + let error = request_game_creator_agent_codex_cli_with_executable( + executable.as_os_str(), + LlmRunRequest::single_turn("系统", "任务"), + ) + .await + .expect_err("fake Codex should fail") + .to_string(); + assert!(error.contains("code=43")); + assert!(error.contains("stderrClass=nonempty")); + assert!(error.contains("stderrBytes=19")); + assert!(error.contains("stderrSha256=")); + assert!(error.contains("登录状态")); + assert!(!error.contains("secret-auth-detail")); + } + + #[cfg(unix)] + #[tokio::test] + async fn codex_cli_mode_enforces_runtime_request_timeout() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp.path().join("fake-codex-timeout"); + std::fs::write(&executable, "#!/bin/sh\ncat >/dev/null\nsleep 30\n") + .expect("write fake codex"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod fake codex"); + + let error = request_game_creator_agent_codex_cli_with_executable( + executable.as_os_str(), + LlmRunRequest::single_turn("系统", "任务").with_request_timeout_ms(20), + ) + .await + .expect_err("fake Codex should time out"); + assert_eq!(error, platform_llm::LlmError::Timeout { attempts: 1 }); + } + + #[tokio::test] + #[ignore = "requires an installed and authenticated Codex CLI"] + async fn codex_cli_real_smoke_uses_existing_local_auth() { + let request = LlmRunRequest::single_turn( + "只回复指定文本,不使用任何工具。", + "请只回复 CODEX_CLI_SMOKE_OK", + ); + let response = request_game_creator_agent_codex_cli(request) + .await + .expect("real Codex CLI request"); + assert_eq!(response.text.trim(), "CODEX_CLI_SMOKE_OK"); + assert!(response.tool_calls.is_empty()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs index cf43c709d..1c3ef53a9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs @@ -6,6 +6,7 @@ const AGENT_INTERACTION_RESUME_TOOL: &str = "runtime_resume"; const AGENT_INTERACTION_PROJECT_LOCATION_TOOL: &str = "project_location"; const AGENT_INTERACTION_MAX_OUTPUT_TOKENS: u32 = 1_200; const AGENT_INTERACTION_PROVIDER_INSTANCE_ID: &str = "agc-interaction"; +pub(crate) const AGENT_RUNTIME_STEER_DECISION_TOOL: &str = "runtime_steer_decision"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum AgentInteractionToolKind { @@ -80,6 +81,171 @@ enum AgentInteractionTextEnvelope { ProjectLocation, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AgentRuntimeSteerDecisionArguments { + reply: String, + interrupt_current_provider: bool, + reason: String, +} + +fn agent_runtime_steer_decision_function_tool() -> platform_llm::LlmFunctionTool { + platform_llm::LlmFunctionTool::new( + AGENT_RUNTIME_STEER_DECISION_TOOL, + "回复用户,并判断是否必须中断当前正在进行的 LLM Provider 请求。", + serde_json::json!({ + "type": "object", + "required": ["reply", "interruptCurrentProvider", "reason"], + "additionalProperties": false, + "properties": { + "reply": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "interruptCurrentProvider": { "type": "boolean" }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + } + }), + ) + .with_strict(true) +} + +fn build_agent_runtime_steer_decision_request( + root: &Path, + state: &AgentRuntimeState, + instruction: &str, +) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> { + let (llm, config_path, context) = build_game_creator_role_agent_context_for_session( + root, + &state.agent_id, + Some(&state.session_id), + )?; + let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?; + let runtime_summary = serde_json::json!({ + "status": state.status, + "phase": state.phase, + "currentAction": state.current_action, + "waitingOn": state.waiting_on, + "nextStep": state.next_step, + "plan": state.plan, + "appliedSteerCursor": state.applied_steer_cursor, + }); + let system = format!( + "{}\n\n你现在是项目总控的非终态对话判定层。一个制作 Run 正在执行,你必须先自然回答用户,再判断是否需要中断当前正在进行的 LLM Provider 请求。不要因为收到新消息就默认中断:状态询问、解释、鼓励、确认以及不冲突的补充应保持 interruptCurrentProvider=false;只有用户明确停止/改向,或新要求会让当前 LLM 继续生成明显过期方案时才设为 true。宿主工具和已经开始的外部动作不会被此判定强杀,它们会在安全边界完成。你的回复不能宣称整个 Run 已完成,也不能把这次回复当作终态。必须调用且只调用 runtime_steer_decision。", + game_creator_project_supervisor_chat_system_prompt() + ); + let user = format!( + "项目上下文如下,只作为背景,不要逐字复述:\n{context}\n\n当前 Runtime 摘要:\n{}\n\n用户在制作过程中发来的消息:\n{}", + serde_json::to_string_pretty(&runtime_summary) + .map_err(|error| format!("序列化 steer Runtime 摘要失败:{error}"))?, + instruction.trim(), + ); + let request = LlmRunRequest::single_turn(system, user) + .with_api_kind(api_kind) + .with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS) + .with_function_tools(vec![agent_runtime_steer_decision_function_tool()]) + .with_tool_choice(platform_llm::LlmToolChoice::Required); + let request = apply_game_creator_llm_reasoning_effort(request, &llm)?; + Ok((llm, config_path, request)) +} + +fn parse_agent_runtime_steer_decision_response( + response: &platform_llm::LlmRunResponse, +) -> Result { + if response.tool_calls.len() != 1 { + return Err("Supervisor steer decision 必须返回一个 runtime_steer_decision".to_string()); + } + let call = &response.tool_calls[0]; + if call.name != AGENT_RUNTIME_STEER_DECISION_TOOL { + return Err(format!( + "Supervisor steer decision 返回未知工具:{}", + call.name + )); + } + let arguments = serde_json::from_str::(&call.arguments) + .map_err(|error| format!("runtime_steer_decision 参数无效:{error}"))?; + let reply = arguments.reply.trim(); + let reason = arguments.reason.trim(); + if reply.is_empty() || reason.is_empty() { + return Err("runtime_steer_decision reply/reason 不能为空".to_string()); + } + Ok(AgentRuntimeSteerDecision { + reply: reply.to_string(), + interrupt_current_provider: arguments.interrupt_current_provider, + reason: reason.to_string(), + }) +} + +pub(crate) async fn decide_game_creator_agent_runtime_steer_at( + root: &Path, + state: &AgentRuntimeState, + steer_id: &str, + sequence: u64, + instruction: &str, +) -> Result { + if let Some(decision) = read_game_creator_agent_runtime_steer_decision_at( + root, + &state.agent_id, + &state.run_id, + steer_id, + )? { + return Ok(decision); + } + if state.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || state.parent_agent_id.is_some() + || state.parent_run_id.is_some() + { + return Err("只有根 Project Supervisor 支持运行中非终态回复".to_string()); + } + let (llm, config_path, request) = + build_agent_runtime_steer_decision_request(root, state, instruction)?; + let decision_snapshot = { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.steer_decision.snapshot", + )?; + let mut snapshot = capture_game_creator_agent_runtime_provider_request_snapshot_at_locked( + root, + &state.agent_id, + &state.session_id, + &state.run_id, + "steer-decision", + &format!("steer-{sequence}"), + state.applied_steer_cursor, + )?; + // The main Runtime Provider request must keep running while this + // independent LLM turn decides whether it is stale. A distinct node + // identity gives Codex app-server a separate process/thread gate. + snapshot.agent_id = format!("{}-steer-decision", state.agent_id); + snapshot.task_id = snapshot.agent_id.clone(); + snapshot + }; + let agent_mode = + normalize_game_creator_agent_mode(&load_game_creator_app_config()?.agent_mode)?; + let response = match agent_mode.as_str() { + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER => { + request_game_creator_agent_codex_app_server(&decision_snapshot, &llm, request).await + } + GAME_CREATOR_AGENT_MODE_CODEX_CLI => request_game_creator_agent_codex_cli(request).await, + GAME_CREATOR_AGENT_MODE_PROVIDER => { + build_game_creator_agent_runtime_llm_client(&llm, &config_path)? + .run(request) + .await + } + _ => unreachable!("agent mode is normalized"), + } + .map_err(|error| format!("Supervisor steer decision 调用 LLM 失败:{error}"))?; + let decision = parse_agent_runtime_steer_decision_response(&response)?; + persist_game_creator_agent_runtime_steer_decision_and_reply_at( + root, state, steer_id, sequence, decision, + ) +} + pub(crate) fn game_creator_agent_uses_interaction_kernel(agent_id: &str) -> bool { if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { return true; @@ -407,6 +573,40 @@ mod tests { assert!(error.contains("最多只能选择一个")); } + #[test] + fn steer_decision_replies_without_interrupting_status_questions() { + let decision = parse_agent_runtime_steer_decision_response(&response( + "", + vec![tool_call( + AGENT_RUNTIME_STEER_DECISION_TOOL, + r#"{"reply":"我正在完成玩法实现,当前任务会继续。","interruptCurrentProvider":false,"reason":"这是状态询问,不会使当前方案过期。"}"#, + )], + )) + .expect("parse non-interrupting steer decision"); + assert_eq!( + decision, + AgentRuntimeSteerDecision { + reply: "我正在完成玩法实现,当前任务会继续。".to_string(), + interrupt_current_provider: false, + reason: "这是状态询问,不会使当前方案过期。".to_string(), + } + ); + } + + #[test] + fn steer_decision_can_request_safe_interrupt_for_conflicting_change() { + let decision = parse_agent_runtime_steer_decision_response(&response( + "", + vec![tool_call( + AGENT_RUNTIME_STEER_DECISION_TOOL, + r#"{"reply":"明白,我会停止旧方向并改成回合制。","interruptCurrentProvider":true,"reason":"用户明确改向,旧 Provider 仍在生成过期方案。"}"#, + )], + )) + .expect("parse interrupting steer decision"); + assert!(decision.interrupt_current_provider); + assert!(decision.reply.contains("改成回合制")); + } + #[test] fn interaction_registry_derives_unique_strict_function_tools() { let registry = agent_interaction_tool_registry().expect("interaction registry"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs index 6cd4c006b..1543951f7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs @@ -135,6 +135,11 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_ let stream_response = llm.stream; let request_stream_snapshot = stream_snapshot.clone(); let response_observations = observations; + let agent_mode = + normalize_game_creator_agent_mode(&load_game_creator_app_config()?.agent_mode)?; + let llm_for_request = llm.clone(); + let snapshot_for_request = provider_snapshot.clone(); + let config_path_for_request = config_path.clone(); let response_result = request_game_creator_agent_runtime_llm_with_persisted_transient_retry_using( root, @@ -143,9 +148,71 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_ &config_path, "后台 Agent 最终回复", &request, - move |client, attempt_request| { + agent_mode.clone(), + move |attempt_request| { let stream_snapshot = request_stream_snapshot.clone(); async move { + if agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { + if stream_response { + let mut publisher = AgentRuntimeResponseStreamPublisher::start( + root, + &stream_snapshot, + response_revision, + ); + return match stream_game_creator_agent_codex_app_server( + &snapshot_for_request, + &llm_for_request, + attempt_request, + |delta| { + if !suppress_private_process_output { + publisher.push(delta); + } + }, + ) + .await + { + Ok(response) => { + match normalize_game_creator_agent_background_final_reply_response( + response, + response_observations, + ) { + Ok(response) => { + publisher.handoff(); + Ok(response) + } + Err(error) => { + publisher.failed(); + Err(error) + } + } + } + Err(error) => { + publisher.failed(); + Err(error) + } + }; + } + return normalize_game_creator_agent_background_final_reply_response( + request_game_creator_agent_codex_app_server( + &snapshot_for_request, + &llm_for_request, + attempt_request, + ) + .await?, + response_observations, + ); + } + if agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_CLI { + return normalize_game_creator_agent_background_final_reply_response( + request_game_creator_agent_codex_cli(attempt_request).await?, + response_observations, + ); + } + let client = build_game_creator_agent_runtime_llm_client( + &llm_for_request, + &config_path_for_request, + ) + .map_err(platform_llm::LlmError::InvalidConfig)?; if stream_response { let mut publisher = AgentRuntimeResponseStreamPublisher::start( root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs index ae7de3fc0..b6953e4a3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs @@ -1,5 +1,18 @@ use super::*; +fn provider_retry_identity_for_response_stream_test( + snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + request: &LlmRunRequest, +) -> Result { + game_creator_agent_runtime_provider_retry_identity_for_mode( + snapshot, + llm, + request, + GAME_CREATOR_AGENT_MODE_PROVIDER, + ) +} + fn response_stream_fixture( run_id: &str, ) -> ( @@ -75,10 +88,10 @@ fn persist_tool_plan_handoff_repair_chain( LlmMessage::user("修复格式"), ]); let base_identity = - game_creator_agent_runtime_provider_retry_identity(&base_snapshot, llm, &base_request) + provider_retry_identity_for_response_stream_test(&base_snapshot, llm, &base_request) .expect("build cleanup base identity"); let repair_identity = - game_creator_agent_runtime_provider_retry_identity(&repair_snapshot, llm, &repair_request) + provider_retry_identity_for_response_stream_test(&repair_snapshot, llm, &repair_request) .expect("build cleanup repair identity"); let base_request_id = game_creator_agent_runtime_provider_request_id(&base_snapshot); let repair_request_id = game_creator_agent_runtime_provider_request_id(&repair_snapshot); @@ -972,7 +985,7 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon let mut new_llm = old_llm.clone(); new_llm.model = "new-provider-model".to_string(); let old_identity = - game_creator_agent_runtime_provider_retry_identity(&snapshot, &old_llm, &request) + provider_retry_identity_for_response_stream_test(&snapshot, &old_llm, &request) .expect("build old handoff identity"); let provider_request_id = game_creator_agent_runtime_provider_request_id(&snapshot); append_game_creator_agent_runtime_provider_request_lifecycle( @@ -1008,7 +1021,8 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon "agentLlm.design-director", "测试 Provider handoff 身份漂移", &request, - |_client, _request| async { + GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), + |_request| async { Err(platform_llm::LlmError::Transport( "identity drift must not call Provider".to_string(), )) @@ -1074,9 +1088,9 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo let mut new_llm = old_llm.clone(); new_llm.model = "new-tool-plan-model".to_string(); let base_identity = - game_creator_agent_runtime_provider_retry_identity(&base_snapshot, &old_llm, &base_request) + provider_retry_identity_for_response_stream_test(&base_snapshot, &old_llm, &base_request) .expect("build old base identity"); - let repair_identity = game_creator_agent_runtime_provider_retry_identity( + let repair_identity = provider_retry_identity_for_response_stream_test( &repair_snapshot, &old_llm, &repair_request, @@ -1135,7 +1149,8 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo "agentLlm.project-supervisor", "测试 tool-plan handoff 身份漂移", &base_request, - |_client, _request| async { + GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), + |_request| async { Err(platform_llm::LlmError::Transport( "identity drift must not call Provider".to_string(), )) @@ -1191,7 +1206,7 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov persist_tool_plan_handoff_repair_chain(root, &snapshot, &old_llm); let request = LlmRunRequest::new(vec![LlmMessage::user("验证通用 retry 身份漂移")]); let old_identity = - game_creator_agent_runtime_provider_retry_identity(&snapshot, &old_llm, &request) + provider_retry_identity_for_response_stream_test(&snapshot, &old_llm, &request) .expect("build old generic retry identity"); provider_retry::write_next_at( root, @@ -1214,7 +1229,8 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov "agentLlm.project-supervisor", "测试通用 retry 身份漂移", &request, - |_client, _request| async { + GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), + |_request| async { Err(platform_llm::LlmError::Transport( "generic retry drift must not call Provider".to_string(), )) @@ -1280,7 +1296,7 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() { "capacity request {loop_iteration}" ))]); let identity = - game_creator_agent_runtime_provider_retry_identity(&entry_snapshot, &llm, &request) + provider_retry_identity_for_response_stream_test(&entry_snapshot, &llm, &request) .expect("build capacity entry identity"); let request_id = game_creator_agent_runtime_provider_request_id(&entry_snapshot); let response = platform_llm::LlmRunResponse { @@ -1326,7 +1342,8 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() { "agentLlm.project-supervisor", "测试 tool-plan 请求前容量门禁", &request, - move |_client, _request| async move { + GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), + move |_request| async move { provider_called_for_request.store(true, Ordering::Release); Err(platform_llm::LlmError::Transport( "capacity gate must run before Provider".to_string(), @@ -1392,10 +1409,10 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem retry_backoff_ms: 1, }; let base_identity = - game_creator_agent_runtime_provider_retry_identity(&base_snapshot, &llm, &base_request) + provider_retry_identity_for_response_stream_test(&base_snapshot, &llm, &base_request) .expect("build durable control base identity"); let repair_identity = - game_creator_agent_runtime_provider_retry_identity(&repair_snapshot, &llm, &repair_request) + provider_retry_identity_for_response_stream_test(&repair_snapshot, &llm, &repair_request) .expect("build durable control repair identity"); let base_request_id = game_creator_agent_runtime_provider_request_id(&base_snapshot); let repair_request_id = game_creator_agent_runtime_provider_request_id(&repair_snapshot); @@ -1458,7 +1475,8 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem "agentLlm.project-supervisor", "测试 durable control 清理 tool-plan handoff", &base_request, - |_client, _request| async { + GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), + |_request| async { Err(platform_llm::LlmError::Transport( "durable control must not call Provider".to_string(), )) @@ -1513,7 +1531,7 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff( max_retries: 0, retry_backoff_ms: 1, }; - let identity = game_creator_agent_runtime_provider_retry_identity(&snapshot, &llm, &request) + let identity = provider_retry_identity_for_response_stream_test(&snapshot, &llm, &request) .expect("build cleanup handoff identity"); let request_id = game_creator_agent_runtime_provider_request_id(&snapshot); append_game_creator_agent_runtime_provider_request_lifecycle( @@ -1583,7 +1601,7 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() { max_retries: 0, retry_backoff_ms: 1, }; - let identity = game_creator_agent_runtime_provider_retry_identity(&snapshot, &llm, &request) + let identity = provider_retry_identity_for_response_stream_test(&snapshot, &llm, &request) .expect("build terminal handoff identity"); let request_id = game_creator_agent_runtime_provider_request_id(&snapshot); append_game_creator_agent_runtime_provider_request_lifecycle( @@ -1677,7 +1695,7 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat max_retries: 1, retry_backoff_ms: 1, }; - let identity = game_creator_agent_runtime_provider_retry_identity(&snapshot, &llm, &request) + let identity = provider_retry_identity_for_response_stream_test(&snapshot, &llm, &request) .expect("build Provider recovery identity"); let provider_request_id = game_creator_agent_runtime_provider_request_id(&snapshot); append_game_creator_agent_runtime_provider_request_lifecycle( @@ -1723,7 +1741,8 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat "agentLlm.design-director", "测试 Provider recovery 冲突", &request, - |_client, _request| async { + GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), + |_request| async { Err(platform_llm::LlmError::Transport( "reconciliation must not call Provider".to_string(), )) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs index 59cdf8a39..6c595a236 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -83,14 +83,20 @@ pub(crate) use run_configuration::{ }; pub(crate) use steering::{ acquire_game_creator_agent_runtime_steer_project_write_lock_with_wait, + append_game_creator_agent_runtime_steer_decision_failure_reply_at, consume_game_creator_agent_runtime_steers, game_creator_agent_runtime_accepts_steer, game_creator_agent_runtime_provider_request_count_for_roots, game_creator_agent_runtime_steer_ledger_path, + interrupt_game_creator_agent_runtime_provider_for_decided_steer_at, interrupt_game_creator_agent_runtime_provider_request_at, interrupt_game_creator_agent_runtime_provider_requests_for_roots, + persist_game_creator_agent_runtime_steer_decision_and_reply_at, + read_game_creator_agent_runtime_steer_decision_at, + register_game_creator_agent_runtime_provider_request, render_game_creator_agent_runtime_steers_for_prompt, steer_game_creator_agent_runtime_task_at, steer_game_creator_agent_runtime_task_for_profile_at, - validate_game_creator_agent_runtime_steer_notification_at, + unregister_game_creator_agent_runtime_provider_request, + validate_game_creator_agent_runtime_steer_notification_at, AgentRuntimeSteerDecision, }; pub(crate) use verification::{ game_creator_agent_runtime_verification_gate_path, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs index 2739cd17a..5f5b26ccf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs @@ -480,9 +480,10 @@ pub(in crate::agent) struct AgentRuntimeSteerLedgerSnapshot { } #[derive(Default)] -pub(in crate::agent) struct AgentRuntimeProviderInterrupt { - pub(in crate::agent) interrupted: AtomicBool, - pub(in crate::agent) notify: tokio::sync::Notify, +pub(crate) struct AgentRuntimeProviderInterrupt { + pub(crate) applied_steer_cursor: u64, + pub(crate) interrupted: AtomicBool, + pub(crate) notify: tokio::sync::Notify, } pub(in crate::agent) static GAME_CREATOR_AGENT_PROVIDER_INTERRUPTS: OnceLock< 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 74b0ce6f6..0a15a6860 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 @@ -71,6 +71,15 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_error_with_transient operation: &str, retry_autonomous_upstream_400: bool, ) -> String { + if matches!( + error, + platform_llm::LlmError::Transport(message) + if message.starts_with(GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX) + ) { + return format!( + "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: Codex app-server turn 终态未知" + ); + } let public_error = format!( "{config_path} {operation}调用 LLM 失败:{}", game_creator_agent_llm_error_public_summary(error) @@ -284,7 +293,76 @@ pub(in crate::agent) fn game_creator_agent_runtime_llm_request_fingerprint( pub(in crate::agent) fn game_creator_agent_runtime_provider_config_fingerprint( llm: &GameCreatorLlmConfig, ) -> Result { + let app_config = load_game_creator_app_config()?; + let agent_mode = normalize_game_creator_agent_mode(&app_config.agent_mode)?; + let codex_cli_version = if matches!( + agent_mode.as_str(), + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER | GAME_CREATOR_AGENT_MODE_CODEX_CLI + ) { + Some(game_creator_codex_cli_version_identity()?) + } else { + None + }; + game_creator_agent_runtime_provider_config_fingerprint_for_mode( + &agent_mode, + codex_cli_version.as_deref(), + llm, + ) +} + +fn game_creator_agent_runtime_provider_config_fingerprint_for_mode( + agent_mode: &str, + codex_cli_version: Option<&str>, + llm: &GameCreatorLlmConfig, +) -> Result { + let agent_mode = normalize_game_creator_agent_mode(agent_mode)?; + let codex_cli = if agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_CLI { + Some(serde_json::json!({ + "executable": "codex", + "version": codex_cli_version.ok_or_else(|| { + "Codex CLI 模式缺少 CLI 版本身份".to_string() + })?, + "protocol": "genarrative-codex-cli-agent.v1", + "sandbox": "read-only", + "shellTool": false, + "ephemeral": true, + "ignoreUserConfig": true, + "ignoreRules": true, + "approvalPolicy": "never", + })) + } else { + None + }; + let codex_app_server = if agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { + Some(serde_json::json!({ + "executable": "codex", + "version": codex_cli_version.ok_or_else(|| { + "Codex app-server 模式缺少 CLI 版本身份".to_string() + })?, + "protocol": game_creator_codex_app_server_protocol_identity(), + "providerRoute": if llm.api_key.trim().is_empty() { + "codex-login" + } else { + "agc-openai-responses" + }, + "sandbox": "read-only", + "networkAccess": false, + "shellTool": false, + "webSearch": false, + "multiAgent": false, + "isolatedOsHome": true, + "mcpServers": false, + "ephemeralThread": true, + "approvalPolicy": "never", + "outputSchema": "per-turn", + })) + } else { + None + }; let stable = serde_json::json!({ + "agentMode": agent_mode, + "codexAppServer": codex_app_server, + "codexCli": codex_cli, "apiKeySha256": format!("{:x}", Sha256::digest(llm.api_key.as_bytes())), "baseUrl": llm.base_url, "model": llm.model, @@ -306,6 +384,30 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_identity( llm: &GameCreatorLlmConfig, request: &LlmRunRequest, ) -> Result { + let app_config = load_game_creator_app_config()?; + game_creator_agent_runtime_provider_retry_identity_for_mode( + snapshot, + llm, + request, + &app_config.agent_mode, + ) +} + +pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_identity_for_mode( + snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + request: &LlmRunRequest, + agent_mode: &str, +) -> Result { + let agent_mode = normalize_game_creator_agent_mode(agent_mode)?; + let codex_cli_version = if matches!( + agent_mode.as_str(), + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER | GAME_CREATOR_AGENT_MODE_CODEX_CLI + ) { + Some(game_creator_codex_cli_version_identity()?) + } else { + None + }; Ok(AgentRuntimeProviderRetryIdentity { project_id: snapshot.project_id.clone(), agent_id: snapshot.agent_id.clone(), @@ -320,7 +422,12 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_identity( request_kind: snapshot.request_kind.clone(), base_request_slot: snapshot.request_slot.clone(), request_fingerprint: game_creator_agent_runtime_llm_request_fingerprint(request)?, - provider_config_fingerprint: game_creator_agent_runtime_provider_config_fingerprint(llm)?, + provider_config_fingerprint: + game_creator_agent_runtime_provider_config_fingerprint_for_mode( + &agent_mode, + codex_cli_version.as_deref(), + llm, + )?, web_search_enabled: snapshot.web_search_enabled, allow_idle_context_compaction: snapshot.allow_idle_context_compaction, }) @@ -566,11 +673,12 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_persis config_path: &str, operation: &str, request: &LlmRunRequest, + agent_mode: String, execute: F, canonicalize_handoff_response: H, ) -> Result where - F: FnOnce(LlmClient, LlmRunRequest) -> Fut, + F: FnOnce(LlmRunRequest) -> Fut, Fut: std::future::Future>, H: FnOnce(&platform_llm::LlmRunResponse) -> platform_llm::LlmRunResponse, { @@ -582,8 +690,12 @@ where )?; let retry_autonomous_upstream_400 = max_retries >= AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR; - let identity = - game_creator_agent_runtime_provider_retry_identity(provider_snapshot, llm, request)?; + let identity = game_creator_agent_runtime_provider_retry_identity_for_mode( + provider_snapshot, + llm, + request, + &agent_mode, + )?; let tool_plan_handoff_missing = if identity.request_kind == "tool-plan" { match tool_plan_handoff::lookup_at( root, @@ -1008,19 +1120,11 @@ where .map_err(|error| redact_agent_runtime_error(root, &error, 500))?; } let attempt_snapshot = provider_snapshot.with_request_slot(request_slot); - let client = match build_game_creator_agent_runtime_llm_client(llm, config_path) { - Ok(client) => client, - Err(error) => { - crate::provider_retry::remove_at( - root, - &provider_snapshot.agent_id, - &provider_snapshot.run_id, - )?; - return Err(error); - } - }; - let provider_request = async { - execute(client, request.clone()).await.map_err(|error| { + // 模式分发的 execute future 会同时携带 Provider、Codex CLI 和 Codex app-server 分支。 + // 在进入下层通用 control/lifecycle helper 前装箱完整请求,保证 debug 构建的队列恢复 + // 仍可安全运行在 Tokio 默认 worker 栈上。 + let provider_request = Box::pin(async { + execute(request.clone()).await.map_err(|error| { game_creator_agent_runtime_provider_error_with_transient_kind( &error, config_path, @@ -1028,7 +1132,7 @@ where retry_autonomous_upstream_400, ) }) - }; + }); let handoff_identity = identity.clone(); let handoff_request_slot = attempt_snapshot.request_slot.clone(); let persist_handoff = provider_handoff::supports_request_kind(&identity.request_kind); @@ -1293,6 +1397,11 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_persis operation: &str, request: &LlmRunRequest, ) -> Result { + let agent_mode = + normalize_game_creator_agent_mode(&load_game_creator_app_config()?.agent_mode)?; + let llm_for_request = llm.clone(); + let snapshot_for_request = provider_snapshot.clone(); + let config_path_for_request = config_path.to_string(); request_game_creator_agent_runtime_llm_with_persisted_transient_retry_using( root, provider_snapshot, @@ -1300,7 +1409,31 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_persis config_path, operation, request, - |client, request| async move { client.run(request).await }, + agent_mode.clone(), + move |request| async move { + match agent_mode.as_str() { + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER => { + request_game_creator_agent_codex_app_server( + &snapshot_for_request, + &llm_for_request, + request, + ) + .await + } + GAME_CREATOR_AGENT_MODE_CODEX_CLI => { + request_game_creator_agent_codex_cli(request).await + } + GAME_CREATOR_AGENT_MODE_PROVIDER => { + let client = build_game_creator_agent_runtime_llm_client( + &llm_for_request, + &config_path_for_request, + ) + .map_err(platform_llm::LlmError::InvalidConfig)?; + client.run(request).await + } + _ => unreachable!("agent mode is normalized"), + } + }, |response| response.clone(), ) .await @@ -1329,9 +1462,35 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi format!("{}-transient-{attempt}", provider_snapshot.request_slot) }; let attempt_snapshot = provider_snapshot.with_request_slot(request_slot.clone()); - let client = build_game_creator_agent_runtime_llm_client(llm, config_path)?; - let provider_request = async { - client.run(request.clone()).await.map_err(|error| { + let agent_mode = + normalize_game_creator_agent_mode(&load_game_creator_app_config()?.agent_mode)?; + let client = (agent_mode == GAME_CREATOR_AGENT_MODE_PROVIDER) + .then(|| build_game_creator_agent_runtime_llm_client(llm, config_path)) + .transpose()?; + // 不让组合后的 Provider/Codex 分发 future 留在调用方 async frame 中;否则 debug + // 构建在把委派子任务回执送回父 Runtime 时会超过 Tokio 默认 worker 栈。 + let provider_request = Box::pin(async { + let response = match agent_mode.as_str() { + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER => { + request_game_creator_agent_codex_app_server( + &attempt_snapshot, + llm, + request.clone(), + ) + .await + } + GAME_CREATOR_AGENT_MODE_CODEX_CLI => { + request_game_creator_agent_codex_cli(request.clone()).await + } + GAME_CREATOR_AGENT_MODE_PROVIDER => { + client + .expect("provider mode constructs an HTTP client") + .run(request.clone()) + .await + } + _ => unreachable!("agent mode is normalized"), + }; + response.map_err(|error| { game_creator_agent_runtime_provider_error_with_transient_kind( &error, config_path, @@ -1339,7 +1498,7 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi retry_autonomous_upstream_400, ) }) - }; + }); match await_game_creator_agent_runtime_provider_request_with_snapshot( root, attempt_snapshot.clone(), @@ -1471,6 +1630,66 @@ pub(crate) fn append_game_creator_agent_runtime_provider_lifecycle_for_test( mod tests { use super::*; + #[test] + fn provider_config_fingerprint_separates_all_agent_modes() { + let llm = GameCreatorLlmConfig::default(); + let app_server = game_creator_agent_runtime_provider_config_fingerprint_for_mode( + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER, + Some("codex-cli test-version"), + &llm, + ) + .expect("Codex app-server config fingerprint"); + let codex = game_creator_agent_runtime_provider_config_fingerprint_for_mode( + GAME_CREATOR_AGENT_MODE_CODEX_CLI, + Some("codex-cli test-version"), + &llm, + ) + .expect("Codex CLI config fingerprint"); + let provider = game_creator_agent_runtime_provider_config_fingerprint_for_mode( + GAME_CREATOR_AGENT_MODE_PROVIDER, + None, + &llm, + ) + .expect("Provider config fingerprint"); + assert_ne!(app_server, codex); + assert_ne!(app_server, provider); + assert_ne!(codex, provider); + assert_ne!( + app_server, + game_creator_agent_runtime_provider_config_fingerprint_for_mode( + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER, + Some("codex-cli changed-version"), + &llm, + ) + .expect("changed Codex app-server config fingerprint") + ); + assert_ne!( + codex, + game_creator_agent_runtime_provider_config_fingerprint_for_mode( + GAME_CREATOR_AGENT_MODE_CODEX_CLI, + Some("codex-cli changed-version"), + &llm, + ) + .expect("changed Codex CLI config fingerprint") + ); + } + + #[test] + fn codex_app_server_unknown_terminal_enters_reconciliation_without_retry() { + let error = platform_llm::LlmError::Transport(format!( + "{GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX} eof" + )); + let encoded = game_creator_agent_runtime_provider_error_with_transient_kind( + &error, + "agentLlm.project-supervisor", + "规划", + false, + ); + assert!(encoded.starts_with(AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX)); + assert!(!encoded.starts_with(AGENT_RUNTIME_PROVIDER_TRANSIENT_ERROR_PREFIX)); + assert!(!encoded.contains("eof")); + } + #[test] fn provider_retry_error_classification_accepts_durable_failures_and_rejects_client_faults() { let retryable = vec![ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs index aff04b4a2..caa6910ae 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs @@ -1075,6 +1075,7 @@ where root, &snapshot.agent_id, &snapshot.run_id, + snapshot.applied_steer_cursor, ) .map_err(|error| redact_agent_runtime_error(root, &error, 500))?; before_control_recheck(); @@ -1246,6 +1247,25 @@ where } } unregister_game_creator_agent_runtime_provider_request(&key, &active); + if result + .as_ref() + .is_err_and(|error| error.starts_with(AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX)) + { + let control_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.provider_request.unknown_terminal_reconciliation", + ); + if control_lock.is_ok() { + let _ = mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked( + root, + &snapshot, + &request_id, + ); + } + return Err(format!( + "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: requestId={request_id} · Codex app-server turn 终态未知" + )); + } let status = match &result { Ok(Some(_)) => "completed", Ok(None) => "interrupted", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs index 76dfa658c..566577b58 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs @@ -100,6 +100,8 @@ pub(in crate::agent) fn normalize_agent_runtime_steer_id(steer_id: &str) -> Resu Ok(steer_id.to_string()) } +pub(in crate::agent) const AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX: &str = "agent-steer-"; + pub(in crate::agent) fn agent_runtime_steer_message_id( project_id: &str, agent_id: &str, @@ -112,7 +114,7 @@ pub(in crate::agent) fn agent_runtime_steer_message_id( format!("{project_id}\n{agent_id}\n{task_id}\n{session_id}\n{run_id}\n{steer_id}"); let fingerprint = format!("{:x}", Sha256::digest(payload.as_bytes())); format!( - "agent-steer-{}", + "{AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX}{}", fingerprint.chars().take(32).collect::() ) } @@ -429,6 +431,202 @@ pub(in crate::agent) fn ensure_game_creator_agent_runtime_steer_audit( ) } +const AGENT_RUNTIME_STEER_DECISION_RECORD_TYPE: &str = "agent.runtime.steer_decision"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct AgentRuntimeSteerDecision { + pub(crate) reply: String, + pub(crate) interrupt_current_provider: bool, + pub(crate) reason: String, +} + +fn parse_game_creator_agent_runtime_steer_decision_record( + record: &serde_json::Value, +) -> Result { + let reply = record + .get("reply") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "Supervisor steer decision 缺少 reply".to_string())?; + let reply_sha256 = format!("{:x}", Sha256::digest(reply.as_bytes())); + if record + .get("replySha256") + .and_then(serde_json::Value::as_str) + != Some(reply_sha256.as_str()) + { + return Err("Supervisor steer decision reply 指纹冲突".to_string()); + } + let interrupt_current_provider = record + .get("interruptCurrentProvider") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| "Supervisor steer decision 缺少 interruptCurrentProvider".to_string())?; + let reason = record + .get("reason") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + Ok(AgentRuntimeSteerDecision { + reply: reply.to_string(), + interrupt_current_provider, + reason: reason.to_string(), + }) +} + +pub(crate) fn read_game_creator_agent_runtime_steer_decision_at( + root: &Path, + agent_id: &str, + run_id: &str, + steer_id: &str, +) -> Result, String> { + let (records, _) = read_agent_db_records_bounded(root, 16 * 1024 * 1024)?; + let mut decision = None; + for record in records.iter().filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some(AGENT_RUNTIME_STEER_DECISION_RECORD_TYPE) + && record.get("agentId").and_then(serde_json::Value::as_str) == Some(agent_id) + && record.get("runId").and_then(serde_json::Value::as_str) == Some(run_id) + && record.get("steerId").and_then(serde_json::Value::as_str) == Some(steer_id) + }) { + let parsed = parse_game_creator_agent_runtime_steer_decision_record(record)?; + if decision + .as_ref() + .is_some_and(|existing| existing != &parsed) + { + return Err("Supervisor steer decision 出现冲突终态".to_string()); + } + decision = Some(parsed); + } + Ok(decision) +} + +pub(crate) fn persist_game_creator_agent_runtime_steer_decision_and_reply_at( + root: &Path, + state: &AgentRuntimeState, + steer_id: &str, + sequence: u64, + decision: AgentRuntimeSteerDecision, +) -> Result { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.steer_decision.persist", + )?; + let snapshot = + read_game_creator_agent_runtime_steer_ledger(root, &state.agent_id, &state.run_id)?; + let entry = snapshot + .entries + .get(steer_id) + .ok_or_else(|| "Supervisor steer decision 未命中持久 steer".to_string())?; + if entry.identity.sequence != sequence + || entry.identity.session_id != state.session_id + || entry.identity.source != state.source + { + return Err("Supervisor steer decision 与持久 steer 身份冲突".to_string()); + } + let reply = sanitize_agent_runtime_text(decision.reply.trim(), 2_000); + if reply.is_empty() { + return Err("Supervisor steer decision reply 不能为空".to_string()); + } + let reason = redact_agent_runtime_project_paths( + root, + &sanitize_agent_runtime_text(decision.reason.trim(), 500), + 500, + ); + let decision = AgentRuntimeSteerDecision { + reply, + interrupt_current_provider: decision.interrupt_current_provider, + reason, + }; + let persisted = if let Some(existing) = read_game_creator_agent_runtime_steer_decision_at( + root, + &state.agent_id, + &state.run_id, + steer_id, + )? { + existing + } else { + append_agent_db_record( + root, + serde_json::json!({ + "recordType": AGENT_RUNTIME_STEER_DECISION_RECORD_TYPE, + "agentId": state.agent_id, + "taskId": state.task_id, + "sessionId": state.session_id, + "runId": state.run_id, + "source": state.source, + "steerId": steer_id, + "sequence": sequence, + "messageId": entry.identity.message_id, + "instructionSha256": entry.identity.instruction_sha256, + "reply": decision.reply, + "replySha256": format!("{:x}", Sha256::digest(decision.reply.as_bytes())), + "interruptCurrentProvider": decision.interrupt_current_provider, + "reason": decision.reason, + "decidedAt": unix_timestamp(), + }), + )?; + decision + }; + let message_id = entry.identity.message_id.as_deref().unwrap_or_default(); + let correlation_id = message_id + .strip_prefix(AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX) + .ok_or_else(|| "Supervisor steer decision 缺少消息关联身份".to_string())?; + append_game_creator_agent_runtime_public_status_message_for_correlation_at( + root, + correlation_id, + "steer-reply", + &persisted.reply, + )?; + Ok(persisted) +} + +pub(crate) fn append_game_creator_agent_runtime_steer_decision_failure_reply_at( + root: &Path, + state: &AgentRuntimeState, + steer_id: &str, + error: &str, +) -> Result { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.steer_decision.failure", + )?; + let snapshot = + read_game_creator_agent_runtime_steer_ledger(root, &state.agent_id, &state.run_id)?; + let entry = snapshot + .entries + .get(steer_id) + .ok_or_else(|| "Supervisor steer decision 失败记录未命中持久 steer".to_string())?; + let correlation_id = entry + .identity + .message_id + .as_deref() + .and_then(|message_id| message_id.strip_prefix(AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX)) + .ok_or_else(|| "Supervisor steer decision 失败记录缺少消息关联身份".to_string())?; + let reply = "我会继续当前任务。这次没有完成中断判断,你的消息仍会在下一个安全边界进入规划。"; + append_game_creator_agent_runtime_public_status_message_for_correlation_at( + root, + correlation_id, + "steer-decision-failed", + reply, + )?; + let safe_error = redact_agent_runtime_error(root, error, 500); + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.steer_decision.failed", + "agentId": state.agent_id, + "taskId": state.task_id, + "sessionId": state.session_id, + "runId": state.run_id, + "source": state.source, + "steerId": steer_id, + "sequence": entry.identity.sequence, + "errorSha256": format!("{:x}", Sha256::digest(safe_error.as_bytes())), + "errorChars": safe_error.chars().count(), + "failedAt": unix_timestamp(), + }), + )?; + Ok(reply.to_string()) +} + pub(crate) fn game_creator_agent_runtime_accepts_steer(state: &AgentRuntimeState) -> bool { !state.agent_id.starts_with("child-") && state.source != AGENT_RUNTIME_ISOLATED_CHILD_SOURCE @@ -658,6 +856,20 @@ pub(crate) fn steer_game_creator_agent_runtime_task_for_profile_at( latest_status = status.to_string(); } } + if state.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && state.parent_agent_id.is_none() + && state.parent_run_id.is_none() + { + let correlation_id = message_id + .strip_prefix(AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX) + .ok_or_else(|| "运行中追加指令消息缺少公开状态关联身份".to_string())?; + append_game_creator_agent_runtime_public_status_message_for_correlation_at( + root, + correlation_id, + "steer-queued", + "收到。我正在判断这条消息是否需要调整当前任务;现有任务会继续运行。", + )?; + } let was_waiting_for_provider_retry = state.phase == "waiting-for-provider-retry"; if was_waiting_for_provider_retry { remove_game_creator_agent_runtime_provider_recovery_at(root, &agent_id, run_id)?; @@ -682,8 +894,7 @@ pub(crate) fn steer_game_creator_agent_runtime_task_for_profile_at( Some(&format!("steerId={steer_id}")), )?; } - let provider_interrupted = - interrupt_game_creator_agent_runtime_provider_request_at(root, &agent_id, run_id)?; + let provider_interrupted = false; ensure_game_creator_agent_runtime_steer_audit( root, &state, @@ -722,6 +933,9 @@ pub(crate) fn steer_game_creator_agent_runtime_task_for_profile_at( sequence: entry.identity.sequence, status: latest_status, provider_interrupted, + assistant_reply: None, + interrupt_decision: None, + decision_reason: None, }) } @@ -772,6 +986,44 @@ pub(crate) fn interrupt_game_creator_agent_runtime_provider_request_at( Ok(first) } +pub(crate) fn interrupt_game_creator_agent_runtime_provider_for_decided_steer_at( + root: &Path, + agent_id: &str, + run_id: &str, + steer_id: &str, +) -> Result { + let decision = + read_game_creator_agent_runtime_steer_decision_at(root, agent_id, run_id, steer_id)? + .ok_or_else(|| "Supervisor steer 尚无 LLM 中断判定".to_string())?; + if !decision.interrupt_current_provider { + return Ok(false); + } + let snapshot = read_game_creator_agent_runtime_steer_ledger(root, agent_id, run_id)?; + let entry = snapshot + .entries + .get(steer_id) + .ok_or_else(|| "LLM 中断判定未命中持久 steer".to_string())?; + let key = agent_runtime_provider_interrupt_key(root, agent_id, run_id)?; + let active = { + let registry = game_creator_agent_provider_interrupts() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + registry.get(&key).cloned() + }; + let Some(active) = active else { + return Ok(false); + }; + if active.applied_steer_cursor >= entry.identity.sequence { + // The provider request was created after this steer was already + // applied. Interrupting it would cancel the new plan, not the stale + // request the LLM evaluated. + return Ok(false); + } + let first = !active.interrupted.swap(true, Ordering::AcqRel); + active.notify.notify_one(); + Ok(first) +} + pub(crate) fn interrupt_game_creator_agent_runtime_provider_requests_for_roots( roots: &[PathBuf], ) -> usize { @@ -833,13 +1085,17 @@ pub(crate) fn validate_game_creator_agent_runtime_steer_notification_at( Ok(()) } -pub(in crate::agent) fn register_game_creator_agent_runtime_provider_request( +pub(crate) fn register_game_creator_agent_runtime_provider_request( root: &Path, agent_id: &str, run_id: &str, + applied_steer_cursor: u64, ) -> Result<(String, Arc), String> { let key = agent_runtime_provider_interrupt_key(root, agent_id, run_id)?; - let active = Arc::new(AgentRuntimeProviderInterrupt::default()); + let active = Arc::new(AgentRuntimeProviderInterrupt { + applied_steer_cursor, + ..AgentRuntimeProviderInterrupt::default() + }); let mut registry = game_creator_agent_provider_interrupts() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -849,7 +1105,7 @@ pub(in crate::agent) fn register_game_creator_agent_runtime_provider_request( Ok((key, active)) } -pub(in crate::agent) fn unregister_game_creator_agent_runtime_provider_request( +pub(crate) fn unregister_game_creator_agent_runtime_provider_request( key: &str, active: &Arc, ) { @@ -1216,12 +1472,14 @@ mod shutdown_tests { &known_root, "code-prototype", "known-run", + 0, ) .expect("register known Provider request"); let (other_key, other_request) = register_game_creator_agent_runtime_provider_request( &other_root, "code-prototype", "other-run", + 0, ) .expect("register other Provider request"); 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 6dd236e66..c5d65d7b6 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 @@ -13,6 +13,13 @@ fn game_creator_agent_runtime_public_status_message_id( ) -> String { let correlation_id = game_creator_agent_runtime_message_correlation_id(agent_id, session_id, run_id); + game_creator_agent_runtime_public_status_message_id_for_correlation(&correlation_id, status) +} + +fn game_creator_agent_runtime_public_status_message_id_for_correlation( + correlation_id: &str, + status: &str, +) -> String { let status_fingerprint = format!("{:x}", Sha256::digest(status.as_bytes())); format!( "{AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX}{correlation_id}-{}", @@ -62,6 +69,28 @@ pub(crate) fn append_game_creator_agent_runtime_public_status_message_at( .map(|_| ()) } +pub(crate) fn append_game_creator_agent_runtime_public_status_message_for_correlation_at( + root: &Path, + correlation_id: &str, + status: &str, + content: &str, +) -> Result<(), String> { + let message_id = + game_creator_agent_runtime_public_status_message_id_for_correlation(correlation_id, status); + append_local_conversation_message_for_session_idempotent_at( + root, + None, + None, + LocalConversationMessage { + role: "assistant".to_string(), + content: content.to_string(), + agent_id: None, + }, + &message_id, + ) + .map(|_| ()) +} + pub(crate) fn append_game_creator_agent_runtime_terminal_public_message_at( root: &Path, state: &AgentRuntimeState, 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 b1fab7688..51c9d2e1e 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 @@ -1262,7 +1262,6 @@ fn runtime_tool_input_schema(tool: &str) -> Value { "missingAssetSlots": { "type": "array", "maxItems": 2, - "uniqueItems": true, "items": { "type": "string", "enum": ["art-spec", "core-spritesheet"] } } } @@ -1386,7 +1385,7 @@ mod tests { let Some(object) = schema.as_object() else { return; }; - for keyword in ["oneOf", "anyOf", "allOf", "not"] { + for keyword in ["oneOf", "anyOf", "allOf", "not", "uniqueItems"] { if object.contains_key(keyword) { issues.push(format!( "strict schema contains unsupported {keyword} at {path}" 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 9f08cf712..eec0c244f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -289,6 +289,7 @@ pub(crate) fn take_cli_runtime_config_dir( pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) -> Vec { let mut lines = vec![ + format!("agent.mode={}", status.agent_mode), format!("llm.configured={}", status.configured), format!("llm.apiKeyPresent={}", status.api_key_present), format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index bff274d4b..4f3af8430 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -12,6 +12,18 @@ pub(crate) fn init_local_game_project( init_local_game_project_at(root, project_id.trim(), name.trim()) } +#[tauri::command] +pub(crate) fn import_local_godot_project( + project_path: String, + project_id: String, + name: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.create")?; + let _lock = acquire_project_write_lock(root, "project.create")?; + import_local_godot_project_at(root, project_id.trim(), name.trim()) +} + #[tauri::command] pub(crate) fn is_local_project_directory_non_empty(project_path: String) -> Result { let root = Path::new(project_path.trim()); @@ -58,6 +70,7 @@ pub(crate) fn inspect_local_project_directory( exists: root.exists(), is_directory: root.is_dir(), is_game_creator_project: is_game_creator_project_directory(root), + is_godot_project: is_godot_project_directory(root), project_name: game_creator_project_name(root), manifest_error: game_creator_project_manifest_error(root), recent_run_status: recent_run_trace.as_ref().map(|trace| trace.status.clone()), @@ -65,6 +78,16 @@ pub(crate) fn inspect_local_project_directory( }) } +pub(crate) fn is_godot_project_directory(root: &Path) -> bool { + if !root.is_dir() { + return false; + } + let project_file = root.join("project.godot"); + fs::symlink_metadata(project_file) + .map(|metadata| metadata.is_file() && !metadata.file_type().is_symlink()) + .unwrap_or(false) +} + pub(crate) fn is_game_creator_project_directory(root: &Path) -> bool { if !root.is_dir() { return false; @@ -675,7 +698,7 @@ pub(crate) fn clear_game_creator_agent_goal( } #[tauri::command] -pub(crate) fn steer_game_creator_agent_runtime_task( +pub(crate) async fn steer_game_creator_agent_runtime_task( project_path: String, agent_id: String, session_id: String, @@ -717,13 +740,90 @@ pub(crate) fn steer_game_creator_agent_runtime_task( run_profile.as_deref(), "tauri", )?; - if !result.provider_interrupted - && external_agent_runner_enabled() - && !external_agent_runner_is_server_process() - { - result.provider_interrupted = + let external_runner = + external_agent_runner_enabled() && !external_agent_runner_is_server_process(); + let wake_external_runner_without_interrupt = || -> Result<(), String> { + let provider_interrupted = steer_external_agent_runner(root, agent_id.trim(), run_id.trim(), steer_id.trim())?; + if provider_interrupted { + return Err("Agent Runner 的 runtime.steer 非法中断了 Provider".to_string()); + } + Ok(()) + }; + let state = result.runtime.state.clone(); + let requires_supervisor_decision = state.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && state.parent_agent_id.is_none() + && state.parent_run_id.is_none(); + if requires_supervisor_decision { + match decide_game_creator_agent_runtime_steer_at( + root, + &state, + steer_id.trim(), + result.sequence, + instruction.trim(), + ) + .await + { + Ok(decision) => { + result.assistant_reply = Some(decision.reply.clone()); + result.interrupt_decision = Some(decision.interrupt_current_provider); + result.decision_reason = Some(decision.reason.clone()); + if decision.interrupt_current_provider { + result.provider_interrupted = if external_runner { + interrupt_external_agent_runner_provider_for_steer_decision( + root, + agent_id.trim(), + run_id.trim(), + steer_id.trim(), + )? + } else { + interrupt_game_creator_agent_runtime_provider_for_decided_steer_at( + root, + agent_id.trim(), + run_id.trim(), + steer_id.trim(), + )? + }; + if !external_runner { + wake_pending_game_creator_agent_background_tasks_at(root) + .map_err(|error| error.to_string())?; + } + } else if external_runner { + wake_external_runner_without_interrupt()?; + } else { + wake_pending_game_creator_agent_background_tasks_at(root) + .map_err(|error| error.to_string())?; + } + } + Err(error) => { + result.assistant_reply = Some( + append_game_creator_agent_runtime_steer_decision_failure_reply_at( + root, + &state, + steer_id.trim(), + &error, + )?, + ); + result.decision_reason = Some("decision-failed".to_string()); + if external_runner { + wake_external_runner_without_interrupt()?; + } else { + wake_pending_game_creator_agent_background_tasks_at(root) + .map_err(|error| error.to_string())?; + } + } + } + } else if external_runner { + wake_external_runner_without_interrupt()?; + } else { + wake_pending_game_creator_agent_background_tasks_at(root) + .map_err(|error| error.to_string())?; } + result.runtime = read_game_creator_agent_runtime_for_session_at( + root, + agent_id.trim(), + Some(session_id.trim()), + )?; Ok(result) } 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 36ce07533..27f8ad432 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -205,6 +205,7 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi Ok(config) => config, Err(error) => { return GameCreatorLlmConfigStatus { + agent_mode: default_game_creator_agent_mode(), configured: false, api_key_present: false, base_url: None, @@ -224,6 +225,9 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi }; } }; + if app_config.agent_mode != GAME_CREATOR_AGENT_MODE_PROVIDER { + return check_game_creator_codex_config(&app_config); + } let global_route_shape_error = validate_game_creator_llm_web_search_config(&app_config.llm, "llm").err(); let mut status = check_game_creator_llm_config_values(&app_config.llm, "llm"); @@ -245,6 +249,7 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi .map(|definition| { let llm = resolve_game_creator_llm_config_for_agent(&app_config, &definition.agent_id); check_game_creator_agent_llm_config_values( + &app_config.agent_mode, &definition.agent_id, &definition.label, &llm, @@ -279,6 +284,120 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi status } +fn check_game_creator_codex_config( + app_config: &GameCreatorAppConfig, +) -> GameCreatorLlmConfigStatus { + let cli_error = check_game_creator_codex_cli_available() + .and_then(|()| { + if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { + check_game_creator_codex_app_server_available() + } else { + Ok(()) + } + }) + .err(); + let global_route_error = game_creator_codex_app_server_llm_route_error( + &app_config.agent_mode, + &app_config.llm, + "llm", + ); + let mut status = check_game_creator_llm_config_values(&app_config.llm, "llm"); + status.agent_mode = app_config.agent_mode.clone(); + status.configured = cli_error.is_none() && global_route_error.is_none(); + status.error = cli_error.clone().or(global_route_error); + status.agents = game_creator_llm_agent_status_definitions() + .iter() + .map(|definition| { + let llm = resolve_game_creator_llm_config_for_agent(app_config, &definition.agent_id); + let mut agent = check_game_creator_agent_llm_config_values( + &app_config.agent_mode, + &definition.agent_id, + &definition.label, + &llm, + ); + let route_error = game_creator_codex_app_server_llm_route_error( + &app_config.agent_mode, + &llm, + &format!("agentLlm.{}", definition.agent_id), + ); + agent.configured = cli_error.is_none() && route_error.is_none(); + agent.error = cli_error.clone().or(route_error); + agent + }) + .collect(); + let required_errors = status + .agents + .iter() + .filter(|agent| GAME_CREATOR_REQUIRED_LLM_AGENT_IDS.contains(&agent.agent_id.as_str())) + .filter_map(|agent| { + agent + .error + .as_ref() + .map(|error| format!("{}:{error}", agent.label)) + }) + .collect::>(); + if !required_errors.is_empty() { + status.configured = false; + let mut errors = status.error.take().into_iter().collect::>(); + errors.extend(required_errors); + errors.dedup(); + status.error = Some(errors.join(";")); + } + status +} + +pub(crate) fn game_creator_codex_app_server_llm_route_error( + agent_mode: &str, + llm: &GameCreatorLlmConfig, + config_path: &str, +) -> Option { + if agent_mode != GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { + return None; + } + if llm.api_kind != "openai_responses" { + return Some(format!( + "配置项 {config_path}.apiKind={} 不能由 codex_app_server 直接映射;请使用 openai_responses 或切换 provider 模式", + llm.api_kind + )); + } + llm.web_search_enabled.then(|| { + format!( + "配置项 {config_path}.webSearchEnabled 在 codex_app_server 模式下必须为 false;该模式由 AGC Runtime 独占工具执行,不能启用 Codex 原生联网工具" + ) + }) +} + +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(()) +} + +fn check_game_creator_codex_app_server_available() -> Result<(), String> { + let output = std::process::Command::new("codex") + .args(["app-server", "--help"]) + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .map_err(|_| "Codex CLI 不支持 app-server 子命令".to_string())?; + if !output.status.success() + || !String::from_utf8_lossy(&output.stdout).contains("codex app-server") + { + return Err("当前 Codex CLI 不支持 app-server 子命令,请升级 Codex CLI".to_string()); + } + Ok(()) +} + pub(crate) fn check_game_creator_llm_config_values( config: &GameCreatorLlmConfig, config_path: &str, @@ -317,6 +436,7 @@ pub(crate) fn check_game_creator_llm_config_values( }); GameCreatorLlmConfigStatus { + agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), configured: error.is_none(), api_key_present, base_url, @@ -339,6 +459,7 @@ pub(crate) fn check_game_creator_llm_config_values( } pub(crate) fn check_game_creator_agent_llm_config_values( + agent_mode: &str, agent_id: &str, label: &str, config: &GameCreatorLlmConfig, @@ -359,6 +480,7 @@ pub(crate) fn check_game_creator_agent_llm_config_values( } } GameCreatorAgentLlmConfigStatus { + agent_mode: agent_mode.to_string(), agent_id: agent_id.to_string(), label: label.to_string(), configured: status.configured, @@ -1135,11 +1257,53 @@ pub(crate) fn configure_game_creator_runtime_config_dir( if !config_path.exists() { write_game_creator_config_atomically(&config_path, DEFAULT_GAME_CREATOR_APP_CONFIG_JSON) .map_err(std::io::Error::other)?; + } else { + migrate_legacy_game_creator_agent_mode(&config_path).map_err(std::io::Error::other)?; } set_game_creator_runtime_config_dir(config_dir); Ok(()) } +pub(crate) fn legacy_game_creator_agent_mode( + config: &GameCreatorAppConfigFile, +) -> Option<&'static str> { + if config.agent_mode.is_some() { + return None; + } + let responses_only = config + .llm + .as_ref() + .and_then(|llm| llm.api_kind.as_deref()) + .map(|kind| kind.trim().to_ascii_lowercase().replace('-', "_") == "openai_responses") + .unwrap_or(true) + && config + .agent_llm + .as_ref() + .into_iter() + .flat_map(|agents| agents.values()) + .filter_map(|llm| llm.api_kind.as_deref()) + .all(|kind| kind.trim().to_ascii_lowercase().replace('-', "_") == "openai_responses"); + Some(if responses_only { + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER + } else { + GAME_CREATOR_AGENT_MODE_PROVIDER + }) +} + +fn migrate_legacy_game_creator_agent_mode(path: &Path) -> Result<(), String> { + let content = fs::read_to_string(path) + .map_err(|error| format!("读取客户端配置失败:{}: {error}", path.display()))?; + let mut config = serde_json::from_str::(&content) + .map_err(|error| format!("解析客户端配置失败:{}: {error}", path.display()))?; + let Some(agent_mode) = legacy_game_creator_agent_mode(&config) else { + return Ok(()); + }; + config.agent_mode = Some(agent_mode.to_string()); + let content = serde_json::to_string_pretty(&config) + .map_err(|error| format!("序列化客户端配置失败:{error}"))?; + write_game_creator_config_atomically(path, &format!("{content}\n")) +} + pub(crate) fn game_creator_runtime_config_dir_lock() -> &'static Mutex> { GAME_CREATOR_RUNTIME_CONFIG_DIR.get_or_init(|| Mutex::new(None)) } @@ -1256,6 +1420,9 @@ pub(crate) fn merge_game_creator_config_file( .map_err(|error| format!("读取客户端配置失败:{}: {error}", read_path.display()))?; let file_config = serde_json::from_str::(&content) .map_err(|error| format!("解析客户端配置失败:{}: {error}", read_path.display()))?; + if let Some(agent_mode) = file_config.agent_mode { + config.agent_mode = agent_mode; + } if let Some(llm) = file_config.llm { merge_game_creator_llm_config(&mut config.llm, llm); } @@ -1508,6 +1675,7 @@ pub(crate) fn trim_config_string(value: &str) -> Option { pub(crate) fn normalize_game_creator_app_config( mut config: GameCreatorAppConfig, ) -> Result { + config.agent_mode = normalize_game_creator_agent_mode(&config.agent_mode)?; config.llm.api_key = config.llm.api_key.trim().to_string(); config.llm.base_url = trim_config_string(&config.llm.base_url).ok_or_else(|| llm_base_url_config_error("llm"))?; @@ -1545,6 +1713,19 @@ pub(crate) fn normalize_game_creator_app_config( Ok(config) } +pub(crate) fn normalize_game_creator_agent_mode(value: &str) -> Result { + match value.trim() { + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER => { + Ok(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string()) + } + GAME_CREATOR_AGENT_MODE_CODEX_CLI => Ok(GAME_CREATOR_AGENT_MODE_CODEX_CLI.to_string()), + GAME_CREATOR_AGENT_MODE_PROVIDER => Ok(GAME_CREATOR_AGENT_MODE_PROVIDER.to_string()), + value => Err(format!( + "配置项 agentMode 无效:{value},请使用 codex_app_server、codex_cli 或 provider" + )), + } +} + pub(crate) fn normalize_game_creator_llm_patch_config( agent_id: &str, mut patch: GameCreatorLlmConfigFile, 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 66ba1a5ed..2428dabf6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -124,6 +124,7 @@ struct LocalProjectDirectoryStatus { exists: bool, is_directory: bool, is_game_creator_project: bool, + is_godot_project: bool, project_name: Option, manifest_error: Option, recent_run_status: Option, @@ -621,6 +622,12 @@ struct AgentRuntimeSteerResult { sequence: u64, status: String, provider_interrupted: bool, + #[serde(skip_serializing_if = "Option::is_none")] + assistant_reply: Option, + #[serde(skip_serializing_if = "Option::is_none")] + interrupt_decision: Option, + #[serde(skip_serializing_if = "Option::is_none")] + decision_reason: Option, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -666,6 +673,7 @@ struct GameCreatorAgentProgressEvent { #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorLlmConfigStatus { + agent_mode: String, configured: bool, api_key_present: bool, base_url: Option, @@ -687,6 +695,7 @@ struct GameCreatorLlmConfigStatus { #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorAgentLlmConfigStatus { + agent_mode: String, agent_id: String, label: String, configured: bool, @@ -709,6 +718,7 @@ struct GameCreatorAgentLlmConfigStatus { #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorAppConfigFile { + agent_mode: Option, llm: Option, agent_llm: Option>, editor_api: Option, @@ -756,6 +766,8 @@ struct GameCreatorEditorApiConfigFile { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorAppConfig { + #[serde(default = "default_game_creator_agent_mode")] + agent_mode: String, llm: GameCreatorLlmConfig, #[serde(default)] agent_llm: BTreeMap, @@ -1196,6 +1208,9 @@ const DEFAULT_GAME_INDEX_HTML: &str = r#" const DEFAULT_EDITOR_BASE_URL: &str = "http://127.0.0.1:3000"; const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json"; const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json"; +const GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER: &str = "codex_app_server"; +const GAME_CREATOR_AGENT_MODE_CODEX_CLI: &str = "codex_cli"; +const GAME_CREATOR_AGENT_MODE_PROVIDER: &str = "provider"; const DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://api.openai.com/v1"; const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-4.1"; const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses"; @@ -1205,6 +1220,10 @@ const DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT: u64 = 64_000; const DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT: u64 = 12_000; const DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES: u32 = 2; +fn default_game_creator_agent_mode() -> String { + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string() +} + fn default_game_creator_llm_context_window_tokens() -> u64 { DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS } @@ -1277,6 +1296,7 @@ static GAME_CREATOR_RUNTIME_CONFIG_DIR: OnceLock>> = OnceL impl Default for GameCreatorAppConfig { fn default() -> Self { Self { + agent_mode: default_game_creator_agent_mode(), llm: GameCreatorLlmConfig::default(), agent_llm: BTreeMap::new(), editor_api: GameCreatorEditorApiConfig::default(), @@ -2017,6 +2037,7 @@ fn main() { let app = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_clipboard_manager::init()) .manage(game_creator_preview_registry()) .setup(move |app| { @@ -2150,6 +2171,7 @@ fn main() { }) .invoke_handler(tauri::generate_handler![ init_local_game_project, + import_local_godot_project, is_local_project_directory_non_empty, inspect_local_project_directory, pick_local_project_directory, 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 80216aa36..4f1e01b7e 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 @@ -138,7 +138,12 @@ fn try_open_manifest_write_lock_file(path: &Path) -> Result, String { Ok(file) => { validate_windows_regular_file_handle(&file, "manifest 锁")?; - crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, true)?; + // 提升权限运行时,Windows 可能用 TokenOwner=Administrators 创建新文件。 + // 独占句柄与普通文件检查通过后,将这个固定锁文件收归当前 TokenUser, + // 再复核句柄并按既有规则验证 owner/DACL;不放宽旧文件的安全门禁。 + crate::initialize_windows_game_creator_file_owner_for_current_user(&lock_path)?; + validate_windows_regular_file_handle(&file, "manifest 锁")?; + crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false)?; Ok(Some(file)) } Err(error) @@ -235,6 +240,75 @@ pub(crate) fn init_local_game_project_at( }) } +pub(crate) fn import_local_godot_project_at( + root: &Path, + project_id: &str, + name: &str, +) -> Result { + if root.as_os_str().is_empty() { + return Err("项目目录不能为空".to_string()); + } + if !root.is_absolute() { + return Err("项目目录必须是绝对路径".to_string()); + } + if project_path_has_control_chars(root) { + return Err("项目目录不能包含控制字符".to_string()); + } + if !root.is_dir() { + return Err("Godot 项目目录不存在或不是文件夹".to_string()); + } + if !is_godot_project_directory(root) { + return Err("所选文件夹不是有效的 Godot 项目:缺少普通文件 project.godot".to_string()); + } + if project_id.is_empty() { + return Err("项目 ID 不能为空".to_string()); + } + if name.is_empty() { + return Err("项目名称不能为空".to_string()); + } + + let manifest_path = root.join(".agent/manifest.json"); + if manifest_storage_exists(&manifest_path)? { + let manifest = read_manifest(&manifest_path)?; + return Ok(InitLocalProjectResult { + project_path: root.to_string_lossy().into_owned(), + manifest_path: manifest_path.to_string_lossy().into_owned(), + manifest, + }); + } + + let agent_db_path = root.join(".agent/agent.db"); + if !agent_db_path.exists() { + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "project.import", + "projectId": project_id, + "name": name, + "projectKind": "godot", + }), + )?; + } + + for relative in [".agent/logs", ".agent/runtime"] { + fs::create_dir_all(root.join(relative)).map_err(|error| { + format!( + "创建 Godot 项目 Agent 目录失败:{}: {error}", + root.join(relative).display() + ) + })?; + } + + let manifest = new_game_creation_app_manifest(project_id, name); + write_manifest(&manifest_path, &manifest)?; + + Ok(InitLocalProjectResult { + project_path: root.to_string_lossy().into_owned(), + manifest_path: manifest_path.to_string_lossy().into_owned(), + manifest, + }) +} + pub(crate) fn record_preview_state( root: &Path, status: GameCreationAppPreviewStatus, @@ -984,5 +1058,7 @@ pub(crate) fn trim_optional_string(value: Option) -> Option { .filter(|value| !value.is_empty()) } +#[cfg(test)] +mod import_tests; #[cfg(test)] mod recovery_tests; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs new file mode 100644 index 000000000..367e46815 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs @@ -0,0 +1,67 @@ +use super::*; + +fn godot_import_test_path(label: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let temp_root = std::env::temp_dir() + .canonicalize() + .unwrap_or_else(|_| std::env::temp_dir()); + temp_root.join(format!( + "genarrative-godot-import-{label}-{}-{nanos}", + std::process::id() + )) +} + +#[test] +fn imports_godot_project_without_creating_parallel_game_directories() { + let root = godot_import_test_path("valid"); + fs::create_dir_all(&root).expect("create Godot project root"); + fs::write( + root.join("project.godot"), + "[application]\nconfig/name=\"Existing\"\n", + ) + .expect("write project.godot"); + fs::create_dir(root.join("scenes")).expect("create existing scenes"); + fs::write(root.join("scenes/main.tscn"), "[gd_scene format=3]\n") + .expect("write existing scene"); + + let result = import_local_godot_project_at(&root, "godot-project", "Existing") + .expect("import Godot project"); + + assert_eq!(result.project_path, root.to_string_lossy()); + assert_eq!(result.manifest.project_id, "godot-project"); + assert!(root.join(".agent/manifest.json").is_file()); + assert!(root.join(".agent/agent.db").is_file()); + assert_eq!( + fs::read_to_string(root.join("scenes/main.tscn")).expect("read preserved scene"), + "[gd_scene format=3]\n" + ); + for unexpected in ["game", "assets", "memory", "exports"] { + assert!( + !root.join(unexpected).exists(), + "Godot import must not create {unexpected}" + ); + } + + let reopened = import_local_godot_project_at(&root, "ignored", "Ignored") + .expect("reopen imported Godot project"); + assert_eq!(reopened.manifest.project_id, "godot-project"); + assert_eq!(reopened.manifest.name, "Existing"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn rejects_non_godot_directory_without_writing_agent_metadata() { + let root = godot_import_test_path("invalid"); + fs::create_dir_all(&root).expect("create non-Godot project root"); + fs::write(root.join("README.md"), "not Godot").expect("write fixture"); + + let error = import_local_godot_project_at(&root, "not-godot", "Not Godot") + .expect_err("non-Godot directory must fail"); + + assert!(error.contains("project.godot")); + assert!(!root.join(".agent").exists()); + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index 475f62ca9..202a774b8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -12,8 +12,9 @@ pub(crate) use client::{ compact_external_agent_runner_context, configure_external_agent_runner, configure_external_agent_runner_read_only, continue_external_agent_runner_action, ensure_external_agent_runner_started, ensure_external_agent_runner_started_for_gui, - notify_external_agent_runner, pause_external_agent_runner, - read_external_agent_runner_mcp_catalog, read_external_agent_runner_status, + interrupt_external_agent_runner_provider_for_steer_decision, notify_external_agent_runner, + pause_external_agent_runner, read_external_agent_runner_mcp_catalog, + read_external_agent_runner_status, require_external_agent_runner_configured_for_cli_runtime_write, require_external_agent_runner_for_cli_runtime_write, resume_external_agent_runner, shutdown_external_agent_runner, shutdown_external_agent_runner_for_client_exit, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index e4d35d654..4f626d26b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -1438,6 +1438,29 @@ pub(crate) fn steer_external_agent_runner( parse_external_agent_runner_steer_result(&result) } +pub(crate) fn interrupt_external_agent_runner_provider_for_steer_decision( + root: &Path, + agent: &str, + run_id: &str, + steer_id: &str, +) -> Result { + if [agent, run_id, steer_id] + .into_iter() + .any(|value| value.trim().is_empty()) + { + return Err("LLM steer 中断判定必须同时提供 agent/runId/steerId".to_string()); + } + let result = send_external_agent_runner_runtime_request( + root, + "runtime.interrupt_for_steer_decision", + Some(agent.trim()), + Some(run_id.trim()), + None, + Some(steer_id.trim()), + )?; + parse_external_agent_runner_steer_result(&result) +} + pub(crate) fn pause_external_agent_runner( root: &Path, agent: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index aa17ffefe..7657aeb2b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -412,6 +412,7 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( | "runtime.resume" | "runtime.continue_action" | "runtime.steer" + | "runtime.interrupt_for_steer_decision" | "runtime.pause" | "runtime.cancel" | "runtime.compact" @@ -430,6 +431,7 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( | "runtime.resume" | "runtime.continue_action" | "runtime.steer" + | "runtime.interrupt_for_steer_decision" | "runtime.pause" | "runtime.cancel" | "runtime.compact" => { @@ -490,10 +492,23 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( crate::validate_game_creator_agent_runtime_steer_notification_at( &root, &agent, &run_id, &steer_id, )?; - let provider_interrupted = - crate::interrupt_game_creator_agent_runtime_provider_request_at( - &root, &agent, &run_id, - )?; + crate::wake_pending_game_creator_agent_background_tasks_at(&root) + .map_err(|error| error.to_string())?; + Ok(json!({ + "accepted": true, + "providerInterrupted": false, + })) + })(), + "runtime.interrupt_for_steer_decision" => (|| { + let agent = external_agent_runner_request_agent(request)?; + let run_id = external_agent_runner_request_run_id(request)?; + let steer_id = external_agent_runner_request_steer_id(request)?; + let provider_interrupted = crate::interrupt_game_creator_agent_runtime_provider_for_decided_steer_at( + &root, + &agent, + &run_id, + &steer_id, + )?; crate::wake_pending_game_creator_agent_background_tasks_at(&root) .map_err(|error| error.to_string())?; Ok(json!({ @@ -845,6 +860,7 @@ pub(super) fn handle_external_agent_runner_request( | "runtime.resume" | "runtime.continue_action" | "runtime.steer" + | "runtime.interrupt_for_steer_decision" | "runtime.pause" | "runtime.cancel" | "runtime.compact" diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs index 20ee160c3..289c76e12 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs @@ -194,6 +194,7 @@ pub(super) fn spawn_external_agent_runner_gui_owner_watchdog( .store(true, Ordering::Release); state.shutdown_requested.store(true, Ordering::Release); thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_WATCHDOG_HARD_EXIT_TIMEOUT); + let _ = crate::agent::shutdown_game_creator_codex_app_servers(); remove_external_agent_runner_endpoint_if_boot_matches(&endpoint_path, &boot_id); std::process::exit(1); }) @@ -356,6 +357,12 @@ pub(crate) fn run_external_agent_runner_server( }); } } + if let Err(app_server_error) = crate::agent::shutdown_game_creator_codex_app_servers() { + process_shutdown = Err(match process_shutdown { + Ok(()) => app_server_error, + Err(process_error) => format!("{process_error};{app_server_error}"), + }); + } if let Some(error) = server_error { Err(match process_shutdown { Ok(()) => error, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 4e28d62ac..0a12e9b69 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -1231,7 +1231,7 @@ fn typed_steer_result_requires_provider_interrupted_boolean() { } #[test] -fn runtime_steer_reports_provider_interrupt_and_deduplicates_request_id() { +fn runtime_steer_queues_without_interrupt_and_deduplicates_request_id() { let directory = unique_test_directory(); let root = directory.0.join("project"); crate::init_local_game_project_at(&root, "project-steer-rpc", "Runner steer 测试") @@ -1265,6 +1265,14 @@ fn runtime_steer_reports_provider_interrupt_and_deduplicates_request_id() { appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), test_endpoint(token, "steer-rpc-boot", 30303), ); + let (provider_key, active_provider) = + crate::register_game_creator_agent_runtime_provider_request( + &root, + "code-prototype", + "run-steer-rpc", + 0, + ) + .expect("register active Provider before runtime.steer"); let request = ExternalAgentRunnerRequest { protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, request_id: "steer-rpc-request-1".to_string(), @@ -1288,9 +1296,14 @@ fn runtime_steer_reports_provider_interrupt_and_deduplicates_request_id() { .and_then(|value| value["providerInterrupted"].as_bool()), Some(false) ); + assert!( + !active_provider.interrupted.load(Ordering::Acquire), + "runtime.steer must not interrupt an active Provider before the LLM decision" + ); let replay = dispatch_external_agent_runner_runtime_request(&request, &state); assert_eq!(replay, first); + assert!(!active_provider.interrupted.load(Ordering::Acquire)); let mut conflict = request; conflict.params.steer_id = Some("steer-rpc-2".to_string()); @@ -1300,6 +1313,294 @@ fn runtime_steer_reports_provider_interrupt_and_deduplicates_request_id() { conflict.error.as_ref().map(|error| error.code.as_str()), Some("request-id-conflict") ); + crate::unregister_game_creator_agent_runtime_provider_request(&provider_key, &active_provider); +} + +#[test] +fn runtime_interrupt_for_steer_decision_rejects_missing_durable_decision() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-steer-missing-decision-rpc", + "Runner steer 缺少判定测试", + ) + .expect("initialize missing-decision project"); + let runtime = crate::start_game_creator_agent_runtime_task_for_session_at( + &root, + "code-prototype", + None, + "保持当前 Provider 请求运行", + "run-steer-missing-decision-rpc", + "agent-background-task", + "等待 Supervisor 判定", + vec!["完成当前任务".to_string()], + ) + .expect("start missing-decision runtime"); + let steer = crate::steer_game_creator_agent_runtime_task_at( + &root, + &runtime.agent_id, + &runtime.session_id, + &runtime.run_id, + "steer-missing-decision-rpc-1", + "先告诉我进度。", + "runner-test", + ) + .expect("persist steer without decision"); + assert_eq!(steer.sequence, 1); + let (provider_key, active_provider) = + crate::register_game_creator_agent_runtime_provider_request( + &root, + &runtime.agent_id, + &runtime.run_id, + 0, + ) + .expect("register active Provider before missing decision request"); + let token = "steer-missing-decision-token-steer-missing-decision-token"; + let state = ExternalAgentRunnerServerState::new( + directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint(token, "steer-missing-decision-boot", 30304), + ); + let response = dispatch_external_agent_runner_runtime_request( + &ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "steer-missing-decision-request-1".to_string(), + token: token.to_string(), + method: "runtime.interrupt_for_steer_decision".to_string(), + params: ExternalAgentRunnerRequestParams { + root: Some(root.to_string_lossy().into_owned()), + agent: Some(runtime.agent_id.clone()), + run_id: Some(runtime.run_id.clone()), + steer_id: Some(steer.steer_id), + ..ExternalAgentRunnerRequestParams::default() + }, + }, + &state, + ); + + assert!(!response.ok); + assert_eq!( + response.error.as_ref().map(|error| error.code.as_str()), + Some("runtime-error") + ); + assert!(response + .error + .as_ref() + .is_some_and(|error| error.message.contains("尚无 LLM 中断判定"))); + assert!( + !active_provider.interrupted.load(Ordering::Acquire), + "a missing durable LLM decision must leave the active Provider running" + ); + crate::unregister_game_creator_agent_runtime_provider_request(&provider_key, &active_provider); +} + +#[test] +fn runtime_interrupt_for_false_steer_decision_keeps_provider_running() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-steer-false-decision-rpc", + "Runner steer 不中断判定测试", + ) + .expect("initialize false-decision project"); + let runtime = crate::start_game_creator_agent_runtime_task_for_session_at( + &root, + "code-prototype", + None, + "保持当前 Provider 请求运行", + "run-steer-false-decision-rpc", + "agent-background-task", + "处理状态询问", + vec!["完成当前任务".to_string()], + ) + .expect("start false-decision runtime"); + let steer = crate::steer_game_creator_agent_runtime_task_at( + &root, + &runtime.agent_id, + &runtime.session_id, + &runtime.run_id, + "steer-false-decision-rpc-1", + "现在做到哪一步了?", + "runner-test", + ) + .expect("persist status-question steer"); + crate::persist_game_creator_agent_runtime_steer_decision_and_reply_at( + &root, + &runtime, + &steer.steer_id, + steer.sequence, + crate::AgentRuntimeSteerDecision { + reply: "当前任务仍在继续,我会按现有方向完成。".to_string(), + interrupt_current_provider: false, + reason: "状态询问不会让当前方案过期。".to_string(), + }, + ) + .expect("persist non-interrupting LLM decision"); + let (provider_key, active_provider) = + crate::register_game_creator_agent_runtime_provider_request( + &root, + &runtime.agent_id, + &runtime.run_id, + 0, + ) + .expect("register active Provider for false decision"); + let token = "steer-false-decision-token-steer-false-decision-token"; + let state = ExternalAgentRunnerServerState::new( + directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint(token, "steer-false-decision-boot", 30305), + ); + let response = dispatch_external_agent_runner_runtime_request( + &ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "steer-false-decision-request-1".to_string(), + token: token.to_string(), + method: "runtime.interrupt_for_steer_decision".to_string(), + params: ExternalAgentRunnerRequestParams { + root: Some(root.to_string_lossy().into_owned()), + agent: Some(runtime.agent_id.clone()), + run_id: Some(runtime.run_id.clone()), + steer_id: Some(steer.steer_id), + ..ExternalAgentRunnerRequestParams::default() + }, + }, + &state, + ); + + assert!( + response.ok, + "false decision RPC failed: {:?}", + response.error + ); + assert_eq!( + response + .result + .as_ref() + .and_then(|value| value["providerInterrupted"].as_bool()), + Some(false) + ); + assert!(!active_provider.interrupted.load(Ordering::Acquire)); + crate::unregister_game_creator_agent_runtime_provider_request(&provider_key, &active_provider); +} + +#[test] +fn runtime_interrupt_for_true_steer_decision_only_interrupts_older_provider_cursor() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-steer-true-decision-rpc", + "Runner steer 条件中断测试", + ) + .expect("initialize true-decision project"); + let runtime = crate::start_game_creator_agent_runtime_task_for_session_at( + &root, + "code-prototype", + None, + "按当前方向实现玩法", + "run-steer-true-decision-rpc", + "agent-background-task", + "生成当前方案", + vec!["完成当前任务".to_string()], + ) + .expect("start true-decision runtime"); + let steer = crate::steer_game_creator_agent_runtime_task_at( + &root, + &runtime.agent_id, + &runtime.session_id, + &runtime.run_id, + "steer-true-decision-rpc-1", + "停止旧方向,改成全新的玩法。", + "runner-test", + ) + .expect("persist conflicting steer"); + crate::persist_game_creator_agent_runtime_steer_decision_and_reply_at( + &root, + &runtime, + &steer.steer_id, + steer.sequence, + crate::AgentRuntimeSteerDecision { + reply: "这个改动会让旧方案过期,我会安全切换方向。".to_string(), + interrupt_current_provider: true, + reason: "当前 Provider 正在生成已经冲突的旧方案。".to_string(), + }, + ) + .expect("persist interrupting LLM decision"); + let token = "steer-true-decision-token-steer-true-decision-token"; + let state = ExternalAgentRunnerServerState::new( + directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint(token, "steer-true-decision-boot", 30306), + ); + let request = |request_id: &str| ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: request_id.to_string(), + token: token.to_string(), + method: "runtime.interrupt_for_steer_decision".to_string(), + params: ExternalAgentRunnerRequestParams { + root: Some(root.to_string_lossy().into_owned()), + agent: Some(runtime.agent_id.clone()), + run_id: Some(runtime.run_id.clone()), + steer_id: Some(steer.steer_id.clone()), + ..ExternalAgentRunnerRequestParams::default() + }, + }; + + let (old_provider_key, old_provider) = + crate::register_game_creator_agent_runtime_provider_request( + &root, + &runtime.agent_id, + &runtime.run_id, + steer.sequence.saturating_sub(1), + ) + .expect("register Provider created before steer"); + let old_response = dispatch_external_agent_runner_runtime_request( + &request("steer-true-decision-old-provider-request"), + &state, + ); + assert!( + old_response.ok, + "old Provider interrupt RPC failed: {:?}", + old_response.error + ); + assert_eq!( + old_response + .result + .as_ref() + .and_then(|value| value["providerInterrupted"].as_bool()), + Some(true) + ); + assert!(old_provider.interrupted.load(Ordering::Acquire)); + crate::unregister_game_creator_agent_runtime_provider_request(&old_provider_key, &old_provider); + + let (new_provider_key, new_provider) = + crate::register_game_creator_agent_runtime_provider_request( + &root, + &runtime.agent_id, + &runtime.run_id, + steer.sequence, + ) + .expect("register Provider created after steer was applied"); + let new_response = dispatch_external_agent_runner_runtime_request( + &request("steer-true-decision-new-provider-request"), + &state, + ); + assert!( + new_response.ok, + "new Provider keep-running RPC failed: {:?}", + new_response.error + ); + assert_eq!( + new_response + .result + .as_ref() + .and_then(|value| value["providerInterrupted"].as_bool()), + Some(false) + ); + assert!( + !new_provider.interrupted.load(Ordering::Acquire), + "a Provider created at the decided steer cursor must keep running" + ); + crate::unregister_game_creator_agent_runtime_provider_request(&new_provider_key, &new_provider); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs index bcf3f77b0..531b0b4c6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs @@ -348,7 +348,7 @@ fn steer_and_wait_for_swarm_turn( ) -> Result { require_external_agent_runner_for_cli_runtime_write(root)?; let steer_id = format!("swarm-steer-{}", unix_millis()); - let result = steer_game_creator_agent_runtime_task( + let result = tauri::async_runtime::block_on(steer_game_creator_agent_runtime_task( root.display().to_string(), parent_agent_id.to_string(), session_id.to_string(), @@ -357,7 +357,7 @@ fn steer_and_wait_for_swarm_turn( message.to_string(), Some(run_profile.to_string()), None, - )?; + ))?; writeln!( output, "[{label}] run={} steer={} providerInterrupted={}", diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs index 99e24580b..994acd3e2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs @@ -347,15 +347,17 @@ pub(super) fn wait_for_swarm_turn( &runtimes, ) { let steer_id = format!("swarm-steer-{}", unix_millis()); - let result = steer_game_creator_agent_runtime_task( - root.display().to_string(), - parent_agent_id.to_string(), - parent.state.session_id.clone(), - parent.state.run_id.clone(), - steer_id.clone(), - message, - Some(run_profile.to_string()), - None, + let result = tauri::async_runtime::block_on( + steer_game_creator_agent_runtime_task( + root.display().to_string(), + parent_agent_id.to_string(), + parent.state.session_id.clone(), + parent.state.run_id.clone(), + steer_id.clone(), + message, + Some(run_profile.to_string()), + None, + ), )?; writeln!( output, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs index e1b84a8c2..a02b21534 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs @@ -2286,6 +2286,7 @@ async fn project_supervisor_mixed_run_status_recovery_reuses_partial_isolated_cl #[tokio::test] async fn project_supervisor_resume_rechecks_delegate_policy_after_delivery_reservation() { + let _config_guard = write_test_local_config("{}".to_string()); let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "项目总控委派恢复策略测试") .expect("project init"); 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 0276cb20d..3983af83f 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 @@ -100,6 +100,114 @@ fn config_file_overrides_defaults_without_env() { fs::remove_dir_all(root).expect("cleanup test config dir"); } +#[test] +fn agent_mode_defaults_to_codex_app_server_and_preserves_explicit_modes() { + let default_config = GameCreatorAppConfig::default(); + assert_eq!( + default_config.agent_mode, + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER + ); + + let root = unique_project_path(); + fs::create_dir_all(&root).expect("runtime config dir"); + let config_path = root.join(GAME_CREATOR_CONFIG_FILE_NAME); + fs::write(&config_path, r#"{"agentMode":"provider"}"#).expect("write explicit provider mode"); + let mut config = GameCreatorAppConfig::default(); + merge_game_creator_config_file(&mut config, &config_path).expect("merge provider mode"); + assert_eq!(config.agent_mode, GAME_CREATOR_AGENT_MODE_PROVIDER); + assert_eq!( + normalize_game_creator_agent_mode(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER) + .expect("app-server mode"), + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER + ); + assert_eq!( + normalize_game_creator_agent_mode(GAME_CREATOR_AGENT_MODE_CODEX_CLI).expect("cli mode"), + GAME_CREATOR_AGENT_MODE_CODEX_CLI + ); + assert!(normalize_game_creator_agent_mode("unknown") + .expect_err("unknown mode") + .contains("agentMode")); + fs::remove_dir_all(root).expect("cleanup runtime config dir"); +} + +#[test] +fn legacy_agent_mode_migration_preserves_non_responses_provider_routes() { + let responses: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({ + "llm": { "apiKind": "openai_responses" } + })) + .expect("responses config"); + assert_eq!( + legacy_game_creator_agent_mode(&responses), + Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER) + ); + + let chat: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({ + "llm": { "apiKind": "openai_chat" } + })) + .expect("chat config"); + assert_eq!( + legacy_game_creator_agent_mode(&chat), + Some(GAME_CREATOR_AGENT_MODE_PROVIDER) + ); + + let mixed: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({ + "llm": { "apiKind": "openai_responses" }, + "agentLlm": { "art-director": { "apiKind": "anthropic" } } + })) + .expect("mixed config"); + assert_eq!( + legacy_game_creator_agent_mode(&mixed), + Some(GAME_CREATOR_AGENT_MODE_PROVIDER) + ); + + let explicit: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({ + "agentMode": "codex_cli", + "llm": { "apiKind": "openai_chat" } + })) + .expect("explicit config"); + assert_eq!(legacy_game_creator_agent_mode(&explicit), None); +} + +#[test] +fn codex_app_server_requires_responses_route_and_disables_native_web_search() { + let mut llm = GameCreatorLlmConfig::default(); + llm.api_key = "secret".to_string(); + llm.api_kind = "anthropic".to_string(); + assert!(game_creator_codex_app_server_llm_route_error( + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER, + &llm, + "llm" + ) + .expect("unsupported route") + .contains("provider 模式")); + + llm.api_key.clear(); + assert!(game_creator_codex_app_server_llm_route_error( + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER, + &llm, + "llm" + ) + .expect("unsupported empty-key route") + .contains("provider 模式")); + llm.api_kind = "openai_responses".to_string(); + llm.web_search_enabled = true; + assert!(game_creator_codex_app_server_llm_route_error( + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER, + &llm, + "llm" + ) + .expect("unsupported web search") + .contains("webSearchEnabled")); + llm.web_search_enabled = false; + llm.api_key = "secret".to_string(); + assert!(game_creator_codex_app_server_llm_route_error( + GAME_CREATOR_AGENT_MODE_CODEX_CLI, + &llm, + "llm" + ) + .is_none()); +} + #[test] fn legacy_llm_config_deserialization_supplies_context_budget_defaults() { let llm: GameCreatorLlmConfig = serde_json::from_value(serde_json::json!({ @@ -189,6 +297,10 @@ fn canonical_agent_reasoning_effort_defaults_are_exhaustive_and_auditable() { let template = serde_json::from_str::(DEFAULT_GAME_CREATOR_APP_CONFIG_JSON) .expect("parse bundled runtime config template"); + assert_eq!( + template.agent_mode.as_deref(), + Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER) + ); assert_eq!( template.llm.as_ref().and_then(|llm| llm.max_retries), Some(DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES) @@ -260,6 +372,7 @@ fn empty_legacy_agent_llm_uses_agent_defaults_and_explicit_patch_wins() { fs::write( root.join(GAME_CREATOR_CONFIG_FILE_NAME), r#"{ + "agentMode": "provider", "llm": { "apiKey": "global-key", "baseUrl": "https://global.example.test/v1", @@ -365,6 +478,10 @@ fn runtime_config_read_returns_defaults_when_file_is_missing() { .to_string() ); assert_eq!(result.config.llm.api_key, ""); + assert_eq!( + result.config.agent_mode, + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER + ); assert_eq!( result.config.llm.base_url, DEFAULT_GAME_CREATOR_LLM_BASE_URL @@ -436,6 +553,7 @@ fn app_config_commands_write_runtime_config_file() { agent_llm.insert("generator".to_string(), GameCreatorLlmConfigFile::default()); let saved = write_game_creator_app_config(GameCreatorAppConfig { + agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), llm: GameCreatorLlmConfig { api_key: " unit-test-key ".to_string(), base_url: " https://runtime.example.test/v1 ".to_string(), @@ -528,6 +646,7 @@ fn app_config_write_rejects_invalid_api_kind() { let _guard = use_test_runtime_config_dir(root.clone()); let result = write_game_creator_app_config(GameCreatorAppConfig { + agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), llm: GameCreatorLlmConfig { api_key: String::new(), api_kind: "legacy".to_string(), @@ -552,6 +671,7 @@ fn app_config_write_rejects_invalid_reasoning_effort() { let _guard = use_test_runtime_config_dir(root.clone()); let result = write_game_creator_app_config(GameCreatorAppConfig { + agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), llm: GameCreatorLlmConfig { reasoning_effort: "maximum".to_string(), ..GameCreatorLlmConfig::default() @@ -575,6 +695,7 @@ fn app_config_write_rejects_too_small_request_timeout() { let _guard = use_test_runtime_config_dir(root.clone()); let result = write_game_creator_app_config(GameCreatorAppConfig { + agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), llm: GameCreatorLlmConfig { request_timeout_ms: MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS - 1, ..GameCreatorLlmConfig::default() @@ -697,6 +818,7 @@ fn llm_config_check_reports_per_agent_status_without_leaking_keys() { fs::write( root.join(GAME_CREATOR_CONFIG_FILE_NAME), r#"{ + "agentMode": "provider", "llm": { "apiKey": "", "baseUrl": "https://global.example.test/v1", @@ -799,6 +921,7 @@ fn llm_config_check_reports_agent_specific_config_paths() { fs::write( root.join(GAME_CREATOR_CONFIG_FILE_NAME), r#"{ + "agentMode": "provider", "llm": { "apiKey": "global-key", "baseUrl": "https://global.example.test/v1", @@ -835,6 +958,7 @@ fn llm_config_check_reports_agent_specific_config_paths() { #[test] fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() { let status = GameCreatorLlmConfigStatus { + agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), configured: false, api_key_present: false, base_url: Some("https://global.example.test/v1".to_string()), @@ -851,6 +975,7 @@ fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() { retry_backoff_ms: 500, error: Some("Generator:缺少 API Key".to_string()), agents: vec![GameCreatorAgentLlmConfigStatus { + agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), agent_id: "generator".to_string(), label: "Generator".to_string(), configured: false, @@ -1050,6 +1175,7 @@ fn anthropic_web_search_is_rejected_with_precise_global_and_agent_paths() { fn llm_config_status_preserves_global_web_search_error_when_required_agents_override() { let _config_guard = write_test_local_config( r#"{ + "agentMode": "provider", "llm": { "apiKey": "test-key", "baseUrl": "https://anthropic.example.test/v1", diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/goal.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/goal.rs index af67174da..da15d08a6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/goal.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/goal.rs @@ -80,7 +80,7 @@ async fn agent_goal_edit_pause_resume_keeps_one_session_and_run_until_completion .expect("edit active Goal"); assert_eq!(edited.goal.revision, 2); assert_eq!(edited.goal.run_id, run_id); - assert!(edited.provider_interrupted); + assert!(!edited.provider_interrupted); let stale_edit = edit_game_creator_agent_goal_at( &root, "code-prototype", 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 888893f6a..5cbcb764e 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 @@ -1075,6 +1075,17 @@ pub(crate) fn write_test_local_config(content: String) -> TestConfigGuard { .unwrap_or_else(|poisoned| poisoned.into_inner()); let path = test_local_config_path(); let previous = fs::read(&path).ok(); + let content = match serde_json::from_str::(&content) { + Ok(mut config) => { + if let Some(config) = config.as_object_mut() { + config + .entry("agentMode") + .or_insert_with(|| serde_json::json!(GAME_CREATOR_AGENT_MODE_PROVIDER)); + } + serde_json::to_vec_pretty(&config).expect("serialize explicit Provider test config") + } + Err(_) => content.into_bytes(), + }; replace_test_local_config(&path, content); TestConfigGuard { _lock: lock, @@ -1116,6 +1127,7 @@ fn write_mcp_runtime_test_config(config_dir: &Path, llm_base_url: &str, marker_p fs::create_dir_all(config_dir).expect("create MCP runtime test config dir"); let marker_arg = format!("--marker={}", marker_path.display()); let config = serde_json::json!({ + "agentMode": "provider", "agentLlm": { "code-prototype": { "apiKey": "mcp-runtime-test-key", @@ -2077,6 +2089,7 @@ fn spawn_mock_llm_tool_plan_then_transient_final_compaction( &config_path, format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "final-compaction-key", 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 71e092fb7..bed788c5a 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 @@ -677,6 +677,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { fs::write( config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), serde_json::json!({ + "agentMode": "provider", "editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-runtime-key" @@ -1301,6 +1302,7 @@ fn project_directory_status_distinguishes_missing_file_and_dir() { exists: false, is_directory: false, is_game_creator_project: false, + is_godot_project: false, project_name: None, manifest_error: None, recent_run_status: None, 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 d74919e16..cc12b52ee 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 @@ -3821,6 +3821,7 @@ async fn provider_retry_http_and_deserialize_failures_recover_through_durable_si ); let _config_guard = write_test_local_config(format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "provider-classification-key", @@ -5430,6 +5431,7 @@ async fn tool_plan_handoff_base_restart_replays_success_without_network_request( &config_path, format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "tool-plan-handoff-base-key", @@ -5568,6 +5570,7 @@ async fn tool_plan_handoff_repair_restart_replays_chain_without_network_request( &config_path, format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "tool-plan-handoff-repair-key", @@ -8213,7 +8216,7 @@ fn prompt_context_tail_truncation_keeps_latest_blackboard_and_agent_message() { } #[tokio::test] -async fn agent_runtime_steer_interrupts_only_the_active_provider_wait() { +async fn agent_runtime_steer_waits_for_llm_decision_before_interrupting_old_provider() { let root = unique_project_path(); let state = start_agent_runtime_steer_fixture(&root, "steer-provider-run"); let (provider_started_tx, provider_started_rx) = tokio::sync::oneshot::channel(); @@ -8221,7 +8224,7 @@ async fn agent_runtime_steer_interrupts_only_the_active_provider_wait() { let wait_agent = state.agent_id.clone(); let wait_session = state.session_id.clone(); let wait_run = state.run_id.clone(); - let provider_wait = tokio::spawn(async move { + let mut provider_wait = tokio::spawn(async move { await_game_creator_agent_runtime_provider_request( &wait_root, &wait_agent, @@ -8248,10 +8251,37 @@ async fn agent_runtime_steer_interrupts_only_the_active_provider_wait() { "test", ) .expect("queue steer during provider wait"); - assert!(steer.provider_interrupted); - let outcome = tokio::time::timeout(Duration::from_secs(2), provider_wait) + assert!(!steer.provider_interrupted); + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut provider_wait) + .await + .is_err(), + "queueing steer alone must leave the active Provider request running" + ); + persist_game_creator_agent_runtime_steer_decision_and_reply_at( + &root, + &state, + "steer-provider-1", + steer.sequence, + AgentRuntimeSteerDecision { + reply: "这个要求会使旧方案过期,我会改向。".to_string(), + interrupt_current_provider: true, + reason: "旧 Provider 正在生成与新要求冲突的方案。".to_string(), + }, + ) + .expect("persist LLM steer decision"); + assert!( + interrupt_game_creator_agent_runtime_provider_for_decided_steer_at( + &root, + &state.agent_id, + &state.run_id, + "steer-provider-1", + ) + .expect("interrupt stale Provider after LLM decision") + ); + let outcome = tokio::time::timeout(Duration::from_secs(2), &mut provider_wait) .await - .expect("provider wait interrupted promptly") + .expect("provider wait interrupted after decision") .expect("provider wait task joined") .expect("provider wait result"); assert!(outcome.is_none()); @@ -8278,6 +8308,269 @@ async fn agent_runtime_steer_interrupts_only_the_active_provider_wait() { })); } +#[tokio::test] +async fn root_supervisor_steer_decision_uses_llm_and_persists_non_terminal_reply() { + let root = unique_project_path(); + init_local_game_project_at(&root, "supervisor-steer-llm", "总控边做边聊测试") + .expect("initialize Supervisor steer decision project"); + let state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "完成当前可玩版本", + "supervisor-steer-llm-run", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + "正在实现玩法", + vec!["实现并验证玩法".to_string()], + ) + .expect("start Supervisor runtime"); + let steer = steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "supervisor-steer-llm-1", + "你现在在做什么?", + "test", + ) + .expect("queue Supervisor status question"); + let arguments = serde_json::json!({ + "reply": "我正在实现核心玩法,当前任务会继续。", + "interruptCurrentProvider": false, + "reason": "状态询问不会让当前方案过期。" + }) + .to_string(); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_raw_responses_with_capture( + vec![native_agent_tool_plan_chat_response( + "steer-decision-call", + AGENT_RUNTIME_STEER_DECISION_TOOL, + arguments, + )], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentMode": "provider", + "agentLlm": {{ + "project-supervisor": {{ + "apiKey": "steer-decision-key", + "baseUrl": {base_url:?}, + "model": "steer-decision-model", + "apiKind": "openai_chat", + "stream": false, + "maxRetries": 0 + }} + }} +}}"# + )); + + let decision = decide_game_creator_agent_runtime_steer_at( + &root, + &state, + &steer.steer_id, + steer.sequence, + "你现在在做什么?", + ) + .await + .expect("run Supervisor steer LLM decision"); + + assert_eq!(decision.reply, "我正在实现核心玩法,当前任务会继续。"); + assert!(!decision.interrupt_current_provider); + let request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("capture Supervisor steer decision request"); + assert!(request.contains(AGENT_RUNTIME_STEER_DECISION_TOOL)); + assert!(request.contains("你现在在做什么?")); + let project_conversation = + read_local_conversation_at(&root, None).expect("read public Supervisor steer reply"); + assert_eq!( + project_conversation + .messages + .iter() + .filter(|message| message.content == decision.reply) + .count(), + 1 + ); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn root_supervisor_steer_decision_failure_keeps_provider_and_persists_fallback_reply() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "supervisor-steer-decision-failure", + "总控判定失败继续任务测试", + ) + .expect("initialize Supervisor steer decision failure project"); + let state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "完成当前可玩版本", + "supervisor-steer-decision-failure-run", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + "正在实现玩法", + vec!["实现并验证玩法".to_string()], + ) + .expect("start Supervisor runtime"); + let (provider_started_tx, provider_started_rx) = tokio::sync::oneshot::channel(); + let wait_root = root.clone(); + let wait_agent = state.agent_id.clone(); + let wait_session = state.session_id.clone(); + let wait_run = state.run_id.clone(); + let mut provider_wait = tokio::spawn(async move { + await_game_creator_agent_runtime_provider_request( + &wait_root, + &wait_agent, + &wait_session, + &wait_run, + "tool-plan", + "steer-decision-failure-active-provider", + 0, + async move { + let _ = provider_started_tx.send(()); + std::future::pending::>().await + }, + ) + .await + }); + provider_started_rx.await.expect("provider future started"); + let steer = steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "supervisor-steer-decision-failure-1", + "现在做到哪里了?", + "test", + ) + .expect("queue Supervisor steer before failed decision"); + let base_url = spawn_mock_llm_raw_responses_with_capture( + vec![native_agent_tool_plan_chat_response( + "unexpected-steer-decision-call", + "runtime_execute", + "{}".to_string(), + )], + None, + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentMode": "provider", + "agentLlm": {{ + "project-supervisor": {{ + "apiKey": "steer-decision-failure-key", + "baseUrl": {base_url:?}, + "model": "steer-decision-failure-model", + "apiKind": "openai_chat", + "stream": false, + "maxRetries": 0 + }} + }} +}}"# + )); + + let error = decide_game_creator_agent_runtime_steer_at( + &root, + &state, + &steer.steer_id, + steer.sequence, + "现在做到哪里了?", + ) + .await + .expect_err("invalid decision tool must fail closed"); + let fallback = append_game_creator_agent_runtime_steer_decision_failure_reply_at( + &root, + &state, + &steer.steer_id, + &error, + ) + .expect("persist non-terminal decision failure reply"); + + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut provider_wait) + .await + .is_err(), + "failed steer decision must not interrupt the active Provider" + ); + let runtime = read_game_creator_agent_runtime_at(&root, &state.agent_id) + .expect("read continuing Supervisor runtime"); + assert_eq!(runtime.state.run_id, state.run_id); + assert!(!matches!( + runtime.state.status.as_str(), + "completed" | "failed" | "cancelled" + )); + let project_conversation = + read_local_conversation_at(&root, None).expect("read public fallback reply"); + assert_eq!( + project_conversation + .messages + .iter() + .filter(|message| message.content == fallback) + .count(), + 1 + ); + assert!(read_game_creator_agent_runtime_steer_decision_at( + &root, + &state.agent_id, + &state.run_id, + &steer.steer_id, + ) + .expect("read absent failed decision") + .is_none()); + + provider_wait.abort(); + let _ = provider_wait.await; + fs::remove_dir_all(root).ok(); +} + +#[test] +fn steer_decision_never_interrupts_provider_started_after_steer_was_applied() { + let root = unique_project_path(); + let state = start_agent_runtime_steer_fixture(&root, "steer-new-provider-run"); + let steer = steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "steer-new-provider-1", + "改成新的实现方向。", + "test", + ) + .expect("queue steer before new Provider"); + persist_game_creator_agent_runtime_steer_decision_and_reply_at( + &root, + &state, + "steer-new-provider-1", + steer.sequence, + AgentRuntimeSteerDecision { + reply: "我会按新方向继续。".to_string(), + interrupt_current_provider: true, + reason: "旧方向已经过期。".to_string(), + }, + ) + .expect("persist interrupt decision"); + let (key, active) = register_game_creator_agent_runtime_provider_request( + &root, + &state.agent_id, + &state.run_id, + steer.sequence, + ) + .expect("register Provider created after steer application"); + + assert!( + !interrupt_game_creator_agent_runtime_provider_for_decided_steer_at( + &root, + &state.agent_id, + &state.run_id, + "steer-new-provider-1", + ) + .expect("keep new Provider running") + ); + assert!(!active.interrupted.load(Ordering::Acquire)); + unregister_game_creator_agent_runtime_provider_request(&key, &active); + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn agent_runtime_durable_cancel_after_provider_registration_starts_no_request() { let root = unique_project_path(); 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 37c5f11f1..b9d345fde 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 @@ -341,6 +341,7 @@ async fn response_stream_disabled_keeps_direct_planning_reply_to_one_request() { let base_url = mock.base_url.clone(); let _config_guard = write_test_local_config(format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "response-stream-direct-key", @@ -434,6 +435,7 @@ async fn response_stream_private_process_output_is_never_published_or_committed_ let base_url = mock.base_url.clone(); let _config_guard = write_test_local_config(format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "code-prototype": {{ "apiKey": "response-stream-private-key", @@ -632,6 +634,7 @@ async fn response_stream_final_failure_with_retry_disabled_commits_planning_fall let base_url = mock.base_url.clone(); let _config_guard = write_test_local_config(format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "response-stream-retry-key", @@ -797,6 +800,7 @@ async fn provider_retry_final_reply_thinking_only_response_retries_before_handof &config_path, format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "final-reply-empty-normalization-key", @@ -3820,6 +3824,7 @@ async fn provider_handoff_final_reply_restart_replays_success_without_network_re &config_path, format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "handoff-final-reply-key", @@ -4071,6 +4076,7 @@ async fn provider_handoff_final_reply_compaction_restart_only_requests_final_rep &config_path, format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "handoff-final-compaction-key", 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 a17612f30..f35224676 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 @@ -243,6 +243,7 @@ async fn background_agent_runtime_asset_generation_respects_project_policy() { fs::write( config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), serde_json::json!({ + "agentMode": "provider", "agentLlm": { "art-asset-plan": { "apiKey": "art-key", 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 905bfc1fa..d44047373 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 @@ -580,7 +580,10 @@ async fn background_agent_runtime_inflight_steer_discards_old_plan_without_new_t "test", ) .expect("steer in-flight provider"); - assert!(steer.provider_interrupted); + assert!( + !steer.provider_interrupted, + "steer must not interrupt an in-flight Provider before an LLM decision" + ); let old_plan = serde_json::json!({ "thinkingSummary": "旧计划准备写文件", 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 5fb1cd29f..4d013debf 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 @@ -957,6 +957,7 @@ async fn background_final_reply_failure_keeps_private_conversation_and_hashes_pu &config_path, format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "design-key", @@ -1168,6 +1169,7 @@ fn structured_plan_state_write_failure_stops_before_context_and_audit() { ); let _config_guard = write_test_local_config(format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "design-key", @@ -3309,6 +3311,7 @@ fn supervisor_background_task_persists_one_public_start_status_before_execution( #[test] fn accepted_supervisor_preparing_task_recovers_to_runnable_queue() { + let _config_guard = write_test_local_config("{}".to_string()); let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "Supervisor 启动确认恢复测试") .expect("project init"); @@ -4934,6 +4937,107 @@ fn agent_runtime_steer_is_idempotent_rejects_conflicts_and_enforces_capacity() { .is_err()); } +#[test] +fn root_supervisor_steer_persists_one_correlated_public_acknowledgement() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-steer-public-ack", "总控追加消息确认测试") + .expect("initialize Supervisor steer fixture"); + let state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "制作一个可玩的游戏", + "supervisor-steer-public-ack-run", + "project-supervisor-gui", + "正在制作游戏", + vec!["完成可玩版本".to_string()], + ) + .expect("start root Supervisor runtime"); + + for _ in 0..2 { + steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "supervisor-steer-public-ack-1", + "你在做什么?", + "test", + ) + .expect("queue idempotent Supervisor steer"); + } + for _ in 0..2 { + persist_game_creator_agent_runtime_steer_decision_and_reply_at( + &root, + &state, + "supervisor-steer-public-ack-1", + 1, + AgentRuntimeSteerDecision { + reply: "我正在完成玩法实现;这是状态询问,当前任务会继续。".to_string(), + interrupt_current_provider: false, + reason: "状态询问不需要中断当前 Provider。".to_string(), + }, + ) + .expect("persist idempotent Supervisor steer reply"); + } + + let supervisor_conversation = read_local_conversation_for_session_at( + &root, + Some(&state.agent_id), + Some(&state.session_id), + ) + .expect("read Supervisor steer conversation"); + let steer_message = supervisor_conversation + .messages + .iter() + .find(|message| message.role == "user" && message.content == "你在做什么?") + .expect("find persisted steer user message"); + let correlation_id = steer_message + .message_id + .as_deref() + .and_then(|message_id| message_id.strip_prefix("agent-steer-")) + .expect("read steer correlation id"); + + let project_conversation = + read_local_conversation_at(&root, None).expect("read public steer acknowledgement"); + let acknowledgements = project_conversation + .messages + .iter() + .filter(|message| { + message.content == "收到。我正在判断这条消息是否需要调整当前任务;现有任务会继续运行。" + }) + .collect::>(); + assert_eq!(acknowledgements.len(), 1); + assert!(acknowledgements[0] + .message_id + .as_deref() + .is_some_and(|message_id| { + message_id.starts_with(&format!( + "{AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX}{correlation_id}-" + )) + })); + assert_eq!( + project_conversation + .messages + .iter() + .filter(|message| { + message.content == "我正在完成玩法实现;这是状态询问,当前任务会继续。" + }) + .count(), + 1 + ); + let decision = read_game_creator_agent_runtime_steer_decision_at( + &root, + &state.agent_id, + &state.run_id, + "supervisor-steer-public-ack-1", + ) + .expect("read persisted Supervisor steer decision") + .expect("Supervisor steer decision exists"); + assert!(!decision.interrupt_current_provider); + + fs::remove_dir_all(root).ok(); +} + #[test] fn agent_runtime_steer_sequences_are_unique_under_concurrent_acceptance() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index fc097ae68..bba00b47c 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -77,6 +77,7 @@ import type { LocalProjectFileMutationResult, LocalProjectFileResult, LocalProjectIndexResult, + LocalProjectKind, LocalProjectRestoreResult, MemoryScope, MemoryWriteMode, @@ -141,8 +142,10 @@ import { formatAgentDialogLlmStatus, formatAgentLlmConfigWarning, formatAgentRunControlError, + formatCodexAgentModeLabel, formatLlmAgentStatusLine, formatLlmRouteEndpoint, + isCodexAgentMode, isMissingAgentRunTraceError, projectAgentRuntimeSummaries, readablePassArtifactsFromAgentRunTrace, @@ -563,6 +566,7 @@ export function GameChatReleaseApp({ type AppProps = { initialProjectPath?: string; initialProjectManifest?: GameCreationAppManifest; + initialProjectKind?: LocalProjectKind; projectSupervisorOnly?: boolean; supervisorChatOnly?: boolean; gameChatOnly?: boolean; @@ -581,6 +585,7 @@ type AppProps = { export function App({ initialProjectPath: initialProjectPathOverride = '', initialProjectManifest, + initialProjectKind = 'web', projectSupervisorOnly = false, supervisorChatOnly = false, gameChatOnly = false, @@ -601,6 +606,8 @@ export function App({ const [projectPath, setProjectPath] = useState( initialProjectPath || defaultProjectPath, ); + const [workspaceProjectKind, setWorkspaceProjectKind] = + useState(initialProjectKind); const [localProject, setLocalProject] = useState(() => eagerSupervisorProject @@ -831,9 +838,7 @@ export function App({ projectSupervisorResponseStreamRef.current = projectSupervisorResponseStream; const refreshManifest = useCallback( - ( - nextProjectPath = localProjectPathRef.current ?? '', - ): Promise => { + (nextProjectPath = localProjectPathRef.current ?? ''): Promise => { const invoke = resolveTauriInvoke(); if (!invoke || !nextProjectPath) { return Promise.resolve(); @@ -1326,7 +1331,12 @@ export function App({ }); return; } - void openWorkspace(initialProjectPath, false); + void openWorkspace( + initialProjectPath, + false, + initialProjectKind === 'godot' ? 'open' : 'create', + initialProjectKind, + ); // Initial project opening is guarded by initialProjectOpenedRef. // eslint-disable-next-line react-hooks/exhaustive-deps }, [gameChatOnly, initialProjectPath, projectSupervisorOnly]); @@ -3060,6 +3070,8 @@ export function App({ async function openWorkspace( nextProjectPath: string, announceToChat: boolean, + mode: 'create' | 'open' = 'create', + projectKind: LocalProjectKind = 'web', ) { const trimmedProjectPath = nextProjectPath.trim(); if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) { @@ -3114,16 +3126,23 @@ export function App({ agentConversationSavingRef.current = false; agentConversationLoadVersionRef.current += 1; try { - const result = await invoke( - 'init_local_game_project', - { - projectPath: trimmedProjectPath, - projectId: seedManifest.projectId, - name: gameChatOnly - ? projectNameFromPath(trimmedProjectPath) - : seedManifest.name, - }, - ); + const result = + mode === 'open' + ? { + projectPath: trimmedProjectPath, + manifestPath: `${trimmedProjectPath.replace(/[\\/]+$/, '')}/.agent/manifest.json`, + manifest: await invoke( + 'get_local_game_manifest', + { projectPath: trimmedProjectPath }, + ), + } + : await invoke('init_local_game_project', { + projectPath: trimmedProjectPath, + projectId: seedManifest.projectId, + name: gameChatOnly + ? projectNameFromPath(trimmedProjectPath) + : seedManifest.name, + }); if (projectScopeVersionRef.current !== projectScopeVersion) { return; } @@ -3155,6 +3174,7 @@ export function App({ } localProjectPathRef.current = openedProject.projectPath; setProjectPath(openedProject.projectPath); + setWorkspaceProjectKind(projectKind); setLocalProject(openedProject); if (gameChatOnly) { setChatInput(readSupervisorChatDraft(openedProject.projectPath)); @@ -3171,11 +3191,11 @@ export function App({ savedConversationCountRef.current = conversationMessages.length; writeRecentWorkspace(openedProject.projectPath); setWorkspaceStatus(`已打开:${openedProject.projectPath}`); - setProjectStatus('已初始化'); + setProjectStatus(mode === 'open' ? '已打开' : '已初始化'); appendLocalPermissionLog( openedProject.projectPath, 'permission.confirm', - 'project.create', + mode === 'open' ? 'project.status' : 'project.create', ); if (announceToChat) { setMessages((current) => [ @@ -3241,7 +3261,20 @@ export function App({ return; } if (directoryStatus.isGameCreatorProject) { - await openWorkspace(trimmedProjectPath, false); + if (directoryStatus.isGodotProject) { + await openWorkspace(trimmedProjectPath, false, 'open', 'godot'); + } else { + await openWorkspace(trimmedProjectPath, false); + } + return; + } + if (directoryStatus.isGodotProject) { + await invoke('import_local_godot_project', { + projectPath: trimmedProjectPath, + projectId: seedManifest.projectId, + name: projectNameFromPath(trimmedProjectPath), + }); + await openWorkspace(trimmedProjectPath, false, 'open', 'godot'); return; } await executeProjectCreate(trimmedProjectPath, false, selectionVersion); @@ -5601,13 +5634,19 @@ export function App({ setLlmConfigStatus(status); setCommandLog((current) => [...current, 'llm.config_check']); const agentLines = (status.agents ?? []).map(formatLlmAgentStatusLine); - const summary = status.configured - ? `LLM 已配置:${formatLlmRouteEndpoint(status)}。` - : `LLM 未就绪:${status.error ?? '配置不完整'}。${ - status.reasoningEffort ? `推理 ${status.reasoningEffort},` : '' - }联网检索 ${status.webSearchEnabled ? '开启' : '关闭'},API Key:${ - status.apiKeyPresent ? '已读取' : '未读取' - }。`; + const summary = isCodexAgentMode(status.agentMode) + ? status.configured + ? `${formatCodexAgentModeLabel(status.agentMode)} 已检测到;登录与网络将在首次节点调用时验证。` + : `${formatCodexAgentModeLabel(status.agentMode)} 未就绪:${ + status.error ?? 'Codex CLI 不可用' + }。` + : status.configured + ? `LLM 已配置:${formatLlmRouteEndpoint(status)}。` + : `LLM 未就绪:${status.error ?? '配置不完整'}。${ + status.reasoningEffort ? `推理 ${status.reasoningEffort},` : '' + }联网检索 ${status.webSearchEnabled ? '开启' : '关闭'},API Key:${ + status.apiKeyPresent ? '已读取' : '未读取' + }。`; setMessages((current) => [ ...current, { @@ -5741,7 +5780,8 @@ export function App({ return; } const submissionRunProfile = - supervisorChatOnly && !gameChatOnly + workspaceProjectKind === 'godot' || + (supervisorChatOnly && !gameChatOnly) ? 'standard' : 'autonomous-game-build'; const runtimeAtSubmission = projectSupervisorRuntimeRef.current; @@ -5797,7 +5837,9 @@ export function App({ prompt, runtime: runtimeAtSubmission, runProfile: submissionRunProfile, - ...(gameChatOnly ? { source: 'project-supervisor-game-chat' } : {}), + source: gameChatOnly + ? 'project-supervisor-game-chat' + : 'project-supervisor-gui', }); const runtimeResult = submission.runtimeResult; const acceptedRunId = submission.acceptedRunId.trim(); diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index abdbae613..74db6f576 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -1,4 +1,11 @@ -import { type FormEvent, type ReactNode, useEffect, useState } from 'react'; +import { + Component, + type ErrorInfo, + type FormEvent, + type ReactNode, + useEffect, + useState, +} from 'react'; import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import { @@ -15,6 +22,55 @@ import { sendClientPhoneLoginCode, } from '../services/clientAuth'; +type ClientRuntimeErrorBoundaryProps = { + children: ReactNode; + onLogout: () => void; +}; + +type ClientRuntimeErrorBoundaryState = { + errorMessage: string; +}; + +export class ClientRuntimeErrorBoundary extends Component< + ClientRuntimeErrorBoundaryProps, + ClientRuntimeErrorBoundaryState +> { + state: ClientRuntimeErrorBoundaryState = { errorMessage: '' }; + + static getDerivedStateFromError(error: unknown) { + return { + errorMessage: + error instanceof Error && error.message.trim() + ? error.message + : '登录后页面发生未知错误', + }; + } + + componentDidCatch(error: unknown, errorInfo: ErrorInfo) { + console.error('AGC authenticated client render failed', error, errorInfo); + } + + render() { + if (!this.state.errorMessage) { + return this.props.children; + } + return ( +
+
+ tn +
+

客户端页面加载失败

+

{this.state.errorMessage}

+
+ +
+
+ ); + } +} + export function AuthenticatedClient({ children, }: { @@ -270,5 +326,9 @@ export function AuthenticatedClient({ ); } - return <>{children({ user: authUser, logout })}; + return ( + + {children({ user: authUser, logout })} + + ); } diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index dc33606dc..37b3e92a1 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -50,9 +50,12 @@ export type LauncherImportedAttachment = { error?: string; }; +export type LocalProjectKind = 'web' | 'godot'; + export type LauncherProjectContext = { projectPath: string; projectName: string; + projectKind: LocalProjectKind; manifest: GameCreationAppManifest; mode: HomeAgentMode | null; initialPrompt: string; @@ -91,6 +94,7 @@ export interface LocalProjectDirectoryStatus { exists: boolean; isDirectory: boolean; isGameCreatorProject: boolean; + isGodotProject: boolean; projectName: string | null; manifestError?: string | null; recentRunStatus: string | null; @@ -419,6 +423,9 @@ export interface AgentRuntimeSteerResult { sequence: number; status: string; providerInterrupted: boolean; + assistantReply?: string | null; + interruptDecision?: boolean | null; + decisionReason?: string | null; } export interface GameCreatorAgentRuntimeUpdateEvent { @@ -446,6 +453,7 @@ export type GameCreatorLlmReasoningEffort = (typeof gameCreatorLlmReasoningEfforts)[number]; export interface GameCreatorLlmConfigStatus { + agentMode: GameCreatorAgentMode; configured: boolean; apiKeyPresent: boolean; baseUrl: string | null; @@ -462,6 +470,7 @@ export interface GameCreatorLlmConfigStatus { } export interface GameCreatorAgentLlmConfigStatus { + agentMode: GameCreatorAgentMode; agentId: string; label: string; configured: boolean; @@ -482,6 +491,10 @@ export type GameCreatorLlmApiKind = | 'openai_responses' | 'openai_chat' | 'anthropic'; +export type GameCreatorAgentMode = + | 'codex_app_server' + | 'codex_cli' + | 'provider'; export type RuntimeLlmProviderPresetId = | 'custom' | 'openai' @@ -568,6 +581,7 @@ export interface GameCreatorMcpCatalog { } export interface GameCreatorAppConfig { + agentMode: GameCreatorAgentMode; llm: GameCreatorLlmConfig; agentLlm: Record; editorApi: { 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 4307d42cd..0f8329aba 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 @@ -23,9 +23,12 @@ import type { const AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX = 'runtime-public-status-'; const AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX = 'runtime-task-'; +const AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX = 'agent-steer-'; const AGENT_RUNTIME_MESSAGE_CORRELATION_PATTERN = /^[0-9a-f]{32}$/; -function agentRuntimeMessageCorrelationId(messageId: string | null | undefined) { +function agentRuntimeMessageCorrelationId( + messageId: string | null | undefined, +) { const normalized = messageId?.trim() ?? ''; const prefix = normalized.startsWith( AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX, @@ -33,7 +36,9 @@ function agentRuntimeMessageCorrelationId(messageId: string | null | undefined) ? AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX : normalized.startsWith(AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX) ? AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX - : null; + : normalized.startsWith(AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX) + ? AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX + : null; if (!prefix) { return null; } @@ -907,7 +912,13 @@ export async function submitProjectSupervisorRuntimeTask({ export function agentRuntimeSteerStatus(result: AgentRuntimeSteerResult) { const runId = result.runtime.state.runId; if (result.providerInterrupted) { - return `已中断 Provider,正在同一 Run 重新规划:${runId}`; + return `LLM 已判定需要改向,旧 Provider 已安全中断:${runId}`; + } + if (result.interruptDecision === false) { + return `LLM 已回复且判定无需中断,当前 Run 继续:${runId}`; + } + if (result.interruptDecision === true) { + return `LLM 已判定需要改向;旧请求已结束或新规划已开始:${runId}`; } if (result.status === 'applied') { return `追加指令已应用,当前 Run 正在继续:${runId}`; @@ -1249,7 +1260,10 @@ export function mergeProjectSupervisorConversation( return; } const correlationId = agentRuntimeMessageCorrelationId(record.messageId); - if (correlationId && !supervisorRecordIndexByCorrelation.has(correlationId)) { + if ( + correlationId && + !supervisorRecordIndexByCorrelation.has(correlationId) + ) { supervisorRecordIndexByCorrelation.set(correlationId, index); } }); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx index 85b68ba94..42baedf55 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx @@ -63,6 +63,12 @@ export function ProjectsPage({ > 选择 +