From 77746423651751fdcd0597d2bf778ad231e24360 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:21:59 +0800 Subject: [PATCH 01/20] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E5=88=B6?= =?UTF-8?q?=E5=AE=9AAGC=E6=97=A0=E4=BA=BA=E5=80=BC=E5=AE=88=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E7=94=9F=E6=88=90=E4=BF=AE=E5=A4=8D=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确普通GUI单主快车道路由与无人值守安全边界 定义验证自修复、产物完成门和Runner恢复对账合同 补充端到端验收矩阵、验证命令与回滚边界 --- ...】AGC无人值守游戏生成可靠性收口-2026-08-13.md | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 docs/project-memory/plans/【实施计划】AGC无人值守游戏生成可靠性收口-2026-08-13.md diff --git a/docs/project-memory/plans/【实施计划】AGC无人值守游戏生成可靠性收口-2026-08-13.md b/docs/project-memory/plans/【实施计划】AGC无人值守游戏生成可靠性收口-2026-08-13.md new file mode 100644 index 000000000..ab91bdf12 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC无人值守游戏生成可靠性收口-2026-08-13.md @@ -0,0 +1,249 @@ +# AGC 无人值守游戏生成可靠性收口实施计划 + +日期:`2026-08-13` + +## 1. 目标 + +本次收口的验收对象不是单个报错点,而是一次完整的用户任务: + +> 用户提交一段游戏需求后,不再确认权限、不再补充“继续”、不再手工重试,AGC 在有界时间内自动产出一版可玩的游戏,并完成当前 revision 的静态验证与桌面、移动双视口真实试玩;若外部依赖或环境确实不可恢复,则必须自动收束为带安全原因的明确失败,不能永久停留在运行中、待确认或等待某个专业 Agent。 + +“产出了一份 HTML”不等于完成。最终完成必须同时满足: + +1. 权威入口产物存在、非占位且可解析。 +2. 游戏具备真实主要玩法,而不是装饰按钮或固定奖励假体。 +3. `playable-web-game-state.v1`、start、primary-action、restart 合同可由真实浏览器执行。 +4. 当前 revision 通过 `game.static_smoke`。 +5. 当前 revision 通过 desktop 与 mobile `preview.validate`。 +6. Runtime、manifest、产物和验证回执的终态一致。 + +## 2. 当前失败基线 + +本次复现暴露的不是孤立实现缺陷,而是生成控制面的系统性断链: + +- 普通工作台提交使用 `project-supervisor-gui`,进入固定专业任务图;一个单 HTML MVP 也会被美术、音频等前置依赖阻塞。 +- 现役 `project-supervisor-game-chat` 已具备“单主 `code-prototype` + 按真实缺口动态委派美术”的快车道,但普通 GUI 默认没有复用它。 +- 工作台仍展示“严格审批”,运行期间可以进入 `waiting-for-confirmation` 或要求用户继续,不满足无人值守目标。 +- `game.static_smoke` 失败时,持久回执可能只剩 `safeDetail=null`、`detailUnavailable=true`;同一 owner 看不到失败项,只能猜测修补。 +- 可修复的验证失败可能结束旧任务、留下空队列或失败卡片,未保证回到同一主 Run 继续“诊断 -> 修复 -> 重验”。 +- 子任务可以先报告 completed,再在投影阶段发现正式产物缺失,造成 Runtime 终态、manifest 状态和文件事实不一致。 +- `execution-owner` 对应进程消失后,持久 Runtime 仍可能显示 running,缺少自动对账和续跑闭环。 +- game-chat 当前首个可玩版本软预算为 4200 秒、硬上限为 4500 秒;固定图和重复猜错会把简单任务拖到一小时以上。 + +直接 Codex 能在数分钟内生成明显更完整的可运行雏形,说明首要瓶颈是 Runtime 的路由、反馈和验收控制,而不是基础模型完全不具备实现能力。 + +## 3. 范围 + +### 3.1 本次必须完成 + +1. **默认单主生成路由** + - 普通 AGC 项目工作台的新建/修改游戏请求默认使用 `project-supervisor-game-chat`。 + - 持久路由前零 child;持久路由后只启动 `code-prototype`。 + - 美术只在 `asset.list` 证明精确缺口后动态委派,且一次只处理一个依赖槽。 + - 保留 `project-supervisor-gui` 给显式专业 DAG/开发诊断入口,不再作为普通生成默认值。 + +2. **无人值守安全策略** + - 对可信 game-chat autonomous root 及其绑定 child,项目内可恢复写入、受限静态验证、真实试玩、任务路由和严格边界内的动态委派自动执行。 + - 该模式不得进入普通 `waiting-for-confirmation` 或 `waiting-for-user-input`;模型信息不足时先采用目标合同允许的安全默认值。 + - 越界路径、任意命令、发布、凭据、系统设置和未列入白名单的外部副作用继续失败关闭,不能为了“无人值守”扩大权限。 + +3. **可操作的验证诊断与自动修复循环** + - `game.static_smoke` 失败回执向同一 run owner 返回脱敏、结构化的失败码、检查项、项目相对路径和有界说明。 + - 公共聊天和跨 owner 回执只展示安全摘要,不泄露绝对路径、命令原始输出、密钥、URL query 或内部 fingerprint。 + - 可修复失败必须回到同一 `code-prototype` Run,继续修改后按“脚本解析 -> static smoke -> desktop/mobile preview.validate”顺序重验。 + - 相同 revision、相同失败指纹无新 mutation 时不得无限重试;命中停滞门后明确失败。 + +4. **完成投影与正式产物一致性** + - owner 子任务进入 completed 投影前,逐项验证其声明的正式 artifact 存在、非空,并对 JSON/PNG/HTML 执行已有格式门。 + - 只读验证任务不得声明由自己写入的正式文件产物。 + - 产物缺失时不能把 manifest 写成 completed;必须产生可修复 blocker 并由主 Run 接管,或在不可恢复时明确 failed。 + - 根 Run 只有在当前任务图、正式产物、static smoke 和双视口试玩全部对齐后完成。 + +5. **Runner 消失后的自动对账** + - 读取 `execution-owner` 时同时核对 boot identity 与进程存活性,不能只相信 JSON 中的 PID。 + - owner 已消失且 durable task 仍为可恢复活跃态时,新 Runner 启动/项目 hydration 自动恢复队列;不能永久显示 running。 + - 外部副作用结果未知时进入 `needs-reconciliation` 并保留证据,不能盲目重做;纯 Provider、项目内文件和验证动作可以按现有 durable checkpoint 安全续跑。 + - 对账与恢复必须幂等,同一 run 不产生重复 child、重复消息或重复付费生成。 + +6. **有界端到端验收** + - 新增一个从普通 GUI 提交到 game-chat 单主路由的回归入口。 + - 用确定性 Provider/工具夹具覆盖“首次 smoke 失败 -> 同主 Run 取得具体诊断 -> 修复 -> smoke 通过 -> 双视口试玩通过 -> completed”。 + - 覆盖 owner 中途消失后新 boot 自动续跑,最终只产生一个根终态。 + - 任何人工确认、人工澄清、固定专业 DAG 等待、旧 revision 验证复用或 console error 都使 E2E 失败。 + +### 3.2 本次不做 + +- 不直接修改用户下载目录中的失败示例;它只作为复现样本。 +- 不把直接 Codex 生成的某个三消 HTML固化成平台模板。 +- 不恢复已退役的主站玩法入口、公开作品系统或旧创作模板后端。 +- 不允许任意 shell、项目外路径写入、自动发布或自动消费未知外部付费能力。 +- 不以隐藏错误、自动点击确认或延长预算冒充可靠性修复。 + +## 4. 目标状态机 + +```text +accepted + -> route-pending + -> code-prototype-running + -> asset-audit + -> optional-art-delivery + -> implementation + -> syntax/static-validation + -> desktop-mobile-playtest + -> completed + +可修复失败: +validation-failed -> owner-repairing -> validation + +进程中断: +owner-lost -> recovery-scan -> same-run-resumed + +外部结果未知: +owner-lost -> needs-reconciliation -> explicit terminal/recovered + +不可恢复或停滞: +any active state -> failed (safe terminal reason) +``` + +普通无人值守生成路径禁止出现: + +```text +waiting-for-confirmation +waiting-for-user-input +fixed-art/audio dependency wait +running with a dead owner and no recovery record +completed with missing artifacts or stale validation +``` + +## 5. 实施切片 + +### 切片 A:入口和路由收敛 + +主要位置: + +- `apps/ai-game-creator-shell/src/App.tsx` +- `apps/ai-game-creator-shell/src/features/agent-runtime/model.ts` +- `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs` +- `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs` + +实施内容: + +- 把普通项目工作台的 autonomous build source 改为 game-chat 单主来源。 +- 把 source 选择抽成可测试的纯函数,避免 UI 分支再次漂移。 +- 保持显式专业/CLI 调试来源不变。 +- 调整工作台状态投影,不再向默认用户展示固定专业 DAG 和无效“严格审批”承诺。 + +### 切片 B:无人值守权限与失败反馈 + +主要位置: + +- `runtime_tools/policy.rs` +- `runtime_actions/provider_action_batch.rs` +- `runtime_tools/command_ops.rs` +- `runtime_actions/action_audit.rs` +- `runtime_actions/provider_request_builders.rs` + +实施内容: + +- 为可信 game-chat autonomous binding 建立窄白名单自动策略。 +- 对该来源的确认型动作做“安全自动执行或明确拒绝”二分,不能挂起等待。 +- 为 `command.run_limited/game.static_smoke` 定义稳定的 safe detail schema。 +- 保证同 owner 的下一轮 Provider context 能读取失败项,公共 UI 仍只拿安全摘要。 +- 增加失败指纹与 revision liveness 测试,阻止无 mutation 重复 smoke。 + +### 切片 C:完成门和恢复对账 + +主要位置: + +- `runtime_protocol/autonomous_completion.rs` +- `runtime_driver/task_start.rs` +- `runtime_driver/recovery_scan.rs` +- `runner/project_owner.rs` +- `runner/recovery.rs`(以实际调用边界为准) + +实施内容: + +- 将 artifact 合同校验前移到 terminal projection。 +- 对完成声明、manifest 状态和文件事实做一次原子/锁内判断。 +- owner 丢失时按 durable action 类型判断 same-run resume 或 reconciliation。 +- 恢复后重新核对当前 revision,旧 smoke/试玩回执不能过门。 +- 缩短无进展窗口;预算只限制时间,不能替代完成门。 + +### 切片 D:端到端门禁和权威文档 + +主要位置: + +- `apps/ai-game-creator-shell/tests/appSurface.test.ts` +- Runtime Rust 定向测试模块 +- `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` +- `docs/project-memory/shared-memory/development-workflow.md` +- `docs/project-memory/shared-memory/decision-log.md` +- 必要时 `docs/project-memory/shared-memory/pitfalls.md` + +实施内容: + +- 固定普通 GUI -> game-chat 的来源契约。 +- 增加无确认、失败自修复、artifact 真实性和 owner-loss 恢复测试。 +- 增加确定性 E2E;真实 Provider smoke 作为现场验收,不把 mock E2E 说成真实生成已经成功。 +- 把新的默认路由、无人值守安全边界和复验命令写回权威文档。 + +## 6. 验收矩阵 + +| 场景 | 预期结果 | +|---|---| +| 普通工作台提交单 HTML 游戏需求 | source 为 `project-supervisor-game-chat`,只启动 `code-prototype` | +| 素材完整 | 零美术委派、零额外扣费 | +| 缺少规范图和图集 | 先规范图后图集,一次一个 delivery,主 Run 认领后继续 | +| 项目内文件修改、static smoke、preview validate | 在可信 autonomous binding 下不等待人工确认 | +| 请求任意系统命令或项目外写入 | 明确 blocked/failed,不执行,也不挂起等待确认 | +| 首次 JS/static smoke 失败 | 同一 owner 得到结构化失败项,修改后自动重验 | +| 连续同 revision 同错误 | 停滞门阻断重复猜测,明确失败或回到可行动步骤 | +| 子任务回复 completed 但 artifact 缺失 | manifest 不得 completed;产生修复 blocker | +| 最后 mutation 后只存在旧验证 | 根 Run 不得 completed,强制重验当前 revision | +| Runner 在 Provider/文件动作间退出 | 新 boot 幂等恢复同 run,不重复 child/消息 | +| Runner 在未知付费外部动作中退出 | `needs-reconciliation`,不自动重复扣费 | +| 最终完成 | 无 console error;static + desktop/mobile playtest 均属于当前 revision | + +## 7. 验证命令 + +按实际改动范围至少运行: + +```bash +npm --prefix apps/ai-game-creator-shell run typecheck +npm run test -- apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts apps/ai-game-creator-shell/tests/appSurface.test.ts --run +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml game_chat_ -- --nocapture --test-threads=1 +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml autonomous_completion_contract -- --nocapture --test-threads=1 +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml action_receipt -- --nocapture --test-threads=1 +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project_execution_owner -- --nocapture --test-threads=1 +npm run check:encoding +git diff --check +``` + +如果完整 Rust suite 受既有 Windows 文件锁影响,必须单独复跑新增 filter,并在交付中如实记录完整门仍不干净;不能用定向通过替代全量结论。 + +现场真实验收还需要: + +1. 使用新的空项目提交一个明确、无需追问的小游戏需求。 +2. 全程不点击确认、不发送继续、不手工重试。 +3. 记录首个可玩版本耗时、Provider/工具轮数、委派数和失败修复次数。 +4. 核对最终 manifest、current revision、static receipt、desktop/mobile playtest receipt 和浏览器 console。 +5. 中途强制结束 Runner 一次,重新启动客户端后确认 same-run 自动续跑且没有重复付费动作。 + +## 8. 完成定义 + +只有以下条件全部满足,本计划才算实现完成: + +- 普通 GUI 默认进入单主快车道。 +- 一个确定性端到端夹具证明首次验证失败可由同一主 Run 自动修好并最终完成。 +- 一个 owner-loss 夹具证明新 boot 自动恢复且终态唯一。 +- 可信无人值守路径不存在普通人工确认或澄清等待。 +- completed 前 artifact、当前 revision static smoke 与双视口试玩均被强制复核。 +- 相关定向测试、类型检查、编码检查和 diff 检查通过。 +- 使用真实 Provider 的空项目 smoke 成功;若现场外部服务不可用,则代码可以提交为“实现完成、真实现场验收未完成”,不得宣称目标已完全达成。 + +## 9. 回滚边界 + +- 默认路由可回退到原 `project-supervisor-gui`,但不能同时保留两套普通入口形成随机分流。 +- 自动权限只由可信 source + root profile + binding fingerprint 共同启用;回滚时删除该窄例外,不修改全局策略默认值。 +- 新 safe detail schema 只增加脱敏诊断,不改变原始审计账本的权限边界。 +- 恢复逻辑只消费现有 durable task/action/profile/owner 记录,不删除 `.agent`、锁文件或用户产物。 From 02fff8f3084fabcdf5f74e2ff2726358ab7d85e0 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:14:25 +0800 Subject: [PATCH 02/20] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E6=97=A0=E4=BA=BA?= =?UTF-8?q?=E5=80=BC=E5=AE=88=E6=B8=B8=E6=88=8F=E7=94=9F=E6=88=90=E9=98=BB?= =?UTF-8?q?=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 普通Web工作台默认使用单Supervisor自主生成链路 补齐安全默认返工、结构化诊断与同Run恢复 绑定当前revision、入口摘要和双视口试玩完成凭证 增强Runner跨boot单飞恢复与game-chat来源隔离 新增真实Provider验收入口及确定性回归 同步更新技术方案、开发流程和共享决策 --- apps/ai-game-creator-shell/package.json | 1 + .../agent-runtime-real-e2e/harness/config.mjs | 2 + .../harness/project.mjs | 37 +- .../agent-runtime-real-e2e/runtime-state.mjs | 3 + .../suites/self-test.mjs | 72 ++ .../suites/supervisor-autonomous-playable.mjs | 124 ++- .../src/agent/generation/draft_validation.rs | 3 +- .../src/agent/runtime_actions/action_audit.rs | 73 ++ .../runtime_actions/autonomous_policy.rs | 3 + .../agent/runtime_actions/project_gates.rs | 59 +- .../runtime_driver/game_chat_fast_path.rs | 13 +- .../src/agent/runtime_driver/main_loop.rs | 28 +- .../agent/runtime_driver/main_loop_tests.rs | 839 +++++++++++++++++- .../agent/runtime_driver/provider_recovery.rs | 18 + .../runtime_protocol/autonomous_completion.rs | 124 ++- .../autonomous_completion_contract_tests.rs | 208 ++++- .../src/agent/runtime_protocol/models.rs | 5 + .../agent/runtime_protocol/verification.rs | 43 +- .../src-tauri/src/agent/runtime_tools.rs | 8 +- .../src/agent/runtime_tools/delegation.rs | 130 ++- .../src/agent/runtime_tools/delivery.rs | 466 +++++++++- .../src-tauri/src/cli.rs | 92 +- .../src-tauri/src/delegation.rs | 270 ++++++ .../src-tauri/src/runner.rs | 34 + .../src-tauri/src/runner/dispatch.rs | 121 ++- .../src-tauri/src/runner/state.rs | 179 +++- .../src-tauri/src/runner/tests.rs | 579 +++++++++++- .../src-tauri/src/swarm_cli.rs | 2 + .../src-tauri/src/swarm_cli/input.rs | 40 +- .../src/swarm_cli/terminal_classification.rs | 50 +- .../src-tauri/src/swarm_cli/tests.rs | 148 ++- .../src-tauri/src/swarm_cli/turn_dispatch.rs | 57 +- .../src-tauri/src/swarm_cli/turn_wait.rs | 8 + .../tests/collaboration/static_deliveries.rs | 389 ++++++++ .../src-tauri/src/tests/command_runtime.rs | 89 ++ .../src-tauri/src/tests/response_stream.rs | 173 ++++ .../tests/runtime_actions/action_execution.rs | 141 +++ .../src/tests/runtime_actions/support.rs | 16 +- apps/ai-game-creator-shell/src/App.tsx | 37 +- .../src/features/agent-runtime/model.ts | 59 +- .../features/app-shell/WorkspaceLauncher.tsx | 2 + .../src/features/app-shell/model.ts | 1 + .../app-shell/useHomeProjectCreation.ts | 1 + .../ProjectSupervisorView.tsx | 74 +- .../src/view/project-development/index.tsx | 156 ++-- .../tests/agentRuntimeModel.test.ts | 126 ++- .../tests/appSurface/home.suite.ts | 1 + .../appSurface/project-development.suite.ts | 47 +- .../shared-memory/decision-log.md | 10 + .../shared-memory/development-workflow.md | 8 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 10 + package.json | 1 + 52 files changed, 4823 insertions(+), 357 deletions(-) diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 5ad694034..69bd74590 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -24,6 +24,7 @@ "agent-runtime:mixed-swarm-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-static-isolated-autonomous-chat", "agent-runtime:supervisor-swarm-autonomous-chat-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-autonomous-chat", "agent-runtime:supervisor-autonomous-playable-lane-defense-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-autonomous-playable-lane-defense", + "agent-runtime:supervisor-game-chat-single-main-playable-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-game-chat-single-main-playable", "agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e": "node scripts/agent-runtime-deterministic-playable-e2e.mjs", "agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-self-test": "node scripts/agent-runtime-deterministic-playable-e2e.mjs --self-test", "agent-runtime:supervisor-swarm-transient-retry-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-transient-retry", diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/config.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/config.mjs index 3f9b19d8f..44d9da6b5 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/config.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/config.mjs @@ -15,6 +15,7 @@ import { scopedAgentsSuite, steerRunnerKillSuite, supervisorAutonomousPlayableLaneDefenseSuite, + supervisorGameChatSingleMainPlayableSuite, supervisorSwarmAutonomousChatSuite, supervisorSwarmCollaborationPolicyMixedRecoverySuite, supervisorSwarmFinalReplyTransientRetrySuite, @@ -70,6 +71,7 @@ export function parseArguments(args) { suite === supervisorSwarmToolPlanHandoffRunnerKillSuite || suite === supervisorSwarmAutonomousChatSuite || suite === supervisorAutonomousPlayableLaneDefenseSuite || + suite === supervisorGameChatSingleMainPlayableSuite || suite === supervisorSwarmStaticIsolatedAutonomousChatSuite || suite === supervisorSwarmCollaborationPolicyMixedRecoverySuite || suite === steerRunnerKillSuite || diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/project.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/project.mjs index 9fcd0cee4..445d66175 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/project.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/project.mjs @@ -28,7 +28,10 @@ import { isGoalRuntimeSuite, } from '../suites/goal.mjs'; import { isResponseStreamSuite } from '../suites/response-stream.mjs'; -import { isSupervisorAutonomousPlayableLaneDefenseSuite } from '../suites/supervisor-autonomous-playable.mjs'; +import { + isSupervisorAutonomousPlayableLaneDefenseSuite, + isSupervisorGameChatSingleMainPlayableSuite, +} from '../suites/supervisor-autonomous-playable.mjs'; import { isSupervisorSwarmSuite, supervisorSwarmVerificationFixtureSource, @@ -40,20 +43,26 @@ import { effectiveAgentLlmConfig } from './config.mjs'; import { runProcess } from './process.mjs'; import { isIsolatedRunnerSuite } from './reporting.mjs'; -export async function checkPrerequisites(config) { - const requiredAgents = isUserInputRuntimeSuite() +export function requiredAgentIdsForSuite() { + return isUserInputRuntimeSuite() ? [projectSupervisorAgentId] - : isSupervisorAutonomousPlayableLaneDefenseSuite() - ? [projectSupervisorAgentId] - : isSupervisorSwarmSuite() - ? [ - projectSupervisorAgentId, - supervisorSwarmDesignAgentId, - supervisorSwarmQualityAgentId, - ] - : isIsolatedRunnerSuite() - ? [mainAgentId] - : [mainAgentId, 'quality-review']; + : isSupervisorGameChatSingleMainPlayableSuite() + ? [projectSupervisorAgentId, mainAgentId] + : isSupervisorAutonomousPlayableLaneDefenseSuite() + ? [projectSupervisorAgentId] + : isSupervisorSwarmSuite() + ? [ + projectSupervisorAgentId, + supervisorSwarmDesignAgentId, + supervisorSwarmQualityAgentId, + ] + : isIsolatedRunnerSuite() + ? [mainAgentId] + : [mainAgentId, 'quality-review']; +} + +export async function checkPrerequisites(config) { + const requiredAgents = requiredAgentIdsForSuite(); const llmConfigured = requiredAgents.every((agentId) => { const effective = effectiveAgentLlmConfig(config, agentId); return ['apiKey', 'baseUrl', 'model'].every( diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/runtime-state.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/runtime-state.mjs index 9937fffd4..ff9b12ffe 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/runtime-state.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/runtime-state.mjs @@ -227,6 +227,9 @@ export const supervisorSwarmAutonomousChatSuite = export const supervisorAutonomousPlayableLaneDefenseSuite = 'supervisor-autonomous-playable-lane-defense'; +export const supervisorGameChatSingleMainPlayableSuite = + 'supervisor-game-chat-single-main-playable'; + export const supervisorSwarmStaticIsolatedAutonomousChatSuite = 'supervisor-swarm-static-isolated-autonomous-chat'; diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/self-test.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/self-test.mjs index 4a43c159f..1700a085b 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/self-test.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/self-test.mjs @@ -22,6 +22,7 @@ import { } from '../harness/app-data.mjs'; import { parseArguments } from '../harness/config.mjs'; import { safeProcessFailureDiagnostic } from '../harness/process.mjs'; +import { requiredAgentIdsForSuite } from '../harness/project.mjs'; import { isIsolatedRunnerSuite } from '../harness/reporting.mjs'; import { agentConversationPath } from '../harness/runtime.mjs'; import { @@ -29,6 +30,7 @@ import { commandFailureMarker, commandPassedMarker, isolatedAgentJoinClaimSchemaVersion, + mainAgentId, projectSupervisorAgentId, providerActionBatchSchemaVersion, repoRoot, @@ -43,6 +45,7 @@ import { supervisorCollaborationPolicySchemaVersion, supervisorCollaborationPolicySnapshotInitialBatchBinding, supervisorCollaborationPolicySnapshotSchemaVersion, + supervisorGameChatSingleMainPlayableSuite, supervisorSwarmAutonomousChatSuite, supervisorSwarmCollaborationPolicyMixedRecoverySuite, supervisorSwarmFollowupIsolatedReviews, @@ -63,6 +66,8 @@ import { buildSupervisorAutonomousPlayableStdin, collectPartialSupervisorAutonomousPlayableEvidence, isSupervisorAutonomousPlayableLaneDefenseSuite, + isSupervisorGameChatSingleMainPlayableSuite, + supervisorAutonomousPlayableMode, } from './supervisor-autonomous-playable.mjs'; import { buildSupervisorSwarmEvidence, @@ -150,6 +155,69 @@ export async function runAgentRuntimeRealE2eSelfTests() { const shellPackage = JSON.parse( readFileSync(path.join(appRoot, 'package.json'), 'utf8'), ); + const previousSuiteForGameChatPlayable = state.suite; + state.suite = supervisorAutonomousPlayableLaneDefenseSuite; + const professionalPlayableMode = supervisorAutonomousPlayableMode(); + const professionalPlayableRequiredAgents = requiredAgentIdsForSuite(); + state.suite = supervisorGameChatSingleMainPlayableSuite; + const gameChatPlayableProfile = isolatedSuiteAppDataProfile(); + const gameChatPlayableParsedArguments = parseArguments([ + '--config-dir', + path.resolve('synthetic-game-chat-playable-config'), + '--suite', + supervisorGameChatSingleMainPlayableSuite, + ]); + const gameChatPlayableMode = supervisorAutonomousPlayableMode(); + const gameChatPlayableRequiredAgents = requiredAgentIdsForSuite(); + const gameChatPlayablePackageCommandsRegistered = + shellPackage.scripts?.[ + 'agent-runtime:supervisor-game-chat-single-main-playable-real-e2e' + ] === + 'node scripts/agent-runtime-real-e2e.mjs --suite supervisor-game-chat-single-main-playable' && + rootPackage.scripts?.[ + 'ai-game-creator-shell:agent-runtime:supervisor-game-chat-single-main-playable-real-e2e' + ] === + 'npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-game-chat-single-main-playable-real-e2e --'; + const gameChatPlayableSuiteRegistered = + gameChatPlayableParsedArguments.suite === + supervisorGameChatSingleMainPlayableSuite && + isSupervisorGameChatSingleMainPlayableSuite() && + isSupervisorAutonomousPlayableLaneDefenseSuite() && + !isSupervisorSwarmSuite() && + isIsolatedRunnerSuite() && + isolatedSuiteProtectsSourceAppData() && + isolatedSuiteUsesSiblingAppData() && + gameChatPlayableProfile.sentinelName === + supervisorAutonomousPlayableAppDataSentinelFileName && + gameChatPlayableProfile.sentinelSchema === + supervisorAutonomousPlayableAppDataSentinelSchema; + const gameChatPlayableModeValidated = + professionalPlayableMode.gameChatSingleMain === false && + professionalPlayableMode.rootSource === 'project-supervisor-cli' && + professionalPlayableMode.evidenceAgentId === projectSupervisorAgentId && + professionalPlayableMode.scenario === + 'project-supervisor-autonomous-playable-lane-defense' && + professionalPlayableMode.cliFlags.length === 0 && + gameChatPlayableMode.gameChatSingleMain === true && + gameChatPlayableMode.rootSource === 'project-supervisor-game-chat' && + gameChatPlayableMode.evidenceAgentId === mainAgentId && + gameChatPlayableMode.scenario === + 'project-supervisor-game-chat-single-main-playable' && + JSON.stringify(gameChatPlayableMode.cliFlags) === + JSON.stringify(['--game-chat-smoke']); + const gameChatPlayableRequiredAgentsValidated = + JSON.stringify(professionalPlayableRequiredAgents) === + JSON.stringify([projectSupervisorAgentId]) && + JSON.stringify(gameChatPlayableRequiredAgents) === + JSON.stringify([projectSupervisorAgentId, mainAgentId]); + state.suite = previousSuiteForGameChatPlayable; + assert( + gameChatPlayableSuiteRegistered && + gameChatPlayablePackageCommandsRegistered && + gameChatPlayableModeValidated && + gameChatPlayableRequiredAgentsValidated, + 'agent-runtime-real-e2e-self-test-game-chat-playable-suite-invalid', + ); const previousSuiteForAutonomousPlayable = state.suite; state.suite = supervisorAutonomousPlayableLaneDefenseSuite; const autonomousPlayableProfile = isolatedSuiteAppDataProfile(); @@ -1593,6 +1661,10 @@ export async function runAgentRuntimeRealE2eSelfTests() { collaborationPolicySnapshotBindingStable: true, durableSnapshotEligibilityAndContractBindingValidated: true, sourceEndpointAbsentLifecycleGuardValidated, + gameChatPlayableSuiteRegistered, + gameChatPlayablePackageCommandsRegistered, + gameChatPlayableModeValidated, + gameChatPlayableRequiredAgentsValidated, autonomousPlayableSuiteRegistered, autonomousPlayablePackageCommandsRegistered, autonomousPlayableDedicatedPathValidated: true, diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs index 7d0acb8f7..3fbdfb34a 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs @@ -52,6 +52,7 @@ import { supervisorAutonomousPlayableSafeTurnOutcomes, supervisorAutonomousPlayableSourceFieldMaxChars, supervisorAutonomousPlayableSourceTotalMaxChars, + supervisorGameChatSingleMainPlayableSuite, supervisorSwarmSessionId, supervisorSwarmTerminalSidecarCleanupTimeoutMs, } from '../runtime-state.mjs'; @@ -65,6 +66,29 @@ import { validateSupervisorSwarmFinalization, } from './supervisor-swarm.mjs'; +const projectSupervisorCliSource = 'project-supervisor-cli'; +const projectSupervisorGameChatSource = 'project-supervisor-game-chat'; +const gameChatMainAgentId = 'code-prototype'; + +export function supervisorAutonomousPlayableMode() { + if (state.suite === supervisorGameChatSingleMainPlayableSuite) { + return { + gameChatSingleMain: true, + rootSource: projectSupervisorGameChatSource, + evidenceAgentId: gameChatMainAgentId, + scenario: 'project-supervisor-game-chat-single-main-playable', + cliFlags: ['--game-chat-smoke'], + }; + } + return { + gameChatSingleMain: false, + rootSource: projectSupervisorCliSource, + evidenceAgentId: projectSupervisorAgentId, + scenario: 'project-supervisor-autonomous-playable-lane-defense', + cliFlags: [], + }; +} + export function buildSupervisorAutonomousPlayableStdin() { const task = supervisorAutonomousPlayableLaneDefenseTask; assert( @@ -776,6 +800,7 @@ export async function validateSupervisorAutonomousPlayableEvidence( residualSidecars, ) { const task = supervisorAutonomousPlayableLaneDefenseTask; + const mode = supervisorAutonomousPlayableMode(); const report = state.supervisorAutonomousPlayable.turnReport; assert( report?.schemaVersion === 'game-creator-swarm-turn-report.v1' && @@ -814,7 +839,7 @@ export async function validateSupervisorAutonomousPlayableEvidence( ); assert( parentTask?.task === task && - parentTask.source === 'project-supervisor-cli' && + parentTask.source === mode.rootSource && parentTask.status === 'completed' && parentTask.phase === 'completed' && parentTask.runProfile === autonomousGameBuildRunProfile && @@ -996,7 +1021,7 @@ export async function validateSupervisorAutonomousPlayableEvidence( binding.rootRunId === state.initialRunId && binding.parentAgentId == null && binding.parentRunId == null && - binding.source === 'project-supervisor-cli' && + binding.source === mode.rootSource && binding.profile === autonomousGameBuildRunProfile && binding.profileFingerprint === hashJsonValue({ @@ -1019,6 +1044,67 @@ export async function validateSupervisorAutonomousPlayableEvidence( ); const contract = contracts[0]; const receipt = receipts[0]; + let evidenceBinding = binding; + let evidenceRunId = state.initialRunId; + if (mode.gameChatSingleMain) { + const manifest = JSON.parse( + decodeUtf8Fatal( + await fs.readFile(path.join(state.projectRoot, '.agent/manifest.json')), + 'supervisor-autonomous-playable-manifest-invalid-utf8', + ), + ); + const tasks = Array.isArray(manifest.tasks) ? manifest.tasks : []; + assert( + tasks.length === 1 && + tasks[0]?.id === gameChatMainAgentId && + tasks[0]?.status === 'completed', + 'supervisor-game-chat-single-main-manifest-invalid', + ); + const rootChildTasks = persistence.taskSnapshot.latest.filter( + (candidate) => + candidate.parentAgentId === projectSupervisorAgentId && + candidate.parentRunId === state.initialRunId, + ); + const rootChildRuntimes = persistence.runtimeStates.filter( + (candidate) => + candidate.parentAgentId === projectSupervisorAgentId && + candidate.parentRunId === state.initialRunId, + ); + const codeTask = rootChildTasks[0]; + const codeRuntime = rootChildRuntimes[0]; + assert( + rootChildTasks.length === 1 && + codeTask.agentId === gameChatMainAgentId && + codeTask.status === 'completed' && + codeTask.phase === 'completed' && + codeTask.source === 'agent-ready-task-scheduler' && + rootChildRuntimes.length === 1 && + codeRuntime.agentId === gameChatMainAgentId && + codeRuntime.runId === codeTask.runId && + codeRuntime.phase === 'completed' && + codeRuntime.pendingToolAction == null && + codeRuntime.pendingAction == null, + 'supervisor-game-chat-single-main-runtime-invalid', + ); + evidenceRunId = codeTask.runId; + const codeBindings = runProfileBindings.filter( + (candidate) => + candidate.agentId === gameChatMainAgentId && + candidate.runId === evidenceRunId && + candidate.rootAgentId === projectSupervisorAgentId && + candidate.rootRunId === state.initialRunId && + candidate.parentAgentId === projectSupervisorAgentId && + candidate.parentRunId === state.initialRunId && + candidate.profile === autonomousGameBuildRunProfile && + candidate.source === 'agent-ready-task-scheduler' && + candidate.parentBindingFingerprint === binding.bindingFingerprint, + ); + assert( + codeBindings.length === 1, + 'supervisor-game-chat-single-main-binding-invalid', + ); + evidenceBinding = codeBindings[0]; + } const contractIdentity = { schemaVersion: contract.schemaVersion, projectId: contract.projectId, @@ -1072,23 +1158,22 @@ export async function validateSupervisorAutonomousPlayableEvidence( await readSupervisorSwarmJsonDirectory('.agent/runtime/verification') ).filter( (gate) => - gate.agentId === projectSupervisorAgentId && - gate.runId === state.initialRunId, + gate.agentId === mode.evidenceAgentId && gate.runId === evidenceRunId, ); const gate = gates[0]; const staticSmokeAudits = persistence.agentDb.filter( (record) => record.recordType === 'agent.runtime.command.run_limited' && - record.agentId === projectSupervisorAgentId && - record.runId === state.initialRunId && + record.agentId === mode.evidenceAgentId && + record.runId === evidenceRunId && record.commandId === 'game.static_smoke' && record.status === 'completed', ); assert( gates.length === 1 && - gate.lastVerificationTool === 'game.static_smoke' && gate.lastVerificationStatus === 'passed' && gate.verifiedRevision === revision.revision && + gate.staticSmokeVerifiedRevision === revision.revision && staticSmokeAudits.length >= 1, 'supervisor-autonomous-playable-static-smoke-invalid', ); @@ -1112,9 +1197,10 @@ export async function validateSupervisorAutonomousPlayableEvidence( assert( receipt.schemaVersion === autonomousPlaytestReceiptSchemaVersion && receipt.projectId === contract.projectId && - receipt.agentId === projectSupervisorAgentId && - receipt.runId === state.initialRunId && - receipt.runProfileBindingFingerprint === binding.bindingFingerprint && + receipt.agentId === mode.evidenceAgentId && + receipt.runId === evidenceRunId && + receipt.runProfileBindingFingerprint === + evidenceBinding.bindingFingerprint && receipt.revision === revision.revision && receipt.gameIndex.path === 'game/index.html' && receipt.gameIndex.sha256 === finalGameIndexSha256 && @@ -1123,8 +1209,8 @@ export async function validateSupervisorAutonomousPlayableEvidence( receipt.receiptFingerprint === hashJsonValue(receiptIdentity) && actionReceipts.filter( (record) => - record.agentId === projectSupervisorAgentId && - record.runId === state.initialRunId && + record.agentId === mode.evidenceAgentId && + record.runId === evidenceRunId && record.actionId === receipt.actionId && record.actionFingerprint === receipt.actionFingerprint && record.tool === 'preview.validate' && @@ -1511,10 +1597,12 @@ export async function runSupervisorAutonomousPlayableLaneDefenseE2e() { state.supervisorAutonomousPlayable.privateValues = [task]; state.supervisorSwarm.userTask = task; state.supervisorSwarm.privateValues = [task]; + const mode = supervisorAutonomousPlayableMode(); state.supervisorAutonomousPlayableCliSession = startInteractiveCli([ '--swarm-chat', '--init', '--autonomous-game-build', + ...mode.cliFlags, state.projectRoot, ]); state.supervisorAutonomousPlayable.stdinWriteCount = 1; @@ -1546,8 +1634,9 @@ export async function runSupervisorAutonomousPlayableLaneDefenseE2e() { } export function emptySupervisorAutonomousPlayableEvidence() { + const mode = supervisorAutonomousPlayableMode(); return { - scenario: 'project-supervisor-autonomous-playable-lane-defense', + scenario: mode.scenario, targetAgentId: projectSupervisorAgentId, runProfile: autonomousGameBuildRunProfile, dedicatedZeroInterventionPath: true, @@ -1688,5 +1777,12 @@ export function emptySupervisorAutonomousPlayableEvidence() { } export function isSupervisorAutonomousPlayableLaneDefenseSuite() { - return state.suite === supervisorAutonomousPlayableLaneDefenseSuite; + return ( + state.suite === supervisorAutonomousPlayableLaneDefenseSuite || + state.suite === supervisorGameChatSingleMainPlayableSuite + ); +} + +export function isSupervisorGameChatSingleMainPlayableSuite() { + return state.suite === supervisorGameChatSingleMainPlayableSuite; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_validation.rs index e2d726e00..42c63705f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_validation.rs @@ -259,10 +259,11 @@ pub(crate) fn validate_safe_game_html_runtime(html: &str, label: &str) -> Result pub(crate) fn validate_game_html_smoke(html: &str) -> Result<(), String> { let lower_html = html.to_ascii_lowercase(); + validate_closed_game_script_blocks(&lower_html)?; + validate_executable_inline_javascript_syntax(html)?; if !lower_html.contains(" "static-smoke-failed", + "game.static_smoke 无法取得项目验证锁" => "verification-lock-unavailable", + "game.static_smoke 无法清除旧验证凭证" => "verification-reset-failed", + "game.static_smoke 结果无法形成有效验证凭证" => { + "verification-receipt-invalid" + } + _ => return None, + }) + }; + let diagnostic_check = observation.detail.as_deref().map_or("none", |detail| { + if detail.contains("JavaScript") { + "javascript-syntax" + } else { + "game-static-contract" + } + }); + let persisted = observation + .detail + .as_deref() + .and_then(|value| serde_json::from_str::(value).ok()) + .filter(|value| { + value.get("commandId").and_then(serde_json::Value::as_str) + == Some("game.static_smoke") + }); + let diagnostic = if let Some(value) = persisted.as_ref() { + if value.get("passed").and_then(serde_json::Value::as_bool) != Some(passed) + || value.get("failureCode").and_then(serde_json::Value::as_str) != failure_code + { + return None; + } + value + .get("diagnostic") + .and_then(serde_json::Value::as_str) + .map(|value| redact_agent_runtime_error(root, value, 500)) + } else { + observation + .detail + .as_deref() + .map(|value| redact_agent_runtime_error(root, value, 500)) + }; + if !passed && diagnostic.is_none() { + return None; + } + if receipt_owner.is_none() { + return serde_json::to_string(&serde_json::json!({ + "commandId": "game.static_smoke", + "passed": passed, + "failureCode": failure_code, + "check": diagnostic_check, + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + })) + .ok(); + } + return serde_json::to_string(&serde_json::json!({ + "commandId": "game.static_smoke", + "passed": passed, + "failureCode": failure_code, + "check": diagnostic_check, + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + "diagnostic": diagnostic, + })) + .ok(); + } if observation.tool == "image.inspect" { let detail = serde_json::from_str::( observation.detail.as_deref().unwrap_or_default(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index 731d40ef0..25a208d6c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -1960,6 +1960,7 @@ mod tests { last_verification_tool: None, last_verification_status: None, static_smoke_verified_revision: None, + static_smoke_verified_game_index_sha256: None, failed_playtest_revision: None, updated_at: 0, }; @@ -2001,6 +2002,7 @@ mod tests { last_verification_tool: None, last_verification_status: None, static_smoke_verified_revision: None, + static_smoke_verified_game_index_sha256: None, failed_playtest_revision: None, updated_at: 0, }; @@ -2036,6 +2038,7 @@ mod tests { last_verification_tool: None, last_verification_status: None, static_smoke_verified_revision: None, + static_smoke_verified_game_index_sha256: None, failed_playtest_revision: None, updated_at: 0, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index 45c8f1111..94616c992 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -157,6 +157,7 @@ pub(crate) fn prepare_agent_runtime_project_mutation_locked( gate.last_verification_tool = None; gate.last_verification_status = None; gate.static_smoke_verified_revision = None; + gate.static_smoke_verified_game_index_sha256 = None; gate.failed_playtest_revision = None; gate.updated_at = now; if let Err(error) = write_game_creator_agent_runtime_verification_gate(root, &gate) { @@ -359,8 +360,59 @@ pub(crate) fn finish_agent_runtime_project_verification_locked( ) -> Result<(), String> { let current_revision = read_game_creator_agent_runtime_project_revision(root)?; let revision_unchanged = current_revision.revision == expected_revision.revision; - let passed = passed && revision_unchanged; + let mut passed = passed && revision_unchanged; let verification_tool = gate.last_verification_tool.clone(); + let mut static_smoke_game_index_sha256 = None; + let mut static_smoke_credential_error = None; + if passed && verification_tool.as_deref() == Some("game.static_smoke") { + match read_autonomous_evidence_file_at( + root, + AGENT_RUNTIME_GAME_INDEX_PATH, + "game.static_smoke 游戏入口", + AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES, + ) { + Ok(Some((digest, bytes))) => { + let html = match std::str::from_utf8(&bytes) { + Ok(html) => html, + Err(error) => { + passed = false; + static_smoke_credential_error = Some(format!( + "game.static_smoke 通过后 game/index.html 不是有效 UTF-8:{error}" + )); + "" + } + }; + if passed && !html.contains(" static_smoke_game_index_sha256 = Some(digest.sha256), + Err(error) => { + passed = false; + static_smoke_credential_error = Some(format!( + "game.static_smoke 通过后 game/index.html 内容已变化且复核失败:{error}" + )); + } + } + } + } + Ok(None) => { + passed = false; + static_smoke_credential_error = + Some("game.static_smoke 通过后 game/index.html 缺失".to_string()); + } + Err(error) => { + passed = false; + static_smoke_credential_error = Some(format!( + "game.static_smoke 通过后无法绑定 game/index.html 摘要:{error}" + )); + } + } + } gate.verified_revision = passed .then_some(current_revision.revision) .filter(|value| *value > 0); @@ -376,6 +428,8 @@ pub(crate) fn finish_agent_runtime_project_verification_locked( } if verification_tool.as_deref() == Some("game.static_smoke") { gate.static_smoke_verified_revision = passed.then_some(current_revision.revision); + gate.static_smoke_verified_game_index_sha256 = + passed.then_some(static_smoke_game_index_sha256).flatten(); } gate.last_verification_status = Some( if passed { @@ -393,6 +447,9 @@ pub(crate) fn finish_agent_runtime_project_verification_locked( expected_revision.revision, current_revision.revision )); } + if let Some(error) = static_smoke_credential_error { + return Err(error); + } Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs index 021ff399c..03750a4dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs @@ -2010,8 +2010,7 @@ fn game_chat_fast_path_current_revision_is_verified( &runtime.agent_id, &runtime.run_id, )?; - Ok(revision.revision > 0 - && agent_runtime_static_smoke_passed_for_revision(&gate, revision.revision)) + agent_runtime_static_smoke_passed_for_current_entry_at(root, &gate, revision.revision) } fn game_chat_fast_path_is_main_runtime( @@ -2256,7 +2255,11 @@ pub(crate) fn game_chat_fast_path_plan_at( revision.revision, )?; let current_revision_static_smoke_passed = owns_current_mutation - && agent_runtime_static_smoke_passed_for_revision(&gate, revision.revision); + && agent_runtime_static_smoke_passed_for_current_entry_at( + root, + &gate, + revision.revision, + )?; let current_revision_failed = owns_current_mutation && gate.last_verification_status.as_deref() == Some(AGENT_RUNTIME_VERIFICATION_STATUS_FAILED); @@ -3663,6 +3666,10 @@ mod tests { gate.last_verification_tool = Some("game.static_smoke".to_string()); gate.static_smoke_verified_revision = Some(1); + gate.static_smoke_verified_game_index_sha256 = + fs::read(root.join(AGENT_RUNTIME_GAME_INDEX_PATH)) + .ok() + .map(|bytes| format!("{:x}", Sha256::digest(bytes))); write_game_creator_agent_runtime_verification_gate(&root, &gate) .expect("write static smoke gate"); assert!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index fa6ecd3fc..e6389adbd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -1923,20 +1923,30 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( ); } }; - if let Err(error) = ensure_static_delegate_user_input_wait_at( + let waiting_for_user = match ensure_static_delegate_user_input_wait_at( &root, &mut runtime, &deliveries, ) { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - &format!("Supervisor 用户澄清请求无法安全进入等待态:{error}"), - ); + Ok(waiting) => waiting, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("Supervisor 用户澄清请求无法安全进入等待态:{error}"), + ); + } + }; + if waiting_for_user { + return AgentBackgroundTaskOutcome::WaitingForUserInput; } - return AgentBackgroundTaskOutcome::WaitingForUserInput; + runtime.phase = "planning".to_string(); + runtime.current_action = + "等待 Project Supervisor 采用安全默认值发起唯一返工".to_string(); + runtime.waiting_on = "已转换为 needs-repair 的自主构建专业回执".to_string(); + runtime.next_step = "调用 agent.delegate,并把 repairOfDelegationId 指向原 delivery;返工任务必须采用安全默认值继续,不能再询问用户".to_string(); } else if repair_required { runtime.phase = "planning".to_string(); runtime.current_action = "等待 Project Supervisor 发起唯一返工".to_string(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index e02eacbd6..67856863f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -843,6 +843,182 @@ async fn game_chat_art_receipt_wakes_the_same_code_prototype_run() { ); } +#[test] +fn game_chat_main_agent_allows_only_one_same_contract_safe_default_repair() { + let temporary = tempfile::tempdir().expect("create game-chat safe-default repair root"); + let root = temporary.path().join("project"); + let (main, mut child, delegation_id, child_lane) = game_chat_main_art_child_fixture_with_lane( + &root, + "art-director", + &["art-spec", "core-spritesheet"], + true, + ); + let child_lane = child_lane.expect("delegated art child lane is held"); + let needs_input = format!( + "AGC_NEEDS_USER_INPUT_V1\n{}", + serde_json::json!({ + "questions": [{ + "id": "visual_preference", + "header": "视觉偏好", + "question": "请选择首版视觉偏好", + "options": [ + {"label": "明亮", "description": "使用明亮、安全的默认配色。"}, + {"label": "柔和", "description": "使用柔和配色。"} + ] + }] + }) + ); + child.status = "completed".to_string(); + child.phase = "completed".to_string(); + child.current_action = "请求非必要视觉偏好".to_string(); + append_game_creator_agent_runtime_task(&root, &child) + .expect("persist completed art child with clarification"); + let child_task = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + &child.agent_id, + &child.run_id, + ) + .expect("read completed clarification child") + .expect("completed clarification child exists"); + publish_game_creator_agent_delegate_result(&root, &child_task, Some(&needs_input)); + claim_ready_static_delegate_receipts_at( + &root, + "code-prototype", + &main.run_id, + "game-chat-safe-default-claim", + ) + .expect("claim converted safe-default delivery"); + let original = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read original safe-default delivery") + .expect("original safe-default delivery exists"); + assert_eq!( + original.status, + StaticDelegateDeliveryStatus::ClaimedByParent + ); + assert_eq!( + original + .structured_result + .as_ref() + .map(|result| result.contract_status), + Some(StaticDelegateContractStatus::NeedsRepair) + ); + assert!(original + .structured_result + .as_ref() + .and_then(|result| result.error.as_deref()) + .is_some_and(|error| error.contains("game-chat-safe-default-repair.v1"))); + + let repair_action_id = "game-chat-safe-default-repair-action"; + let repair = observe_agent_runtime_agent_delegate( + &root, + "code-prototype", + &main.run_id, + Some(repair_action_id), + &serde_json::json!({ + "agentId": "art-director", + "task": "不要追问用户,采用安全默认视觉偏好继续交付", + "acceptanceCriteria": original.acceptance_criteria, + "expectedArtifacts": original.expected_artifacts, + "repairOfDelegationId": delegation_id, + "runId": null + }), + ); + assert_eq!(repair.status, "ok", "{repair:?}"); + let repair_delegation_id = agent_runtime_delegation_id( + "code-prototype", + &main.run_id, + "art-director", + repair_action_id, + ); + let repaired = read_static_delegate_delivery_at(&root, &repair_delegation_id) + .expect("read safe-default repair delivery") + .expect("safe-default repair delivery exists"); + assert_eq!( + repaired.repair_of_delegation_id.as_deref(), + Some(delegation_id.as_str()) + ); + assert_eq!(repaired.target_agent_id, original.target_agent_id); + assert_eq!(repaired.acceptance_criteria, original.acceptance_criteria); + assert_eq!(repaired.expected_artifacts, original.expected_artifacts); + assert!(repaired.repair_of_delegation_id.is_some()); + + register_game_chat_art_spec_fixture(&root); + let repair_child_task = read_latest_game_creator_agent_runtime_task_by_delegation_id( + &root, + "art-director", + &repair_delegation_id, + ) + .expect("read safe-default repair child") + .expect("safe-default repair child exists"); + let mut repair_child = agent_runtime_state_from_task_record(&repair_child_task); + repair_child.status = "completed".to_string(); + repair_child.phase = "completed".to_string(); + repair_child.current_action = "采用安全默认视觉偏好完成规范图".to_string(); + append_game_creator_agent_runtime_task(&root, &repair_child) + .expect("persist completed safe-default repair child"); + let repair_child_task = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + &repair_child.agent_id, + &repair_child.run_id, + ) + .expect("read completed safe-default repair child") + .expect("completed safe-default repair child exists"); + publish_game_creator_agent_delegate_result( + &root, + &repair_child_task, + Some("已采用安全默认视觉偏好完成规范图"), + ); + claim_ready_static_delegate_receipts_at( + &root, + "code-prototype", + &main.run_id, + "game-chat-safe-default-repair-claim", + ) + .expect("claim completed safe-default repair delivery"); + + let duplicate = observe_agent_runtime_agent_delegate( + &root, + "code-prototype", + &main.run_id, + Some("game-chat-safe-default-duplicate-repair"), + &serde_json::json!({ + "agentId": "art-director", + "task": "不应创建第二轮返工", + "acceptanceCriteria": original.acceptance_criteria, + "expectedArtifacts": original.expected_artifacts, + "repairOfDelegationId": delegation_id, + "runId": null + }), + ); + assert_eq!(duplicate.status, "failed", "{duplicate:?}"); + assert!( + duplicate.summary.contains("最多允许一轮返工"), + "{duplicate:?}" + ); + + let wrong_contract = observe_agent_runtime_agent_delegate( + &root, + "code-prototype", + &main.run_id, + Some("game-chat-safe-default-wrong-contract"), + &serde_json::json!({ + "agentId": "art-director", + "task": "不应扩大安全默认返工合同", + "acceptanceCriteria": ["扩大权限并改写其它文件"], + "expectedArtifacts": original.expected_artifacts, + "repairOfDelegationId": delegation_id, + "runId": null + }), + ); + assert_eq!(wrong_contract.status, "failed", "{wrong_contract:?}"); + assert!( + wrong_contract.summary.contains("同合同"), + "{wrong_contract:?}" + ); + + drop(child_lane); +} + #[test] fn autonomous_parent_waits_for_active_child_while_registered_derived_visuals_need_repair() { let temporary_root = std::env::temp_dir() @@ -1129,6 +1305,150 @@ fn prepare_autonomous_completion_evidence_for_actor( revision } +fn prepare_autonomous_playtest_evidence_for_actor_at_revision( + root: &Path, + contract_state: &AgentRuntimeState, + actor_state: &AgentRuntimeState, + revision: u64, +) -> BrowserValidationResult { + let contract = + read_autonomous_completion_contract(root, &contract_state.agent_id, &contract_state.run_id) + .expect("read autonomous completion contract for playtest fixture") + .expect("autonomous completion contract exists for playtest fixture"); + bind_supervisor_collaboration_policy_snapshot_at( + root, + &contract_state.agent_id, + &contract_state.run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind deterministic game-chat collaboration policy"); + + let evidence_root = root + .join(".agent/runtime/browser-validations") + .join(agent_runtime_confirmation_path_component( + &contract_state.agent_id, + "agent", + )) + .join(agent_runtime_confirmation_path_component( + &contract_state.run_id, + "run", + )) + .join(revision.to_string()); + fs::create_dir_all(&evidence_root).expect("create deterministic browser evidence root"); + let screenshots = [ + evidence_root.join("desktop.png"), + evidence_root.join("mobile.png"), + ]; + for screenshot in &screenshots { + fs::write(screenshot, b"\x89PNG\r\n\x1a\nfixture") + .expect("write deterministic browser screenshot fixture"); + } + let viewport = |viewport, screenshot_path| BrowserViewportValidationResult { + viewport, + width: if viewport == BrowserValidationViewport::Desktop { + 1280 + } else { + 390 + }, + height: if viewport == BrowserValidationViewport::Desktop { + 720 + } else { + 844 + }, + final_url: "http://127.0.0.1:34567/".to_string(), + title: "自主试玩测试".to_string(), + ready_state: "complete".to_string(), + visible_text_summary: "可试玩项目".to_string(), + visible_text_character_count: 5, + dom_character_count: 100, + expected_text: Vec::new(), + console_errors: Vec::new(), + console_warnings: Vec::new(), + exceptions: Vec::new(), + failed_requests: Vec::new(), + canvases: Vec::new(), + blocked_popup_count: 0, + blocked_dialog_count: 0, + blocked_download_count: 0, + blocked_permission_count: 0, + blocked_service_worker_count: 0, + screenshot_path, + passed: true, + diagnostics: Vec::new(), + }; + let scenario = contract.playtest_scenario; + let result = BrowserValidationResult { + schema_version: "browser-validation.v1".to_string(), + url: "http://127.0.0.1:34567/".to_string(), + browser: BrowserIdentity { + kind: DiscoveredBrowserKind::Chrome, + product: "test-browser".to_string(), + protocol_version: "1".to_string(), + }, + passed: true, + viewport_results: vec![ + viewport(BrowserValidationViewport::Desktop, screenshots[0].clone()), + viewport(BrowserValidationViewport::Mobile, screenshots[1].clone()), + ], + playtest: Some(BrowserPlaytestResult { + scenario, + scenario_fingerprint: browser_playtest_scenario_fingerprint(scenario), + passed: true, + initial_sequence: Some(1), + initial_phase: Some(BrowserPlaytestPhase::Ready), + initial_level: Some(1), + final_sequence: Some(8), + final_phase: Some(BrowserPlaytestPhase::Playing), + final_level: Some(2), + assertions: scenario + .assertion_names() + .iter() + .map(|name| BrowserPlaytestAssertion { + name: (*name).to_string(), + passed: true, + }) + .collect(), + diagnostics: Vec::new(), + }), + diagnostics: Vec::new(), + evidence: BrowserValidationEvidencePaths { + root: evidence_root.clone(), + report_path: evidence_root.join("validation.json"), + }, + completed_at_unix_ms: 1, + }; + fs::write( + &result.evidence.report_path, + serde_json::to_vec_pretty(&result).expect("serialize deterministic browser evidence"), + ) + .expect("write deterministic browser evidence report"); + let action = AgentRuntimeToolAction { + tool: "preview.validate".to_string(), + reason: Some("验证真实可玩闭环".to_string()), + input: serde_json::json!({}), + }; + let action_fingerprint = + agent_runtime_tool_action_fingerprint(&action, &actor_state.current_task); + let action_id = agent_runtime_tool_action_id( + &actor_state.run_id, + actor_state.loop_iteration.max(1), + 3, + 1, + &action_fingerprint, + ); + write_autonomous_playtest_receipt_at( + root, + &contract, + &action_id, + &action_fingerprint, + revision, + &result, + ) + .expect("write deterministic autonomous playtest receipt"); + result +} + #[test] fn game_chat_preview_playtest_migrates_legacy_generic_tetris_receipt_before_delivery() { let temporary = tempfile::tempdir().expect("create legacy game-chat Tetris root"); @@ -1355,6 +1675,10 @@ fn game_chat_code_completion_blocker_reopens_active_repair_before_delivery() { gate.last_verification_tool = Some("game.static_smoke".to_string()); gate.last_verification_status = Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string()); gate.static_smoke_verified_revision = Some(revision.revision); + gate.static_smoke_verified_game_index_sha256 = + fs::read(root.join(AGENT_RUNTIME_GAME_INDEX_PATH)) + .ok() + .map(|bytes| format!("{:x}", Sha256::digest(bytes))); write_game_creator_agent_runtime_verification_gate(&root, &gate) .expect("write code verification gate"); child_state.applied_steer_cursor = 2; @@ -1472,7 +1796,11 @@ fn game_chat_code_completion_blocker_reopens_active_repair_before_delivery() { .expect("evaluate repaired code delivery") .expect("fully repaired code may use deterministic delivery"); assert_eq!(playtest.actions.len(), 1); - assert_eq!(playtest.actions[0].tool, "preview.validate"); + assert_eq!(playtest.actions[0].tool, "command.run_limited"); + assert_eq!( + playtest.actions[0].input.get("commandId"), + Some(&serde_json::json!("game.static_smoke")) + ); assert!(playtest.response.is_empty()); } @@ -1509,6 +1837,10 @@ fn game_chat_code_failed_patch_revision_does_not_count_as_owned_mutation() { gate.last_verification_tool = Some("game.static_smoke".to_string()); gate.last_verification_status = Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string()); gate.static_smoke_verified_revision = Some(revision.revision); + gate.static_smoke_verified_game_index_sha256 = + fs::read(root.join(AGENT_RUNTIME_GAME_INDEX_PATH)) + .ok() + .map(|bytes| format!("{:x}", Sha256::digest(bytes))); write_game_creator_agent_runtime_verification_gate(&root, &gate) .expect("write failed patch verification gate"); let prior_action = AgentRuntimeToolAction { @@ -1639,6 +1971,10 @@ fn game_chat_code_cannot_borrow_successful_mutation_receipt_from_another_run() { gate.last_verification_tool = Some("game.static_smoke".to_string()); gate.last_verification_status = Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string()); gate.static_smoke_verified_revision = Some(revision.revision); + gate.static_smoke_verified_game_index_sha256 = + fs::read(root.join(AGENT_RUNTIME_GAME_INDEX_PATH)) + .ok() + .map(|bytes| format!("{:x}", Sha256::digest(bytes))); write_game_creator_agent_runtime_verification_gate(&root, &gate) .expect("write current verification gate"); let action = AgentRuntimeToolAction { @@ -2174,8 +2510,7 @@ fn game_chat_existing_game_requires_code_mutation_and_full_completion_before_del let root = temporary.path().join("project"); init_local_game_project_at(&root, "game-chat-existing-code", "水晶俄罗斯方块") .expect("init existing game project"); - let existing = - ""; + let existing = "

目标:旋转方块消除整行获得胜利;堆满后失败,按 R 重新开始。

"; fs::write(root.join(AGENT_RUNTIME_GAME_INDEX_PATH), existing) .expect("write existing non-placeholder game"); let (root_state, mut code_state) = queue_game_chat_fast_path_child( @@ -2263,6 +2598,504 @@ fn game_chat_existing_game_requires_code_mutation_and_full_completion_before_del assert_eq!(code_gate.verified_revision, Some(1)); } +#[test] +fn game_chat_trusted_single_main_repairs_javascript_smoke_and_completes_without_intervention() { + const ROOT_RUN_ID: &str = "game-chat-deterministic-repair-root"; + const TASK: &str = "生成一版完整可玩的彩虹餐厅经营小游戏,全程自主修复并完成"; + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let temporary = tempfile::tempdir().expect("create deterministic game-chat repair root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-deterministic-repair", TASK) + .expect("init deterministic game-chat repair project"); + + let seed_tasks = + autonomous_manifest_seed_tasks_for_source(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE); + assert_eq!( + seed_tasks + .iter() + .map(|task| task.id.as_str()) + .collect::>(), + vec!["code-prototype"], + "trusted game-chat must persist one single-main manifest lane" + ); + let (mut root_state, mut code_state) = + queue_game_chat_fast_path_child(&root, ROOT_RUN_ID, TASK, "code-prototype"); + crate::tests::freeze_test_root_goal_contract_at(&root, ROOT_RUN_ID); + assert_eq!(root_state.source, AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE); + assert_eq!( + root_state.run_profile, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + ); + assert_eq!(code_state.agent_id, "code-prototype"); + assert_eq!(code_state.source, "agent-ready-task-scheduler"); + assert_eq!( + code_state.parent_agent_id.as_deref(), + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + ); + assert_eq!(code_state.parent_run_id.as_deref(), Some(ROOT_RUN_ID)); + assert_eq!( + code_state.run_id, + autonomous_manifest_ready_task_run_id(ROOT_RUN_ID, "code-prototype") + ); + register_game_chat_art_spec_fixture(&root); + register_game_chat_art_spritesheet_fixture(&root); + let art_manifest = read_manifest_for_project(&root).expect("read registered art manifest"); + validate_manifest_required_visual_asset(&root, &art_manifest, "art-director") + .expect("deterministic art spec is reusable"); + validate_manifest_required_visual_asset(&root, &art_manifest, "art-asset-plan") + .expect("deterministic spritesheet is reusable"); + write_local_project_file_at( + &root, + "assets/manifest.art.json", + &game_chat_fast_path_art_manifest_content(), + ) + .expect("write reusable deterministic art manifest before audit"); + code_state.status = "running".to_string(); + code_state.phase = "planning".to_string(); + code_state.loop_iteration = 1; + append_game_creator_agent_runtime_task(&root, &code_state) + .expect("persist running deterministic code child"); + write_game_creator_agent_runtime_state(&root, &code_state) + .expect("persist deterministic code runtime"); + persist_game_chat_main_asset_audit_and_route(&root, &root_state, &mut code_state); + + let playable_html = render_game_chat_fast_path_html("彩虹餐厅经营挑战"); + let invalid_html = playable_html.replacen( + " stateNode.textContent = JSON.stringify(state);\n requestAnimationFrame(draw);", + " function terminalRepairProbe() {} terminalRepairProbe()const terminalPhases = ['won', 'lost'];\n stateNode.textContent = JSON.stringify(state);\n requestAnimationFrame(draw);", + 1, + ); + assert_ne!( + invalid_html, playable_html, + "invalid JavaScript fixture marker" + ); + assert!(invalid_html.contains("terminalRepairProbe()const terminalPhases")); + let initial_write = AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: Some("生成首版可试玩游戏入口".to_string()), + input: serde_json::json!({ + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + "content": invalid_html, + }), + }; + let initial_write_fingerprint = + agent_runtime_tool_action_fingerprint(&initial_write, &code_state.current_task); + let initial_write_id = agent_runtime_tool_action_id( + &code_state.run_id, + code_state.loop_iteration, + 0, + 1, + &initial_write_fingerprint, + ); + let initial_write_observation = observe_agent_runtime_file_write( + &root, + &code_state.agent_id, + &code_state.run_id, + &initial_write, + &initial_write_fingerprint, + None, + ); + assert_eq!(initial_write_observation.status, "ok"); + let initial_write_task = code_state.current_task.clone(); + append_agent_runtime_tool_call_record( + &root, + &mut code_state, + &initial_write_task, + &initial_write, + &initial_write_observation, + Some(&initial_write_id), + Some(&initial_write_fingerprint), + ); + append_agent_runtime_action_receipt( + &root, + &code_state, + &initial_write_id, + &initial_write_fingerprint, + "file.write", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + None, + &initial_write_observation, + ) + .expect("persist initial game write receipt"); + + let failed_smoke = AgentRuntimeToolAction { + tool: "command.run_limited".to_string(), + reason: Some("检查首版游戏入口".to_string()), + input: serde_json::json!({ "commandId": "game.static_smoke" }), + }; + let failed_smoke_fingerprint = + agent_runtime_tool_action_fingerprint(&failed_smoke, &code_state.current_task); + let failed_smoke_id = agent_runtime_tool_action_id( + &code_state.run_id, + code_state.loop_iteration, + 1, + 1, + &failed_smoke_fingerprint, + ); + let failed_smoke_observation = observe_agent_runtime_limited_command( + &root, + &code_state.agent_id, + &code_state.run_id, + &failed_smoke.input, + ); + assert_eq!(failed_smoke_observation.status, "failed"); + let failed_smoke_detail = failed_smoke_observation + .detail + .as_deref() + .expect("failed smoke diagnostic"); + assert!( + failed_smoke_detail.contains("不是有效 JavaScript"), + "{failed_smoke_detail}" + ); + assert!( + !failed_smoke_detail.contains("可渲染画布"), + "JavaScript syntax must fail before surface checks: {failed_smoke_detail}" + ); + let failed_smoke_task = code_state.current_task.clone(); + append_agent_runtime_tool_call_record( + &root, + &mut code_state, + &failed_smoke_task, + &failed_smoke, + &failed_smoke_observation, + Some(&failed_smoke_id), + Some(&failed_smoke_fingerprint), + ); + append_agent_runtime_action_receipt( + &root, + &code_state, + &failed_smoke_id, + &failed_smoke_fingerprint, + "command.run_limited", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + None, + &failed_smoke_observation, + ) + .expect("persist failed static smoke receipt"); + + let (history, count, truncated, output_truncated) = read_agent_runtime_action_history( + &root, + &code_state.agent_id, + &code_state.run_id, + &serde_json::json!({ + "runId": code_state.run_id, + "actionId": failed_smoke_id, + "tool": "command.run_limited", + "status": "failed", + "limit": 1, + }), + ) + .expect("read owner-visible failed smoke action history"); + assert_eq!(count, 1); + assert!(!truncated); + assert!(!output_truncated); + let history: serde_json::Value = + serde_json::from_str(&history).expect("parse failed smoke history"); + let history_item = &history["actions"][0]; + assert_eq!(history_item["actionId"], failed_smoke_id); + let diagnostic: serde_json::Value = serde_json::from_str( + history_item["safeDetail"] + .as_str() + .expect("owner-visible structured smoke detail"), + ) + .expect("parse owner-visible structured smoke detail"); + assert_eq!(diagnostic["failureCode"], "static-smoke-failed"); + assert_eq!(diagnostic["check"], "javascript-syntax"); + assert_eq!(diagnostic["path"], AGENT_RUNTIME_GAME_INDEX_PATH); + assert!(diagnostic["diagnostic"] + .as_str() + .is_some_and(|value| value.contains("不是有效 JavaScript"))); + + code_state.loop_iteration = 2; + let repair = AgentRuntimeToolAction { + tool: "file.patch".to_string(), + reason: Some("根据结构化 JavaScript 语法诊断修复当前入口".to_string()), + input: serde_json::json!({ + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + "oldText": "terminalRepairProbe()const terminalPhases", + "newText": "terminalRepairProbe();const terminalPhases", + "expectedReplacements": 1, + }), + }; + let repair_fingerprint = + agent_runtime_tool_action_fingerprint(&repair, &code_state.current_task); + let repair_id = agent_runtime_tool_action_id( + &code_state.run_id, + code_state.loop_iteration, + 0, + 1, + &repair_fingerprint, + ); + let repair_observation = observe_agent_runtime_file_patch( + &root, + &code_state.agent_id, + &code_state.run_id, + &repair, + &repair_fingerprint, + None, + ); + assert_eq!(repair_observation.status, "ok", "{repair_observation:?}"); + let repair_task = code_state.current_task.clone(); + append_agent_runtime_tool_call_record( + &root, + &mut code_state, + &repair_task, + &repair, + &repair_observation, + Some(&repair_id), + Some(&repair_fingerprint), + ); + append_agent_runtime_action_receipt( + &root, + &code_state, + &repair_id, + &repair_fingerprint, + "file.patch", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + None, + &repair_observation, + ) + .expect("persist same-run repair receipt"); + + let repaired_smoke = AgentRuntimeToolAction { + tool: "command.run_limited".to_string(), + reason: Some("重新验证已修复的当前 revision".to_string()), + input: serde_json::json!({ "commandId": "game.static_smoke" }), + }; + let repaired_smoke_fingerprint = + agent_runtime_tool_action_fingerprint(&repaired_smoke, &code_state.current_task); + let repaired_smoke_id = agent_runtime_tool_action_id( + &code_state.run_id, + code_state.loop_iteration, + 1, + 1, + &repaired_smoke_fingerprint, + ); + let repaired_smoke_observation = observe_agent_runtime_limited_command( + &root, + &code_state.agent_id, + &code_state.run_id, + &repaired_smoke.input, + ); + assert_eq!( + repaired_smoke_observation.status, "ok", + "{repaired_smoke_observation:?}" + ); + let repaired_smoke_task = code_state.current_task.clone(); + append_agent_runtime_tool_call_record( + &root, + &mut code_state, + &repaired_smoke_task, + &repaired_smoke, + &repaired_smoke_observation, + Some(&repaired_smoke_id), + Some(&repaired_smoke_fingerprint), + ); + append_agent_runtime_action_receipt( + &root, + &code_state, + &repaired_smoke_id, + &repaired_smoke_fingerprint, + "command.run_limited", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + None, + &repaired_smoke_observation, + ) + .expect("persist repaired static smoke receipt"); + let revision = read_game_creator_agent_runtime_project_revision(&root) + .expect("read repaired project revision") + .revision; + let code_gate = read_game_creator_agent_runtime_verification_gate( + &root, + &code_state.agent_id, + &code_state.run_id, + ) + .expect("read repaired code verification gate"); + assert_eq!(code_gate.mutation_revision, Some(revision)); + assert_eq!(code_gate.verified_revision, Some(revision)); + assert_eq!(code_gate.static_smoke_verified_revision, Some(revision)); + assert_eq!(code_gate.last_mutation_tool.as_deref(), Some("file.patch")); + + for (path, content) in [ + ("memory/project.md", "# 项目记忆\n\n正式约束。\n"), + ("game/game_design.md", "# 游戏设计\n\n核心循环。\n"), + ("game/balance.json", r#"{"lives":3,"speed":1}"#), + ("assets/manifest.audio.json", r#"{"bgm":[],"sfx":[]}"#), + ("exports/README.md", "# 发布说明\n\n可试玩。\n"), + ] { + write_local_project_file_at(&root, path, content) + .unwrap_or_else(|error| panic!("write deterministic artifact {path}: {error}")); + } + let browser_result = prepare_autonomous_playtest_evidence_for_actor_at_revision( + &root, + &root_state, + &code_state, + revision, + ); + assert!(browser_result.passed); + assert_eq!(browser_result.viewport_results.len(), 2); + assert!(browser_result + .viewport_results + .iter() + .any( + |viewport| viewport.viewport == BrowserValidationViewport::Desktop && viewport.passed + )); + assert!(browser_result + .viewport_results + .iter() + .any(|viewport| viewport.viewport == BrowserValidationViewport::Mobile && viewport.passed)); + let code_completion_blocker = + autonomous_game_build_completion_blocker_at_locked(&root, &code_state); + assert!( + code_completion_blocker.is_none(), + "deterministic code completion blocker: {code_completion_blocker:?}" + ); + + let validated_game_index = + fs::read(root.join(AGENT_RUNTIME_GAME_INDEX_PATH)).expect("snapshot validated game entry"); + fs::write( + root.join(AGENT_RUNTIME_GAME_INDEX_PATH), + "", + ) + .expect("mutate game entry without advancing durable revision"); + let stale_receipt_blocker = + autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("same-revision stale playtest receipt must block completion"); + assert!( + stale_receipt_blocker.summary.contains("game.static_smoke"), + "unexpected stale receipt blocker: {stale_receipt_blocker:?}" + ); + fs::write( + root.join(AGENT_RUNTIME_GAME_INDEX_PATH), + validated_game_index, + ) + .expect("restore validated game entry"); + assert!( + autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none(), + "restored validated entry must satisfy completion gate" + ); + + let completed_code = finish_game_creator_agent_runtime_turn_at( + &root, + code_state, + &format!("首版已完成,并通过 revision {revision} 的静态检查与双视口试玩。"), + ) + .expect("finish deterministic code-prototype run"); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("restore running manifest projection before deterministic terminal projection"); + assert!( + project_autonomous_manifest_ready_task_terminal_at(&root, &completed_code) + .expect("project deterministic code-prototype completion") + ); + let root_stale_receipt_blocker = + autonomous_game_build_completion_blocker_at_locked(&root, &root_state); + assert!( + root_stale_receipt_blocker.is_none(), + "root must accept the restored current entry: {root_stale_receipt_blocker:?}" + ); + crate::tests::pass_test_root_acceptance_graph_at(&root, &root_state); + let mut provider_plan = AgentRuntimeToolPlan { + actions: vec![AgentRuntimeToolAction { + tool: "agent.run_status".to_string(), + reason: Some("模型原本仍想继续轮询".to_string()), + input: serde_json::json!({}), + }], + ..AgentRuntimeToolPlan::default() + }; + let convergence = prepare_game_chat_single_round_convergence_at( + &root, + &mut root_state, + &mut provider_plan, + unix_timestamp(), + ) + .expect("prepare deterministic root convergence") + .expect("fully verified game-chat root must converge without another Provider plan"); + assert_eq!(convergence.1, revision); + assert!(provider_plan.actions.is_empty()); + let completed_root = + finish_game_creator_agent_runtime_turn_at(&root, root_state, &convergence.0) + .expect("finish deterministic game-chat root once"); + assert_eq!(completed_root.phase, "completed"); + + let logical_code_runs = latest_game_creator_agent_runtime_tasks( + read_all_game_creator_agent_runtime_tasks(&game_creator_agent_runtime_task_path( + &root, + "code-prototype", + )) + .expect("read deterministic code journal"), + ) + .into_iter() + .filter(|record| { + record.source == "agent-ready-task-scheduler" + && record.parent_run_id.as_deref() == Some(ROOT_RUN_ID) + }) + .collect::>(); + assert_eq!(logical_code_runs.len(), 1); + assert_eq!(logical_code_runs[0].run_id, completed_code.run_id); + assert_eq!(logical_code_runs[0].phase, "completed"); + let root_terminal_records = read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + ) + .expect("read deterministic root journal") + .into_iter() + .filter(|record| record.run_id == ROOT_RUN_ID && record.phase == "completed") + .count(); + assert_eq!( + root_terminal_records, 1, + "root must have one completed terminal" + ); + let (db_records, _) = + read_agent_db_records_bounded(&root, AGENT_RUNTIME_ACTION_HISTORY_MAX_DB_BYTES) + .expect("read deterministic Agent DB records"); + assert_eq!( + db_records + .iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some("agent.runtime.completed") + && record.get("agentId").and_then(serde_json::Value::as_str) + == Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + && record.get("runId").and_then(serde_json::Value::as_str) == Some(ROOT_RUN_ID) + }) + .count(), + 1, + "root completion audit must be unique" + ); + for agent_id in [GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "code-prototype"] { + let records = read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(&root, agent_id), + ) + .expect("read no-intervention journal"); + assert!(records.iter().all(|record| { + record.status != "waiting-for-confirmation" + && record.status != "waiting-for-user-input" + && record.phase != "waiting-for-confirmation" + && record.phase != "waiting-for-user-input" + })); + } + assert!(!game_creator_agent_runtime_pending_tool_action_path( + &root, + &completed_code.agent_id, + &completed_code.run_id, + ) + .exists()); + assert!(!game_creator_agent_runtime_pending_tool_action_path( + &root, + &completed_root.agent_id, + &completed_root.run_id, + ) + .exists()); + let confirmations_root = root.join(".agent/runtime/confirmations"); + assert!( + !confirmations_root.exists() + || fs::read_dir(&confirmations_root) + .expect("read confirmation root") + .next() + .is_none(), + "deterministic game-chat completion must not require confirmation" + ); +} + #[test] fn autonomous_supervisor_empty_plan_uses_deterministic_final_reply_fallback() { assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs index 3b6af0f9d..6b0e60d62 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs @@ -329,6 +329,24 @@ pub(crate) fn ensure_static_delegate_user_input_wait_at( runtime: &mut AgentRuntimeState, deliveries: &[StaticDelegateDeliveryRecord], ) -> Result { + let parent_task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &runtime.agent_id, + &runtime.run_id, + )? + .ok_or_else(|| "needs-user-input 转换缺少当前 parent task".to_string())?; + if parent_task.session_id != runtime.session_id + || parent_task.source != runtime.source + || parent_task.run_profile != runtime.run_profile + || parent_task.run_profile_binding_fingerprint != runtime.run_profile_binding_fingerprint + { + return Err("needs-user-input 转换的 parent task 与 runtime 身份不一致".to_string()); + } + if convert_claimed_game_chat_user_input_deliveries_to_repair_at(root, &parent_task, deliveries)? + > 0 + { + return Ok(false); + } let mut pending_deliveries = deliveries.iter().filter(|delivery| { delivery.structured_result.as_ref().is_some_and(|result| { result.contract_status == StaticDelegateContractStatus::NeedsUserInput diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index 8d2d866d8..75ea76559 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -6145,7 +6145,21 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( )); } }; - if !agent_runtime_static_smoke_passed_for_revision(&gate, revision.revision) { + let static_smoke_matches_current_entry = + match agent_runtime_static_smoke_passed_for_current_entry_at( + root, + &gate, + revision.revision, + ) { + Ok(matches) => matches, + Err(error) => { + return Some(autonomous_completion_blocker( + "preview-readiness 无法复核当前 game.static_smoke 入口摘要", + error, + )); + } + }; + if !static_smoke_matches_current_entry { return Some(autonomous_completion_blocker( "preview-readiness 尚未通过当前 revision 的 game.static_smoke", format!( @@ -6198,9 +6212,29 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( )); } }; - if receipt.revision != revision.revision { + let current_index = match read_autonomous_evidence_file_at( + root, + AGENT_RUNTIME_GAME_INDEX_PATH, + "preview-playtest 当前游戏入口", + AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES, + ) { + Ok(Some((digest, _))) => digest, + Ok(None) => { + return Some(autonomous_completion_blocker( + "preview-playtest 当前游戏入口缺失", + "必须重新生成 game/index.html 并在当前 revision 执行 preview.validate。", + )); + } + Err(error) => { + return Some(autonomous_completion_blocker( + "preview-playtest 当前游戏入口无法安全读取", + error, + )); + } + }; + if receipt.revision != revision.revision || receipt.game_index != current_index { return Some(autonomous_completion_blocker( - "preview-playtest 浏览器试玩回执不属于当前 revision", + "preview-playtest 浏览器试玩回执不属于当前 revision 或入口产物", format!( "receiptRevision={}, currentRevision={}", receipt.revision, revision.revision @@ -6374,13 +6408,13 @@ fn game_chat_main_completion_blocker_at_locked( ), )); } - let current_index_bytes = match read_autonomous_evidence_file_at( + let (current_index, current_index_bytes) = match read_autonomous_evidence_file_at( root, AGENT_RUNTIME_GAME_INDEX_PATH, "game-chat 主 Agent 游戏入口", AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES, ) { - Ok(Some((_, bytes))) => bytes, + Ok(Some(value)) => value, Ok(None) => { return Some(autonomous_completion_blocker( "game-chat code-prototype 尚未生成 game/index.html", @@ -6422,11 +6456,12 @@ fn game_chat_main_completion_blocker_at_locked( )); } }; - if !agent_runtime_static_smoke_passed_for_revision(&gate, revision.revision) { + if !agent_runtime_static_smoke_passed_for_entry(&gate, revision.revision, ¤t_index.sha256) + { return Some(autonomous_completion_blocker( - "game-chat code-prototype 尚未通过当前 revision 的 game.static_smoke", + "game-chat code-prototype 尚未通过当前 game/index.html 的 game.static_smoke", format!( - "currentRevision={}, staticSmokeVerifiedRevision={}", + "path=game/index.html, currentRevision={}, staticSmokeVerifiedRevision={}", revision.revision, gate.static_smoke_verified_revision .map(|value| value.to_string()) @@ -6449,9 +6484,9 @@ fn game_chat_main_completion_blocker_at_locked( )); } }; - if receipt.revision != revision.revision { + if receipt.revision != revision.revision || receipt.game_index != current_index { return Some(autonomous_completion_blocker( - "game-chat code-prototype 试玩回执不属于当前 revision", + "game-chat code-prototype 试玩回执不属于当前 revision 或入口产物", format!( "receiptRevision={}, currentRevision={}", receipt.revision, revision.revision @@ -7309,6 +7344,52 @@ pub(in crate::agent) fn javascript_is_syntactically_valid(content: &str, is_modu semantic.diagnostics.is_empty() } +pub(in crate::agent) fn validate_executable_inline_javascript_syntax( + html: &str, +) -> Result<(), String> { + let lower = html.to_ascii_lowercase(); + let mut cursor = 0usize; + let mut executable_index = 0usize; + while let Some(offset) = lower[cursor..].find('<') { + let tag_start = cursor + offset; + if lower[tag_start..].starts_with("") + .map(|end| tag_start + 4 + end + 3) + .unwrap_or(lower.len()); + continue; + } + let Some(tag_after) = html_tag_end(&lower, tag_start) else { + break; + }; + let tag = &lower[tag_start..tag_after]; + if let Some(end) = html_non_executable_container_end(&lower, tag, tag_after - 1) { + cursor = end; + continue; + } + if !html_tag_starts_element(tag, "script") { + cursor = tag_after; + continue; + } + let Some((close_start, close_end)) = + raw_text_html_element_close(&lower, tag_after, "script") + else { + return Err("游戏入口的 " + "

目标:移动角色收集全部目标,避开障碍并获得胜利;失败后可按 R 重新开始。

" +} + +fn with_static_smoke_contract(html: &str) -> String { + let contract = "

目标:移动角色收集全部目标并获得胜利;碰到危险即失败,按 R 重新开始。

"; + if let Some(body_end) = html.rfind("") { + format!("{}{}{}", &html[..body_end], contract, &html[body_end..]) + } else { + format!("{html}{contract}") + } } fn executable_tetris_game_html() -> String { @@ -5378,11 +5387,10 @@ fn autonomous_preview_manifest_tasks_accept_bound_current_revision_receipts() { let readiness_child = queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-readiness"); let readiness_state = agent_runtime_state_from_task_record(&readiness_child); - advance_game_index_revision( - &root, - &parent_state, - "静态检查通过", + let readiness_html = with_static_smoke_contract( + "静态检查通过", ); + advance_game_index_revision(&root, &parent_state, &readiness_html); mark_verification_passed(&root, &readiness_state, "game.static_smoke"); assert!(autonomous_game_build_completion_blocker_at_locked(&root, &readiness_state).is_none()); @@ -5655,6 +5663,50 @@ fn autonomous_playtest_receipt_rejects_previous_scenario_fingerprint() { assert!(error.contains("场景指纹"), "unexpected error: {error}"); } +#[test] +fn autonomous_playtest_receipt_requires_passed_desktop_and_mobile_viewports() { + let (_temporary, root, state, contract) = + autonomous_fixture("做一个完整小游戏", "autonomous-required-viewports-run"); + let revision = advance_game_index_revision( + &root, + &state, + "双视口试玩", + ); + let result = + browser_result_fixture(&root, &state, revision, BrowserPlaytestScenario::GenericV1); + let action = AgentRuntimeToolAction { + tool: "preview.validate".to_string(), + reason: Some("验证 desktop/mobile 试玩合同".to_string()), + input: serde_json::json!({}), + }; + let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task); + let action_id = agent_runtime_tool_action_id(&state.run_id, 1, 0, 1, &action_fingerprint); + + let mut duplicate_desktop = result.clone(); + duplicate_desktop.viewport_results[1].viewport = BrowserValidationViewport::Desktop; + write_autonomous_playtest_receipt_at( + &root, + &contract, + &action_id, + &action_fingerprint, + revision, + &duplicate_desktop, + ) + .expect_err("duplicate desktop must not satisfy the mobile viewport gate"); + + let mut failed_mobile = result; + failed_mobile.viewport_results[1].passed = false; + write_autonomous_playtest_receipt_at( + &root, + &contract, + &action_id, + &action_fingerprint, + revision, + &failed_mobile, + ) + .expect_err("failed mobile must not satisfy the dual viewport gate"); +} + #[test] fn stale_scenario_receipt_reads_as_missing_and_can_be_replaced() { let (_temporary, root, state, contract) = autonomous_fixture( @@ -5849,6 +5901,107 @@ fn game_chat_parent_completion_stops_after_main_agent_smoke_and_dual_viewport_pl assert!(autonomous_game_build_completion_blocker_at_locked(&root, &state).is_none()); } +#[test] +fn game_chat_completion_rejects_same_revision_entry_rewrite_after_static_smoke() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮植物塔防游戏", + "game-chat-static-smoke-entry-rewrite", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + install_game_chat_existing_art_manifest(&root); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("start the game-chat main agent"); + let mut main_state = start_game_chat_main_agent(&root, &parent_state); + persist_game_chat_main_asset_audit_and_route(&root, &parent_state, &mut main_state); + let revision = advance_game_index_revision(&root, &main_state, cropped_spritesheet_game_html()); + mark_verification_passed(&root, &main_state, "game.static_smoke"); + + let rewritten = format!( + "{}\n", + cropped_spritesheet_game_html() + ); + fs::write(root.join(AGENT_RUNTIME_GAME_INDEX_PATH), rewritten) + .expect("rewrite game entry without advancing project revision"); + persist_game_chat_main_playtest_receipt(&root, &parent_state, &main_state, revision); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &main_state) + .expect("a static-smoke credential for different entry bytes must not complete"); + assert_eq!(blocker.tool, "runtime.autonomous_completion"); + assert!(blocker.summary.contains("game.static_smoke")); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("game/index.html"))); +} + +#[test] +fn game_chat_prepared_finalization_rejects_same_revision_entry_rewrite() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮植物塔防游戏", + "game-chat-static-smoke-prepared-rewrite", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + install_game_chat_existing_art_manifest(&root); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("start the game-chat main agent"); + let mut main_state = start_game_chat_main_agent(&root, &parent_state); + persist_game_chat_main_asset_audit_and_route(&root, &parent_state, &mut main_state); + let revision = advance_game_index_revision(&root, &main_state, cropped_spritesheet_game_html()); + mark_verification_passed(&root, &main_state, "game.static_smoke"); + persist_game_chat_main_playtest_receipt(&root, &parent_state, &main_state, revision); + main_state.status = "running".to_string(); + main_state.phase = "finalizing".to_string(); + main_state.current_action = "测试恢复 prepared finalization".to_string(); + append_game_creator_agent_runtime_task(&root, &main_state) + .expect("append finalizing game-chat main task"); + write_game_creator_agent_runtime_state(&root, &main_state) + .expect("persist finalizing game-chat main state"); + + let response = "不应复用旧静态检查凭证的完成回复"; + let journal = build_game_creator_agent_runtime_finalization_journal( + &root, + &main_state, + response, + revision, + ) + .expect("build prepared game-chat finalization"); + write_game_creator_agent_runtime_finalization_journal(&root, &journal) + .expect("write prepared game-chat finalization"); + append_game_creator_agent_runtime_finalization_lifecycle_stage( + &root, + &journal, + "prepared", + journal.prepared_at, + ) + .expect("write prepared game-chat lifecycle"); + fs::write( + root.join(AGENT_RUNTIME_GAME_INDEX_PATH), + format!( + "{}\n", + cropped_spritesheet_game_html() + ), + ) + .expect("rewrite game entry after prepared finalization"); + + assert_eq!( + resume_game_creator_agent_finalization_for_test_at(&root, &main_state.agent_id) + .expect("resume game-chat prepared finalization"), + "not-found" + ); + let conversation = read_local_conversation_for_session_at( + &root, + Some(&main_state.agent_id), + Some(&main_state.session_id), + ) + .expect("read game-chat main conversation"); + assert!(!conversation + .messages + .iter() + .any(|message| message.role == "assistant" && message.content == response)); +} + #[test] fn game_chat_schedule_ready_tool_cannot_bypass_the_single_round_publish_boundary() { let (_temporary, root, state, _contract) = autonomous_fixture_with_source( @@ -6111,11 +6264,10 @@ fn game_chat_code_prototype_requires_cropped_spritesheet_use() { .expect("mark code prototype running"); let mut code_state = start_game_chat_main_agent(&root, &parent_state); persist_game_chat_main_asset_audit_and_route(&root, &parent_state, &mut code_state); - let revision = advance_game_index_revision( - &root, - &code_state, + let pure_code_html = with_static_smoke_contract( "", ); + let revision = advance_game_index_revision(&root, &code_state, &pure_code_html); mark_verification_passed(&root, &code_state, "game.static_smoke"); persist_game_chat_main_playtest_receipt(&root, &parent_state, &code_state, revision); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) @@ -6131,7 +6283,8 @@ fn game_chat_code_prototype_requires_cropped_spritesheet_use() { "", "", ] { - let revision = advance_game_index_revision(&root, &code_state, html); + let html = with_static_smoke_contract(html); + let revision = advance_game_index_revision(&root, &code_state, &html); mark_verification_passed(&root, &code_state, "game.static_smoke"); persist_game_chat_main_playtest_receipt(&root, &parent_state, &code_state, revision); assert!( @@ -6140,11 +6293,10 @@ fn game_chat_code_prototype_requires_cropped_spritesheet_use() { ); } - let overwritten_revision = advance_game_index_revision( - &root, - &code_state, + let overwritten_html = with_static_smoke_contract( "", ); + let overwritten_revision = advance_game_index_revision(&root, &code_state, &overwritten_html); mark_verification_passed(&root, &code_state, "game.static_smoke"); persist_game_chat_main_playtest_receipt( &root, @@ -6262,11 +6414,10 @@ fn cli_code_prototype_keeps_registered_canvas_spritesheet_gate_when_editor_is_co let code_record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); let code_state = agent_runtime_state_from_task_record(&code_record); - advance_game_index_revision( - &root, - &code_state, + let art_spec_only_html = with_static_smoke_contract( "", ); + advance_game_index_revision(&root, &code_state, &art_spec_only_html); mark_verification_passed(&root, &code_state, "game.static_smoke"); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) .expect("CLI must still require the art spritesheet"); @@ -6370,11 +6521,10 @@ fn game_chat_code_prototype_fails_closed_without_generated_spritesheet() { persist_game_chat_main_asset_audit_and_route(&root, &parent_state, &mut code_state); fs::remove_file(root.join("assets/art-spritesheet.png")) .expect("remove required art-spritesheet file"); - let revision = advance_game_index_revision( - &root, - &code_state, + let pure_code_html = with_static_smoke_contract( "", ); + let revision = advance_game_index_revision(&root, &code_state, &pure_code_html); mark_verification_passed(&root, &code_state, "game.static_smoke"); persist_game_chat_main_playtest_receipt(&root, &parent_state, &code_state, revision); @@ -7768,11 +7918,8 @@ fn autonomous_completion_requires_changed_index_static_smoke_and_bound_playtest( .as_deref() .is_some_and(|detail| detail.contains("game/index.html(initial-placeholder)"))); - let revision = advance_game_index_revision( - &root, - &state, - "可玩塔防", - ); + let valid_game = cropped_spritesheet_game_html().to_string(); + let revision = advance_game_index_revision(&root, &state, &valid_game); mark_verification_passed(&root, &state, "project.verify"); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state) .expect("project.verify cannot replace static smoke"); @@ -7839,11 +7986,8 @@ fn autonomous_completion_requires_changed_index_static_smoke_and_bound_playtest( .expect("persist autonomous playtest receipt"); assert!(autonomous_game_build_completion_blocker_at_locked(&root, &state).is_none()); - advance_game_index_revision( - &root, - &state, - "试玩后又修改", - ); + let changed_game = format!("{valid_game}\n"); + advance_game_index_revision(&root, &state, &changed_game); mark_verification_passed(&root, &state, "game.static_smoke"); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state) .expect("stale playtest must block completion"); @@ -7856,11 +8000,10 @@ fn autonomous_prepared_finalization_without_playtest_is_discarded_before_assista "做一个植物大战僵尸式塔防游戏", "autonomous-prepared-finalization-run", ); - let revision = advance_game_index_revision( - &root, - &state, - "尚未试玩的塔防", + let unplayed_html = with_static_smoke_contract( + "尚未试玩的塔防", ); + let revision = advance_game_index_revision(&root, &state, &unplayed_html); mark_verification_passed(&root, &state, "game.static_smoke"); state.status = "running".to_string(); state.phase = "finalizing".to_string(); @@ -7920,6 +8063,7 @@ fn autonomous_playtest_liveness_only_enforces_the_latest_preview_result() { last_verification_tool: Some("game.static_smoke".to_string()), last_verification_status: Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string()), static_smoke_verified_revision: Some(2), + static_smoke_verified_game_index_sha256: Some("a".repeat(64)), failed_playtest_revision: None, updated_at: 0, }; 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 5f5b26ccf..3607bb842 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 @@ -286,6 +286,11 @@ pub(crate) struct AgentRuntimeVerificationGate { /// proof that the exact same revision passed `game.static_smoke`. #[serde(default)] pub(crate) static_smoke_verified_revision: Option, + /// Bind the durable static-smoke credential to the exact entry bytes. + /// A project revision alone is insufficient because files can be changed + /// outside the Runtime mutation path without advancing that revision. + #[serde(default)] + pub(crate) static_smoke_verified_game_index_sha256: Option, #[serde(default)] pub(crate) failed_playtest_revision: Option, pub(crate) updated_at: u64, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs index 2ee35f3bf..30592a7d7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs @@ -49,6 +49,7 @@ pub(in crate::agent) fn default_agent_runtime_verification_gate( last_verification_tool: None, last_verification_status: None, static_smoke_verified_revision: None, + static_smoke_verified_game_index_sha256: None, failed_playtest_revision: None, updated_at: 0, }) @@ -125,6 +126,20 @@ pub(in crate::agent) fn validate_agent_runtime_verification_gate( "Agent Runtime verification gate 的 failedPlaytestRevision 必须大于 0".to_string(), ); } + if gate + .static_smoke_verified_game_index_sha256 + .as_deref() + .is_some_and(|sha256| !is_lowercase_sha256(sha256)) + { + return Err("Agent Runtime verification gate 的 static smoke 入口摘要无效".to_string()); + } + if gate.static_smoke_verified_revision.is_none() + && gate.static_smoke_verified_game_index_sha256.is_some() + { + return Err( + "Agent Runtime verification gate 的 static smoke 入口摘要缺少 revision".to_string(), + ); + } if gate .failed_playtest_revision .zip(gate.mutation_revision) @@ -195,11 +210,35 @@ pub(in crate::agent) fn validate_agent_runtime_verification_gate( Ok(()) } -pub(in crate::agent) fn agent_runtime_static_smoke_passed_for_revision( +pub(in crate::agent) fn agent_runtime_static_smoke_passed_for_entry( gate: &AgentRuntimeVerificationGate, revision: u64, + game_index_sha256: &str, ) -> bool { - revision > 0 && gate.static_smoke_verified_revision == Some(revision) + revision > 0 + && gate.static_smoke_verified_revision == Some(revision) + && gate.static_smoke_verified_game_index_sha256.as_deref() == Some(game_index_sha256) +} + +pub(in crate::agent) fn agent_runtime_static_smoke_passed_for_current_entry_at( + root: &Path, + gate: &AgentRuntimeVerificationGate, + revision: u64, +) -> Result { + let Some((game_index, _)) = read_autonomous_evidence_file_at( + root, + AGENT_RUNTIME_GAME_INDEX_PATH, + "game.static_smoke 当前游戏入口", + AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES, + )? + else { + return Ok(false); + }; + Ok(agent_runtime_static_smoke_passed_for_entry( + gate, + revision, + &game_index.sha256, + )) } pub(crate) fn read_game_creator_agent_runtime_project_revision( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index c8327c490..80db1ffa4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -35,7 +35,11 @@ pub(in crate::agent) use run_status::*; pub(in crate::agent) use task_ops::*; #[cfg(test)] -pub(crate) use delivery::build_static_delegate_result_for_child_at; +pub(crate) use delivery::{ + build_static_delegate_result_for_child_at, + convert_game_chat_child_user_input_to_safe_default_repair, + trusted_game_chat_autonomous_root_parent_at, +}; #[cfg(test)] pub(crate) use media::validate_agent_runtime_canvas_replacement_authorization_at; @@ -51,7 +55,9 @@ pub(crate) use delegation::{ }; pub(crate) use delivery::{ agent_runtime_delegation_id, dispatch_isolated_agent_join_at, + game_chat_safe_default_repair_replacement, game_chat_safe_default_repair_task_instruction, game_creator_agent_runtime_terminal_status, publish_game_creator_agent_delegate_result, + reconcile_claimed_game_chat_safe_default_half_states_at, reconcile_game_creator_agent_delegate_receipts_at, }; #[allow(unused_imports)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index 165830cec..af44bb3e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -286,18 +286,18 @@ fn validate_game_chat_main_art_delegation_at( acceptance_criteria: &[String], expected_artifacts: &[String], repair_of_delegation_id: Option<&str>, -) -> Result<(), String> { +) -> Result { let Some(binding) = read_game_creator_agent_runtime_run_profile_binding(root, parent_agent_id, parent_run_id)? else { - return Ok(()); + return Ok(false); }; let may_be_game_chat = binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE || (binding.source == "agent-ready-task-scheduler" && binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && binding.root_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID); if !may_be_game_chat { - return Ok(()); + return Ok(false); } let root_binding = read_game_creator_agent_runtime_run_profile_binding( root, @@ -320,13 +320,32 @@ fn validate_game_chat_main_art_delegation_at( ); } if !is_game_chat_main { - return Ok(()); - } - if repair_of_delegation_id.is_some() { - return Err( - "game-chat 主 Agent 美术委派不允许发起返工链;请认领当前回执后重新审计".to_string(), - ); + return Ok(false); } + let safe_default_repair = if let Some(original_delegation_id) = repair_of_delegation_id { + let parent_task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + parent_agent_id, + parent_run_id, + )? + .ok_or_else(|| "game-chat 安全默认返工缺少 code-prototype 父任务".to_string())?; + let original = read_static_delegate_delivery_at(root, original_delegation_id)? + .ok_or_else(|| "game-chat 安全默认返工引用的原 delivery 不存在".to_string())?; + if original.repair_of_delegation_id.is_some() + || original.target_agent_id != target_agent_id + || original.acceptance_criteria != acceptance_criteria + || original.expected_artifacts != expected_artifacts + || !trusted_game_chat_safe_default_repair_delivery_at(root, &parent_task, &original)? + { + return Err( + "game-chat code-prototype 只能对同一安全默认 delivery 发起唯一、同合同的一层返工" + .to_string(), + ); + } + true + } else { + false + }; if !matches!(target_agent_id, "art-director" | "art-asset-plan") { return Err( "game-chat code-prototype 只能按审计结果委派 art-director 或 art-asset-plan" @@ -387,13 +406,14 @@ fn validate_game_chat_main_art_delegation_at( && delivery.parent_run_id == parent_run_id && delivery.parent_action_id == action_identity && delivery.target_agent_id == target_agent_id - && delivery.repair_of_delegation_id.is_none() + && delivery.repair_of_delegation_id.as_deref() == repair_of_delegation_id && delivery.status != StaticDelegateDeliveryStatus::Suppressed }); if static_delegate_target_agent_ids_at(root, parent_agent_id, parent_run_id)? .iter() .any(|existing_target| existing_target == target_agent_id) && !same_action_replay + && !safe_default_repair { return Err(format!( "game-chat 每个审计缺口最多委派一次:target={target_agent_id}" @@ -404,7 +424,7 @@ fn validate_game_chat_main_art_delegation_at( { return Err("game-chat code-prototype 同一时刻最多保留一个活跃美术委派".to_string()); } - Ok(()) + Ok(safe_default_repair) } pub(crate) fn observe_agent_runtime_agent_delegate( @@ -528,7 +548,68 @@ pub(crate) fn observe_agent_runtime_agent_delegate( detail: None, }; } - if repair_of_delegation_id.is_some() && agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + let mut safe_default_repair_instruction = None; + let game_chat_safe_default_repair = if let Some(original_delegation_id) = + repair_of_delegation_id.as_deref() + { + let parent_task = match read_latest_game_creator_agent_runtime_task_by_run_id( + root, + agent_id, + parent_run_id, + ) { + Ok(parent_task) => parent_task, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; + let original = match read_static_delegate_delivery_at(root, original_delegation_id) { + Ok(original) => original, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; + match (parent_task.as_ref(), original.as_ref()) { + (Some(parent_task), Some(original)) => { + match trusted_game_chat_safe_default_repair_delivery_at(root, parent_task, original) + { + Ok(authorized) => { + if authorized { + safe_default_repair_instruction = original + .structured_result + .as_ref() + .and_then(game_chat_safe_default_repair_task_instruction); + } + authorized + } + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + } + } + _ => false, + } + } else { + false + }; + if repair_of_delegation_id.is_some() + && agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && !game_chat_safe_default_repair + { return AgentRuntimeToolObservation { tool: "agent.delegate".to_string(), status: "failed".to_string(), @@ -573,7 +654,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate( detail: None, }; } - if let Err(error) = validate_game_chat_main_art_delegation_at( + let game_chat_safe_default_repair = match validate_game_chat_main_art_delegation_at( root, agent_id, parent_run_id, @@ -583,13 +664,16 @@ pub(crate) fn observe_agent_runtime_agent_delegate( &expected_artifacts, repair_of_delegation_id.as_deref(), ) { - return AgentRuntimeToolObservation { - tool: "agent.delegate".to_string(), - status: "failed".to_string(), - summary: redact_agent_runtime_project_paths(root, &error, 240), - detail: None, - }; - } + Ok(safe_default_repair) => safe_default_repair, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; if let Err(error) = ensure_current_autonomous_ready_child_mutation_at_locked(root, agent_id, parent_run_id) { @@ -609,8 +693,11 @@ pub(crate) fn observe_agent_runtime_agent_delegate( &target_agent_id, delegation_action_identity, ); + let delegated_task_text = safe_default_repair_instruction + .map(|instruction| format!("{instruction}\n\n{task}")) + .unwrap_or(task); let delegated_task = match render_static_delegate_task_contract( - &task, + &delegated_task_text, agent_id, parent_run_id, &delegation_id, @@ -638,6 +725,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate( && binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && binding.root_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID }); + debug_assert!(!game_chat_safe_default_repair || main_art_delegation); let run_id = if run_id_input.trim().is_empty() { if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || main_art_delegation { format!("delegated-{delegation_id}") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs index a4bbf91db..4f0152312 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs @@ -1,5 +1,432 @@ use super::*; +const GAME_CHAT_SAFE_DEFAULT_REPAIR_SCHEMA_VERSION: &str = "game-chat-safe-default-repair.v1"; +const GAME_CHAT_SAFE_DEFAULT_REPAIR_CODE: &str = "child-needs-user-input"; +const GAME_CHAT_SAFE_DEFAULT_REPAIR_STRATEGY: &str = "continue-with-safe-defaults"; +const GAME_CHAT_SAFE_DEFAULT_REASON_PREFERENCE: &str = "preference-clarification"; +const GAME_CHAT_SAFE_DEFAULT_REASON_SENSITIVE: &str = "sensitive-or-permission-request"; +const GAME_CHAT_SAFE_DEFAULT_DECISION_USE_DEFAULT: &str = "use-safe-default"; +const GAME_CHAT_SAFE_DEFAULT_DECISION_SKIP_DENIED: &str = "skip-denied"; +const GAME_CHAT_SAFE_DEFAULT_REPAIR_SUMMARY: &str = + "专业 Agent 请求了用户补充信息;自主构建必须采用安全默认值继续,并由 Supervisor 发起唯一返工。"; +const GAME_CHAT_SAFE_DEFAULT_RESULT_SUMMARY: &str = + "自主构建已将专业 Agent 的补充信息请求转换为安全默认返工。"; + +fn game_chat_safe_default_request_allows_preference_default( + result: &StaticDelegateStructuredResult, +) -> bool { + let serialized = serde_json::to_string(&result.user_input_questions).unwrap_or_default(); + let lower = serialized.to_ascii_lowercase(); + if redact_secret_tokens(&serialized) != serialized + || redact_absolute_path_tokens(&serialized) != serialized + || [ + ".env", + "api key", + "api_key", + "apikey", + "token", + "secret", + "password", + "credential", + "authorization", + "cookie", + "bearer ", + "权限", + "授权", + "删除", + "发布", + "支付", + "外部副作用", + "permission", + "authorize", + "delete", + "publish", + "payment", + ] + .iter() + .any(|marker| lower.contains(marker)) + { + return false; + } + [ + "偏好", + "视觉", + "配色", + "色彩", + "颜色", + "风格", + "主题", + "布局", + "难度", + "节奏", + "preference", + "visual", + "palette", + "color", + "style", + "theme", + "layout", + "difficulty", + "pace", + ] + .iter() + .any(|marker| lower.contains(marker)) +} + +fn game_chat_safe_default_repair_error(result: &StaticDelegateStructuredResult) -> String { + let use_preference_default = game_chat_safe_default_request_allows_preference_default(result); + serde_json::json!({ + "schemaVersion": GAME_CHAT_SAFE_DEFAULT_REPAIR_SCHEMA_VERSION, + "code": GAME_CHAT_SAFE_DEFAULT_REPAIR_CODE, + "strategy": GAME_CHAT_SAFE_DEFAULT_REPAIR_STRATEGY, + "reasonCode": if use_preference_default { + GAME_CHAT_SAFE_DEFAULT_REASON_PREFERENCE + } else { + GAME_CHAT_SAFE_DEFAULT_REASON_SENSITIVE + }, + "defaultDecision": if use_preference_default { + GAME_CHAT_SAFE_DEFAULT_DECISION_USE_DEFAULT + } else { + GAME_CHAT_SAFE_DEFAULT_DECISION_SKIP_DENIED + }, + "requestSha256": result.user_input_questions_sha256, + "summary": GAME_CHAT_SAFE_DEFAULT_REPAIR_SUMMARY, + }) + .to_string() +} + +fn game_chat_safe_default_repair_marker( + result: &StaticDelegateStructuredResult, +) -> Option { + if result.contract_status != StaticDelegateContractStatus::NeedsRepair + || !result.user_input_questions.is_empty() + || result.user_input_questions_sha256.is_some() + { + return None; + } + let marker = serde_json::from_str::(result.error.as_deref()?).ok()?; + let reason = marker.get("reasonCode")?.as_str()?; + let decision = marker.get("defaultDecision")?.as_str()?; + let request_sha256 = marker.get("requestSha256")?.as_str()?; + (marker.get("schemaVersion")?.as_str()? == GAME_CHAT_SAFE_DEFAULT_REPAIR_SCHEMA_VERSION + && marker.get("code")?.as_str()? == GAME_CHAT_SAFE_DEFAULT_REPAIR_CODE + && marker.get("strategy")?.as_str()? == GAME_CHAT_SAFE_DEFAULT_REPAIR_STRATEGY + && marker.get("summary")?.as_str()? == GAME_CHAT_SAFE_DEFAULT_REPAIR_SUMMARY + && matches!( + (reason, decision), + ( + GAME_CHAT_SAFE_DEFAULT_REASON_PREFERENCE, + GAME_CHAT_SAFE_DEFAULT_DECISION_USE_DEFAULT + ) | ( + GAME_CHAT_SAFE_DEFAULT_REASON_SENSITIVE, + GAME_CHAT_SAFE_DEFAULT_DECISION_SKIP_DENIED + ) + ) + && request_sha256.len() == 64 + && request_sha256.bytes().all(|byte| byte.is_ascii_hexdigit())) + .then_some(marker) +} + +pub(crate) fn game_chat_safe_default_repair_result_is_valid( + result: &StaticDelegateStructuredResult, +) -> bool { + game_chat_safe_default_repair_marker(result).is_some() +} + +pub(crate) fn game_chat_safe_default_repair_task_instruction( + result: &StaticDelegateStructuredResult, +) -> Option<&'static str> { + let marker = game_chat_safe_default_repair_marker(result)?; + match marker.get("defaultDecision")?.as_str()? { + GAME_CHAT_SAFE_DEFAULT_DECISION_USE_DEFAULT => Some( + "Runtime 安全默认决策:use-safe-default。仅采用无需用户补充、无需新增权限的普通偏好默认值继续;不得再次询问用户。", + ), + GAME_CHAT_SAFE_DEFAULT_DECISION_SKIP_DENIED => Some( + "Runtime 安全默认决策:skip-denied。跳过被拒绝、敏感、越权或无法证明安全的输入与动作,仅在原合同和现有权限内继续;不得再次询问用户。", + ), + _ => None, + } +} + +pub(crate) fn game_chat_safe_default_repair_replacement( + result: &StaticDelegateStructuredResult, +) -> Option<(String, StaticDelegateStructuredResult)> { + let mut replacement = result.clone(); + if replacement.contract_status == StaticDelegateContractStatus::NeedsUserInput { + convert_game_chat_child_user_input_to_safe_default_repair(&mut replacement); + } else if !game_chat_safe_default_repair_result_is_valid(&replacement) { + return None; + } + Some(( + GAME_CHAT_SAFE_DEFAULT_RESULT_SUMMARY.to_string(), + replacement, + )) +} + +fn trusted_game_chat_autonomous_root_binding( + parent_task: &AgentRuntimeTaskRecord, + binding: &AgentRuntimeRunProfileBinding, +) -> bool { + parent_task.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && parent_task.run_id == binding.run_id + && parent_task.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + && parent_task.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && parent_task.parent_agent_id.is_none() + && parent_task.parent_run_id.is_none() + && binding.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && binding.root_agent_id == binding.agent_id + && binding.root_run_id == binding.run_id + && binding.parent_agent_id.is_none() + && binding.parent_run_id.is_none() + && binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + && binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && parent_task.run_profile_binding_fingerprint == binding.binding_fingerprint +} + +fn trusted_game_chat_autonomous_parent_chain_at( + root: &Path, + parent_task: &AgentRuntimeTaskRecord, +) -> Result, String> { + let Some(parent_binding) = read_game_creator_agent_runtime_run_profile_binding( + root, + &parent_task.agent_id, + &parent_task.run_id, + )? + else { + return Ok(None); + }; + if parent_task.agent_id != parent_binding.agent_id + || parent_task.run_id != parent_binding.run_id + || parent_task.source != parent_binding.source + || parent_task.run_profile != parent_binding.profile + || parent_task.parent_agent_id != parent_binding.parent_agent_id + || parent_task.parent_run_id != parent_binding.parent_run_id + || parent_task.run_profile_binding_fingerprint != parent_binding.binding_fingerprint + { + return Ok(None); + } + if trusted_game_chat_autonomous_root_binding(parent_task, &parent_binding) { + return Ok(Some(parent_binding)); + } + if parent_task.agent_id != "code-prototype" + || parent_task.source != "agent-ready-task-scheduler" + || parent_task.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || parent_task.delegation_id.is_some() + || parent_binding.parent_agent_id.as_deref() + != Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + || parent_binding.parent_run_id.as_deref() != Some(parent_binding.root_run_id.as_str()) + || parent_binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || parent_binding.parent_binding_fingerprint.is_none() + { + return Ok(None); + } + let Some(root_binding) = read_game_creator_agent_runtime_run_profile_binding( + root, + &parent_binding.root_agent_id, + &parent_binding.root_run_id, + )? + else { + return Ok(None); + }; + let Some(root_task) = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &root_binding.agent_id, + &root_binding.run_id, + )? + else { + return Ok(None); + }; + if !trusted_game_chat_autonomous_root_binding(&root_task, &root_binding) + || parent_binding.parent_binding_fingerprint.as_deref() + != Some(root_binding.binding_fingerprint.as_str()) + { + return Ok(None); + } + Ok(Some(parent_binding)) +} + +pub(crate) fn trusted_game_chat_autonomous_root_parent_at( + root: &Path, + parent_task: &AgentRuntimeTaskRecord, +) -> Result { + if parent_task.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || parent_task.source != AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + || parent_task.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || parent_task.parent_agent_id.is_some() + || parent_task.parent_run_id.is_some() + { + return Ok(false); + } + let Some(binding) = read_game_creator_agent_runtime_run_profile_binding( + root, + &parent_task.agent_id, + &parent_task.run_id, + )? + else { + return Ok(false); + }; + Ok(trusted_game_chat_autonomous_root_binding( + parent_task, + &binding, + )) +} + +fn trusted_game_chat_autonomous_child_delivery_at( + root: &Path, + parent_task: &AgentRuntimeTaskRecord, + delivery: &StaticDelegateDeliveryRecord, + supplied_child_task: Option<&AgentRuntimeTaskRecord>, +) -> Result { + let Some(parent_binding) = trusted_game_chat_autonomous_parent_chain_at(root, parent_task)? + else { + return Ok(false); + }; + if delivery.parent_agent_id != parent_task.agent_id + || delivery.parent_session_id != parent_task.session_id + || delivery.parent_run_id != parent_task.run_id + || agent_runtime_delegation_id( + &delivery.parent_agent_id, + &delivery.parent_run_id, + &delivery.target_agent_id, + &delivery.parent_action_id, + ) != delivery.delegation_id + || !matches!( + delivery.target_agent_id.as_str(), + "code-prototype" | "art-director" | "art-asset-plan" + ) + || (parent_task.agent_id == "code-prototype" + && !matches!( + delivery.target_agent_id.as_str(), + "art-director" | "art-asset-plan" + )) + { + return Err("game-chat 安全默认返工 delivery 与可信父责任链不一致".to_string()); + } + let child_task = match supplied_child_task { + Some(task) => task.clone(), + None => read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &delivery.target_agent_id, + &delivery.target_run_id, + )? + .ok_or_else(|| "game-chat 安全默认返工缺少专业 child task".to_string())?, + }; + validate_static_delegate_delivery_for_child_result(delivery, parent_task, &child_task)?; + let child_binding = read_game_creator_agent_runtime_run_profile_binding( + root, + &child_task.agent_id, + &child_task.run_id, + )? + .ok_or_else(|| "game-chat 安全默认返工缺少专业 child binding".to_string())?; + if child_task.source != "agent-delegate" + || child_task.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || child_task.run_profile_binding_fingerprint != child_binding.binding_fingerprint + || child_binding.agent_id != delivery.target_agent_id + || child_binding.run_id != delivery.target_run_id + || child_binding.source != "agent-delegate" + || child_binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || child_binding.root_agent_id != parent_binding.root_agent_id + || child_binding.root_run_id != parent_binding.root_run_id + || child_binding.parent_agent_id.as_deref() != Some(parent_task.agent_id.as_str()) + || child_binding.parent_run_id.as_deref() != Some(parent_task.run_id.as_str()) + || child_binding.parent_binding_fingerprint.as_deref() + != Some(parent_binding.binding_fingerprint.as_str()) + { + return Err("game-chat 安全默认返工专业 child binding/link 身份不一致".to_string()); + } + Ok(true) +} + +pub(crate) fn convert_game_chat_child_user_input_to_safe_default_repair( + result: &mut StaticDelegateStructuredResult, +) -> bool { + if result.contract_status != StaticDelegateContractStatus::NeedsUserInput { + return false; + } + let error = game_chat_safe_default_repair_error(result); + result.contract_status = StaticDelegateContractStatus::NeedsRepair; + result.user_input_questions.clear(); + result.user_input_questions_sha256 = None; + result.error = Some(error); + true +} + +pub(in crate::agent) fn convert_claimed_game_chat_user_input_deliveries_to_repair_at( + root: &Path, + parent_task: &AgentRuntimeTaskRecord, + deliveries: &[StaticDelegateDeliveryRecord], +) -> Result { + if trusted_game_chat_autonomous_parent_chain_at(root, parent_task)?.is_none() { + return Ok(0); + } + let mut converted = 0_usize; + for expected in deliveries.iter().filter(|delivery| { + delivery.parent_agent_id == parent_task.agent_id + && delivery.parent_session_id == parent_task.session_id + && delivery.parent_run_id == parent_task.run_id + && delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent + && delivery.clarification_request_id.is_none() + && delivery.clarification_answers_sha256.is_none() + && delivery.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsUserInput + }) + }) { + if !trusted_game_chat_autonomous_child_delivery_at(root, parent_task, expected, None)? { + continue; + } + replace_claimed_static_delegate_result_for_game_chat_safe_default_at(root, expected)?; + converted = converted.saturating_add(1); + } + Ok(converted) +} + +pub(crate) fn reconcile_claimed_game_chat_safe_default_half_states_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result<(), String> { + let Some(parent_task) = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + parent_agent_id, + parent_run_id, + )? + else { + return Ok(()); + }; + if trusted_game_chat_autonomous_parent_chain_at(root, &parent_task)?.is_none() { + return Ok(()); + } + for delivery in claimed_static_delegate_deliveries_at(root, parent_agent_id, parent_run_id)? { + if delivery.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsUserInput + || game_chat_safe_default_repair_result_is_valid(result) + }) { + if !trusted_game_chat_autonomous_child_delivery_at(root, &parent_task, &delivery, None)? + { + continue; + } + replace_claimed_static_delegate_result_for_game_chat_safe_default_at(root, &delivery)?; + } + } + Ok(()) +} + +pub(crate) fn trusted_game_chat_safe_default_repair_delivery_at( + root: &Path, + parent_task: &AgentRuntimeTaskRecord, + delivery: &StaticDelegateDeliveryRecord, +) -> Result { + Ok( + delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent + && delivery.result_summary.as_deref() == Some(GAME_CHAT_SAFE_DEFAULT_RESULT_SUMMARY) + && delivery + .structured_result + .as_ref() + .is_some_and(game_chat_safe_default_repair_result_is_valid) + && trusted_game_chat_autonomous_child_delivery_at(root, parent_task, delivery, None)?, + ) +} + pub(crate) fn agent_runtime_delegation_id( parent_agent_id: &str, parent_run_id: &str, @@ -334,7 +761,22 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( ¤t_task.run_id, )?; let mut state = state; - ensure_static_delegate_user_input_wait_at(root, &mut state, &deliveries)?; + if !ensure_static_delegate_user_input_wait_at(root, &mut state, &deliveries)? { + let state = advance_game_creator_agent_runtime_turn_at( + root, + state, + "planning", + "自主构建澄清已转换为安全默认返工", + "专业 Agent 回执已按安全默认策略进入 needs-repair,恢复同一父 run。", + )?; + let root = root.to_path_buf(); + let agent_id = current_task.agent_id.clone(); + let task = current_task.task.clone(); + tauri::async_runtime::spawn(async move { + let _runtime_lock = runtime_lock; + drain_game_creator_agent_background_tasks(root, agent_id, task, state).await; + }); + } return Ok(true); } let state = advance_game_creator_agent_runtime_turn_at( @@ -889,8 +1331,8 @@ pub(crate) fn publish_game_creator_agent_delegate_result( return; } let was_dispatched = existing_delivery.status == StaticDelegateDeliveryStatus::Dispatched; - let safe_result_summary = truncate_agent_runtime_text(&result_detail, 140); - let structured_result = match build_static_delegate_result_for_child_at( + let mut safe_result_summary = truncate_agent_runtime_text(&result_detail, 140); + let mut structured_result = match build_static_delegate_result_for_child_at( root, &existing_delivery, child_task, @@ -918,6 +1360,24 @@ pub(crate) fn publish_game_creator_agent_delegate_result( return; } }; + match trusted_game_chat_autonomous_child_delivery_at( + root, + &parent_task, + &existing_delivery, + Some(child_task), + ) { + Ok(true) => { + if convert_game_chat_child_user_input_to_safe_default_repair(&mut structured_result) + { + safe_result_summary = GAME_CHAT_SAFE_DEFAULT_RESULT_SUMMARY.to_string(); + } + } + Ok(false) => {} + Err(error) => { + record_game_creator_agent_delegate_result_failure(root, child_task, &error); + return; + } + } let delivery = match mark_static_delegate_delivery_ready_with_result_at( root, &child_task.agent_id, 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 076a4f0aa..4cc1439b4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -24,6 +24,7 @@ pub(crate) enum CliCommand { parent_agent_id: String, initialize: bool, run_profile: String, + supervisor_source: &'static str, }, AgentEnqueue { project_path: PathBuf, @@ -687,7 +688,7 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S })); } if args.first().map(String::as_str) == Some("--swarm-chat") { - const USAGE: &str = "用法:--swarm-chat [--init] [--autonomous-game-build] <本地项目绝对路径> [parentAgentId]"; + const USAGE: &str = "用法:--swarm-chat [--init] [--autonomous-game-build] [--game-chat-smoke] <本地项目绝对路径> [parentAgentId]"; let mut rest = args[1..].to_vec(); let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") { rest.remove(index); @@ -711,9 +712,36 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S } _ => return Err(USAGE.to_string()), }; + let game_chat_smoke = match rest + .iter() + .filter(|arg| arg.as_str() == "--game-chat-smoke") + .count() + { + 0 => false, + 1 => { + let index = rest + .iter() + .position(|arg| arg == "--game-chat-smoke") + .expect("counted game-chat smoke flag"); + rest.remove(index); + true + } + _ => return Err(USAGE.to_string()), + }; if !(1..=2).contains(&rest.len()) || rest.iter().any(|value| value.trim().is_empty()) { return Err(USAGE.to_string()); } + if game_chat_smoke + && (!autonomous_game_build + || rest.get(1).is_some_and(|parent| { + parent.trim() != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + })) + { + return Err( + "--game-chat-smoke 仅允许 project-supervisor 的 --autonomous-game-build 受限验收入口" + .to_string(), + ); + } return Ok(Some(CliCommand::SwarmChat { project_path: PathBuf::from(&rest[0]), parent_agent_id: rest @@ -726,6 +754,11 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S } else { AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string() }, + supervisor_source: if game_chat_smoke { + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + } else { + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE + }, })); } if args.first().map(String::as_str) == Some("--agent-task") { @@ -984,11 +1017,17 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { parent_agent_id, initialize, run_profile, + supervisor_source, } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", initialize)?; require_external_agent_runner_for_cli_runtime_write(&project_path)?; initialize_cli_agent_project(&project_path, initialize)?; - run_game_creator_swarm_chat_at(&project_path, &parent_agent_id, &run_profile) + run_game_creator_swarm_chat_at( + &project_path, + &parent_agent_id, + &run_profile, + supervisor_source, + ) } CliCommand::AgentEnqueue { project_path, @@ -1869,6 +1908,7 @@ mod tests { parent_agent_id: "code-prototype".to_string(), initialize: true, run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), + supervisor_source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, } ); assert!(command.requires_external_agent_runner()); @@ -1948,6 +1988,7 @@ mod tests { parent_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), initialize: false, run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), + supervisor_source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, } ); } @@ -1970,10 +2011,57 @@ mod tests { parent_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), initialize: false, run_profile: AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD.to_string(), + supervisor_source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, } ); } + #[test] + fn swarm_chat_game_chat_smoke_flag_is_restricted_and_selects_trusted_source() { + let project_path = std::env::current_dir().expect("current directory"); + let command = parse_cli_command(&[ + "--swarm-chat".to_string(), + "--init".to_string(), + "--autonomous-game-build".to_string(), + "--game-chat-smoke".to_string(), + project_path.display().to_string(), + ]) + .expect("parse game-chat smoke") + .expect("game-chat smoke command"); + assert_eq!( + command, + CliCommand::SwarmChat { + project_path, + parent_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + initialize: true, + run_profile: AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD.to_string(), + supervisor_source: AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + } + ); + assert!(parse_cli_command(&[ + "--swarm-chat".to_string(), + "--game-chat-smoke".to_string(), + "/tmp/game-project".to_string(), + ]) + .is_err()); + assert!(parse_cli_command(&[ + "--swarm-chat".to_string(), + "--autonomous-game-build".to_string(), + "--game-chat-smoke".to_string(), + "/tmp/game-project".to_string(), + "code-prototype".to_string(), + ]) + .is_err()); + assert!(parse_cli_command(&[ + "--swarm-chat".to_string(), + "--autonomous-game-build".to_string(), + "--game-chat-smoke".to_string(), + "--game-chat-smoke".to_string(), + "/tmp/game-project".to_string(), + ]) + .is_err()); + } + #[test] fn swarm_chat_rejects_missing_or_extra_arguments() { assert!(parse_cli_command(&["--swarm-chat".to_string()]).is_err()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index d726b825b..573cccd63 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -419,6 +419,7 @@ pub(crate) fn static_delegate_completion_barrier_at( ) -> Result { validate_static_delegate_id(parent_agent_id, "parentAgentId", 96)?; validate_static_delegate_id(parent_run_id, "parentRunId", 160)?; + reconcile_claimed_game_chat_safe_default_half_states_at(root, parent_agent_id, parent_run_id)?; let claims = list_static_delegate_claims_at(root)? .into_iter() .filter(|claim| { @@ -1677,6 +1678,141 @@ fn write_static_delegate_delivery_at( ) } +pub(crate) fn replace_claimed_static_delegate_result_for_game_chat_safe_default_at( + root: &Path, + expected: &StaticDelegateDeliveryRecord, +) -> Result { + validate_static_delegate_delivery_record(expected)?; + let expected_result = expected + .structured_result + .as_ref() + .ok_or_else(|| "game-chat 安全默认返工 delivery 缺少 structuredResult".to_string())?; + let (result_summary, structured_result) = + game_chat_safe_default_repair_replacement(expected_result) + .ok_or_else(|| "game-chat 安全默认返工转换状态无效".to_string())?; + let expected_is_original = + expected_result.contract_status == StaticDelegateContractStatus::NeedsUserInput; + let expected_is_converted = expected.result_summary.as_deref() == Some(result_summary.as_str()) + && expected_result == &structured_result; + if expected.status != StaticDelegateDeliveryStatus::ClaimedByParent + || expected.clarification_request_id.is_some() + || expected.clarification_answers_sha256.is_some() + || (!expected_is_original && !expected_is_converted) + { + return Err("game-chat 安全默认返工转换状态无效".to_string()); + } + validate_static_delegate_structured_result( + &structured_result, + expected.terminal_status.as_deref().unwrap_or_default(), + &expected.expected_artifacts, + )?; + let claim_action_id = expected + .claimed_by_action_id + .as_deref() + .ok_or_else(|| "game-chat 安全默认返工 delivery 缺少 claim actionId".to_string())?; + let _claim_lock = acquire_static_delegate_claim_lock_at( + root, + &expected.parent_agent_id, + &expected.parent_run_id, + claim_action_id, + )?; + let _delivery_locks = + acquire_static_delegate_delivery_locks_at(root, vec![expected.delegation_id.clone()])?; + let mut delivery = read_static_delegate_delivery_at(root, &expected.delegation_id)? + .ok_or_else(|| { + format!( + "game-chat 安全默认返工 delivery 不存在:{}", + expected.delegation_id + ) + })?; + validate_static_delegate_delivery_identity(&delivery, expected)?; + if delivery.status != StaticDelegateDeliveryStatus::ClaimedByParent + || delivery.claimed_by_action_id.as_deref() != Some(claim_action_id) + || delivery.clarification_request_id.is_some() + || delivery.clarification_answers_sha256.is_some() + { + return Err("game-chat 安全默认返工 delivery 当前状态无效".to_string()); + } + let mut claim = read_static_delegate_claim_at( + root, + &expected.parent_agent_id, + &expected.parent_run_id, + claim_action_id, + )? + .ok_or_else(|| "game-chat 安全默认返工缺少原 claim".to_string())?; + let receipt_index = claim + .receipts + .iter() + .position(|receipt| receipt.delegation_id == expected.delegation_id) + .ok_or_else(|| "game-chat 安全默认返工 claim 缺少原 delivery".to_string())?; + let receipt = &claim.receipts[receipt_index]; + if receipt.target_agent_id != delivery.target_agent_id + || receipt.status != delivery.terminal_status.as_deref().unwrap_or_default() + || receipt.acceptance_criteria != delivery.acceptance_criteria + || receipt.expected_artifacts != delivery.expected_artifacts + || receipt.repair_of_delegation_id != delivery.repair_of_delegation_id + { + return Err("game-chat 安全默认返工 claim 与 delivery 身份冲突".to_string()); + } + + let delivery_is_original = delivery.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsUserInput + }); + let delivery_is_converted = delivery.result_summary.as_deref() == Some(result_summary.as_str()) + && delivery.structured_result.as_ref() == Some(&structured_result); + let receipt_is_original = receipt.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsUserInput + }); + let receipt_is_converted = receipt.summary == result_summary + && receipt.structured_result.as_ref() == Some(&structured_result); + if (!delivery_is_original && !delivery_is_converted) + || (!receipt_is_original && !receipt_is_converted) + { + return Err("game-chat 安全默认返工 delivery/claim 含非唯一转换差异".to_string()); + } + + if delivery_is_original { + let original = delivery + .structured_result + .as_ref() + .expect("original delivery result exists"); + let (_, converted) = game_chat_safe_default_repair_replacement(original) + .ok_or_else(|| "game-chat 安全默认返工原 delivery 无法转换".to_string())?; + if converted != structured_result + || (receipt_is_original + && (receipt.summary != delivery.result_summary.as_deref().unwrap_or_default() + || receipt.structured_result.as_ref() != Some(original))) + { + return Err("game-chat 安全默认返工 claim 与原 delivery 结果冲突".to_string()); + } + } else if receipt_is_original { + let original = receipt + .structured_result + .as_ref() + .expect("original receipt result exists"); + let (_, converted) = game_chat_safe_default_repair_replacement(original) + .ok_or_else(|| "game-chat 安全默认返工原 claim 无法转换".to_string())?; + if converted != structured_result { + return Err("game-chat 安全默认返工 converted delivery 与原 claim 冲突".to_string()); + } + } + + if !delivery_is_converted { + delivery.result_summary = Some(result_summary.clone()); + delivery.structured_result = Some(structured_result.clone()); + delivery.updated_at = unix_timestamp(); + write_static_delegate_delivery_at(root, &delivery)?; + } + if !receipt_is_converted { + let receipt = &mut claim.receipts[receipt_index]; + receipt.summary = result_summary; + receipt.structured_result = Some(structured_result); + claim.updated_at = unix_timestamp(); + write_static_delegate_claim_at(root, &claim)?; + } + Ok(delivery) +} + fn read_static_delegate_claim_at( root: &Path, parent_agent_id: &str, @@ -2271,6 +2407,140 @@ mod tests { assert!(error.contains("user.input_request")); } + #[test] + fn game_chat_safe_default_replacement_recovers_both_persisted_half_states() { + let root = std::env::temp_dir().join(format!( + "genarrative-safe-default-half-state-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after unix epoch") + .as_nanos() + )); + init_local_game_project_at(&root, "project-1", "安全默认半状态恢复测试") + .expect("project init"); + let parent_agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; + let parent_run_id = "safe-default-half-state-parent-run"; + bind_supervisor_collaboration_policy_snapshot_at( + &root, + parent_agent_id, + parent_run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind collaboration snapshot"); + + for (suffix, convert_delivery_first) in [("delivery", true), ("claim", false)] { + let action_id = format!("safe-default-half-state-action-{suffix}"); + let delegation_id = format!("safe-default-half-state-delegation-{suffix}"); + let mut delivery = new_static_delegate_delivery_with_contract( + parent_agent_id, + "safe-default-half-state-session", + parent_run_id, + &format!("safe-default-half-state-parent-action-{suffix}"), + &delegation_id, + "code-prototype", + &format!("safe-default-half-state-child-session-{suffix}"), + &format!("safe-default-half-state-child-run-{suffix}"), + &["采用安全默认值继续".to_string()], + &[], + None, + ); + create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create delivery"); + let original = build_static_delegate_structured_result_at( + &root, + "completed", + &[], + false, + None, + None, + None, + Some(&format!( + "{STATIC_DELEGATE_USER_INPUT_PREFIX}{}", + serde_json::json!({ + "questions": [{ + "id": "visual_style", + "header": "风格", + "question": "请选择视觉风格", + "options": [ + {"label": "明亮", "description": "使用明亮配色"}, + {"label": "柔和", "description": "使用柔和配色"} + ] + }] + }) + )), + ) + .expect("build needs-user-input result"); + mark_static_delegate_delivery_ready_with_result_at( + &root, + &delivery.target_agent_id, + &delivery.target_session_id, + &delivery.target_run_id, + &delivery.delegation_id, + "completed", + "需要用户选择", + original.clone(), + ) + .expect("mark delivery ready"); + claim_ready_static_delegate_receipts_at( + &root, + parent_agent_id, + parent_run_id, + &action_id, + ) + .expect("claim delivery"); + delivery = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read delivery") + .expect("delivery exists"); + let (summary, converted) = game_chat_safe_default_repair_replacement(&original) + .expect("convert safe default result"); + if convert_delivery_first { + delivery.result_summary = Some(summary.clone()); + delivery.structured_result = Some(converted.clone()); + delivery.updated_at = unix_timestamp(); + write_static_delegate_delivery_at(&root, &delivery).expect("write delivery half"); + } else { + let mut claim = read_static_delegate_claim_at( + &root, + parent_agent_id, + parent_run_id, + &action_id, + ) + .expect("read claim") + .expect("claim exists"); + let receipt = claim + .receipts + .iter_mut() + .find(|receipt| receipt.delegation_id == delegation_id) + .expect("claim receipt"); + receipt.summary = summary.clone(); + receipt.structured_result = Some(converted.clone()); + claim.updated_at = unix_timestamp(); + write_static_delegate_claim_at(&root, &claim).expect("write claim half"); + } + + let reconciled = replace_claimed_static_delegate_result_for_game_chat_safe_default_at( + &root, &delivery, + ) + .expect("reconcile half state"); + assert_eq!(reconciled.result_summary.as_deref(), Some(summary.as_str())); + assert_eq!(reconciled.structured_result.as_ref(), Some(&converted)); + let claim = + read_static_delegate_claim_at(&root, parent_agent_id, parent_run_id, &action_id) + .expect("read reconciled claim") + .expect("reconciled claim exists"); + let receipt = claim + .receipts + .iter() + .find(|receipt| receipt.delegation_id == delegation_id) + .expect("reconciled receipt"); + assert_eq!(receipt.summary, summary); + assert_eq!(receipt.structured_result.as_ref(), Some(&converted)); + } + + fs::remove_dir_all(root).ok(); + } + #[test] fn stale_prepared_claim_snapshot_cannot_downgrade_observed_claim() { let root = std::env::temp_dir().join(format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index 202a774b8..f6e1099b2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -33,5 +33,39 @@ pub(crate) use server::{ bind_loopback_listener_with_linux_fallback, run_external_agent_runner_server, }; +#[cfg(test)] +pub(crate) fn simulate_external_agent_runner_cross_boot_owner_claim_for_test( + root: &std::path::Path, + config_dir: &std::path::Path, + previous_boot_id: &str, + current_boot_id: &str, +) -> Result<(std::path::PathBuf, Option), String> { + let previous = project_owner::acquire_external_agent_runner_project_execution_owner( + root, + previous_boot_id, + protocol::EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + )?; + drop(previous); + let state = state::ExternalAgentRunnerServerState::new( + config_dir.join(protocol::EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + protocol::ExternalAgentRunnerEndpoint { + protocol_version: protocol::EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + pid: std::process::id(), + boot_id: current_boot_id.to_string(), + port: 41009, + token: "owner-provider-token-owner-provider-token".to_string(), + heartbeat_at: 1_725_000_000_000, + executable_fingerprint: Some("a".repeat(64)), + process_start_identity: None, + }, + ); + let claim = state.claim_project_execution_owner(root)?; + let result = (claim.root, claim.recovered_from_boot_id); + let second = state.claim_project_execution_owner(root)?; + if second.recovered_from_boot_id.is_some() { + return Err("同 boot execution owner 重入重复触发恢复".to_string()); + } + Ok(result) +} #[cfg(test)] mod tests; 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 7657aeb2b..bbf6a918d 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 @@ -368,10 +368,12 @@ pub(super) fn dispatch_external_agent_runner_wake_pending_request( pub(super) fn external_agent_runner_response_is_cacheable( response: &ExternalAgentRunnerResponse, ) -> bool { - response - .error - .as_ref() - .is_none_or(|error| error.code != EXTERNAL_AGENT_RUNNER_RETRYABLE_WAKE_ERROR_CODE) + response.error.as_ref().is_none_or(|error| { + !matches!( + error.code.as_str(), + EXTERNAL_AGENT_RUNNER_RETRYABLE_WAKE_ERROR_CODE | "project-execution-owned" + ) + }) } pub(super) fn cache_external_agent_runner_response_if_cacheable( @@ -392,8 +394,77 @@ pub(super) fn cache_external_agent_runner_response_if_cacheable( pub(super) fn dispatch_external_agent_runner_runtime_request( request: &ExternalAgentRunnerRequest, state: &ExternalAgentRunnerServerState, +) -> ExternalAgentRunnerResponse { + dispatch_external_agent_runner_runtime_request_with_owner_claim(request, state, |root| { + state.claim_project_execution_owner(root) + }) +} + +pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim( + request: &ExternalAgentRunnerRequest, + state: &ExternalAgentRunnerServerState, + claim_owner: impl Fn(&Path) -> Result, ) -> ExternalAgentRunnerResponse { let fingerprint = external_agent_runner_request_fingerprint(request); + { + let cache = lock_unpoisoned(&state.write_request_cache); + if let Some(cached) = cache.find(&request.request_id) { + if cached.fingerprint == fingerprint { + return cached.response.clone(); + } + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "request-id-conflict", + "同一 requestId 不能用于不同请求", + ); + } + } + + let requires_project_execution_owner = matches!( + request.method.as_str(), + "runtime.wake_pending" + | "runtime.resume" + | "runtime.continue_action" + | "runtime.steer" + | "runtime.interrupt_for_steer_decision" + | "runtime.pause" + | "runtime.cancel" + | "runtime.compact" + ); + if requires_project_execution_owner && state.draining.load(Ordering::Acquire) { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "runner-draining", + "Agent Runner 正在排空并准备退出,拒绝新的写请求", + ); + } + + let token = state.endpoint_snapshot().token; + let claimed_project_root = if requires_project_execution_owner { + let root = match external_agent_runner_request_root(request) { + Ok(root) => root, + Err(error) => { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "invalid-params", + error, + ); + } + }; + match claim_owner(&root) { + Ok(claim) => Some(claim.root), + Err(error) => { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "project-execution-owned", + error, + ); + } + } + } else { + None + }; + let mut cache = lock_unpoisoned(&state.write_request_cache); if let Some(cached) = cache.find(&request.request_id) { if cached.fingerprint == fingerprint { @@ -406,26 +477,6 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( ); } - if matches!( - request.method.as_str(), - "runtime.wake_pending" - | "runtime.resume" - | "runtime.continue_action" - | "runtime.steer" - | "runtime.interrupt_for_steer_decision" - | "runtime.pause" - | "runtime.cancel" - | "runtime.compact" - ) && state.draining.load(Ordering::Acquire) - { - return ExternalAgentRunnerResponse::failure( - &request.request_id, - "runner-draining", - "Agent Runner 正在排空并准备退出,拒绝新的写请求", - ); - } - - let token = state.endpoint_snapshot().token; let response = match request.method.as_str() { "runtime.wake_pending" | "runtime.resume" @@ -435,26 +486,8 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( | "runtime.pause" | "runtime.cancel" | "runtime.compact" => { - let root = match external_agent_runner_request_root(request) { - Ok(root) => root, - Err(error) => { - return ExternalAgentRunnerResponse::failure( - &request.request_id, - "invalid-params", - error, - ); - } - }; - let root = match state.claim_project_execution_owner(&root) { - Ok(root) => root, - Err(error) => { - return ExternalAgentRunnerResponse::failure( - &request.request_id, - "project-execution-owned", - error, - ); - } - }; + let root = claimed_project_root + .expect("runtime write request must claim its project execution owner"); if request.method == "runtime.wake_pending" { dispatch_external_agent_runner_wake_pending_request(request, &root, &token) } else { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs index a59bc1af2..74b0a5402 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs @@ -1,9 +1,10 @@ use super::{endpoint::*, project_owner::*, protocol::*}; use std::collections::{BTreeMap, BTreeSet}; use std::fs::{self, File}; +use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{Mutex, MutexGuard}; +use std::sync::{Condvar, Mutex, MutexGuard}; pub(super) struct ExternalAgentRunnerServerState { pub(super) endpoint_path: PathBuf, @@ -16,10 +17,66 @@ pub(super) struct ExternalAgentRunnerServerState { pub(super) known_roots: Mutex>, pub(super) gui_owner_lock_path: PathBuf, pub(super) project_execution_owners: - Mutex>, + Mutex>, + project_execution_owner_recovery_changed: Condvar, pub(super) write_request_cache: Mutex, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct ExternalAgentRunnerProjectExecutionOwnerClaim { + pub(super) root: PathBuf, + pub(super) recovered_from_boot_id: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus { + Pending, + Running, + Completed, +} + +pub(super) struct ExternalAgentRunnerProjectExecutionOwnerEntry { + _owner: ExternalAgentRunnerProjectExecutionOwner, + recovered_from_boot_id: Option, + recovery_status: ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus, +} + +struct ExternalAgentRunnerProjectExecutionOwnerRecoveryGuard<'a> { + state: &'a ExternalAgentRunnerServerState, + root: PathBuf, + running: bool, +} + +impl ExternalAgentRunnerProjectExecutionOwnerRecoveryGuard<'_> { + fn complete(mut self) { + self.update_status(ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Completed); + self.running = false; + } + + fn update_status(&self, status: ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus) { + let mut owners = lock_unpoisoned(&self.state.project_execution_owners); + if let Some(entry) = owners.get_mut(&self.root) { + if entry.recovery_status + == ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Running + { + entry.recovery_status = status; + } + } + drop(owners); + self.state + .project_execution_owner_recovery_changed + .notify_all(); + } +} + +impl Drop for ExternalAgentRunnerProjectExecutionOwnerRecoveryGuard<'_> { + fn drop(&mut self) { + if self.running { + self.update_status(ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Pending); + } + } +} + impl ExternalAgentRunnerServerState { pub(super) fn new(endpoint_path: PathBuf, endpoint: ExternalAgentRunnerEndpoint) -> Self { let gui_owner_lock_path = endpoint_path @@ -37,6 +94,7 @@ impl ExternalAgentRunnerServerState { known_roots: Mutex::new(BTreeSet::new()), gui_owner_lock_path, project_execution_owners: Mutex::new(BTreeMap::new()), + project_execution_owner_recovery_changed: Condvar::new(), write_request_cache: Mutex::new(ExternalAgentRunnerRequestCache::default()), } } @@ -57,28 +115,103 @@ impl ExternalAgentRunnerServerState { lock_unpoisoned(&self.known_roots).iter().cloned().collect() } - pub(super) fn claim_project_execution_owner(&self, root: &Path) -> Result { + pub(super) fn claim_project_execution_owner( + &self, + root: &Path, + ) -> Result { + self.claim_project_execution_owner_with_recovery(root, |root| { + crate::resume_game_creator_agent_background_tasks_at(root).map(|_| ()) + }) + } + + pub(super) fn claim_project_execution_owner_with_recovery( + &self, + root: &Path, + recover: impl Fn(&Path) -> Result<(), String>, + ) -> Result { let root = canonicalize_external_agent_runner_project_root(root)?; let config_dir = self .endpoint_path .parent() .ok_or_else(|| "Agent Runner endpoint 缺少 AppData 父目录".to_string())?; crate::validate_game_creator_runtime_config_dir_outside_project(config_dir, &root)?; - - let mut owners = lock_unpoisoned(&self.project_execution_owners); - if owners.contains_key(&root) { - self.remember_root(&root); - return Ok(root); - } let endpoint = self.endpoint_snapshot(); - let owner = acquire_external_agent_runner_project_execution_owner( - &root, - &endpoint.boot_id, - endpoint.protocol_version, - )?; - owners.insert(root.clone(), owner); - self.remember_root(&root); - Ok(root) + + loop { + let mut owners = lock_unpoisoned(&self.project_execution_owners); + if !owners.contains_key(&root) { + let owner = acquire_external_agent_runner_project_execution_owner( + &root, + &endpoint.boot_id, + endpoint.protocol_version, + )?; + let recovered_from_boot_id = owner.recovered_from_boot_id().map(str::to_string); + let recovery_status = if recovered_from_boot_id.is_some() { + ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Pending + } else { + ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Completed + }; + owners.insert( + root.clone(), + ExternalAgentRunnerProjectExecutionOwnerEntry { + _owner: owner, + recovered_from_boot_id, + recovery_status, + }, + ); + } + + let entry = owners + .get_mut(&root) + .expect("project execution owner entry must exist after acquisition"); + match entry.recovery_status { + ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Completed => { + drop(owners); + self.remember_root(&root); + return Ok(ExternalAgentRunnerProjectExecutionOwnerClaim { + root, + recovered_from_boot_id: None, + }); + } + ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Running => { + owners = + wait_unpoisoned(&self.project_execution_owner_recovery_changed, owners); + drop(owners); + } + ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Pending => { + entry.recovery_status = + ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Running; + let recovered_from_boot_id = entry + .recovered_from_boot_id + .clone() + .expect("pending project owner recovery must name the previous boot"); + drop(owners); + self.remember_root(&root); + + let recovery_guard = ExternalAgentRunnerProjectExecutionOwnerRecoveryGuard { + state: self, + root: root.clone(), + running: true, + }; + match catch_unwind(AssertUnwindSafe(|| recover(&root))) { + Ok(Ok(())) => { + recovery_guard.complete(); + return Ok(ExternalAgentRunnerProjectExecutionOwnerClaim { + root, + recovered_from_boot_id: Some(recovered_from_boot_id), + }); + } + Ok(Err(error)) => return Err(error), + Err(_) => { + return Err( + "Agent Runner 项目恢复回调异常终止,可复用同一 requestId 重试" + .to_string(), + ) + } + } + } + } + } } } @@ -122,6 +255,12 @@ pub(super) struct ExternalAgentRunnerProjectExecutionOwner { pub(super) _record: ExternalAgentRunnerProjectExecutionOwnerRecord, } +impl ExternalAgentRunnerProjectExecutionOwner { + pub(super) fn recovered_from_boot_id(&self) -> Option<&str> { + self._record.recovered_from_boot_id.as_deref() + } +} + pub(super) struct ExternalAgentRunnerEndpointGuard { pub(super) path: PathBuf, pub(super) boot_id: String, @@ -160,3 +299,9 @@ pub(super) fn lock_unpoisoned(mutex: &Mutex) -> MutexGuard<'_, T> { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } + +fn wait_unpoisoned<'a, T>(condvar: &Condvar, guard: MutexGuard<'a, T>) -> MutexGuard<'a, T> { + condvar + .wait(guard) + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} 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 c09ee7e49..719895e28 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 @@ -9,10 +9,15 @@ use std::fs; use std::io::{self, Cursor}; use std::net::{Ipv4Addr, SocketAddrV4, TcpListener}; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Mutex; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc, Barrier, Mutex}; use std::time::{Duration, Instant}; +use crate::{ + default_agent_runtime_run_profile, unix_timestamp, AgentRuntimeTaskRecord, + ProcessSessionRecord, AGENT_RUNTIME_SCHEMA_VERSION, +}; + static TEST_DIRECTORY_COUNTER: AtomicU64 = AtomicU64::new(0); struct TestDirectoryGuard(PathBuf); @@ -2590,9 +2595,10 @@ fn project_execution_owner_is_unique_across_appdata_and_records_recovery() { ), ); - state_a + let first_claim = state_a .claim_project_execution_owner(&root) .expect("first appdata owns project"); + assert!(first_claim.recovered_from_boot_id.is_none()); let conflict = state_b .claim_project_execution_owner(&root) .expect_err("second appdata must not own the same project"); @@ -2602,7 +2608,11 @@ fn project_execution_owner_is_unique_across_appdata_and_records_recovery() { let mut recovered = false; for attempt in 0..100 { match state_b.claim_project_execution_owner(&root) { - Ok(_) => { + Ok(claim) => { + assert_eq!( + claim.recovered_from_boot_id.as_deref(), + Some("owner-boot-a") + ); recovered = true; break; } @@ -2636,6 +2646,567 @@ fn project_execution_owner_is_unique_across_appdata_and_records_recovery() { .expect("project owner diagnostic must match TokenUser SID"); } +#[test] +fn project_execution_owner_cross_boot_claim_recovers_durable_runtime_once() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-owner-auto-recovery", + "Runner owner 自动恢复测试", + ) + .expect("initialize owner auto recovery project"); + let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare owner auto recovery appdata"); + let previous = acquire_external_agent_runner_project_execution_owner( + &root, + "owner-auto-recovery-boot-a", + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + ) + .expect("acquire previous owner"); + drop(previous); + + let state = ExternalAgentRunnerServerState::new( + appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint( + "owner-auto-recovery-token-owner-auto-recovery-token", + "owner-auto-recovery-boot-b", + 41003, + ), + ); + let task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: "design-director".to_string(), + session_id: "agent-session-design-director".to_string(), + run_id: "owner-auto-recovery-run".to_string(), + source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "跨 boot 恢复 durable task".to_string(), + status: "running".to_string(), + phase: "planning".to_string(), + current_action: "上一 boot 正在规划".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp().saturating_sub(60), + }; + let task_path = root.join(".agent/runtime/tasks/design-director.jsonl"); + fs::create_dir_all(task_path.parent().expect("task parent")).expect("runtime task dir"); + fs::write( + &task_path, + format!( + "{}\n", + serde_json::to_string(&task).expect("serialize durable task") + ), + ) + .expect("write durable task"); + let mut runtime = crate::agent_runtime_state_from_task_record(&task); + crate::write_game_creator_agent_runtime_state(&root, &runtime) + .expect("write stale runtime state"); + let project_id = crate::game_creator_agent_runtime_context_project_id(&root) + .expect("read runtime project id"); + let process_id = "proc-0123456789abcdef0123456789abcdef"; + let now = unix_timestamp(); + let process_record = ProcessSessionRecord { + schema_version: "3".to_string(), + project_id, + agent_id: task.agent_id.clone(), + task_id: task.task_id.clone(), + conversation_session_id: task.session_id.clone(), + run_id: task.run_id.clone(), + start_action_id: "action-0123456789abcdef01234567".to_string(), + start_action_fingerprint: "a".repeat(64), + process_id: process_id.to_string(), + owner_boot_id: "owner-auto-recovery-boot-a".to_string(), + command_id: "command-owner-auto-recovery".to_string(), + program: "node".to_string(), + cwd: ".".to_string(), + sandbox_backend: "test-unknown".to_string(), + sandbox_mode: "unknown".to_string(), + network_access: "unknown".to_string(), + sandbox_profile_version: "test-v1".to_string(), + sandbox_establishment: "established".to_string(), + target_exec: "established".to_string(), + launch_failure_kind: None, + sandbox_ready_at: Some(now), + exec_established_at: Some(now), + status: "running".to_string(), + exit_code: None, + signal: None, + stdin_open: true, + output_bytes: 0, + output_sha256: format!("{:x}", Sha256::digest([])), + output_ref: None, + source_fingerprint_before: "b".repeat(64), + source_fingerprint_after: None, + source_changed: None, + needs_reconciliation: false, + started_at: now, + terminal_at: None, + updated_at: now, + }; + let process_path = root.join(format!(".agent/runtime/process-sessions/{process_id}.json")); + fs::create_dir_all(process_path.parent().expect("process session parent")) + .expect("process session directory"); + fs::write( + &process_path, + serde_json::to_vec_pretty(&process_record).expect("serialize process session"), + ) + .expect("write previous boot process session"); + + let first = state + .claim_project_execution_owner(&root) + .expect("cross boot claim automatically scans recovery"); + assert_eq!( + first.recovered_from_boot_id.as_deref(), + Some("owner-auto-recovery-boot-a") + ); + runtime = crate::read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read reconciled runtime") + .state; + assert_eq!(runtime.run_id, "owner-auto-recovery-run"); + assert_eq!(runtime.phase, "needs-reconciliation"); + assert!(runtime + .error + .as_deref() + .is_some_and(|error| error.contains("禁止按 PID 重连或重放启动动作"))); + let process = serde_json::from_slice::( + &fs::read(&process_path).expect("read reconciled process session"), + ) + .expect("parse reconciled process session"); + assert_eq!(process.status, "needs-reconciliation"); + assert!(process.needs_reconciliation); + let records = fs::read_to_string(root.join(".agent/agent.db")).expect("read recovery audit"); + assert_eq!( + records + .matches("agent.runtime.process_session.reconciled_after_runner_restart") + .count(), + 1 + ); + assert!(!records.contains("agent.runtime.tool_action.executing")); + assert!(!records.contains("agent.runtime.agent.delegate")); + let task_records_before = fs::read_to_string(&task_path).expect("read reconciled task journal"); + let task_record_count_before = task_records_before + .lines() + .filter(|line| !line.trim().is_empty()) + .count(); + let conversation_directory = root.join(".agent/conversations"); + let conversation_snapshot_before = if conversation_directory.exists() { + let mut files = fs::read_dir(&conversation_directory) + .expect("read conversation directory") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_file()) + .map(|path| { + ( + path.clone(), + fs::read(path).expect("read conversation file"), + ) + }) + .collect::>(); + files.sort_by(|left, right| left.0.cmp(&right.0)); + files + } else { + Vec::new() + }; + + let second = state + .claim_project_execution_owner(&root) + .expect("same boot claim is idempotent"); + assert!(second.recovered_from_boot_id.is_none()); + let records_after = + fs::read_to_string(root.join(".agent/agent.db")).expect("read idempotent recovery audit"); + assert_eq!(records_after, records); + let task_records_after = + fs::read_to_string(&task_path).expect("reread reconciled task journal"); + assert_eq!( + task_records_after + .lines() + .filter(|line| !line.trim().is_empty()) + .count(), + task_record_count_before, + "same-boot reentry must not duplicate durable task/child projection" + ); + let conversation_snapshot_after = if conversation_directory.exists() { + let mut files = fs::read_dir(&conversation_directory) + .expect("reread conversation directory") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_file()) + .map(|path| { + ( + path.clone(), + fs::read(path).expect("reread conversation file"), + ) + }) + .collect::>(); + files.sort_by(|left, right| left.0.cmp(&right.0)); + files + } else { + Vec::new() + }; + assert_eq!(conversation_snapshot_after, conversation_snapshot_before); +} + +#[test] +fn project_execution_owner_recovery_runs_after_owner_map_lock_release() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-owner-lock-order", + "Runner owner 恢复锁顺序测试", + ) + .expect("initialize owner lock order project"); + let previous = acquire_external_agent_runner_project_execution_owner( + &root, + "owner-lock-order-boot-a", + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + ) + .expect("acquire previous owner"); + drop(previous); + let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare owner lock order appdata"); + let state = ExternalAgentRunnerServerState::new( + appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint( + "owner-lock-order-token-owner-lock-order-token", + "owner-lock-order-boot-b", + 41004, + ), + ); + + let claim = state + .claim_project_execution_owner_with_recovery(&root, |claimed_root| { + let owners = state + .project_execution_owners + .try_lock() + .expect("recovery callback must run after owner-map lock release"); + assert!(owners.contains_key(claimed_root)); + Ok(()) + }) + .expect("claim with lock-order probe"); + assert_eq!( + claim.recovered_from_boot_id.as_deref(), + Some("owner-lock-order-boot-a") + ); +} + +#[test] +fn project_execution_owner_recovery_failure_retries_in_same_boot() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-owner-recovery-retry", + "Runner owner 恢复重试测试", + ) + .expect("initialize owner recovery retry project"); + let previous = acquire_external_agent_runner_project_execution_owner( + &root, + "owner-recovery-retry-boot-a", + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + ) + .expect("acquire previous owner"); + drop(previous); + let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare owner recovery retry appdata"); + let state = ExternalAgentRunnerServerState::new( + appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint( + "owner-recovery-retry-token-owner-retry-token", + "owner-recovery-retry-boot-b", + 41005, + ), + ); + let recoveries = AtomicUsize::new(0); + + let first = state + .claim_project_execution_owner_with_recovery(&root, |_| { + recoveries.fetch_add(1, Ordering::SeqCst); + Err("injected recovery failure".to_string()) + }) + .expect_err("first recovery attempt must surface its failure"); + assert!(first.contains("injected recovery failure")); + assert_eq!(recoveries.load(Ordering::SeqCst), 1); + + let second = state + .claim_project_execution_owner_with_recovery(&root, |_| { + recoveries.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + .expect("same boot must retry failed recovery"); + assert_eq!( + second.recovered_from_boot_id.as_deref(), + Some("owner-recovery-retry-boot-a") + ); + assert_eq!(recoveries.load(Ordering::SeqCst), 2); + + let third = state + .claim_project_execution_owner_with_recovery(&root, |_| { + recoveries.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + .expect("completed recovery remains idempotent"); + assert!(third.recovered_from_boot_id.is_none()); + assert_eq!(recoveries.load(Ordering::SeqCst), 2); +} + +#[test] +fn project_execution_owner_concurrent_claims_single_flight_recovery() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-owner-recovery-single-flight", + "Runner owner 单飞恢复测试", + ) + .expect("initialize owner recovery single-flight project"); + let previous = acquire_external_agent_runner_project_execution_owner( + &root, + "owner-recovery-single-flight-boot-a", + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + ) + .expect("acquire previous owner"); + drop(previous); + let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare owner recovery single-flight appdata"); + let state = Arc::new(ExternalAgentRunnerServerState::new( + appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint( + "owner-recovery-single-flight-token-single-flight", + "owner-recovery-single-flight-boot-b", + 41006, + ), + )); + let recoveries = Arc::new(AtomicUsize::new(0)); + let recovery_entered = Arc::new(Barrier::new(2)); + let (release_recovery_tx, release_recovery_rx) = mpsc::channel::<()>(); + + let first_state = Arc::clone(&state); + let first_root = root.clone(); + let first_recoveries = Arc::clone(&recoveries); + let first_recovery_entered = Arc::clone(&recovery_entered); + let first = std::thread::spawn(move || { + first_state.claim_project_execution_owner_with_recovery(&first_root, |_| { + first_recoveries.fetch_add(1, Ordering::SeqCst); + first_recovery_entered.wait(); + release_recovery_rx + .recv_timeout(Duration::from_secs(5)) + .expect("test must release the active recovery"); + Ok(()) + }) + }); + recovery_entered.wait(); + + let second_state = Arc::clone(&state); + let second_root = root.clone(); + let second_recoveries = Arc::clone(&recoveries); + let (second_started_tx, second_started_rx) = mpsc::channel(); + let second = std::thread::spawn(move || { + second_started_tx.send(()).expect("signal second claim"); + second_state.claim_project_execution_owner_with_recovery(&second_root, |_| { + second_recoveries.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + }); + second_started_rx + .recv_timeout(Duration::from_secs(5)) + .expect("second claim must start"); + std::thread::sleep(Duration::from_millis(50)); + assert_eq!( + recoveries.load(Ordering::SeqCst), + 1, + "concurrent claim must wait instead of duplicating recovery" + ); + + release_recovery_tx + .send(()) + .expect("release active recovery"); + let first_claim = first + .join() + .expect("first claim thread") + .expect("first claim"); + let second_claim = second + .join() + .expect("second claim thread") + .expect("second claim"); + assert_eq!( + first_claim.recovered_from_boot_id.as_deref(), + Some("owner-recovery-single-flight-boot-a") + ); + assert!(second_claim.recovered_from_boot_id.is_none()); + assert_eq!(recoveries.load(Ordering::SeqCst), 1); +} + +#[test] +fn project_execution_owner_recovery_panic_resets_running_and_wakes_waiter() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-owner-recovery-panic", + "Runner owner 恢复 panic 测试", + ) + .expect("initialize owner recovery panic project"); + let previous = acquire_external_agent_runner_project_execution_owner( + &root, + "owner-recovery-panic-boot-a", + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + ) + .expect("acquire previous owner"); + drop(previous); + let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare owner recovery panic appdata"); + let state = Arc::new(ExternalAgentRunnerServerState::new( + appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint( + "owner-recovery-panic-token-owner-panic-token", + "owner-recovery-panic-boot-b", + 41007, + ), + )); + let recoveries = Arc::new(AtomicUsize::new(0)); + let (first_entered_tx, first_entered_rx) = mpsc::channel(); + let (release_first_tx, release_first_rx) = mpsc::channel(); + + let first_state = Arc::clone(&state); + let first_root = root.clone(); + let first_recoveries = Arc::clone(&recoveries); + let first = std::thread::spawn(move || { + first_state.claim_project_execution_owner_with_recovery(&first_root, |_| { + first_recoveries.fetch_add(1, Ordering::SeqCst); + first_entered_tx.send(()).expect("signal panic recovery"); + release_first_rx + .recv_timeout(Duration::from_secs(5)) + .expect("release panic recovery"); + panic!("injected recovery callback panic"); + }) + }); + first_entered_rx + .recv_timeout(Duration::from_secs(5)) + .expect("first recovery must enter"); + + let second_state = Arc::clone(&state); + let second_root = root.clone(); + let second_recoveries = Arc::clone(&recoveries); + let (second_started_tx, second_started_rx) = mpsc::channel(); + let second = std::thread::spawn(move || { + second_started_tx.send(()).expect("signal waiting claim"); + second_state.claim_project_execution_owner_with_recovery(&second_root, |_| { + second_recoveries.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + }); + second_started_rx + .recv_timeout(Duration::from_secs(5)) + .expect("waiting claim must start"); + std::thread::sleep(Duration::from_millis(50)); + assert_eq!( + recoveries.load(Ordering::SeqCst), + 1, + "waiter must not run while the first recovery is Running" + ); + + release_first_tx + .send(()) + .expect("release the panicking recovery"); + let first_error = first + .join() + .expect("panic must be contained inside owner recovery") + .expect_err("panicking recovery must fail its own claim"); + assert!(first_error.contains("恢复回调异常终止")); + let second_claim = second + .join() + .expect("waiting claim thread must be woken") + .expect("waiting claim must retry recovery"); + assert_eq!( + second_claim.recovered_from_boot_id.as_deref(), + Some("owner-recovery-panic-boot-a") + ); + assert_eq!(recoveries.load(Ordering::SeqCst), 2); +} + +#[test] +fn project_execution_owner_recovery_runs_without_request_cache_and_failure_is_retryable() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-owner-request-cache", + "Runner owner 请求缓存锁测试", + ) + .expect("initialize owner request cache project"); + let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare owner request cache appdata"); + let token = "owner-request-cache-token-owner-request-cache-token"; + let state = ExternalAgentRunnerServerState::new( + appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint(token, "owner-request-cache-boot", 41008), + ); + let request = ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "owner-request-cache-request".to_string(), + token: token.to_string(), + method: "runtime.resume".to_string(), + params: ExternalAgentRunnerRequestParams { + root: Some(root.to_string_lossy().into_owned()), + ..ExternalAgentRunnerRequestParams::default() + }, + }; + let attempts = AtomicUsize::new(0); + + let first = + dispatch_external_agent_runner_runtime_request_with_owner_claim(&request, &state, |_| { + attempts.fetch_add(1, Ordering::SeqCst); + let cache = state + .write_request_cache + .try_lock() + .expect("owner recovery must not run under the global request-cache mutex"); + assert!(cache.find(&request.request_id).is_none()); + Err("injected owner recovery failure".to_string()) + }); + assert!(!first.ok); + assert_eq!( + first.error.as_ref().map(|error| error.code.as_str()), + Some("project-execution-owned") + ); + assert!(lock_unpoisoned(&state.write_request_cache) + .find(&request.request_id) + .is_none()); + + let second = dispatch_external_agent_runner_runtime_request_with_owner_claim( + &request, + &state, + |requested_root| { + attempts.fetch_add(1, Ordering::SeqCst); + Ok(ExternalAgentRunnerProjectExecutionOwnerClaim { + root: canonicalize_external_agent_runner_project_root(requested_root)?, + recovered_from_boot_id: Some("previous-boot".to_string()), + }) + }, + ); + assert!(second.ok, "same requestId retry failed: {:?}", second.error); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + + let replay = dispatch_external_agent_runner_runtime_request_with_owner_claim( + &request, + &state, + |_| -> Result { + panic!("cached replay must not claim the owner again") + }, + ); + assert_eq!(replay, second); + assert_eq!(attempts.load(Ordering::SeqCst), 2); +} + #[test] fn corrupt_legacy_owner_diagnostic_does_not_block_lock_recovery() { let directory = unique_test_directory(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs index 6e80fdbba..9bbdae1bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs @@ -31,6 +31,7 @@ pub(crate) fn run_game_creator_swarm_chat_at( root: &Path, parent_agent_id: &str, run_profile: &str, + supervisor_source: &'static str, ) -> Result<(), String> { let (input_tx, input_rx) = mpsc::channel(); std::thread::spawn(move || { @@ -64,6 +65,7 @@ pub(crate) fn run_game_creator_swarm_chat_at( root, parent_agent_id, run_profile, + supervisor_source, &input_rx, &mut output, ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs index cc4bb3c7a..dd4c51ee4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs @@ -44,9 +44,23 @@ pub(super) enum SwarmNewRunLaunch<'a> { ExplicitParentDebug, } +impl SwarmNewRunLaunch<'_> { + pub(super) fn expected_parent_source(self) -> Option<&'static str> { + match self { + Self::ProjectSupervisor { source, .. } + if source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE => + { + Some(source) + } + Self::ProjectSupervisor { .. } | Self::ExplicitParentDebug => None, + } + } +} + pub(super) fn resolve_swarm_new_run_launch<'a>( parent_agent_id: &str, run_profile: &'a str, + supervisor_source: &'static str, ) -> Result, String> { if !matches!( run_profile, @@ -55,11 +69,27 @@ pub(super) fn resolve_swarm_new_run_launch<'a>( return Err(format!("不支持的 Agent Runtime Run Profile:{run_profile}")); } if parent_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + if !matches!( + supervisor_source, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE | AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + ) { + return Err(format!( + "不支持的 Project Supervisor source:{supervisor_source}" + )); + } + if supervisor_source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + && run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + { + return Err("game-chat smoke source 仅支持 autonomous-game-build".to_string()); + } return Ok(SwarmNewRunLaunch::ProjectSupervisor { - source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + source: supervisor_source, run_profile, }); } + if supervisor_source != AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE { + return Err("game-chat smoke source 仅支持 project-supervisor 总控入口".to_string()); + } if run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD { return Err("--autonomous-game-build 仅支持 project-supervisor 总控入口".to_string()); } @@ -70,6 +100,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input( root: &Path, parent_agent_id: &str, run_profile: &str, + supervisor_source: &'static str, input: &Receiver, output: &mut W, ) -> Result<(), String> { @@ -77,7 +108,9 @@ pub(super) fn run_game_creator_swarm_chat_with_input( if parent_agent_id.is_empty() { return Err("parentAgentId 不能为空".to_string()); } - let new_run_launch = resolve_swarm_new_run_launch(parent_agent_id, run_profile)?; + let new_run_launch = + resolve_swarm_new_run_launch(parent_agent_id, run_profile, supervisor_source)?; + let expected_parent_source = new_run_launch.expected_parent_source(); enforce_project_permission_policy(root, "conversation.read")?; enforce_project_permission_policy(root, "conversation.write")?; enforce_project_permission_policy(root, "agent.run_status")?; @@ -103,6 +136,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input( parent_agent_id, active_session_id, run_profile, + expected_parent_source, &existing_runtimes, ) .is_some_and(runtime_is_busy); @@ -152,6 +186,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input( parent_agent_id, &observation.session_id, run_profile, + expected_parent_source, conversation_baseline, input, output, @@ -177,6 +212,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input( parent_agent_id, session_id, run_profile, + expected_parent_source, before.messages.len(), input, output, diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs index 155ac9ac3..129d9a255 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs @@ -25,12 +25,14 @@ pub(super) fn swarm_parent_runtime<'a>( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, runtimes: &'a [AgentRuntimeResult], ) -> Option<&'a AgentRuntimeResult> { runtimes.iter().find(|runtime| { runtime.state.agent_id == parent_agent_id && runtime.state.session_id == session_id && runtime.state.run_profile == run_profile + && expected_source.is_none_or(|source| runtime.state.source == source) }) } @@ -38,21 +40,36 @@ pub(super) fn swarm_parent_runtime_for_run<'a>( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, expected_run_id: &str, runtimes: &'a [AgentRuntimeResult], ) -> Option<&'a AgentRuntimeResult> { - swarm_parent_runtime(parent_agent_id, session_id, run_profile, runtimes) - .filter(|runtime| runtime.state.run_id == expected_run_id) + swarm_parent_runtime( + parent_agent_id, + session_id, + run_profile, + expected_source, + runtimes, + ) + .filter(|runtime| runtime.state.run_id == expected_run_id) } pub(super) fn swarm_parent_steer_target<'a>( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, expected_run_id: Option<&str>, runtimes: &'a [AgentRuntimeResult], ) -> Option<&'a AgentRuntimeResult> { - swarm_parent_runtime(parent_agent_id, session_id, run_profile, runtimes).filter(|runtime| { + swarm_parent_runtime( + parent_agent_id, + session_id, + run_profile, + expected_source, + runtimes, + ) + .filter(|runtime| { expected_run_id.is_none_or(|run_id| runtime.state.run_id == run_id) && game_creator_agent_runtime_accepts_steer(&runtime.state) }) @@ -62,6 +79,7 @@ pub(super) fn matching_pending_swarm_run_id<'a>( runtime: &'a AgentRuntimeResult, session_id: &str, run_profile: &str, + expected_source: Option<&str>, message: &str, ) -> Option<&'a str> { let message = message.trim(); @@ -72,6 +90,7 @@ pub(super) fn matching_pending_swarm_run_id<'a>( .find(|task| { task.session_id == session_id && task.run_profile == run_profile + && expected_source.is_none_or(|source| task.source == source) && task.status == "pending" && task.phase == "queued" && task.task.trim() == message @@ -83,10 +102,12 @@ pub(super) fn next_pending_swarm_task<'a>( runtime: &'a AgentRuntimeResult, session_id: &str, run_profile: &str, + expected_source: Option<&str>, ) -> Option<&'a AgentRuntimeTaskRecord> { runtime.recent_tasks.iter().find(|task| { task.session_id == session_id && task.run_profile == run_profile + && expected_source.is_none_or(|source| task.source == source) && task.status == "pending" && task.phase == "queued" }) @@ -96,14 +117,23 @@ pub(super) fn swarm_parent_task_for_run<'a>( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, expected_run_id: &str, runtimes: &'a [AgentRuntimeResult], ) -> Option<&'a AgentRuntimeTaskRecord> { - swarm_parent_runtime(parent_agent_id, session_id, run_profile, runtimes).and_then(|runtime| { + swarm_parent_runtime( + parent_agent_id, + session_id, + run_profile, + expected_source, + runtimes, + ) + .and_then(|runtime| { runtime.recent_tasks.iter().find(|task| { task.agent_id == parent_agent_id && task.session_id == session_id && task.run_profile == run_profile + && expected_source.is_none_or(|source| task.source == source) && task.run_id == expected_run_id }) }) @@ -147,6 +177,7 @@ pub(super) fn swarm_parent_runtime_snapshot_for_run( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, expected_run_id: &str, runtimes: &[AgentRuntimeResult], ) -> Result, String> { @@ -154,6 +185,7 @@ pub(super) fn swarm_parent_runtime_snapshot_for_run( parent_agent_id, session_id, run_profile, + expected_source, expected_run_id, runtimes, ) { @@ -167,6 +199,7 @@ pub(super) fn swarm_parent_runtime_snapshot_for_run( if task.agent_id != parent_agent_id || task.session_id != session_id || task.run_profile != run_profile + || expected_source.is_some_and(|source| task.source != source) || task.run_id != expected_run_id { return Err("Swarm parent task journal 与目标 Session/run 不匹配".to_string()); @@ -177,6 +210,7 @@ pub(super) fn swarm_parent_runtime_snapshot_for_run( parent_agent_id, session_id, run_profile, + expected_source, expected_run_id, runtimes, ) @@ -188,6 +222,7 @@ pub(super) fn swarm_turn_is_busy( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, expected_run_id: &str, runtimes: &[AgentRuntimeResult], ) -> Result { @@ -196,6 +231,7 @@ pub(super) fn swarm_turn_is_busy( parent_agent_id, session_id, run_profile, + expected_source, expected_run_id, runtimes, )? @@ -216,6 +252,7 @@ pub(super) fn swarm_current_runtimes_for_run( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, expected_run_id: &str, runtimes: &[AgentRuntimeResult], ) -> Vec { @@ -225,6 +262,7 @@ pub(super) fn swarm_current_runtimes_for_run( (runtime.state.agent_id == parent_agent_id && runtime.state.session_id == session_id && runtime.state.run_profile == run_profile + && expected_source.is_none_or(|source| runtime.state.source == source) && runtime.state.run_id == expected_run_id) || (runtime.state.parent_agent_id.as_deref() == Some(parent_agent_id) && runtime.state.parent_run_id.as_deref() == Some(expected_run_id)) @@ -336,6 +374,7 @@ pub(super) fn scan_swarm_terminal_failures_at( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, expected_parent_run_id: &str, runtimes: &[AgentRuntimeResult], ) -> SwarmTerminalFailureScan { @@ -344,6 +383,7 @@ pub(super) fn scan_swarm_terminal_failures_at( parent_agent_id, session_id, run_profile, + expected_source, expected_parent_run_id, runtimes, ); @@ -356,6 +396,7 @@ pub(super) fn scan_swarm_terminal_failures_at( if task.agent_id == parent_agent_id && task.session_id == session_id && task.run_profile == run_profile + && expected_source.is_none_or(|source| task.source == source) && task.run_id == expected_parent_run_id => { Some(task) @@ -375,6 +416,7 @@ pub(super) fn scan_swarm_terminal_failures_at( parent_agent_id, session_id, run_profile, + expected_source, expected_parent_run_id, runtimes, ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs index 05e6e3049..ba88f0e55 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs @@ -64,8 +64,12 @@ fn new_supervisor_runs_fix_cli_source_and_select_requested_profile() { AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, ] { assert_eq!( - resolve_swarm_new_run_launch(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_profile,) - .expect("resolve supervisor launch"), + resolve_swarm_new_run_launch( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_profile, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + ) + .expect("resolve supervisor launch"), SwarmNewRunLaunch::ProjectSupervisor { source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, run_profile, @@ -77,20 +81,61 @@ fn new_supervisor_runs_fix_cli_source_and_select_requested_profile() { #[test] fn explicit_parent_debug_keeps_standard_profile_only() { assert_eq!( - resolve_swarm_new_run_launch("code-prototype", AGENT_RUNTIME_RUN_PROFILE_STANDARD,) - .expect("resolve explicit parent debug launch"), + resolve_swarm_new_run_launch( + "code-prototype", + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + ) + .expect("resolve explicit parent debug launch"), SwarmNewRunLaunch::ExplicitParentDebug, ); assert!(resolve_swarm_new_run_launch( "code-prototype", AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, ) .expect_err("autonomous profile must stay on the supervisor root run") .contains(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)); - assert!( - resolve_swarm_new_run_launch(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "unsupported",) - .is_err() + assert!(resolve_swarm_new_run_launch( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "unsupported", + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + ) + .is_err()); +} + +#[test] +fn restricted_game_chat_smoke_launch_selects_trusted_single_main_source() { + assert_eq!( + resolve_swarm_new_run_launch( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ) + .expect("resolve restricted game-chat smoke launch"), + SwarmNewRunLaunch::ProjectSupervisor { + source: AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + run_profile: AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + } ); + assert!(resolve_swarm_new_run_launch( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ) + .is_err()); + assert!(resolve_swarm_new_run_launch( + "code-prototype", + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ) + .is_err()); + assert!(resolve_swarm_new_run_launch( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + "untrusted-supervisor-source", + ) + .is_err()); } #[test] @@ -270,6 +315,7 @@ fn parent_runtime_matching_is_scoped_to_requested_profile() { GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "session-profile-match", AGENT_RUNTIME_RUN_PROFILE_STANDARD, + None, &runtimes, ) .map(|runtime| runtime.state.run_id.as_str()), @@ -280,6 +326,7 @@ fn parent_runtime_matching_is_scoped_to_requested_profile() { GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "session-profile-match", AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + None, &runtimes, ) .map(|runtime| runtime.state.run_id.as_str()), @@ -287,6 +334,72 @@ fn parent_runtime_matching_is_scoped_to_requested_profile() { ); } +#[test] +fn game_chat_smoke_does_not_steer_a_cli_source_runtime() { + let session_id = "session-source-isolation"; + let run_profile = AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD; + let mut old_cli = runtime("running", "planning", 0); + old_cli.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); + old_cli.state.session_id = session_id.to_string(); + old_cli.state.run_id = "run-old-cli".to_string(); + old_cli.state.run_profile = run_profile.to_string(); + old_cli.state.source = AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE.to_string(); + old_cli.recent_tasks.push( + serde_json::from_value(serde_json::json!({ + "agentId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "taskId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "sessionId": session_id, + "runId": "run-old-cli-pending", + "source": AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "runProfile": run_profile, + "task": "生成完整可玩游戏", + "status": "pending", + "phase": "queued" + })) + .expect("deserialize old CLI pending task"), + ); + let runtimes = vec![old_cli]; + + assert!(swarm_parent_steer_target( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + session_id, + run_profile, + Some(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE), + None, + &runtimes, + ) + .is_none()); + assert!(swarm_parent_runtime( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + session_id, + run_profile, + Some(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE), + &runtimes, + ) + .is_none()); + assert!(matching_pending_swarm_run_id( + &runtimes[0], + session_id, + run_profile, + Some(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE), + "生成完整可玩游戏", + ) + .is_none()); + assert_eq!( + swarm_parent_steer_target( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + session_id, + run_profile, + None, + None, + &runtimes, + ) + .map(|runtime| runtime.state.run_id.as_str()), + Some("run-old-cli"), + "ordinary CLI lookup must preserve its existing source-agnostic behavior" + ); +} + #[test] fn completed_parent_with_pending_task_is_busy_but_not_a_steer_target() { let session_id = "session-pending-after-completed"; @@ -319,13 +432,20 @@ fn completed_parent_with_pending_task_is_busy_but_not_a_steer_target() { session_id, run_profile, None, + None, &runtimes, ) .is_none(), "queued work must not make the completed canonical run steerable" ); assert_eq!( - matching_pending_swarm_run_id(&runtimes[0], session_id, run_profile, "继续补齐游戏功能",), + matching_pending_swarm_run_id( + &runtimes[0], + session_id, + run_profile, + None, + "继续补齐游戏功能", + ), Some("run-pending-after-completed") ); } @@ -465,6 +585,7 @@ fn completed_target_turn_settles_after_canonical_advances_to_next_run() { GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, session_id, run_profile, + None, "run-target-completed", &runtimes, ) @@ -474,6 +595,7 @@ fn completed_target_turn_settles_after_canonical_advances_to_next_run() { GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, session_id, run_profile, + None, "run-next-running", &runtimes, ) @@ -483,6 +605,7 @@ fn completed_target_turn_settles_after_canonical_advances_to_next_run() { GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, session_id, run_profile, + None, "run-target-completed", &runtimes, ) @@ -509,6 +632,7 @@ fn running_parent_remains_the_only_valid_swarm_steer_target() { GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, session_id, run_profile, + None, Some("run-running-steer"), &runtimes, ) @@ -519,6 +643,7 @@ fn running_parent_remains_the_only_valid_swarm_steer_target() { GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, session_id, run_profile, + None, Some("another-run"), &runtimes, ) @@ -807,6 +932,7 @@ fn active_turn_eof_keeps_observing_until_parent_completes() { parent_agent_id, &session_id, AGENT_RUNTIME_RUN_PROFILE_STANDARD, + None, conversation_baseline, &rx, &mut output, @@ -1083,6 +1209,7 @@ fn wait_for_turn_keeps_consecutive_run_reply_and_report_scoped() { parent_agent_id, &session_id, AGENT_RUNTIME_RUN_PROFILE_STANDARD, + None, baseline, &rx, &mut output, @@ -1338,6 +1465,7 @@ fn observer_failure_scan_waits_for_original_repair_and_fails_repair_child() { &parent.state.agent_id, &parent.state.session_id, AGENT_RUNTIME_RUN_PROFILE_STANDARD, + None, &parent.state.run_id, &[parent.clone(), child.clone()], ); @@ -1366,6 +1494,7 @@ fn observer_failure_scan_waits_for_original_repair_and_fails_repair_child() { &parent.state.agent_id, &parent.state.session_id, AGENT_RUNTIME_RUN_PROFILE_STANDARD, + None, &parent.state.run_id, &[parent.clone(), child], ); @@ -1528,6 +1657,7 @@ fn failure_scan_reads_all_historical_runs_for_the_same_specialist() { &parent.state.agent_id, &parent.state.session_id, AGENT_RUNTIME_RUN_PROFILE_STANDARD, + None, &parent.state.run_id, &[parent.clone(), current_specialist, current_quality], ); @@ -1575,6 +1705,7 @@ fn failure_scan_ignores_cancelled_parent_and_children_from_another_run() { &old_parent.state.agent_id, session_id, run_profile, + None, "run-new-pending", &runtimes, ); @@ -1587,6 +1718,7 @@ fn failure_scan_ignores_cancelled_parent_and_children_from_another_run() { &old_parent.state.agent_id, session_id, run_profile, + None, &old_parent.state.run_id, &runtimes, ); 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 531b0b4c6..7d98a5cd6 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 @@ -10,6 +10,7 @@ pub(super) fn handle_swarm_user_turn( input: &Receiver, output: &mut W, ) -> Result { + let expected_parent_source = new_run_launch.expected_parent_source(); let before = read_local_conversation_for_session_at(root, Some(parent_agent_id), Some(session_id))?; let active_goal_run_id = @@ -49,6 +50,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, active_goal_run_id.as_deref(), &runtimes, ) { @@ -57,6 +59,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &runtime.state.run_id, message, before.messages.len(), @@ -75,15 +78,34 @@ pub(super) fn handle_swarm_user_turn( .last() .is_some_and(|item| item.role == "user" && item.content.trim() == message.trim()); let matching_pending_run_id = pending_message_is_latest - .then(|| swarm_parent_runtime(parent_agent_id, session_id, run_profile, &runtimes)) + .then(|| { + swarm_parent_runtime( + parent_agent_id, + session_id, + run_profile, + expected_parent_source, + &runtimes, + ) + }) .flatten() .and_then(|runtime| { - matching_pending_swarm_run_id(runtime, session_id, run_profile, message) + matching_pending_swarm_run_id( + runtime, + session_id, + run_profile, + expected_parent_source, + message, + ) }) .map(str::to_string); - let parent_has_queued_work = - swarm_parent_runtime(parent_agent_id, session_id, run_profile, &runtimes) - .is_some_and(runtime_is_busy); + let parent_has_queued_work = swarm_parent_runtime( + parent_agent_id, + session_id, + run_profile, + expected_parent_source, + &runtimes, + ) + .is_some_and(runtime_is_busy); if parent_has_queued_work { require_external_agent_runner_for_cli_runtime_write(root)?; resume_game_creator_agent_background_tasks_at(root)?; @@ -104,6 +126,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, conversation_baseline, input, output, @@ -114,6 +137,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, active_goal_run_id.as_deref(), &runtimes, ) { @@ -122,6 +146,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &runtime.state.run_id, message, before.messages.len(), @@ -155,6 +180,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, None, ¤t_runtimes, ) { @@ -163,6 +189,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &runtime.state.run_id, message, before.messages.len(), @@ -226,6 +253,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, before.messages.len(), input, output, @@ -339,6 +367,7 @@ fn steer_and_wait_for_swarm_turn( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_parent_source: Option<&str>, run_id: &str, message: &str, previous_message_count: usize, @@ -371,6 +400,7 @@ fn steer_and_wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, conversation_baseline, input, output, @@ -424,6 +454,7 @@ fn start_and_wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + new_run_launch.expected_parent_source(), conversation_baseline, input, output, @@ -448,6 +479,7 @@ pub(super) fn handle_swarm_resume_turn( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_parent_source: Option<&str>, previous_message_count: usize, input: &Receiver, output: &mut W, @@ -459,14 +491,18 @@ pub(super) fn handle_swarm_resume_turn( .map_err(|error| format!("写入终端失败:{error}"))?; } let current_runtimes = read_game_creator_agent_runtimes_at(root)?; - let Some(parent) = - swarm_parent_runtime(parent_agent_id, session_id, run_profile, ¤t_runtimes) - else { + let Some(parent) = swarm_parent_runtime( + parent_agent_id, + session_id, + run_profile, + expected_parent_source, + ¤t_runtimes, + ) else { writeln!(output, "[恢复] 当前 Session 没有可恢复的运行任务。") .map_err(|error| format!("写入终端失败:{error}"))?; return Ok(SwarmChatFlow::Continue); }; - let pending = next_pending_swarm_task(parent, session_id, run_profile); + let pending = next_pending_swarm_task(parent, session_id, run_profile, expected_parent_source); let (target_run_id, target_status, target_phase) = if game_creator_agent_runtime_accepts_steer(&parent.state) || parent.state.status == "pending" @@ -506,6 +542,7 @@ pub(super) fn handle_swarm_resume_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, conversation_baseline, input, output, @@ -517,6 +554,7 @@ fn wait_and_print_swarm_turn( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_parent_source: Option<&str>, conversation_baseline: SwarmTurnConversationBaseline, input: &Receiver, output: &mut W, @@ -527,6 +565,7 @@ fn wait_and_print_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, conversation_baseline, input, output, diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs index 994acd3e2..59d03b910 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 @@ -14,6 +14,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_parent_source: Option<&str>, conversation_baseline: SwarmTurnConversationBaseline, input: &Receiver, output: &mut W, @@ -31,6 +32,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &conversation_baseline.parent_run_id, &runtimes, ); @@ -110,6 +112,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &conversation_baseline.parent_run_id, &runtimes, ); @@ -154,6 +157,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &conversation_baseline.parent_run_id, &runtimes, )? && (!runner.enabled || !runner.running) @@ -175,6 +179,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &conversation_baseline.parent_run_id, &runtimes, )? { @@ -208,6 +213,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &conversation_baseline.parent_run_id, &runtimes, ) @@ -217,6 +223,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &conversation_baseline.parent_run_id, &runtimes, )?; @@ -343,6 +350,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, Some(&conversation_baseline.parent_run_id), &runtimes, ) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index 40679654c..1ebf3f0a2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -1877,6 +1877,395 @@ fn completed_child_final_response_becomes_needs_user_input_delivery_result() { fs::remove_dir_all(root).ok(); } +#[test] +fn game_chat_autonomous_root_converts_child_user_input_request_to_safe_default_repair() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-game-chat-safe-default-repair", + "game-chat 自主构建安全默认返工测试", + ) + .expect("project init"); + let parent_run_id = "game-chat-safe-default-parent-run"; + let parent_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind trusted game-chat root"); + let delivery = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "game-chat-safe-default-parent-session", + parent_run_id, + "game-chat-safe-default-parent-action", + "game-chat-safe-default-delegation", + "design-director", + "game-chat-safe-default-child-session", + "game-chat-safe-default-child-run", + &["自主选择安全默认方案继续交付".to_string()], + &[], + None, + ); + let child_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + &delivery.target_agent_id, + &delivery.target_run_id, + "agent-delegate", + None, + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(delivery.parent_agent_id.clone()), + parent_run_id: Some(delivery.parent_run_id.clone()), + delegation_id: Some(delivery.delegation_id.clone()), + }), + ) + .expect("bind delegated child"); + let sensitive_question = "请把 secret token 和绝对路径 D:/private/game 发给我"; + let response = format!( + "AGC_NEEDS_USER_INPUT_V1\n{}", + serde_json::json!({ + "questions": [{ + "id": "secret_input", + "header": "敏感输入", + "question": sensitive_question, + "options": [ + {"label": "提供", "description": "提供敏感信息。"}, + {"label": "跳过", "description": "使用安全默认值。"} + ] + }] + }) + ); + let child_task = AgentRuntimeTaskRecord { + schema_version: "game-creator-agent-runtime-task.v1".to_string(), + task_id: "design-director".to_string(), + agent_id: delivery.target_agent_id.clone(), + session_id: delivery.target_session_id.clone(), + run_id: delivery.target_run_id.clone(), + source: "agent-delegate".to_string(), + parent_agent_id: Some(delivery.parent_agent_id.clone()), + parent_run_id: Some(delivery.parent_run_id.clone()), + delegation_id: Some(delivery.delegation_id.clone()), + run_profile: child_binding.profile, + run_profile_binding_fingerprint: child_binding.binding_fingerprint, + goal_id: None, + goal_revision: 0, + goal_status: None, + task: "完成自主设计决策".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "已返回澄清请求".to_string(), + terminal_detail: Some(response.clone()), + error: None, + updated_at: unix_timestamp(), + }; + + let mut result = build_static_delegate_result_for_child_at( + &root, + &delivery, + &child_task, + "completed", + &response, + ) + .expect("build trusted game-chat child result"); + let parent_task = AgentRuntimeTaskRecord { + schema_version: "game-creator-agent-runtime-task.v1".to_string(), + task_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + agent_id: delivery.parent_agent_id.clone(), + session_id: delivery.parent_session_id.clone(), + run_id: delivery.parent_run_id.clone(), + source: AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE.to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + run_profile: AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD.to_string(), + run_profile_binding_fingerprint: parent_binding.binding_fingerprint, + goal_id: None, + goal_revision: 0, + goal_status: None, + task: "自主生成游戏".to_string(), + status: "running".to_string(), + phase: "planning".to_string(), + current_action: "等待专业 Agent".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }; + assert!( + trusted_game_chat_autonomous_root_parent_at(&root, &parent_task) + .expect("validate trusted game-chat parent") + ); + assert!(convert_game_chat_child_user_input_to_safe_default_repair( + &mut result + )); + + assert_eq!( + result.contract_status, + StaticDelegateContractStatus::NeedsRepair + ); + assert!(result.user_input_questions.is_empty()); + assert!(result.user_input_questions_sha256.is_none()); + let error = result.error.expect("safe-default repair summary"); + let summary: Value = serde_json::from_str(&error).expect("structured safe-default summary"); + assert_eq!(summary["schemaVersion"], "game-chat-safe-default-repair.v1"); + assert_eq!(summary["code"], "child-needs-user-input"); + assert_eq!(summary["strategy"], "continue-with-safe-defaults"); + assert!(error.chars().count() <= 500); + assert!(!error.contains(sensitive_question)); + assert!(!error.contains("D:/private/game")); + assert!(!error.contains("API Key")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn game_chat_safe_default_decision_is_allowlisted_and_fail_closed() { + let question = |id: &str, header: &str, text: &str| AgentRuntimeUserInputQuestion { + id: id.to_string(), + header: header.to_string(), + question: text.to_string(), + options: vec![ + AgentRuntimeUserInputOption { + label: "默认".to_string(), + description: "使用平台已有的默认值。".to_string(), + }, + AgentRuntimeUserInputOption { + label: "跳过".to_string(), + description: "跳过当前不确定项。".to_string(), + }, + ], + }; + let convert = |question: AgentRuntimeUserInputQuestion| { + let questions = vec![question]; + let sha256 = format!( + "{:x}", + Sha256::digest(serde_json::to_vec(&questions).expect("serialize question")) + ); + let mut result = StaticDelegateStructuredResult { + contract_status: StaticDelegateContractStatus::NeedsUserInput, + user_input_questions: questions, + user_input_questions_sha256: Some(sha256), + ..StaticDelegateStructuredResult::default() + }; + assert!(convert_game_chat_child_user_input_to_safe_default_repair( + &mut result + )); + let marker = + serde_json::from_str::(result.error.as_deref().expect("safe-default marker")) + .expect("parse safe-default marker"); + let instruction = game_chat_safe_default_repair_task_instruction(&result) + .expect("safe-default task instruction"); + (marker, instruction) + }; + + let (preference, preference_instruction) = convert(question( + "visual_preference", + "视觉偏好", + "首版采用明亮还是柔和配色?", + )); + assert_eq!(preference["reasonCode"], "preference-clarification"); + assert_eq!(preference["defaultDecision"], "use-safe-default"); + assert!(preference_instruction.contains("use-safe-default")); + + for question in [ + question("unknown_fact", "补充信息", "请告诉我还缺什么再继续。"), + question("secret", "接入信息", "请提供 API Key 后继续。"), + question("permission", "外部操作", "是否允许将结果发布到外部平台?"), + ] { + let (marker, instruction) = convert(question); + assert_eq!(marker["reasonCode"], "sensitive-or-permission-request"); + assert_eq!(marker["defaultDecision"], "skip-denied"); + assert!(instruction.contains("skip-denied")); + assert!(instruction.contains("不得再次询问用户")); + } +} + +#[test] +fn legacy_claimed_game_chat_user_input_delivery_recovers_as_repair_without_pending_question() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-game-chat-legacy-safe-default-repair", + "game-chat legacy 澄清恢复测试", + ) + .expect("project init"); + let parent_run_id = "game-chat-legacy-safe-default-parent-run"; + let parent_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind trusted game-chat root"); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "自主完成可玩游戏", + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + "等待专业 Agent", + vec!["收齐专业回执".to_string()], + ) + .expect("start trusted game-chat parent"); + state.run_profile = parent_binding.profile.clone(); + state.run_profile_binding_fingerprint = parent_binding.binding_fingerprint.clone(); + write_game_creator_agent_runtime_state(&root, &state).expect("persist bound game-chat state"); + append_game_creator_agent_runtime_task(&root, &state).expect("persist bound game-chat task"); + let delivery = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + parent_run_id, + "game-chat-legacy-parent-action", + &agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + "code-prototype", + "game-chat-legacy-parent-action", + ), + "code-prototype", + "game-chat-legacy-child-session", + "game-chat-legacy-child-run", + &["采用安全默认值继续完成设计".to_string()], + &[], + None, + ); + create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create legacy delivery"); + let child_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + &delivery.target_agent_id, + &delivery.target_run_id, + "agent-delegate", + None, + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(delivery.parent_agent_id.clone()), + parent_run_id: Some(delivery.parent_run_id.clone()), + delegation_id: Some(delivery.delegation_id.clone()), + }), + ) + .expect("bind legacy delegated child"); + let child_task = AgentRuntimeTaskRecord { + schema_version: "game-creator-agent-runtime-task.v1".to_string(), + task_id: delivery.target_agent_id.clone(), + agent_id: delivery.target_agent_id.clone(), + session_id: delivery.target_session_id.clone(), + run_id: delivery.target_run_id.clone(), + source: "agent-delegate".to_string(), + parent_agent_id: Some(delivery.parent_agent_id.clone()), + parent_run_id: Some(delivery.parent_run_id.clone()), + delegation_id: Some(delivery.delegation_id.clone()), + run_profile: child_binding.profile, + run_profile_binding_fingerprint: child_binding.binding_fingerprint, + goal_id: None, + goal_revision: 0, + goal_status: None, + task: "完成自主代码原型".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "已返回澄清请求".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }; + append_game_creator_agent_runtime_task( + &root, + &agent_runtime_state_from_task_record(&child_task), + ) + .expect("persist legacy delegated child task"); + let sensitive_question = "请提供 C:/secret/token.txt 内的 token"; + let result = build_static_delegate_structured_result_at( + &root, + "completed", + &[], + false, + None, + None, + None, + Some(&format!( + "AGC_NEEDS_USER_INPUT_V1\n{}", + serde_json::json!({ + "questions": [{ + "id": "secret_token", + "header": "密钥", + "question": sensitive_question, + "options": [ + {"label": "提供", "description": "读取秘密文件。"}, + {"label": "跳过", "description": "使用安全默认值。"} + ] + }] + }) + )), + ) + .expect("build legacy needs-user-input result"); + mark_static_delegate_delivery_ready_with_result_at( + &root, + &delivery.target_agent_id, + &delivery.target_session_id, + &delivery.target_run_id, + &delivery.delegation_id, + "completed", + "需要敏感用户输入", + result, + ) + .expect("mark legacy delivery ready"); + bind_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind collaboration policy"); + claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + "game-chat-legacy-claim-action", + ) + .expect("claim legacy delivery"); + let deliveries = claimed_static_delegate_deliveries_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read legacy claimed delivery"); + + assert!( + !ensure_static_delegate_user_input_wait_at(&root, &mut state, &deliveries,) + .expect("convert legacy clarification") + ); + let converted = read_static_delegate_delivery_at(&root, &delivery.delegation_id) + .expect("read converted delivery") + .expect("converted delivery exists"); + let converted_result = converted.structured_result.expect("converted result"); + assert_eq!( + converted_result.contract_status, + StaticDelegateContractStatus::NeedsRepair + ); + assert!(converted_result.user_input_questions.is_empty()); + assert!(converted_result.user_input_questions_sha256.is_none()); + let error = converted_result.error.expect("safe-default repair summary"); + assert!(!error.contains(sensitive_question)); + assert!(read_game_creator_agent_runtime_pending_tool_action( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .is_err()); + let barrier = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read converted barrier"); + assert_eq!(barrier.user_input_required_count, 0); + assert_eq!(barrier.repair_required_count, 1); + + fs::remove_dir_all(root).ok(); +} + #[test] fn project_supervisor_run_status_replays_receipts_for_same_action() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs index b06cfa55b..ad5d30974 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs @@ -1,5 +1,65 @@ use super::*; +#[test] +fn static_smoke_finish_rejects_same_revision_entry_rewrite_before_credential_binding() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-static-smoke-toctou", + "静态验证入口摘要时序测试", + ) + .expect("project init"); + fs::write( + root.join("game/index.html"), + fake_llm_game_draft().game_html, + ) + .expect("write initially valid game entry"); + let run_id = "static-smoke-toctou-run"; + let _lock = acquire_project_write_lock(&root, "test.static-smoke.toctou") + .expect("acquire project write lock"); + prepare_agent_runtime_project_mutation_locked( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "file.write", + ) + .expect("prepare project mutation"); + let (expected_revision, gate) = begin_agent_runtime_project_verification_locked( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "game.static_smoke", + ) + .expect("begin static smoke"); + + fs::write( + root.join("game/index.html"), + "unverified replacement", + ) + .expect("replace entry without advancing revision"); + let error = + finish_agent_runtime_project_verification_locked(&root, &expected_revision, gate, true) + .expect_err("replacement bytes must be smoke-validated before credential binding"); + assert!(error.contains("game.static_smoke"), "{error}"); + + let gate = read_game_creator_agent_runtime_verification_gate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read failed static smoke gate"); + assert_eq!(gate.verified_revision, None); + assert_eq!(gate.static_smoke_verified_revision, None); + assert_eq!(gate.static_smoke_verified_game_index_sha256, None); + assert_eq!( + gate.last_verification_status.as_deref(), + Some(AGENT_RUNTIME_VERIFICATION_STATUS_FAILED) + ); + + drop(_lock); + fs::remove_dir_all(root).ok(); +} + #[test] fn failed_autonomous_preview_invalidates_static_smoke_verification_gate() { let root = unique_project_path(); @@ -8,6 +68,11 @@ fn failed_autonomous_preview_invalidates_static_smoke_verification_gate() { write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) .expect("write default collaboration policy"); let run_id = "preview-gate-failure-run"; + fs::write( + root.join("game/index.html"), + fake_llm_game_draft().game_html, + ) + .expect("write smoke-valid game entry"); let revision = prepare_agent_runtime_project_mutation_locked( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, @@ -2809,6 +2874,30 @@ fn limited_local_command_rejects_blank_canvas_game_smoke() { fs::remove_dir_all(root).ok(); } +#[test] +fn limited_local_command_rejects_invalid_javascript_before_surface_checks() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "脚本语法优先验证测试").expect("project init"); + let html = r#" + + +

目标:完成订单。胜利或失败后可以重开。

+ + +"#; + fs::write(root.join("game/index.html"), html).expect("write invalid JavaScript game html"); + + let error = run_limited_local_command_at(&root, "game.static_smoke") + .expect_err("invalid JavaScript must fail before canvas keywords"); + + assert!(error.contains("不是有效 JavaScript"), "{error}"); + assert!(!error.contains("可渲染画布"), "{error}"); + fs::remove_dir_all(root).ok(); +} + #[test] fn limited_local_command_rejects_token_rich_truncated_script() { 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 b0dadfb3f..1eae8af9a 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 @@ -4033,6 +4033,179 @@ async fn provider_handoff_final_reply_restart_replays_success_without_network_re fs::remove_dir_all(config_dir).ok(); } +#[tokio::test] +async fn project_execution_owner_cross_boot_replays_same_run_provider_handoff_to_completion_once() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-owner-provider-handoff", + "execution owner 跨 boot Provider 恢复测试", + ) + .expect("project init"); + let config_dir = unique_project_path(); + fs::create_dir_all(&config_dir).expect("create cross-boot Provider config dir"); + let config_guard = use_test_runtime_config_dir(config_dir.clone()); + let config_path = config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME); + let injection = root.join(".agent/runtime/test-stop-after-provider-handoff"); + fs::create_dir_all(injection.parent().expect("handoff injection parent")) + .expect("create handoff injection parent"); + fs::write(&injection, b"stop-after-final-reply-provider-handoff") + .expect("write final reply handoff injection"); + let agent_id = "design-director"; + let run_id = "owner-cross-boot-provider-handoff-run"; + let final_response = "新 boot 从原 Provider handoff 恢复并唯一完成。"; + let (request_notice_sender, request_notice_receiver) = mpsc::channel(); + let (request_capture_sender, request_capture_receiver) = mpsc::channel(); + let (base_url, server_handle) = spawn_mock_llm_tool_plan_then_transient_final_reply( + final_tool_plan_response("不得提交 planning fallback。"), + final_response.to_string(), + request_notice_sender, + request_capture_sender, + ); + replace_test_local_config( + &config_path, + format!( + r#"{{ + "agentMode": "provider", + "agentLlm": {{ + "design-director": {{ + "apiKey": "owner-cross-boot-provider-key", + "baseUrl": {base_url:?}, + "model": "owner-cross-boot-provider-model", + "apiKind": "openai_responses", + "stream": true, + "maxRetries": 1, + "retryBackoffMs": 1000 + }} + }} +}}"# + ), + ); + let started = start_game_creator_agent_background_task_at( + &root, + agent_id, + "验证新 execution owner 恢复纯 Provider durable run", + run_id, + ) + .expect("start cross-boot Provider task"); + + for _ in 0..3 { + request_notice_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("Provider request before simulated old Runner exit"); + } + let handoff = wait_for_provider_handoff_test_stop(&root, agent_id, run_id); + server_handle.join().expect("join cross-boot Provider mock"); + let captured_requests = (0..3) + .map(|_| { + request_capture_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("captured Provider request before ownership transfer") + }) + .collect::>(); + assert_eq!(captured_requests.len(), 3); + assert!(request_capture_receiver.try_recv().is_err()); + + let (claimed_root, recovered_from_boot_id) = + crate::runner::simulate_external_agent_runner_cross_boot_owner_claim_for_test( + &root, + &config_dir, + "owner-provider-boot-a", + "owner-provider-boot-b", + ) + .expect("new boot claims and resumes Provider handoff"); + assert_eq!(claimed_root, root); + assert_eq!( + recovered_from_boot_id.as_deref(), + Some("owner-provider-boot-a") + ); + + let completed = wait_for_agent_runtime_terminal_and_lane_release( + &root, + agent_id, + run_id, + "idle", + "completed", + ); + assert_eq!(completed.state.session_id, started.state.session_id); + assert_eq!( + completed.state.last_response.as_deref(), + Some(final_response) + ); + assert!(request_capture_receiver.try_recv().is_err()); + wait_for_provider_handoff_terminal_cleanup(&root, agent_id, run_id); + + let records = read_agent_db_records_for_test(&root); + assert_eq!( + records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == run_id + && record["requestId"] == handoff.provider_request_id + && record["status"] == "started" + }) + .count(), + 1 + ); + assert_eq!( + records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == run_id + && record["requestId"] == handoff.provider_request_id + && record["status"] == "completed" + }) + .count(), + 1 + ); + assert_eq!( + records + .iter() + .filter(|record| { + record["runId"] == run_id + && matches!( + record["recordType"].as_str(), + Some("agent.runtime.agent.delegate") + | Some("agent.runtime.tool_action.executing") + ) + }) + .count(), + 0 + ); + let conversation = read_local_conversation_for_session_at( + &root, + Some(agent_id), + Some(&started.state.session_id), + ) + .expect("read recovered conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant" && message.content == final_response) + .count(), + 1 + ); + assert_eq!( + completed + .recent_tasks + .iter() + .filter(|task| task.run_id == run_id && task.status == "completed") + .count(), + 1 + ); + + assert!(request_capture_receiver.try_recv().is_err()); + let records_after = read_agent_db_records_for_test(&root); + assert_eq!(records_after, records); + + fs::remove_dir_all(root).ok(); + drop(config_guard); + fs::remove_dir_all(config_dir).ok(); +} + #[tokio::test] async fn provider_handoff_final_reply_compaction_restart_only_requests_final_reply() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs index 51ed3113a..8fd82c4b6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs @@ -2041,6 +2041,147 @@ fn preview_validate_public_event_detail_stays_structured_below_event_limit() { fs::remove_dir_all(root).ok(); } +#[test] +fn static_smoke_failure_receipt_keeps_owner_diagnostic_and_redacts_public_detail() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "静态验证诊断回执测试").expect("project init"); + let observation = AgentRuntimeToolObservation { + tool: "command.run_limited".to_string(), + status: "failed".to_string(), + summary: "game.static_smoke 执行失败".to_string(), + detail: Some(format!( + "游戏入口第 1 个可执行内联脚本不是有效 JavaScript:{}", + root.join("game/index.html").display() + )), + }; + + let public = agent_runtime_action_receipt_public_safe_detail_for_test(&root, &observation) + .expect("public static smoke metadata"); + let public = serde_json::from_str::(&public).expect("parse public metadata"); + assert_eq!(public["commandId"], "game.static_smoke"); + assert_eq!(public["passed"], false); + assert_eq!(public["failureCode"], "static-smoke-failed"); + assert_eq!(public["check"], "javascript-syntax"); + assert_eq!(public["path"], "game/index.html"); + assert!(public.get("diagnostic").is_none()); + + let owner = agent_runtime_action_receipt_safe_detail_for_owner_for_test( + &root, + "code-prototype", + "static-smoke-owner-run", + &observation, + ) + .expect("owner static smoke metadata"); + let owner = serde_json::from_str::(&owner).expect("parse owner metadata"); + assert_eq!(owner["failureCode"], "static-smoke-failed"); + assert_eq!(owner["check"], "javascript-syntax"); + assert_eq!(owner["path"], "game/index.html"); + let diagnostic = owner["diagnostic"].as_str().expect("owner diagnostic"); + assert!(diagnostic.contains("不是有效 JavaScript"), "{diagnostic}"); + assert!(!diagnostic.contains(root.to_string_lossy().as_ref())); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn static_smoke_failure_receipt_round_trips_owner_diagnostic_from_agent_db() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "静态验证诊断持久化测试").expect("project init"); + let run_id = "static-smoke-owner-round-trip-run"; + let runtime = start_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "修复游戏入口语法并重新验证", + run_id, + "agent-background-task", + "执行静态验证", + vec!["读取失败诊断并修复".to_string()], + ) + .expect("start static smoke owner runtime"); + let action = AgentRuntimeToolAction { + tool: "command.run_limited".to_string(), + reason: Some("验证当前游戏入口".to_string()), + input: serde_json::json!({ "commandId": "game.static_smoke" }), + }; + let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &runtime.current_task); + let action_id = agent_runtime_tool_action_id(run_id, 1, 0, 0, &action_fingerprint); + let observation = AgentRuntimeToolObservation { + tool: action.tool.clone(), + status: "failed".to_string(), + summary: "game.static_smoke 执行失败".to_string(), + detail: Some(format!( + "游戏入口第 1 个可执行内联脚本不是有效 JavaScript:{}", + root.join("game/index.html").display() + )), + }; + + append_agent_runtime_action_receipt( + &root, + &runtime, + &action_id, + &action_fingerprint, + &action.tool, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + Some("game.static_smoke"), + &observation, + ) + .expect("persist static smoke failure receipt"); + + let records = read_agent_db_records_for_test(&root); + let receipt = records + .iter() + .find(|record| { + record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && record["actionId"] == action_id + }) + .expect("persisted static smoke receipt"); + let persisted = serde_json::from_str::( + receipt["safeDetail"] + .as_str() + .expect("persisted owner safe detail"), + ) + .expect("parse persisted owner safe detail"); + assert_eq!(persisted["failureCode"], "static-smoke-failed"); + assert!(persisted["diagnostic"] + .as_str() + .is_some_and(|value| value.contains("不是有效 JavaScript"))); + assert!(!receipt + .to_string() + .contains(root.to_string_lossy().as_ref())); + + let history = observe_agent_runtime_action_history( + &root, + "code-prototype", + run_id, + &serde_json::json!({ "actionId": action_id, "limit": 1 }), + ); + assert_eq!(history.status, "ok", "{history:?}"); + let history = serde_json::from_str::( + history + .detail + .as_deref() + .expect("round-tripped action history detail"), + ) + .expect("parse round-tripped action history detail"); + let round_tripped = history["actions"][0]["safeDetail"] + .as_str() + .expect("round-tripped owner safe detail"); + let round_tripped = + serde_json::from_str::(round_tripped).expect("parse round-tripped safe detail"); + assert_eq!(round_tripped["commandId"], "game.static_smoke"); + assert_eq!(round_tripped["passed"], false); + assert_eq!(round_tripped["failureCode"], "static-smoke-failed"); + assert_eq!(round_tripped["check"], "javascript-syntax"); + assert_eq!(round_tripped["path"], "game/index.html"); + let diagnostic = round_tripped["diagnostic"] + .as_str() + .expect("round-tripped diagnostic"); + assert!(diagnostic.contains("不是有效 JavaScript"), "{diagnostic}"); + assert!(!diagnostic.contains(root.to_string_lossy().as_ref())); + + fs::remove_dir_all(root).ok(); +} + #[test] fn seed_refresh_downgrades_completed_visual_tasks_when_registered_file_is_missing() { let _config_guard = crate::tests::write_test_local_config( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index 4cc6d88c5..0a9ca06bf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -42,11 +42,12 @@ pub(super) use crate::{ agent_runtime_tool_requires_repository_context_fingerprint_gate, agent_runtime_verified_delivery_completion_plan_update, append_agent_db_record, append_agent_db_terminal_observation_if_missing_for_action, - append_agent_runtime_tool_call_record, append_game_creator_agent_runtime_action_event, - append_game_creator_agent_runtime_task, append_game_creator_agent_runtime_task_projection_once, - append_local_conversation_message_at, append_local_conversation_message_for_session_at, - append_local_permission_log_at, append_or_read_exact_game_creator_agent_runtime_pending_task, - apply_agent_runtime_plan_update, await_game_creator_agent_runtime_provider_request, + append_agent_runtime_action_receipt, append_agent_runtime_tool_call_record, + append_game_creator_agent_runtime_action_event, append_game_creator_agent_runtime_task, + append_game_creator_agent_runtime_task_projection_once, append_local_conversation_message_at, + append_local_conversation_message_for_session_at, append_local_permission_log_at, + append_or_read_exact_game_creator_agent_runtime_pending_task, apply_agent_runtime_plan_update, + await_game_creator_agent_runtime_provider_request, begin_agent_runtime_project_verification_locked, bind_game_creator_agent_runtime_run_profile_at, build_game_creation_seed_task_graph, build_repository_startup_context_at, cancel_game_creator_agent_runtime_task_at, @@ -64,8 +65,9 @@ pub(super) use crate::{ game_creator_agent_runtime_task_lock_is_available, game_creator_agent_runtime_task_path, game_creator_agent_runtime_tool_policy_rule_for_run, init_local_game_project_at, invalidate_agent_runtime_project_verification_after_preview_failure_at, - native_runtime_function_name, observe_agent_runtime_agent_message, - plan_game_creation_agent_pass, prepare_agent_runtime_project_mutation_locked, + native_runtime_function_name, observe_agent_runtime_action_history, + observe_agent_runtime_agent_message, plan_game_creation_agent_pass, + prepare_agent_runtime_project_mutation_locked, prepare_game_creator_agent_runtime_provider_action_batch, project_verification_completion_blocker_at, read_all_game_creator_agent_runtime_tasks, read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_context_bundle, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 2dc8e129b..a37e3f958 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -119,6 +119,7 @@ import { projectSupervisorPendingRepairMatchesProfessional, projectSupervisorResponseStreamIdentity, readProjectSupervisorActiveSessionId, + resolveProjectSupervisorRuntimeSubmission, sameAgentRuntimeRun, submitProjectSupervisorRuntimeTask, taskRowsFromManifest, @@ -568,6 +569,7 @@ type AppProps = { initialProjectPath?: string; initialProjectManifest?: GameCreationAppManifest; initialProjectKind?: LocalProjectKind; + orchestrationMode?: 'single-supervisor' | 'professional-dag'; projectSupervisorOnly?: boolean; supervisorChatOnly?: boolean; gameChatOnly?: boolean; @@ -588,6 +590,7 @@ export function App({ initialProjectPath: initialProjectPathOverride = '', initialProjectManifest, initialProjectKind = 'web', + orchestrationMode = 'professional-dag', projectSupervisorOnly = false, supervisorChatOnly = false, gameChatOnly = false, @@ -5781,21 +5784,20 @@ export function App({ if (!sessionId || localProjectPathRef.current !== nextProjectPath) { return; } - const submissionRunProfile = - workspaceProjectKind === 'godot' || - (supervisorChatOnly && !gameChatOnly) - ? 'standard' - : 'autonomous-game-build'; + const submissionRoute = resolveProjectSupervisorRuntimeSubmission({ + workspaceProjectKind, + orchestrationMode, + supervisorChatOnly, + gameChatOnly, + }); const runtimeAtSubmission = projectSupervisorRuntimeRef.current; - const steerRuntime = gameChatOnly - ? matchingAgentRuntimeForSteer( - [runtimeAtSubmission], - PROJECT_SUPERVISOR_AGENT_ID, - sessionId, - submissionRunProfile, - 'project-supervisor-game-chat', - ) - : null; + const steerRuntime = matchingAgentRuntimeForSteer( + [runtimeAtSubmission], + PROJECT_SUPERVISOR_AGENT_ID, + sessionId, + submissionRoute.runProfile, + submissionRoute.source, + ); let autoPreviewAfterRevision = 0; let autoPreviewAfterValidatedAt = 0; if (steerRuntime) { @@ -5838,10 +5840,8 @@ export function App({ sessionId, prompt, runtime: runtimeAtSubmission, - runProfile: submissionRunProfile, - source: gameChatOnly - ? 'project-supervisor-game-chat' - : 'project-supervisor-gui', + runProfile: submissionRoute.runProfile, + source: submissionRoute.source, }); const runtimeResult = submission.runtimeResult; const acceptedRunId = submission.acceptedRunId.trim(); @@ -11072,6 +11072,7 @@ export function App({ transientReply={projectSupervisorTransientReply} visibleMessages={visibleMessages} visibleProfessionalAgentCards={visibleProfessionalAgentCards} + showProfessionalCollaboration={orchestrationMode === 'professional-dag'} workspaceStatus={workspaceStatus} runtime={projectSupervisorRuntime} error={projectSupervisorRuntimeError} 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 8cfb64d71..bac015459 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 @@ -26,6 +26,43 @@ 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}$/; +export type ProjectSupervisorRuntimeSubmission = { + runProfile: 'standard' | 'autonomous-game-build'; + source: 'project-supervisor-gui' | 'project-supervisor-game-chat'; +}; + +export function resolveProjectSupervisorRuntimeSubmission({ + workspaceProjectKind, + orchestrationMode, + supervisorChatOnly, + gameChatOnly, +}: { + workspaceProjectKind: 'web' | 'godot'; + orchestrationMode: 'single-supervisor' | 'professional-dag'; + supervisorChatOnly: boolean; + gameChatOnly: boolean; +}): ProjectSupervisorRuntimeSubmission { + if ( + workspaceProjectKind === 'godot' || + (supervisorChatOnly && !gameChatOnly) + ) { + return { + runProfile: 'standard', + source: 'project-supervisor-gui', + }; + } + if (!gameChatOnly && orchestrationMode !== 'single-supervisor') { + return { + runProfile: 'autonomous-game-build', + source: 'project-supervisor-gui', + }; + } + return { + runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', + }; +} + function agentRuntimeMessageCorrelationId( messageId: string | null | undefined, ) { @@ -912,7 +949,7 @@ export async function submitProjectSupervisorRuntimeTask({ prompt: string; runtime: AgentRuntimeState | null; runProfile: 'standard' | 'autonomous-game-build'; - source?: string; + source: ProjectSupervisorRuntimeSubmission['source']; }) { const steerRuntime = matchingAgentRuntimeForSteer( [runtime], @@ -932,7 +969,7 @@ export async function submitProjectSupervisorRuntimeTask({ steerId: createAgentChatRunId('project-supervisor-steer'), instruction: prompt, runProfile, - ...(source ? { source } : {}), + source, }, ); return { @@ -950,7 +987,7 @@ export async function submitProjectSupervisorRuntimeTask({ task: prompt, runId: requestedRunId, runProfile, - ...(source ? { source } : {}), + source, }, ); return { @@ -1465,7 +1502,9 @@ export function projectSupervisorRuntimeStatusLabel( return '等待专业 Agent'; } if (runtime.phase === 'waiting-for-manifest-tasks') { - return '等待项目任务'; + return runtime.source === 'project-supervisor-game-chat' + ? '生成中' + : '等待项目任务'; } if ( ['action', 'observation', 'executing'].includes(runtime.phase) || @@ -1603,7 +1642,13 @@ export function projectRuntimeStatusPresentation(runtime: AgentRuntimeState) { return { label: '协作中', tone: 'running' }; } if (runtime.phase === 'waiting-for-manifest-tasks') { - return { label: '项目任务中', tone: 'running' }; + return { + label: + runtime.source === 'project-supervisor-game-chat' + ? '生成中' + : '项目任务中', + tone: 'running', + }; } return { label: '执行中', tone: 'running' }; } @@ -1655,7 +1700,9 @@ export function projectRuntimeVisibleCurrentWork(runtime: AgentRuntimeState) { return '正在等待专业 Agent 回执'; } if (runtime.phase === 'waiting-for-manifest-tasks') { - return '正在等待项目专业任务完成'; + return runtime.source === 'project-supervisor-game-chat' + ? '正在推进游戏生成' + : '正在等待项目专业任务完成'; } if (runtime.status === 'failed' || runtime.phase === 'failed') { return '本轮工作执行失败'; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 2fab75181..3ed8b76eb 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -279,6 +279,7 @@ export function WorkspaceLauncherShell({ /> ) : launcherView === 'project-development' && currentProjectContext ? ( void; onSubmit: FormEventHandler; pendingConfirmation: PendingUiConfirmation | null; + showProfessionalCollaboration?: boolean; transientReply: string; visibleMessages: ChatMessage[]; visibleProfessionalAgentCards: AgentStatusCard[]; @@ -52,6 +53,7 @@ export function ProjectSupervisorView({ onShowEarlierMessages, onSubmit, pendingConfirmation, + showProfessionalCollaboration = true, transientReply, visibleMessages, visibleProfessionalAgentCards, @@ -149,40 +151,44 @@ export function ProjectSupervisorView({ {projectWorkspaceStatusForDisplay(workspaceStatus)} - + {showProfessionalCollaboration ? ( + + ) : null} ); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index c74750bc6..94a4d405b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -117,6 +117,7 @@ type ResourceCategory = ProjectResourceCategory; type ResourceSortMode = ProjectResourceCanvasLayoutMode; type WorkbenchMode = 'resources' | 'run'; type ApprovalMode = 'strict' | 'risk' | 'none'; +type OrchestrationMode = 'single-supervisor' | 'professional-dag'; type WebKitGestureEvent = Event & { clientX?: number; @@ -234,6 +235,7 @@ const RESOURCE_DEPENDENCY_VISUAL_GUTTER = 64; type AgentSummary = ProjectAgentRuntimeSummary; export type ProjectDevelopmentViewProps = { + orchestrationMode?: OrchestrationMode; projectName: string; projectPath: string; manifest: GameCreationAppManifest; @@ -670,6 +672,7 @@ const ResourceCard = memo(function ResourceCard({ }); export default function ProjectDevelopmentView({ + orchestrationMode = 'professional-dag', projectName, projectPath, manifest, @@ -680,6 +683,7 @@ export default function ProjectDevelopmentView({ supervisor, onManifestChange, }: ProjectDevelopmentViewProps) { + const professionalDagVisible = orchestrationMode === 'professional-dag'; const [mode, setMode] = useState('resources'); const [sortMode, setSortMode] = useState('dependency'); const [searchText, setSearchText] = useState(''); @@ -1395,7 +1399,8 @@ export default function ProjectDevelopmentView({ } const viewport = resolveViewport(event); const category = viewport?.dataset.resourceSectionScroll as - ResourceCategory | undefined; + | ResourceCategory + | undefined; if (!viewport || !category) { return; } @@ -1414,7 +1419,8 @@ export default function ProjectDevelopmentView({ const event = rawEvent as WebKitGestureEvent; const viewport = resolveViewport(event); const category = viewport?.dataset.resourceSectionScroll as - ResourceCategory | undefined; + | ResourceCategory + | undefined; if (!viewport || !category) { return; } @@ -1502,7 +1508,8 @@ export default function ProjectDevelopmentView({ .querySelectorAll('[data-resource-section-scroll]') .forEach((viewport) => { const category = viewport.dataset.resourceSectionScroll as - ResourceCategory | undefined; + | ResourceCategory + | undefined; if (!category) { return; } @@ -1522,7 +1529,8 @@ export default function ProjectDevelopmentView({ ?.querySelectorAll('[data-resource-section-scroll]') .forEach((viewport) => { const category = viewport.dataset.resourceSectionScroll as - ResourceCategory | undefined; + | ResourceCategory + | undefined; if (!category) { return; } @@ -2816,16 +2824,16 @@ export default function ProjectDevelopmentView({ dependencyLayoutSettled: dependencyLayout.settled, dependencyPositioned: Boolean( intent.resourceId && - dependencyLayout.layout.positions.some( - (position) => position.resourceId === intent.resourceId, - ), + dependencyLayout.layout.positions.some( + (position) => position.resourceId === intent.resourceId, + ), ), typeLayoutSettled: typeLayout.settled, typePositioned: Boolean( intent.resourceId && - typeLayout.layout.positions.some( - (position) => position.resourceId === intent.resourceId, - ), + typeLayout.layout.positions.some( + (position) => position.resourceId === intent.resourceId, + ), ), visible: targetVisible, domRendered: Boolean(card), @@ -3777,69 +3785,81 @@ export default function ProjectDevelopmentView({ 与陶泥儿的对话 项目总控 Agent - + {professionalDagVisible ? ( + + ) : ( + + 自动执行 + + )} {supervisor} -
- {agentSummaries.map((agent) => ( -
- - { - { - design: '策', - art: '美', - code: '程', - balance: '数', - audio: '音', - publishing: '发', - }[agent.group] - } - - - {agent.label} - {agent.failureSummary ?? agent.statusLabel} - - + {agentSummaries.map((agent) => ( +
- {agent.currentTask} - {agent.currentAction ? ( - {agent.currentAction} - ) : null} - {agent.waitingOn ? ( - {`等待:${agent.waitingOn}`} - ) : null} - {`任务完成 ${agent.completedCount}/${agent.totalCount}`} - -
- ))} - -
+ + { + { + design: '策', + art: '美', + code: '程', + balance: '数', + audio: '音', + publishing: '发', + }[agent.group] + } + + + {agent.label} + {agent.failureSummary ?? agent.statusLabel} + + + {agent.currentTask} + {agent.currentAction ? ( + {agent.currentAction} + ) : null} + {agent.waitingOn ? ( + {`等待:${agent.waitingOn}`} + ) : null} + {`任务完成 ${agent.completedCount}/${agent.totalCount}`} + + + ))} + + + ) : null} {resourceRecoveryPanelOpen ? (
) : null} - {approvalDialogOpen ? ( + {professionalDagVisible && approvalDialogOpen ? (
{ '正在读取项目', ); }); + + test.each([ + { + name: '普通 Web 工作台', + input: { + workspaceProjectKind: 'web' as const, + orchestrationMode: 'single-supervisor' as const, + supervisorChatOnly: false, + gameChatOnly: false, + }, + expected: { + runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', + }, + }, + { + name: '游戏生成聊天', + input: { + workspaceProjectKind: 'web' as const, + orchestrationMode: 'professional-dag' as const, + supervisorChatOnly: false, + gameChatOnly: true, + }, + expected: { + runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', + }, + }, + { + name: '显式项目总控调试', + input: { + workspaceProjectKind: 'web' as const, + orchestrationMode: 'professional-dag' as const, + supervisorChatOnly: true, + gameChatOnly: false, + }, + expected: { + runProfile: 'standard', + source: 'project-supervisor-gui', + }, + }, + { + name: 'Godot 项目调试', + input: { + workspaceProjectKind: 'godot' as const, + orchestrationMode: 'single-supervisor' as const, + supervisorChatOnly: false, + gameChatOnly: false, + }, + expected: { + runProfile: 'standard', + source: 'project-supervisor-gui', + }, + }, + { + name: '显式专业 DAG Web 工作台', + input: { + workspaceProjectKind: 'web' as const, + orchestrationMode: 'professional-dag' as const, + supervisorChatOnly: false, + gameChatOnly: false, + }, + expected: { + runProfile: 'autonomous-game-build', + source: 'project-supervisor-gui', + }, + }, + ])('$name 使用稳定的 Runtime 提交路由', ({ input, expected }) => { + expect(resolveProjectSupervisorRuntimeSubmission(input)).toEqual(expected); + }); }); describe('Agent 最近任务失败摘要', () => { @@ -484,7 +557,7 @@ describe('Game Chat stream identity and source', () => { ).not.toBe(projectSupervisorResponseStreamIdentity(base)); }); - test('game-chat source is forwarded for start and steer without changing default source behavior', async () => { + test('Project Supervisor source is explicit for every start and steer', async () => { const runtimeResult = { state: { schemaVersion: 'game-creator-agent-runtime.v1', @@ -538,8 +611,12 @@ describe('Game Chat stream identity and source', () => { prompt: 'chat', runtime: null, runProfile: 'standard', + source: 'project-supervisor-gui', }); - expect(invoke.mock.calls[0]?.[1]).not.toHaveProperty('source'); + expect(invoke).toHaveBeenCalledWith( + 'start_game_creator_supervisor_runtime_task', + expect.objectContaining({ source: 'project-supervisor-gui' }), + ); invoke.mockClear(); const steerRuntime = { @@ -572,13 +649,7 @@ describe('Game Chat stream identity and source', () => { ); invoke.mockClear(); - invoke.mockResolvedValueOnce({ - runtime: runtimeResult, - steerId: 'steer-2', - sequence: 2, - status: 'applied', - providerInterrupted: false, - }); + invoke.mockResolvedValueOnce(runtimeResult); await submitProjectSupervisorRuntimeTask({ invoke, projectPath: '/tmp/game-chat', @@ -586,8 +657,12 @@ describe('Game Chat stream identity and source', () => { prompt: 'continue this run', runtime: steerRuntime, runProfile: 'autonomous-game-build', + source: 'project-supervisor-gui', }); - expect(invoke.mock.calls[0]?.[1]).not.toHaveProperty('source'); + expect(invoke).toHaveBeenCalledWith( + 'start_game_creator_supervisor_runtime_task', + expect.objectContaining({ source: 'project-supervisor-gui' }), + ); invoke.mockClear(); await submitProjectSupervisorRuntimeTask({ @@ -696,6 +771,37 @@ function providerRetryRuntime(): AgentRuntimeState { } describe('Agent Runtime Provider 状态投影', () => { + test('单主管来源使用中性的游戏生成状态,GUI 调试来源保留专业任务状态', () => { + const gameChatRuntime = { + ...providerRetryRuntime(), + source: 'project-supervisor-game-chat', + phase: 'waiting-for-manifest-tasks', + currentAction: null, + waitingOn: null, + planSteps: [], + }; + const guiRuntime = { + ...gameChatRuntime, + source: 'project-supervisor-gui', + }; + + expect(projectSupervisorRuntimeStatusLabel(gameChatRuntime, '')).toBe( + '生成中', + ); + expect(projectRuntimeStatusPresentation(gameChatRuntime).label).toBe( + '生成中', + ); + expect(projectRuntimeVisibleCurrentWork(gameChatRuntime)).toBe( + '正在推进游戏生成', + ); + expect(projectSupervisorRuntimeStatusLabel(guiRuntime, '')).toBe( + '等待项目任务', + ); + expect(projectRuntimeVisibleCurrentWork(guiRuntime)).toBe( + '正在等待项目专业任务完成', + ); + }); + test('等待 503 重试时优先显示 Runtime 的真实动作和等待时间', () => { const runtime = providerRetryRuntime(); const expected = diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 58e090dfd..abcb3d347 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1351,6 +1351,7 @@ export function registerHomeProjectCreationTests() { task: expect.stringContaining('初始意图:art / 做素材'), runId: expect.stringMatching(/^project-supervisor-task-/), runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', }); expect(startCalls[0]?.[1]).toMatchObject({ task: expect.stringContaining( diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 0c67e2014..3fc36ddf5 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -22,6 +22,7 @@ import { mergeGameChatFinalReplyMessagesIntoHistory, SupervisorChatOnlyView, } from '../../src/features/project-workspace/SupervisorChatOnlyView'; +import type { ProjectAgentRuntimeSummary } from '../../src/view/project-development'; import { RESOURCE_CANVAS_CARD_WIDTH, RESOURCE_CANVAS_COLUMN_GAP, @@ -9131,7 +9132,11 @@ export function registerProjectSupervisorSurfaceTests() { agentId: 'project-supervisor', sessionId: supervisorHarness.sessionId, }); - expect(screen.getByLabelText('专业 Agent 协作状态')).not.toBeNull(); + expect(screen.queryByLabelText('专业 Agent 协作状态')).toBeNull(); + expect(screen.getByRole('status', { name: '自动执行' })).not.toBeNull(); + expect(screen.queryByLabelText('子 Agent 状态栏')).toBeNull(); + expect(screen.queryByText('严格审批')).toBeNull(); + expect(screen.queryByRole('button', { name: /审批配置/ })).toBeNull(); expect(screen.queryByLabelText('选择 Agent')).toBeNull(); expect(screen.queryByRole('dialog', { name: 'Agent 对话' })).toBeNull(); expect(supervisorHarness.listen).not.toHaveBeenCalledWith( @@ -9157,7 +9162,7 @@ export function registerProjectSupervisorSurfaceTests() { task: '先完成正式客户端玩法拆解', runId: expect.stringMatching(/^project-supervisor-task-/), runProfile: 'autonomous-game-build', - source: 'project-supervisor-gui', + source: 'project-supervisor-game-chat', }, ); }); @@ -9192,7 +9197,7 @@ export function registerProjectSupervisorSurfaceTests() { steerId: expect.stringMatching(/^project-supervisor-steer-/), instruction: '补充:优先复用现有素材', runProfile: 'autonomous-game-build', - source: 'project-supervisor-gui', + source: 'project-supervisor-game-chat', }, ); }); @@ -9541,12 +9546,36 @@ export function registerProjectSupervisorSurfaceTests() { }, ); window.__TAURI__ = { core: { invoke } }; - renderLauncherProjectsAt('/?launcher'); - - fireEvent.change(screen.getByLabelText('项目目录'), { - target: { value: projectPath }, - }); - fireEvent.click(screen.getByRole('button', { name: '打开' })); + function ProfessionalWorkbenchFixture() { + const [agentRuntimeSummaries, setAgentRuntimeSummaries] = React.useState< + ProjectAgentRuntimeSummary[] + >([]); + const [agentResults, setAgentResults] = React.useState< + ProjectAgentResultSummary[] + >([]); + return React.createElement(ProjectDevelopmentView, { + orchestrationMode: 'professional-dag', + projectName: manifest.name, + projectPath, + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + agentRuntimeSummaries, + agentResults, + supervisor: React.createElement(App, { + initialProjectPath: projectPath, + initialProjectManifest: manifest, + orchestrationMode: 'professional-dag', + projectSupervisorOnly: true, + onAgentRuntimeSummariesChange: setAgentRuntimeSummaries, + onAgentResultsChange: setAgentResults, + }), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }); + } + render(React.createElement(ProfessionalWorkbenchFixture)); expect(await screen.findByLabelText('项目总控对话')).not.toBeNull(); await waitFor(() => { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1c69eb86c..648c30f4c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7166,6 +7166,16 @@ - HTTP 传输边界:根 H5 包不得依赖 Tauri guest 插件;AGC 独立包保留 `@tauri-apps/plugin-http`。Rust 插件显式关闭默认特性,只启用 `charset`、`cookies`、`http2` 和 `rustls-tls`,避免 `reqwest/system-proxy` 通过 Cargo feature union 把画布、Provider、Runtime 与本地回环夹具统一接入 OS 自动系统代理;如未来产品要求正式客户端继承系统代理,必须按各客户端明确设计并单独完成跨平台验证。 - 运行决策:Godot 项目提交给 Project Supervisor 时使用 `standard` Run Profile,避免触发 Web 专用 `game/index.html`、HTTP preview 与自主 Web 完成门。Godot 编辑器启动和内嵌运行预览不在本切片范围。 +## 2026-08-13 AGC 普通 Web 工作台默认单主无人值守生成 + +- 决策:项目首页进入的普通 Web 工作台默认 `single-supervisor`,提交固定映射为 `project-supervisor-game-chat + autonomous-game-build`。该决策覆盖此前“普通 GUI autonomous 继续固定完整 DAG”的现行入口规则;固定专业 autonomous DAG 只保留给显式 `professional-dag` 和专业 CLI 验收,Supervisor 调试与 Godot 继续 `standard + project-supervisor-gui`。 +- 路由:game-chat 根先由 Supervisor 持久化意图,随后只启动唯一 `code-prototype`;主 Run 完成资产审计后,只有真实缺口才可一次委派一个受限美术 child。普通用户界面不再展示固定专业 DAG、子 Agent Dock 或“严格审批”,统一显示自动执行与中性生成状态。 +- 澄清:仅可信 game-chat autonomous 根链及其绑定 child 将 `NeedsUserInput` 转为安全默认返工,并保持同一主 Run 继续;问题正文与公开问题指纹清除,内部只保留不公开的 SHA-256 幂等 marker。standard、GUI/CLI 调试、非可信或身份不完整链路仍保留原人工澄清,自动化不能扩大项目外写入、任意命令、发布、凭据或未知外部副作用权限。 +- 诊断:`game.static_smoke` 必须先解析可执行内联 JavaScript。公共失败回执只暴露稳定结构化字段;同一 owner 额外取得脱敏有界诊断。可修复失败按同 Run 的“诊断、修改、当前 revision 静态复验、双视口试玩”循环收口,不能以继续堆关键词或要求用户发送“继续”代替修复。 +- 恢复:跨 boot 取得 `execution-owner` 即幂等触发既有 durable recovery scan;安全动作续跑,未知外部副作用保留证据并进入 reconciliation,同 boot 重入不得重复恢复副作用。 +- 完成:根 Run 只有在正式 artifact、manifest、当前 revision static smoke 和 desktop/mobile `preview.validate` 一致时唯一 completed。确定性 loopback E2E 是提交门,真实 Provider 空项目单输入、零人工介入 smoke 是现场验收门,两者不得混称。 +- 关联:`docs/project-memory/plans/【实施计划】AGC无人值守游戏生成可靠性收口-2026-08-13.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + ## 2026-08-12 AGC Agent 设置分层界面 - `RuntimeConfigDialog` 保持现有配置字段、持久化格式、Runner 读取和 MCP 测试语义,只重构界面信息架构。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 1a64e8c5f..42f7732f4 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -680,3 +680,11 @@ npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-tool-plan-handoff-r - 修改预览完成门时,分别覆盖 child `preview-readiness` 当前 revision smoke、child `preview-playtest` 到根 Supervisor 合同的 browser receipt,以及 WebSocket 启动前退出的稳定基础设施分类。基础设施错误必须在一次浏览器调用后让 run 失败,不能只做到后续调用快速失败而继续消耗 Provider 轮次。 - 修改 game-chat Runner 生命周期时,至少覆盖 busy durable sidecar 拒绝 client-exit、拒绝后 `draining=false` 可继续执行、清空后 idle shutdown、重复 shutdown 幂等;Windows target check 继续保留,不能以删除 Job Object 或放任后台继续来规避 reconciliation。 - LLM 配置回归必须穷举全部规范 Agent,校验无遗漏/重复、显式 patch 覆盖默认,并锁定 GUI 展示映射和 Rust resolver 一致;模板与 GUI 初始草稿不得把规范默认持久化成 `agentLlm` 显式覆盖。`--llm-status` 要输出逐 Agent 实际 reasoning/timing/retry 值。运行日志与当前配置冲突时,先区分 durable run snapshot 和后来修改的文件,不能按当前文件反推历史请求。 + +## AI 游戏创作普通 Web 工作台无人值守验收 + +- 普通项目首页进入的 Web 工作台使用 `single-supervisor`,提交必须是 `project-supervisor-game-chat + autonomous-game-build`;持久 Supervisor 决策前零 child,之后只启动 `code-prototype`。显式 `professional-dag` 与 CLI 专业验收继续完整 autonomous DAG,Supervisor 调试与 Godot 继续 standard;这些显式入口不受普通默认值影响。 +- 修改入口或路由后,至少运行 AGC typecheck、`agentRuntimeModel.test.ts` 和挂载后的 `appSurface.test.ts`,同时断言普通工作台不展示专业 Agent 栏、子 Agent Dock 或审批按钮;显式专业入口仍可访问这些开发能力。steer 必须精确匹配 source、profile、Session 和 run。 +- 修改 smoke、delivery 或 recovery 后,必须用确定性路径证明:首次 `game.static_smoke` 精确报告 JavaScript/合同失败;公共回执不含诊断和绝对路径;同 owner 取得脱敏诊断;同一 `code-prototype` Run 有真实 mutation;最新 revision 的 static smoke 与 desktop/mobile `preview.validate` 均通过;根 Run 只有一个 completed;confirmation 与 user-input 为零。 +- 修改 `execution-owner` 后必须覆盖跨 boot 首次 claim 自动触发一次 recovery scan、同 boot/重复 hydration 幂等不重复,以及未知外部副作用仍进入 reconciliation。不能只证明 OS 锁可重新取得,也不能依赖用户显式 `/resume` 或点击继续。 +- 最终交付把“确定性 mock/loopback E2E”和“真实 Provider 空项目 smoke”分开报告。未完成后一项时可以说明实现和回归已完成,但不得宣称已经证明真实场景全程无人介入。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 300e30c82..47fe26bce 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1074,3 +1074,13 @@ game-project/ - 所有静态 delegate、ready child 和 isolated child 都只读继承根合同与当前 Acceptance Graph;isolated child 还必须实际收到自己的 `acceptanceCriteria / expectedArtifacts / writeScopes`。继承上下文不扩大工具、目录、写入权限或 expectedArtifacts。专业 Agent 只能报告局部结果、证据和剩余风险,不能修改根合同、根验收图或宣布用户总目标完成。 - Acceptance Graph 节点由 Supervisor 针对当前任务动态生成,不来自玩法模板。节点记录 required/optional、依赖、状态、证据引用与摘要;只有同一可信根 Supervisor 能调用 `agent.acceptance_update`,且该动作必须独占一轮。failed 与 not-observed 节点形成下一轮定向返工集合,未提交的 passed 节点保持不变。 - 完成门在普通收束、finalization 和 prepared 恢复中统一消费 Acceptance Graph。任一 required 节点为 failed、not-observed 或缺失都会阻止最终回复;工具成功、计划 completed、文件存在、smoke 或 preview 单独通过均不代表用户目标完成。项目 revision 改变后,整张图先进入待 Supervisor 确认状态;Supervisor 只更新它判断为受影响的节点并确认 current revision,未提交的 passed 节点及其证据保持不变。requiredEvidence 必须命中 Runtime 明确允许的持久证据工具集合,拼错、控制面、纯协调工具和依赖动态 catalog 的 MCP 调用不能形成不可完成合同。动作回执记录执行边界的 `projectRevisionBefore / projectRevisionAfter`:非 mutation evidence 的 before、after 都必须等于验收时 current revision;mutation evidence 的 after 必须等于 current revision。旧 revision、跨 revision 只读结果或执行后延迟落账的回执都不能重放为 passed。 + +## 2026-08-13 普通 Web 工作台无人值守生成可靠性收口 + +- **入口覆盖**:本节取代本文更早“普通 GUI autonomous 继续固定完整 DAG”的现行含义。项目首页进入的普通 Web 工作台必须显式使用 `single-supervisor` 编排,提交映射为 `project-supervisor-game-chat + autonomous-game-build`;持久化 Supervisor 决策前零 child,决策后只启动唯一 `code-prototype` 主 Run,美术仅在该 Run 完成 `asset.list` 并形成可证实缺口后一次委派一个受限 child。独立 game-chat 入口沿用相同规则。显式 `professional-dag` 与 CLI 专业 autonomous 验收继续使用 `project-supervisor-gui|cli + autonomous-game-build` 的原有完整 DAG;Supervisor 调试与 Godot 继续 `project-supervisor-gui + standard`,不得被普通入口默认值误改。 +- **普通界面**:`single-supervisor` 工作台不展示固定专业 DAG、子 Agent Dock 或“严格审批”,只显示总控对话、生成状态与“自动执行”。这只是产品投影收敛,不删除开发诊断入口,也不放宽权限;项目外写入、任意 shell、发布、凭据、系统设置和未知外部副作用继续失败关闭。 +- **无人介入语义**:可信 game-chat autonomous 根链路及其绑定 child 不得停在普通 `waiting-for-confirmation` 或 `waiting-for-user-input`。child 的信息不足交付在严格身份校验后转为同一父链可执行的安全默认返工,问题正文与公开问题指纹清除,内部只保留不公开的 SHA-256 幂等 marker;standard、GUI/CLI 调试和非可信绑定继续保留人工澄清。可恢复失败必须回到同一 `code-prototype` Run,不能要求用户发送“继续”。 +- **验证反馈与完成门**:`game.static_smoke` 先校验 `') && + lowerCandidate.includes('') + ); + }; + assert( + satisfiesStaticSmokeContract(html) && + lowerHtml.includes('real_e2e_target:before'.toLowerCase()), + 'agent-runtime-real-e2e-self-test-seeded-game-static-smoke-contract-invalid', + ); + for (const [label, brokenHtml] of [ + ['input', html.replaceAll('pointer', 'input-event-removed')], + ['objective', html.replace(/objective|survive/giu, 'purpose-removed')], + ['terminal', html.replace(/victory|defeat/giu, 'terminal-state-removed')], + ['restart', html.replace(/restart/giu, 'retry-path-removed')], + ]) { + assert( + !satisfiesStaticSmokeContract(brokenHtml), + `agent-runtime-real-e2e-self-test-seeded-game-negative-${label}-invalid`, + ); + } + return { + seededGameHtmlStaticSmokeContractValidated: true, + seededGameHtmlStaticSmokeNegativeCasesValidated: true, + }; +} + +async function validateFreshInitProductionBaselineSelfTest() { + const mainSource = readFileSync( + path.join(appRoot, 'src-tauri', 'src', 'main.rs'), + 'utf8', + ); + const defaultIndexStartMarker = 'const DEFAULT_GAME_INDEX_HTML: &str = r#"'; + const defaultIndexStart = mainSource.indexOf(defaultIndexStartMarker); + const defaultIndexBodyStart = + defaultIndexStart + defaultIndexStartMarker.length; + const defaultIndexEnd = mainSource.indexOf('"#;', defaultIndexBodyStart); + assert( + defaultIndexStart >= 0 && + defaultIndexStart === mainSource.lastIndexOf(defaultIndexStartMarker) && + defaultIndexEnd > defaultIndexBodyStart, + 'agent-runtime-real-e2e-self-test-production-default-index-source-invalid', + ); + const rustProductionDefault = mainSource.slice( + defaultIndexBodyStart, + defaultIndexEnd, + ); + const canonicalProductionDefault = productionDefaultGameIndexHtml(); + assert( + rustProductionDefault === canonicalProductionDefault, + 'agent-runtime-real-e2e-self-test-production-default-index-drifted', + ); + + const previousSuite = state.suite; + const previousProjectRoot = state.projectRoot; + const previousProjectPathScanner = state.projectPathTranscriptScanner; + const previousSentinelToken = state.sentinelToken; + const previousLures = state.lures; + let freshProjectRoot = null; + try { + state.suite = supervisorGameChatSingleMainPlayableSuite; + await seedDisposableProject({ preserveProductionInitBaseline: true }); + freshProjectRoot = state.projectRoot; + + const omittedGeneratedPaths = [ + 'package.json', + 'verify-e2e.mjs', + 'game/index.html', + ]; + const omittedGeneratedMetadata = await Promise.all( + omittedGeneratedPaths.map((relativePath) => + fs.lstat(path.join(freshProjectRoot, relativePath)).catch(() => null), + ), + ); + const preservedBaselinePaths = [ + sentinelFileName, + 'AGENTS.md', + '.env', + configFileName, + '.agent/private-secret.txt', + gitSensitivePath, + 'e2e/isolated-a/evidence.txt', + 'e2e/isolated-b/evidence.txt', + 'e2e/isolated-c/evidence.txt', + ]; + const preservedBaselineMetadata = await Promise.all( + preservedBaselinePaths.map((relativePath) => + fs.lstat(path.join(freshProjectRoot, relativePath)).catch(() => null), + ), + ); + const gameDirectoryEntries = await fs.readdir( + path.join(freshProjectRoot, 'game'), + ); + const [envLure, configLure, privateLure, gitLure] = state.lures; + const [envBytes, configBytes, privateBytes, gitBytes] = await Promise.all([ + fs.readFile(path.join(freshProjectRoot, '.env')), + fs.readFile(path.join(freshProjectRoot, configFileName)), + fs.readFile(path.join(freshProjectRoot, '.agent/private-secret.txt')), + fs.readFile(path.join(freshProjectRoot, gitSensitivePath)), + ]); + const sensitiveLureContentsValid = + state.lures.length === 4 && + envBytes.equals(Buffer.from(`REAL_E2E_SECRET=${envLure}\n`)) && + JSON.parse(configBytes.toString('utf8')).apiKey === configLure && + privateBytes.equals(Buffer.from(`${privateLure}\n`)) && + gitBytes.equals(Buffer.from(`${gitLure}\n`)); + const injectedLureLeakPath = path.join( + freshProjectRoot, + 'e2e/isolated-a/lure-leak-self-test.txt', + ); + await fs.writeFile(injectedLureLeakPath, `${envLure}\n`); + const injectedLureLeakCount = await countLureLeaks(); + await fs.rm(injectedLureLeakPath, { force: false }); + const clearedLureLeakCount = await countLureLeaks(); + const tracked = await runProcess('git', ['ls-files', '-z'], { + cwd: freshProjectRoot, + timeoutMs: 30_000, + }); + const trackedPaths = tracked.stdout + .toString('utf8') + .split('\0') + .filter(Boolean) + .sort(); + const expectedTrackedPaths = [ + 'AGENTS.md', + 'e2e/isolated-a/evidence.txt', + 'e2e/isolated-b/evidence.txt', + 'e2e/isolated-c/evidence.txt', + ].sort(); + await runProcess('git', ['rev-parse', '--verify', 'HEAD'], { + cwd: freshProjectRoot, + timeoutMs: 30_000, + }); + assert( + omittedGeneratedMetadata.every((metadata) => metadata === null) && + preservedBaselineMetadata.every( + (metadata) => metadata?.isFile() && !metadata.isSymbolicLink(), + ) && + gameDirectoryEntries.length === 0 && + sensitiveLureContentsValid && + injectedLureLeakCount === 1 && + clearedLureLeakCount === 0 && + JSON.stringify(trackedPaths) === JSON.stringify(expectedTrackedPaths), + 'agent-runtime-real-e2e-self-test-fresh-init-seed-invalid', + ); + } finally { + try { + if (freshProjectRoot) { + assert( + await removeDisposableProject(), + 'agent-runtime-real-e2e-self-test-fresh-init-cleanup-refused', + ); + } + } finally { + state.suite = previousSuite; + state.projectRoot = previousProjectRoot; + state.projectPathTranscriptScanner = previousProjectPathScanner; + state.sentinelToken = previousSentinelToken; + state.lures = previousLures; + } + } + + return { + productionDefaultGameIndexExactBytesValidated: true, + productionDefaultGameIndexSha256: hashValue( + Buffer.from(canonicalProductionDefault, 'utf8'), + ), + gameChatFreshInitGeneratedBaselineOmitted: true, + gameChatFreshInitGitBaselineValidated: true, + gameChatFreshInitSensitiveLuresPreserved: true, + gameChatFreshInitLureLeakDetected: true, + gameChatFreshInitLureLeakClearValidated: true, + }; +} + +function validateSupervisorGameChatTopologySelfTest() { + const previousRunId = state.initialRunId; + const previousSuite = state.suite; + state.initialRunId = 'synthetic-game-chat-root-run'; + state.suite = supervisorGameChatSingleMainPlayableSuite; + const task = ( + agentId, + runId, + parentAgentId, + parentRunId, + source, + delegationId = null, + ) => ({ + agentId, + runId, + parentAgentId, + parentRunId, + source, + delegationId, + status: 'completed', + phase: 'completed', + }); + const rootTask = task( + projectSupervisorAgentId, + state.initialRunId, + null, + null, + 'project-supervisor-game-chat', + ); + const codeTask = task( + mainAgentId, + 'synthetic-code-run', + projectSupervisorAgentId, + state.initialRunId, + 'agent-ready-task-scheduler', + ); + const artDirectorTask = task( + 'art-director', + 'synthetic-art-director-run', + mainAgentId, + codeTask.runId, + 'agent-delegate', + 'synthetic-art-director-delivery', + ); + const artAssetTask = task( + 'art-asset-plan', + 'synthetic-art-asset-run', + mainAgentId, + codeTask.runId, + 'agent-delegate', + 'synthetic-art-asset-delivery', + ); + const tasks = [rootTask, codeTask, artDirectorTask, artAssetTask]; + const runtimes = tasks.map((entry) => ({ + ...entry, + pendingToolAction: null, + pendingAction: null, + })); + const actionReceipt = (actionId, tool) => ({ + recordType: 'agent.runtime.action_receipt', + agentId: mainAgentId, + runId: codeTask.runId, + actionId, + tool, + status: 'ok', + }); + const deliveries = [ + { + schemaVersion: staticDelegateDeliverySchemaVersion, + parentAgentId: mainAgentId, + parentRunId: codeTask.runId, + parentActionId: 'delegate-art-director', + delegationId: artDirectorTask.delegationId, + targetAgentId: artDirectorTask.agentId, + targetRunId: artDirectorTask.runId, + expectedArtifacts: ['assets/art-spec.png'], + status: 'claimed-by-parent', + terminalStatus: 'completed', + claimedByActionId: 'claim-art-director', + repairOfDelegationId: null, + structuredResult: { + contractStatus: 'evidence-ready', + artifacts: [{ path: 'assets/art-spec.png', sha256: '1'.repeat(64) }], + }, + }, + { + schemaVersion: staticDelegateDeliverySchemaVersion, + parentAgentId: mainAgentId, + parentRunId: codeTask.runId, + parentActionId: 'delegate-art-asset', + delegationId: artAssetTask.delegationId, + targetAgentId: artAssetTask.agentId, + targetRunId: artAssetTask.runId, + expectedArtifacts: ['assets/art-spritesheet.png'], + status: 'claimed-by-parent', + terminalStatus: 'completed', + claimedByActionId: 'claim-art-asset', + repairOfDelegationId: null, + structuredResult: { + contractStatus: 'evidence-ready', + artifacts: [ + { path: 'assets/art-spritesheet.png', sha256: '2'.repeat(64) }, + { path: 'assets/manifest.art.json', sha256: '3'.repeat(64) }, + ], + }, + }, + ]; + const claims = deliveries.map((delivery) => ({ + schemaVersion: staticDelegateClaimSchemaVersion, + parentAgentId: mainAgentId, + parentRunId: codeTask.runId, + actionId: delivery.claimedByActionId, + status: 'observed', + receipts: [ + { + delegationId: delivery.delegationId, + targetAgentId: delivery.targetAgentId, + status: delivery.terminalStatus, + }, + ], + })); + const persistence = { + taskSnapshot: { latest: tasks }, + runtimeStates: runtimes, + deliveries, + claims, + agentDb: [ + actionReceipt('asset-list', 'asset.list'), + actionReceipt('delegate-art-director', 'agent.delegate'), + actionReceipt('claim-art-director', 'agent.run_status'), + actionReceipt('delegate-art-asset', 'agent.delegate'), + actionReceipt('claim-art-asset', 'agent.run_status'), + ], + }; + const binding = ( + agentId, + runId, + parentAgentId, + parentRunId, + bindingFingerprint, + parentBindingFingerprint, + ) => ({ + agentId, + runId, + rootAgentId: projectSupervisorAgentId, + rootRunId: state.initialRunId, + parentAgentId, + parentRunId, + profile: 'autonomous-game-build', + source: tasks.find( + (entry) => entry.agentId === agentId && entry.runId === runId, + )?.source, + bindingFingerprint, + parentBindingFingerprint, + }); + const bindings = [ + binding( + projectSupervisorAgentId, + state.initialRunId, + null, + null, + 'root-binding', + null, + ), + binding( + mainAgentId, + codeTask.runId, + projectSupervisorAgentId, + state.initialRunId, + 'code-binding', + 'root-binding', + ), + binding( + artDirectorTask.agentId, + artDirectorTask.runId, + mainAgentId, + codeTask.runId, + 'art-director-binding', + 'code-binding', + ), + binding( + artAssetTask.agentId, + artAssetTask.runId, + mainAgentId, + codeTask.runId, + 'art-asset-binding', + 'code-binding', + ), + ]; + const routeEvidence = { + workflowDecision: { + rootAgentId: projectSupervisorAgentId, + rootRunId: state.initialRunId, + strategy: 'audit-existing-first', + }, + assetCoverage: { + rootRunId: state.initialRunId, + auditedByAgentId: mainAgentId, + auditedByRunId: codeTask.runId, + requiredSlots: ['art-spec', 'core-spritesheet'], + missingSlots: ['art-spec', 'core-spritesheet'], + }, + assetRoute: { + rootRunId: state.initialRunId, + strategy: 'generate-missing-art', + generatedTaskIds: ['art-director', 'art-asset-plan'], + reusedTaskIds: [], + }, + }; + try { + const topology = validateSupervisorGameChatSingleMainTopology( + persistence, + bindings, + routeEvidence, + ); + const failedDescendantPersistence = structuredClone(persistence); + failedDescendantPersistence.taskSnapshot.latest.find( + (entry) => entry.agentId === 'art-director', + ).status = 'failed'; + let failedDescendantRejected = false; + try { + validateSupervisorGameChatSingleMainTopology( + failedDescendantPersistence, + bindings, + routeEvidence, + ); + } catch { + failedDescendantRejected = true; + } + const duplicateRootPersistence = structuredClone(persistence); + duplicateRootPersistence.taskSnapshot.latest.push({ + ...rootTask, + runId: 'synthetic-second-root', + }); + duplicateRootPersistence.runtimeStates.push({ + ...runtimes[0], + runId: 'synthetic-second-root', + }); + let duplicateRootRejected = false; + try { + validateSupervisorGameChatSingleMainTopology( + duplicateRootPersistence, + bindings, + routeEvidence, + ); + } catch { + duplicateRootRejected = true; + } + const nestedChildPersistence = structuredClone(persistence); + const nestedTask = task( + 'nested-forbidden-agent', + 'synthetic-nested-forbidden-run', + artDirectorTask.agentId, + artDirectorTask.runId, + 'agent-delegate', + 'synthetic-nested-forbidden-delivery', + ); + nestedChildPersistence.taskSnapshot.latest.push(nestedTask); + nestedChildPersistence.runtimeStates.push({ + ...nestedTask, + pendingToolAction: null, + pendingAction: null, + }); + let nestedChildRejected = false; + try { + validateSupervisorGameChatSingleMainTopology( + nestedChildPersistence, + bindings, + routeEvidence, + ); + } catch { + nestedChildRejected = true; + } + const outOfScopeArtifactPersistence = structuredClone(persistence); + outOfScopeArtifactPersistence.deliveries[0].structuredResult.artifacts[0] = + { path: 'game/index.html', sha256: '4'.repeat(64) }; + let outOfScopeArtifactRejected = false; + try { + validateSupervisorGameChatSingleMainTopology( + outOfScopeArtifactPersistence, + bindings, + routeEvidence, + ); + } catch { + outOfScopeArtifactRejected = true; + } + assert( + topology.uniqueRootRunCount === 1 && + topology.recursiveDescendantCount === 3 && + topology.completedDescendantCount === 3 && + topology.artChildCount === 2 && + topology.artDeliveryClaimCount === 2 && + topology.assetListBeforeArtDelegation === true && + failedDescendantRejected && + duplicateRootRejected && + nestedChildRejected && + outOfScopeArtifactRejected, + 'agent-runtime-real-e2e-self-test-game-chat-topology-invalid', + ); + return { + gameChatUniqueSupervisorRootValidated: true, + gameChatUniqueFixedCodePrototypeChildValidated: true, + gameChatRecursiveChildTopologyValidated: true, + gameChatAllDescendantsCompletedValidated: true, + gameChatNestedFailureRejected: true, + gameChatAssetListBeforeDelegationValidated: true, + gameChatArtDeliveryClaimValidated: true, + gameChatAssetsWriteBoundaryValidated: true, + }; + } finally { + state.initialRunId = previousRunId; + state.suite = previousSuite; + } +} + +async function waitForSelfTestChildSpawn(child) { + if (Number.isSafeInteger(child.pid) && child.pid > 1) return; + await new Promise((resolve, reject) => { + child.once('spawn', resolve); + child.once('error', reject); + }); +} + +async function stopSelfTestChild(child) { + if (!child || child.exitCode !== null || child.signalCode !== null) return; + child.kill('SIGKILL'); + await waitForChildClose(child, 10_000); +} + +async function validateWaitForChildCloseHardTimeout(killBehavior) { + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore', + windowsHide: true, + }); + await waitForSelfTestChildSpawn(child); + const originalKill = child.kill.bind(child); + child.kill = () => { + if (killBehavior === 'false') return false; + const error = new Error('synthetic-child-kill-failure'); + error.code = 'EPERM'; + throw error; + }; + const startedAt = Date.now(); + let failure = null; + try { + await waitForChildClose(child, 25, 75); + } catch (error) { + failure = error; + } finally { + child.kill = originalKill; + await stopSelfTestChild(child); + } + assert( + failure?.code === + 'isolated-runner-stable-kill-handle-helper-reap-timeout' && + Date.now() - startedAt < 2_000, + `agent-runtime-real-e2e-self-test-child-close-${killBehavior}-kill-hard-timeout-invalid`, + ); + return true; +} + +async function validateStableKillHandleCommandFailureCleanup() { + const victim = spawn( + process.execPath, + ['-e', 'setInterval(() => {}, 1000)'], + { stdio: 'ignore', windowsHide: true }, + ); + await waitForSelfTestChildSpawn(victim); + const handle = await openOwnedRunnerKillHandle(victim.pid); + const originalEnd = handle.child.stdin.end.bind(handle.child.stdin); + const originalKill = handle.child.kill.bind(handle.child); + let helperClosedWhenCleanupKillStarted = null; + let failure = null; + handle.child.stdin.end = () => { + const error = new Error('synthetic-stable-handle-epipe'); + error.code = 'EPIPE'; + throw error; + }; + handle.child.kill = (...args) => { + helperClosedWhenCleanupKillStarted ??= handle.closed; + return originalKill(...args); + }; + try { + await closeOwnedRunnerKillHandle(handle); + } catch (error) { + failure = error; + } finally { + handle.child.stdin.end = originalEnd; + handle.child.kill = originalKill; + } + try { + assert( + failure?.code === 'isolated-runner-stable-kill-handle-stdin-failed' && + handle.command === 'CLOSE' && + helperClosedWhenCleanupKillStarted === false && + handle.closed === true && + (handle.child.exitCode !== null || handle.child.signalCode !== null) && + !activeCommandChildren.has(handle.child) && + isProcessAlive(victim.pid), + 'agent-runtime-real-e2e-self-test-stable-handle-command-failure-cleanup-invalid', + ); + return true; + } finally { + await closeOwnedRunnerKillHandle(handle).catch(() => {}); + await stopSelfTestChild(victim); + } +} + +async function validateInteractiveCliInheritedStdioLifecycle() { + const expectedReport = { + schemaVersion: 'game-creator-swarm-turn-report.v1', + outcome: 'settled', + parentAgentId: 'project-supervisor', + sessionId: 'interactive-cli-fd3-self-test-session', + parentRunId: 'interactive-cli-fd3-self-test-run', + runtimeCount: 1, + busyRuntimeCount: 0, + pendingTaskCount: 0, + runningTaskCount: 0, + waitingForConfirmationCount: 0, + waitingForUserInputCount: 0, + newAssistantMessageCount: 1, + finalReplyChars: 1, + reconciliationAgentCount: 0, + }; + const stderrMarker = 'interactive-cli-fd3-holder-stderr-marker'; + const previousProjectPathScanner = state.projectPathTranscriptScanner; + const projectPathScanner = new StreamingSecretScanner([stderrMarker]); + state.projectPathTranscriptScanner = projectPathScanner; + const reportLine = `[turn.report] ${JSON.stringify(expectedReport)}\n`; + const splitIndex = Math.max(1, Math.floor(reportLine.length / 2)); + const holderSource = ` +process.stdin.setEncoding('utf8'); +let input = ''; +const safetyTimer = setTimeout(() => process.exit(74), 15_000); +process.stdin.on('data', (chunk) => { + input += chunk; + if (!input.includes('CLOSE\\n')) return; + clearTimeout(safetyTimer); + process.exit(0); +}); +process.stdin.on('end', () => process.exit(75)); +process.stdin.resume(); +`; + const wrapperSource = ` +const { spawn } = require('node:child_process'); +const holder = spawn( + process.execPath, + ['--input-type=commonjs', '-e', ${JSON.stringify(holderSource)}], + { stdio: [3, 'inherit', 'inherit'], windowsHide: true }, +); +holder.once('error', () => { + process.exitCode = 73; +}); +holder.unref(); +const reportLine = ${JSON.stringify(reportLine)}; +process.stdout.write(reportLine.slice(0, ${splitIndex}), () => { + setImmediate(() => { + process.stdout.write(reportLine.slice(${splitIndex}), () => { + process.stderr.write(${JSON.stringify(`${stderrMarker}\n`)}, () => { + process.exit(process.exitCode ?? 0); + }); + }); + }); +}); +`; + const wrapper = spawn( + process.execPath, + ['--input-type=commonjs', '-e', wrapperSource], + { + stdio: ['ignore', 'pipe', 'pipe', 'pipe'], + windowsHide: true, + }, + ); + const session = createInteractiveCliSession(wrapper); + const originalKill = wrapper.kill.bind(wrapper); + let killCallCount = 0; + wrapper.kill = (...args) => { + killCallCount += 1; + return originalKill(...args); + }; + let controlStreamError = null; + wrapper.stdio[3].on('error', (error) => { + controlStreamError ??= error; + }); + try { + await waitForInteractiveCliExit(session, 10_000); + assert( + session.exited && !session.stdioClosed, + 'agent-runtime-real-e2e-self-test-interactive-cli-two-phase-not-observed', + ); + const parsedReport = + await waitForSupervisorAutonomousPlayableCliExit(session); + await waitForInteractiveCliOutput( + session, + () => session.stderr.toString('utf8').includes(`${stderrMarker}\n`), + 'agent-runtime-real-e2e-self-test-interactive-cli-output-timeout', + 10_000, + { allowAfterProcessExit: true }, + ); + assert( + JSON.stringify(canonicalJsonValue(parsedReport)) === + JSON.stringify(canonicalJsonValue(expectedReport)), + 'agent-runtime-real-e2e-self-test-interactive-cli-report-mismatch', + ); + await closeInteractiveCli(session); + assert( + killCallCount === 0 && !session.stdioClosed, + 'agent-runtime-real-e2e-self-test-interactive-cli-exit-cleanup-killed', + ); + wrapper.stdio[3].end('CLOSE\n'); + await waitForInteractiveCliStdioClose(session, 10_000); + assert( + controlStreamError === null && + session.stdioClosed && + projectPathScanner.count === 1, + 'agent-runtime-real-e2e-self-test-interactive-cli-stdio-close-invalid', + ); + return { + interactiveCliExitBeforeStdioCloseValidated: true, + interactiveCliPostExitReportValidated: true, + interactiveCliExitCleanupDidNotKillValidated: true, + interactiveCliStdioCloseAfterHolderReleaseValidated: true, + interactiveCliProjectPathScannerValidated: true, + }; + } finally { + if (!wrapper.stdio[3].destroyed && !wrapper.stdio[3].writableEnded) { + wrapper.stdio[3].end('CLOSE\n'); + } + if (!session.stdioClosed) { + await waitForInteractiveCliStdioClose(session, 10_000).catch(() => { + destroyInteractiveCliOutputStreams(session); + }); + } + state.projectPathTranscriptScanner = previousProjectPathScanner; + } +} diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs index 3fbdfb34a..18289737e 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs @@ -15,6 +15,7 @@ import { duplicateCount, finalMessageId, isNonEmptyString, + runtimePublicStatusMessageId, } from '../assertions/runtime.mjs'; import { fs, path } from '../dependencies.mjs'; import { @@ -29,10 +30,18 @@ import { interactiveCliOutput, prepareCliBinary, startInteractiveCli, + waitForInteractiveCliOutput, } from '../harness/process.mjs'; -import { seedDisposableProject } from '../harness/project.mjs'; +import { + productionDefaultGameIndexHtml, + seedDisposableProject, +} from '../harness/project.mjs'; import { decodeUtf8Fatal } from '../harness/reporting.mjs'; -import { findPendingActions, readRuntime } from '../harness/runtime.mjs'; +import { + countLureLeaks, + findPendingActions, + readRuntime, +} from '../harness/runtime.mjs'; import { autonomousCompletionContractSchemaVersion, autonomousGameBuildRunProfile, @@ -42,6 +51,8 @@ import { providerRequestLifecycleSchemaVersion, runProfileBindingSchemaVersion, state, + staticDelegateClaimSchemaVersion, + staticDelegateDeliverySchemaVersion, supervisorAutonomousPlayableLaneDefenseSuite, supervisorAutonomousPlayableLaneDefenseTask, supervisorAutonomousPlayableRunTimeoutMs, @@ -68,7 +79,63 @@ import { const projectSupervisorCliSource = 'project-supervisor-cli'; const projectSupervisorGameChatSource = 'project-supervisor-game-chat'; +const projectSupervisorAcceptedPublicStatus = 'accepted'; +const projectSupervisorAcceptedPublicStatusContent = + '任务已接收,项目总控 Agent 正在启动处理。'; const gameChatMainAgentId = 'code-prototype'; +const gameChatArtAgentIds = ['art-director', 'art-asset-plan']; +const gameChatArtSlotToAgent = new Map([ + ['art-spec', 'art-director'], + ['core-spritesheet', 'art-asset-plan'], +]); +const gameChatArtExpectedArtifact = new Map([ + ['art-director', 'assets/art-spec.png'], + ['art-asset-plan', 'assets/art-spritesheet.png'], +]); + +export function isSupervisorAutonomousPlayableAcceptedPublicStatus( + message, + runId, +) { + return ( + isPlainObject(message) && + message.role === 'assistant' && + message.agentId == null && + message.sessionId == null && + message.messageId === + runtimePublicStatusMessageId( + projectSupervisorAgentId, + supervisorSwarmSessionId, + runId, + projectSupervisorAcceptedPublicStatus, + ) && + message.content === projectSupervisorAcceptedPublicStatusContent + ); +} + +export function inspectSupervisorAutonomousPlayableConversationBoundary( + legacyConversation, + userFacing, + runId, +) { + const legacyUserFacingMessages = legacyConversation.filter((message) => + ['user', 'assistant'].includes(message.role), + ); + const acceptedPublicStatuses = legacyUserFacingMessages.filter((message) => + isSupervisorAutonomousPlayableAcceptedPublicStatus(message, runId), + ); + const wrongAgentFinalAssistants = userFacing.filter( + (message) => + message.role === 'assistant' && + message.agentId !== projectSupervisorAgentId && + !isSupervisorAutonomousPlayableAcceptedPublicStatus(message, runId), + ); + return { + legacyUserFacingMessageCount: legacyUserFacingMessages.length, + acceptedPublicStatusCount: acceptedPublicStatuses.length, + wrongAgentFinalAssistantCount: wrongAgentFinalAssistants.length, + }; +} export function supervisorAutonomousPlayableMode() { if (state.suite === supervisorGameChatSingleMainPlayableSuite) { @@ -157,22 +224,33 @@ export async function waitForSupervisorAutonomousPlayableCliExit(session) { }); let result; try { - result = await Promise.race([session.closePromise, timeout]); + result = await Promise.race([session.exitPromise, timeout]); } finally { clearTimeout(timeoutHandle); } if (!result) { throw codedError('supervisor-autonomous-playable-cli-timeout'); } - const output = interactiveCliOutput(session); - state.supervisorAutonomousPlayable.cliOutput = output; if (result.error || result.code !== 0 || result.signal !== null) { + state.supervisorAutonomousPlayable.cliOutput = + interactiveCliOutput(session); throw codedProcessError('supervisor-autonomous-playable-cli-failed', { ...result, stdout: session.stdout, stderr: session.stderr, }); } + const output = await waitForInteractiveCliOutput( + session, + () => + /(?:^|\r?\n)\[turn\.report\] [^\r\n]+\r?\n/u.test( + session.stdout.toString('utf8'), + ), + 'supervisor-autonomous-playable-turn-report-timeout', + 10_000, + { allowAfterProcessExit: true }, + ); + state.supervisorAutonomousPlayable.cliOutput = output; const report = parseSingleSwarmTurnReport( output, 'supervisor-autonomous-playable', @@ -291,6 +369,9 @@ export function buildSupervisorAutonomousPlayablePartialEvidence( partialEvidenceReadErrorCount = 0, } = {}, ) { + const productionDefaultGameIndexSha256 = hashValue( + Buffer.from(productionDefaultGameIndexHtml(), 'utf8'), + ); const parentTask = persistence.taskSnapshot.latest.find( (task) => task.agentId === projectSupervisorAgentId && @@ -410,6 +491,9 @@ export function buildSupervisorAutonomousPlayablePartialEvidence( summarizeSupervisorAutonomousPlayableSourcePayloadAudits( persistence.agentDb, ); + const providerBinding = + state.supervisorAutonomousPlayable.effectiveProviderBinding ?? + state.supervisorAutonomousPlayable.expectedProviderBinding; return { evidenceCompleteness: 'partial', @@ -481,6 +565,20 @@ export function buildSupervisorAutonomousPlayablePartialEvidence( stdinEndedAfterTask: state.supervisorAutonomousPlayable.stdinEnded, stdinBytes: state.supervisorAutonomousPlayable.stdinBytes, taskSha256: state.initialTask?.sha256 ?? null, + providerAgentMode: providerBinding?.providerAgentMode ?? null, + providerModel: providerBinding?.providerModel ?? null, + providerApiKind: providerBinding?.providerApiKind ?? null, + providerReasoningEffort: providerBinding?.providerReasoningEffort ?? null, + providerBaseUrlSha256: providerBinding?.providerBaseUrlSha256 ?? null, + providerBoundAgentCount: providerBinding?.boundAgentIds?.length ?? 0, + providerBindingMatched: + state.supervisorAutonomousPlayable.effectiveProviderBinding != null && + JSON.stringify( + state.supervisorAutonomousPlayable.effectiveProviderBinding, + ) === + JSON.stringify( + state.supervisorAutonomousPlayable.expectedProviderBinding, + ), turnReportCaptured, turnReportParentIdentityStable, turnReportNewAssistantMessageCount: @@ -587,6 +685,11 @@ export function buildSupervisorAutonomousPlayablePartialEvidence( finalizationJournalCount: residualSidecars.finalizations ?? 0, reconciliationResidueCount: supervisorAutonomousPlayableReconciliationResidueCount(persistence), + freshInitBaselineUsed: + state.supervisorAutonomousPlayable.freshInitBaselineUsed, + initialGameIndexMatchesProductionDefault: + state.supervisorAutonomousPlayable.initialGameIndexSha256 === + productionDefaultGameIndexSha256, initialGameIndexSha256: state.supervisorAutonomousPlayable.initialGameIndexSha256, }; @@ -795,12 +898,383 @@ export async function waitForSupervisorAutonomousPlayableDurableQuiescence() { throw codedError('supervisor-autonomous-playable-residue-timeout'); } +export function validateSupervisorGameChatSingleMainTopology( + persistence, + runProfileBindings, + { workflowDecision, assetCoverage, assetRoute }, +) { + const tasks = persistence.taskSnapshot.latest; + const runtimes = persistence.runtimeStates; + const runKey = (record) => `${record.agentId}\0${record.runId}`; + const parentKey = (record) => + isNonEmptyString(record.parentAgentId) && + isNonEmptyString(record.parentRunId) + ? `${record.parentAgentId}\0${record.parentRunId}` + : null; + const rootKey = `${projectSupervisorAgentId}\0${state.initialRunId}`; + const rootTasks = tasks.filter( + (candidate) => + candidate.agentId === projectSupervisorAgentId && + candidate.parentAgentId == null && + candidate.parentRunId == null, + ); + const rootRuntimes = runtimes.filter( + (candidate) => + candidate.agentId === projectSupervisorAgentId && + candidate.parentAgentId == null && + candidate.parentRunId == null, + ); + assert( + rootTasks.length === 1 && + rootRuntimes.length === 1 && + runKey(rootTasks[0]) === rootKey && + runKey(rootRuntimes[0]) === rootKey, + 'supervisor-game-chat-single-main-root-run-not-unique', + ); + + const tasksByKey = new Map(); + const runtimesByKey = new Map(); + for (const task of tasks) { + assert( + isNonEmptyString(task.agentId) && + isNonEmptyString(task.runId) && + !tasksByKey.has(runKey(task)), + 'supervisor-game-chat-single-main-task-identity-duplicate', + ); + tasksByKey.set(runKey(task), task); + } + for (const runtime of runtimes) { + assert( + isNonEmptyString(runtime.agentId) && + isNonEmptyString(runtime.runId) && + !runtimesByKey.has(runKey(runtime)), + 'supervisor-game-chat-single-main-runtime-identity-duplicate', + ); + runtimesByKey.set(runKey(runtime), runtime); + } + const childrenByParent = new Map(); + for (const task of tasks) { + const parent = parentKey(task); + if (!parent) continue; + const children = childrenByParent.get(parent) ?? []; + children.push(task); + childrenByParent.set(parent, children); + } + const reachableKeys = new Set(); + const queue = [rootTasks[0]]; + const artTasks = []; + while (queue.length > 0) { + const task = queue.shift(); + const key = runKey(task); + assert( + !reachableKeys.has(key), + 'supervisor-game-chat-single-main-task-cycle-detected', + ); + reachableKeys.add(key); + const runtime = runtimesByKey.get(key); + assert( + task.status === 'completed' && + task.phase === 'completed' && + runtime?.phase === 'completed' && + runtime.pendingToolAction == null && + runtime.pendingAction == null, + 'supervisor-game-chat-single-main-descendant-not-completed', + ); + const children = childrenByParent.get(key) ?? []; + if (task.agentId === projectSupervisorAgentId) { + assert( + children.length === 1 && + children[0].agentId === gameChatMainAgentId && + children[0].source === 'agent-ready-task-scheduler', + 'supervisor-game-chat-single-main-code-child-invalid', + ); + } else if (task.agentId === gameChatMainAgentId) { + assert( + children.length <= gameChatArtAgentIds.length && + children.every( + (child) => + gameChatArtAgentIds.includes(child.agentId) && + child.source === 'agent-delegate', + ) && + new Set(children.map((child) => child.agentId)).size === + children.length, + 'supervisor-game-chat-single-main-art-child-topology-invalid', + ); + artTasks.push(...children); + } else { + assert( + gameChatArtAgentIds.includes(task.agentId) && children.length === 0, + 'supervisor-game-chat-single-main-nested-child-forbidden', + ); + } + queue.push(...children); + } + assert( + reachableKeys.size === tasks.length && + reachableKeys.size === runtimes.length && + [...runtimesByKey].every( + ([key, runtime]) => + reachableKeys.has(key) && + parentKey(runtime) === parentKey(tasksByKey.get(key)), + ), + 'supervisor-game-chat-single-main-unreachable-run-detected', + ); + + const bindingGroups = new Map(); + for (const binding of runProfileBindings) { + const key = runKey(binding); + const group = bindingGroups.get(key) ?? []; + group.push(binding); + bindingGroups.set(key, group); + } + assert( + runProfileBindings.length === reachableKeys.size && + bindingGroups.size === reachableKeys.size && + [...bindingGroups.keys()].every((key) => reachableKeys.has(key)), + 'supervisor-game-chat-single-main-binding-set-invalid', + ); + for (const key of reachableKeys) { + const task = tasksByKey.get(key); + const binding = bindingGroups.get(key)?.[0]; + const parentTask = parentKey(task) ? tasksByKey.get(parentKey(task)) : null; + const parentBinding = parentTask + ? bindingGroups.get(runKey(parentTask))?.[0] + : null; + assert( + bindingGroups.get(key)?.length === 1 && + isNonEmptyString(binding?.bindingFingerprint) && + binding.source === task.source && + binding?.profile === autonomousGameBuildRunProfile && + binding.rootAgentId === projectSupervisorAgentId && + binding.rootRunId === state.initialRunId && + (parentTask + ? isNonEmptyString(parentBinding?.bindingFingerprint) && + binding.parentAgentId === parentTask.agentId && + binding.parentRunId === parentTask.runId && + binding.parentBindingFingerprint === + parentBinding?.bindingFingerprint + : binding.parentAgentId == null && + binding.parentRunId == null && + binding.parentBindingFingerprint == null), + 'supervisor-game-chat-single-main-binding-chain-invalid', + ); + } + + const codeTask = tasks.find( + (task) => + task.agentId === gameChatMainAgentId && + task.parentAgentId === projectSupervisorAgentId && + task.parentRunId === state.initialRunId, + ); + assert( + workflowDecision?.rootAgentId === projectSupervisorAgentId && + workflowDecision.rootRunId === state.initialRunId && + workflowDecision.strategy === 'audit-existing-first' && + assetCoverage?.rootRunId === state.initialRunId && + assetCoverage.auditedByAgentId === gameChatMainAgentId && + assetCoverage.auditedByRunId === codeTask.runId && + JSON.stringify(assetCoverage.requiredSlots) === + JSON.stringify([...gameChatArtSlotToAgent.keys()]) && + Array.isArray(assetCoverage.missingSlots) && + new Set(assetCoverage.missingSlots).size === + assetCoverage.missingSlots.length && + assetCoverage.missingSlots.every((slot) => + gameChatArtSlotToAgent.has(slot), + ) && + assetRoute?.rootRunId === state.initialRunId, + 'supervisor-game-chat-single-main-asset-route-binding-invalid', + ); + const expectedGeneratedAgentIds = assetCoverage.missingSlots.map((slot) => + gameChatArtSlotToAgent.get(slot), + ); + const expectedReusedAgentIds = [...gameChatArtSlotToAgent] + .filter(([slot]) => !assetCoverage.missingSlots.includes(slot)) + .map(([, agentId]) => agentId); + assert( + assetRoute.strategy === + (expectedGeneratedAgentIds.length === 0 + ? 'use-existing-art' + : 'generate-missing-art') && + JSON.stringify(assetRoute.generatedTaskIds) === + JSON.stringify(expectedGeneratedAgentIds) && + JSON.stringify(assetRoute.reusedTaskIds) === + JSON.stringify(expectedReusedAgentIds) && + JSON.stringify(artTasks.map((task) => task.agentId).sort()) === + JSON.stringify([...expectedGeneratedAgentIds].sort()), + 'supervisor-game-chat-single-main-art-gap-mismatch', + ); + + const actionReceiptIndexes = new Map(); + persistence.agentDb.forEach((record, index) => { + if ( + record.recordType === 'agent.runtime.action_receipt' && + record.agentId === gameChatMainAgentId && + record.runId === codeTask.runId && + isNonEmptyString(record.actionId) + ) { + actionReceiptIndexes.set(record.actionId, { record, index }); + } + }); + const assetListIndexes = [...actionReceiptIndexes.values()] + .filter( + ({ record }) => record.tool === 'asset.list' && record.status === 'ok', + ) + .map(({ index }) => index); + assert( + assetListIndexes.length >= 1, + 'supervisor-game-chat-single-main-asset-list-evidence-missing', + ); + const artDeliveryByAgent = new Map(); + for (const artTask of artTasks) { + const expectedArtifact = gameChatArtExpectedArtifact.get(artTask.agentId); + const deliveries = persistence.deliveries.filter( + (delivery) => + delivery.parentAgentId === gameChatMainAgentId && + delivery.parentRunId === codeTask.runId && + delivery.targetAgentId === artTask.agentId && + delivery.targetRunId === artTask.runId && + delivery.delegationId === artTask.delegationId, + ); + const delivery = deliveries[0]; + const delegateReceipt = actionReceiptIndexes.get(delivery?.parentActionId); + const claims = persistence.claims.filter((claim) => + (claim.receipts ?? []).some( + (receipt) => receipt.delegationId === delivery?.delegationId, + ), + ); + const claim = claims[0]; + const claimReceipt = actionReceiptIndexes.get(claim?.actionId); + const claimedReceipt = claim?.receipts?.find( + (candidate) => candidate.delegationId === delivery?.delegationId, + ); + const resultArtifacts = delivery?.structuredResult?.artifacts; + assert( + deliveries.length === 1 && + delivery.schemaVersion === staticDelegateDeliverySchemaVersion && + delivery.status === 'claimed-by-parent' && + delivery.terminalStatus === 'completed' && + delivery.repairOfDelegationId == null && + JSON.stringify(delivery.expectedArtifacts) === + JSON.stringify([expectedArtifact]) && + delivery.structuredResult?.contractStatus === 'evidence-ready' && + Array.isArray(resultArtifacts) && + resultArtifacts.length > 0 && + resultArtifacts.some( + (artifact) => artifact.path === expectedArtifact, + ) && + resultArtifacts.every( + (artifact) => + isNonEmptyString(artifact.path) && + !path.isAbsolute(artifact.path) && + artifact.path.startsWith('assets/') && + !artifact.path.split('/').includes('..') && + /^[0-9a-f]{64}$/u.test(artifact.sha256), + ) && + delegateReceipt?.record.tool === 'agent.delegate' && + delegateReceipt.record.status === 'ok' && + assetListIndexes.some((index) => index < delegateReceipt.index) && + claims.length === 1 && + claim.schemaVersion === staticDelegateClaimSchemaVersion && + claim.parentAgentId === gameChatMainAgentId && + claim.parentRunId === codeTask.runId && + claim.status === 'observed' && + claimReceipt?.record.tool === 'agent.run_status' && + claimReceipt.record.status === 'ok' && + delivery.claimedByActionId === claim.actionId && + claimedReceipt?.targetAgentId === artTask.agentId && + claimedReceipt.status === delivery.terminalStatus, + 'supervisor-game-chat-single-main-art-delivery-invalid', + ); + artDeliveryByAgent.set(artTask.agentId, { + delivery, + delegateReceiptIndex: delegateReceipt.index, + claimReceiptIndex: claimReceipt.index, + }); + } + if ( + artDeliveryByAgent.has('art-director') && + artDeliveryByAgent.has('art-asset-plan') + ) { + assert( + artDeliveryByAgent.get('art-director').claimReceiptIndex >= 0 && + artDeliveryByAgent.get('art-director').claimReceiptIndex < + artDeliveryByAgent.get('art-asset-plan').delegateReceiptIndex, + 'supervisor-game-chat-single-main-art-order-invalid', + ); + } + return { + uniqueRootRunCount: rootTasks.length, + recursiveDescendantCount: reachableKeys.size - 1, + completedDescendantCount: reachableKeys.size - 1, + artChildCount: artTasks.length, + artDeliveryClaimCount: artDeliveryByAgent.size, + assetListBeforeArtDelegation: true, + }; +} + +export function supervisorAutonomousPlayableStaticSmokeMatchesFinalIndex( + gate, + revision, + finalGameIndexSha256, +) { + return ( + gate?.lastVerificationStatus === 'passed' && + gate.verifiedRevision === revision && + gate.staticSmokeVerifiedRevision === revision && + gate.staticSmokeVerifiedGameIndexSha256 === finalGameIndexSha256 + ); +} + +export function supervisorGameChatFreshInitAcceptanceEvidence({ + freshInitBaselineUsed, + initialGameIndexSha256, + productionDefaultGameIndexSha256, + uniqueFixedChildCodePrototype, + finalGameIndexSha256, + gate, + revision, + viewportResults, +}) { + return { + freshInitBaselineUsed: freshInitBaselineUsed === true, + initialGameIndexMatchesProductionDefault: + /^[0-9a-f]{64}$/u.test(initialGameIndexSha256 ?? '') && + initialGameIndexSha256 === productionDefaultGameIndexSha256, + uniqueFixedChildCodePrototype: uniqueFixedChildCodePrototype === true, + finalGameIndexDiffersFromBaseline: + /^[0-9a-f]{64}$/u.test(finalGameIndexSha256 ?? '') && + finalGameIndexSha256 !== initialGameIndexSha256, + staticSmokeGameIndexSha256Matched: + supervisorAutonomousPlayableStaticSmokeMatchesFinalIndex( + gate, + revision, + finalGameIndexSha256, + ), + desktopPlaytestPassed: + viewportResults?.find((entry) => entry.viewport === 'desktop')?.passed === + true, + mobilePlaytestPassed: + viewportResults?.find((entry) => entry.viewport === 'mobile')?.passed === + true, + }; +} + export async function validateSupervisorAutonomousPlayableEvidence( persistence, residualSidecars, ) { const task = supervisorAutonomousPlayableLaneDefenseTask; const mode = supervisorAutonomousPlayableMode(); + const productionDefaultGameIndex = Buffer.from( + productionDefaultGameIndexHtml(), + 'utf8', + ); + const productionDefaultGameIndexSha256 = hashValue( + productionDefaultGameIndex, + ); + const initialGameIndexMatchesProductionDefault = + state.supervisorAutonomousPlayable.initialGameIndexSha256 === + productionDefaultGameIndexSha256; const report = state.supervisorAutonomousPlayable.turnReport; assert( report?.schemaVersion === 'game-creator-swarm-turn-report.v1' && @@ -886,6 +1360,12 @@ export async function validateSupervisorAutonomousPlayableEvidence( ...persistence.supervisorConversation, ...persistence.legacyConversation, ]; + const conversationBoundary = + inspectSupervisorAutonomousPlayableConversationBoundary( + persistence.legacyConversation, + userFacing, + state.initialRunId, + ); const allMessages = [ ...userFacing, ...professionalMessages, @@ -919,14 +1399,9 @@ export async function validateSupervisorAutonomousPlayableEvidence( supervisorSwarmSessionId, state.initialRunId, ) && - persistence.legacyConversation.filter((message) => - ['user', 'assistant'].includes(message.role), - ).length === 0 && - userFacing.filter( - (message) => - message.role === 'assistant' && - message.agentId !== projectSupervisorAgentId, - ).length === 0 && + conversationBoundary.legacyUserFacingMessageCount === 1 && + conversationBoundary.acceptedPublicStatusCount === 1 && + conversationBoundary.wrongAgentFinalAssistantCount === 0 && parentAssistantAudits.length === 1 && parentCompletedAudits.length === 1 && duplicateMessageCount === 0, @@ -1046,6 +1521,8 @@ export async function validateSupervisorAutonomousPlayableEvidence( const receipt = receipts[0]; let evidenceBinding = binding; let evidenceRunId = state.initialRunId; + let gameChatTopologyEvidence = {}; + let uniqueFixedChildCodePrototype = false; if (mode.gameChatSingleMain) { const manifest = JSON.parse( decodeUtf8Fatal( @@ -1103,7 +1580,32 @@ export async function validateSupervisorAutonomousPlayableEvidence( codeBindings.length === 1, 'supervisor-game-chat-single-main-binding-invalid', ); + uniqueFixedChildCodePrototype = true; evidenceBinding = codeBindings[0]; + const [workflowDecisions, assetCoverages, assetRoutes] = await Promise.all([ + readSupervisorSwarmJsonDirectory( + '.agent/runtime/game-chat-workflow-decisions', + ), + readSupervisorSwarmJsonDirectory( + '.agent/runtime/game-chat-asset-coverage', + ), + readSupervisorSwarmJsonDirectory('.agent/runtime/game-chat-asset-routes'), + ]); + assert( + workflowDecisions.length === 1 && + assetCoverages.length === 1 && + assetRoutes.length === 1, + 'supervisor-game-chat-single-main-asset-sidecar-count-invalid', + ); + gameChatTopologyEvidence = validateSupervisorGameChatSingleMainTopology( + persistence, + runProfileBindings, + { + workflowDecision: workflowDecisions[0], + assetCoverage: assetCoverages[0], + assetRoute: assetRoutes[0], + }, + ); } const contractIdentity = { schemaVersion: contract.schemaVersion, @@ -1134,6 +1636,11 @@ export async function validateSupervisorAutonomousPlayableEvidence( state.supervisorAutonomousPlayable.initialGameIndexSha256 && Number.isInteger(contract.baselineArtifacts[0]?.sizeBytes) && contract.baselineArtifacts[0].sizeBytes > 0 && + (!mode.gameChatSingleMain || + (state.supervisorAutonomousPlayable.freshInitBaselineUsed === true && + initialGameIndexMatchesProductionDefault && + contract.baselineArtifacts[0].sizeBytes === + productionDefaultGameIndex.length)) && contract.playtestScenario === 'lane-defense-v1' && contract.contractFingerprint === hashJsonValue(contractIdentity), 'supervisor-autonomous-playable-completion-contract-invalid', @@ -1171,9 +1678,11 @@ export async function validateSupervisorAutonomousPlayableEvidence( ); assert( gates.length === 1 && - gate.lastVerificationStatus === 'passed' && - gate.verifiedRevision === revision.revision && - gate.staticSmokeVerifiedRevision === revision.revision && + supervisorAutonomousPlayableStaticSmokeMatchesFinalIndex( + gate, + revision.revision, + finalGameIndexSha256, + ) && staticSmokeAudits.length >= 1, 'supervisor-autonomous-playable-static-smoke-invalid', ); @@ -1269,6 +1778,12 @@ export async function validateSupervisorAutonomousPlayableEvidence( const mobile = screenshotEntries.find(({ digest }) => digest.path.endsWith('/mobile.png'), ); + const desktopViewport = browserReport.viewportResults?.find( + (entry) => entry.viewport === 'desktop', + ); + const mobileViewport = browserReport.viewportResults?.find( + (entry) => entry.viewport === 'mobile', + ); const playtest = browserReport.playtest; const assertionNames = new Set( (playtest?.assertions ?? []).map((entry) => entry.name), @@ -1283,7 +1798,8 @@ export async function validateSupervisorAutonomousPlayableEvidence( JSON.stringify( browserReport.viewportResults.map((entry) => entry.viewport).sort(), ) === JSON.stringify(['desktop', 'mobile']) && - browserReport.viewportResults.every((entry) => entry.passed === true) && + desktopViewport?.passed === true && + mobileViewport?.passed === true && playtest?.scenario === 'lane-defense-v1' && playtest.passed === true && playtest.scenarioFingerprint === receipt.scenarioFingerprint && @@ -1293,6 +1809,26 @@ export async function validateSupervisorAutonomousPlayableEvidence( ), 'supervisor-autonomous-playable-lane-defense-playtest-invalid', ); + const gameChatFreshInitAcceptance = + supervisorGameChatFreshInitAcceptanceEvidence({ + freshInitBaselineUsed: + state.supervisorAutonomousPlayable.freshInitBaselineUsed, + initialGameIndexSha256: + state.supervisorAutonomousPlayable.initialGameIndexSha256, + productionDefaultGameIndexSha256, + uniqueFixedChildCodePrototype, + finalGameIndexSha256, + gate, + revision: revision.revision, + viewportResults: browserReport.viewportResults, + }); + assert( + !mode.gameChatSingleMain || + Object.values(gameChatFreshInitAcceptance).every( + (value) => value === true, + ), + 'supervisor-game-chat-fresh-init-acceptance-evidence-invalid', + ); const lifecycle = persistence.agentDb.filter( (record) => @@ -1385,6 +1921,13 @@ export async function validateSupervisorAutonomousPlayableEvidence( const publicLeaks = collectSupervisorSwarmPublicLeakEvidence(persistence, { requireZero: true, }); + const projectLureLeakCount = await countLureLeaks(); + const cliLureLeakCount = countExactSecrets( + Buffer.from(state.supervisorAutonomousPlayable.cliOutput), + state.lures, + ); + state.lureLeakCount = projectLureLeakCount + cliLureLeakCount; + assert(state.lureLeakCount === 0, 'sensitive-lure-leak-detected'); state.supervisorAutonomousPlayable.privateValues = [ ...new Set(state.supervisorSwarm.privateValues), ]; @@ -1438,6 +1981,22 @@ export async function validateSupervisorAutonomousPlayableEvidence( turnReport: report, }, ); + const providerBinding = + state.supervisorAutonomousPlayable.effectiveProviderBinding; + assert( + providerBinding && + JSON.stringify(providerBinding) === + JSON.stringify( + state.supervisorAutonomousPlayable.expectedProviderBinding, + ) && + (!isSupervisorGameChatSingleMainPlayableSuite() || + providerBinding.providerAgentMode === 'provider') && + isNonEmptyString(providerBinding.providerModel) && + isNonEmptyString(providerBinding.providerApiKind) && + isNonEmptyString(providerBinding.providerReasoningEffort) && + /^[0-9a-f]{64}$/u.test(providerBinding.providerBaseUrlSha256), + 'supervisor-autonomous-playable-provider-binding-invalid', + ); return { ...emptySupervisorAutonomousPlayableEvidence(), @@ -1450,6 +2009,14 @@ export async function validateSupervisorAutonomousPlayableEvidence( stdinEndedAfterTask: state.supervisorAutonomousPlayable.stdinEnded, stdinBytes: state.supervisorAutonomousPlayable.stdinBytes, taskSha256: hashValue(task), + providerAgentMode: providerBinding.providerAgentMode, + providerModel: providerBinding.providerModel, + providerApiKind: providerBinding.providerApiKind, + providerReasoningEffort: providerBinding.providerReasoningEffort, + providerBaseUrlSha256: providerBinding.providerBaseUrlSha256, + providerBoundAgentCount: providerBinding.boundAgentIds.length, + providerBindingMatched: true, + ...gameChatTopologyEvidence, turnReportCaptured: true, turnReportParentIdentityStable: true, turnReportNewAssistantMessageCount: report.newAssistantMessageCount, @@ -1520,6 +2087,7 @@ export async function validateSupervisorAutonomousPlayableEvidence( baselineRevision: contract.baselineRevision, projectRevision: revision.revision, projectRevisionDelta: revision.revision - contract.baselineRevision, + ...gameChatFreshInitAcceptance, initialGameIndexSha256: state.supervisorAutonomousPlayable.initialGameIndexSha256, finalGameIndexSha256, @@ -1578,12 +2146,37 @@ export async function validateSupervisorAutonomousPlayableEvidence( export async function runSupervisorAutonomousPlayableLaneDefenseE2e() { await ensureOwnedRunnerStableKillSupport(); - await seedDisposableProject(); - const initialGameIndex = await fs.readFile( - path.join(state.projectRoot, 'game/index.html'), - ); - state.supervisorAutonomousPlayable.initialGameIndexSha256 = - hashValue(initialGameIndex); + const mode = supervisorAutonomousPlayableMode(); + state.supervisorAutonomousPlayable.freshInitBaselineUsed = + mode.gameChatSingleMain; + await seedDisposableProject({ + preserveProductionInitBaseline: mode.gameChatSingleMain, + }); + if (mode.gameChatSingleMain) { + const generatedBaselinePaths = [ + 'package.json', + 'verify-e2e.mjs', + 'game/index.html', + ]; + const generatedBaselineMetadata = await Promise.all( + generatedBaselinePaths.map((relativePath) => + fs.lstat(path.join(state.projectRoot, relativePath)).catch(() => null), + ), + ); + assert( + generatedBaselineMetadata.every((metadata) => metadata === null), + 'supervisor-game-chat-fresh-init-project-not-empty', + ); + state.supervisorAutonomousPlayable.initialGameIndexSha256 = hashValue( + Buffer.from(productionDefaultGameIndexHtml(), 'utf8'), + ); + } else { + const initialGameIndex = await fs.readFile( + path.join(state.projectRoot, 'game/index.html'), + ); + state.supervisorAutonomousPlayable.initialGameIndexSha256 = + hashValue(initialGameIndex); + } state.cliBinary = await prepareCliBinary(); await prepareIsolatedSuiteAppData(); state.isolatedRunner.launchAttempted = true; @@ -1597,7 +2190,6 @@ export async function runSupervisorAutonomousPlayableLaneDefenseE2e() { state.supervisorAutonomousPlayable.privateValues = [task]; state.supervisorSwarm.userTask = task; state.supervisorSwarm.privateValues = [task]; - const mode = supervisorAutonomousPlayableMode(); state.supervisorAutonomousPlayableCliSession = startInteractiveCli([ '--swarm-chat', '--init', @@ -1617,7 +2209,6 @@ export async function runSupervisorAutonomousPlayableLaneDefenseE2e() { const report = await waitForSupervisorAutonomousPlayableCliExit( state.supervisorAutonomousPlayableCliSession, ); - state.supervisorAutonomousPlayableCliSession = null; assert( report.outcome === 'settled', 'supervisor-autonomous-playable-turn-not-settled', @@ -1649,6 +2240,13 @@ export function emptySupervisorAutonomousPlayableEvidence() { stdinEndedAfterTask: false, stdinBytes: 0, taskSha256: null, + providerAgentMode: null, + providerModel: null, + providerApiKind: null, + providerReasoningEffort: null, + providerBaseUrlSha256: null, + providerBoundAgentCount: 0, + providerBindingMatched: false, evidenceCompleteness: 'none', partialEvidenceCollected: false, partialEvidenceReadErrorCount: 0, @@ -1694,6 +2292,12 @@ export function emptySupervisorAutonomousPlayableEvidence() { childTaskCount: 0, runtimeCount: 0, childRuntimeCount: 0, + uniqueRootRunCount: 0, + recursiveDescendantCount: 0, + completedDescendantCount: 0, + artChildCount: 0, + artDeliveryClaimCount: 0, + assetListBeforeArtDelegation: false, finalSupervisorAssistantCount: 0, supervisorUserMessageCount: 0, professionalAssistantCount: 0, @@ -1724,11 +2328,16 @@ export function emptySupervisorAutonomousPlayableEvidence() { baselineRevision: 0, projectRevision: 0, projectRevisionDelta: 0, + freshInitBaselineUsed: false, + initialGameIndexMatchesProductionDefault: false, initialGameIndexSha256: null, finalGameIndexSha256: null, gameIndexChanged: false, + finalGameIndexDiffersFromBaseline: false, + uniqueFixedChildCodePrototype: false, gameIndexBytes: 0, staticSmokePassed: false, + staticSmokeGameIndexSha256Matched: false, staticSmokeAuditCount: 0, autonomousCompletionContractCount: 0, autonomousPlaytestReceiptCount: 0, @@ -1737,6 +2346,8 @@ export function emptySupervisorAutonomousPlayableEvidence() { laneDefenseAssertionCount: 0, laneDefensePassedAssertionCount: 0, browserValidationPassed: false, + desktopPlaytestPassed: false, + mobilePlaytestPassed: false, desktopScreenshotSha256: null, desktopScreenshotBytes: 0, mobileScreenshotSha256: null, @@ -1770,6 +2381,20 @@ export function emptySupervisorAutonomousPlayableEvidence() { supervisorAutonomousPlayableReportLeakCount: 0, supervisorAutonomousPlayableRunnerStopped: false, supervisorAutonomousPlayableAppDataCleanupPerformed: false, + ownedProcessIdentityCaptured: false, + ownedRunnerObservedCount: 0, + ownedHelperObservedCount: 0, + ownedNodeDescendantObservedCount: 0, + ownedBrowserDescendantObservedCount: 0, + ownedCommandDescendantObservedCount: 0, + ownedRunnerResidualCount: 0, + ownedHelperResidualCount: 0, + ownedNodeDescendantResidualCount: 0, + ownedBrowserDescendantResidualCount: 0, + ownedCommandDescendantResidualCount: 0, + activeCommandChildrenAfterCleanup: 0, + activeInteractiveCliSessionsAfterCleanup: 0, + ownedProcessCleanupPassed: false, secretLeakCount: 0, lureLeakCount: 0, paths: [], diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-swarm/persistence.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-swarm/persistence.mjs index 877a8aa70..278c7acf3 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-swarm/persistence.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-swarm/persistence.mjs @@ -90,6 +90,37 @@ export async function listOptionalSupervisorSwarmFile(file) { return [file]; } +export function supervisorSwarmProfessionalSessions({ + deliveries = [], + latestTasks = [], + rootAgentId, + rootRunId, +}) { + const sessions = new Map(); + const registerSession = (agentId, sessionId) => { + if (!isNonEmptyString(agentId) || !isNonEmptyString(sessionId)) return; + sessions.set(`${agentId}\0${sessionId}`, { agentId, sessionId }); + }; + for (const delivery of deliveries) { + registerSession(delivery.targetAgentId, delivery.targetSessionId); + } + if (!isNonEmptyString(rootAgentId) || !isNonEmptyString(rootRunId)) { + return [...sessions.values()]; + } + for (const task of latestTasks) { + if ( + task.source !== 'agent-ready-task-scheduler' || + task.agentId === rootAgentId || + task.parentAgentId !== rootAgentId || + task.parentRunId !== rootRunId + ) { + continue; + } + registerSession(task.agentId, task.sessionId); + } + return [...sessions.values()]; +} + export async function readSupervisorSwarmPersistence({ tolerateErrors = false, } = {}) { @@ -274,25 +305,15 @@ export async function readSupervisorSwarmPersistence({ ), ); const supervisorConversation = supervisorConversationSurface.records; - const professionalSessions = new Map(); - for (const delivery of deliveries) { - if ( - !isNonEmptyString(delivery.targetAgentId) || - !isNonEmptyString(delivery.targetSessionId) - ) { - continue; - } - professionalSessions.set( - `${delivery.targetAgentId}\0${delivery.targetSessionId}`, - { - agentId: delivery.targetAgentId, - sessionId: delivery.targetSessionId, - }, - ); - } + const professionalSessions = supervisorSwarmProfessionalSessions({ + deliveries, + latestTasks: taskSnapshot.latest, + rootAgentId: projectSupervisorAgentId, + rootRunId: state.initialRunId, + }); const professionalConversations = []; const professionalConversationErrors = []; - for (const session of professionalSessions.values()) { + for (const session of professionalSessions) { const conversationSurface = await readJsonlSurface( 'professional-conversation', () => diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index a541abce3..560d8ee6f 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1186,6 +1186,7 @@ const allowedLlmReasoningEfforts = new Set([ 'low', 'medium', 'high', + 'max', ]); if (defaultAppConfig.llm?.reasoningEffort !== 'high') { diff --git a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs index 7cd45583a..4289684ec 100644 --- a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs @@ -43,7 +43,6 @@ export const deterministicManifestReadyAgentIds = Object.freeze([ const deterministicReadOnlyReadyAgentIds = new Set([ 'design-director', 'balance-director', - 'art-director', 'art-polish', 'audio-director', 'code-director', @@ -82,7 +81,11 @@ function pngChunk(type, data) { return Buffer.concat([length, typeBytes, data, crc]); } -function deterministicPng(width, height) { +function deterministicPng( + width, + height, + { variant = 0, transparent = false } = {}, +) { const stride = width * 4 + 1; const pixels = Buffer.alloc(stride * height); for (let y = 0; y < height; y += 1) { @@ -91,10 +94,11 @@ function deterministicPng(width, height) { for (let x = 0; x < width; x += 1) { const offset = row + 1 + x * 4; const tile = (Math.floor(x / 48) + Math.floor(y / 48)) % 6; - pixels[offset] = (42 + x + tile * 29) % 256; - pixels[offset + 1] = (86 + y * 2 + tile * 17) % 256; - pixels[offset + 2] = (118 + x + y + tile * 31) % 256; - pixels[offset + 3] = 255; + pixels[offset] = (42 + x + tile * 29 + variant * 13) % 256; + pixels[offset + 1] = (86 + y * 2 + tile * 17 + variant * 19) % 256; + pixels[offset + 2] = (118 + x + y + tile * 31 + variant * 23) % 256; + pixels[offset + 3] = + transparent && (x + y + variant) % 11 === 0 ? 0 : 255; } } const ihdr = Buffer.alloc(13); @@ -120,7 +124,7 @@ export function deterministicLaneDefenseInitialHtml() { *{box-sizing:border-box}body{margin:0;min-height:100vh;background:#f4f8ee;color:#18351f;font:16px system-ui,sans-serif}main{width:min(960px,100%);margin:auto;padding:18px}h1{margin:0 0 4px;font-size:clamp(28px,7vw,46px)}p{margin:4px 0 14px}.toolbar,.plants{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0}button{min-height:44px;border:1px solid #315d35;background:#fff;color:#18351f;padding:9px 14px;font:inherit;font-weight:700;cursor:pointer}button:hover{background:#e6f3dc}.board{display:grid;gap:10px;background:#d9edc8;border:2px solid #315d35;padding:10px}.lane{display:grid;grid-template-columns:repeat(5,1fr);gap:6px}.cell{min-height:54px;background:#eef8e7}.status{font-weight:700;min-height:24px}#game{display:none;width:100%;height:auto;aspect-ratio:20/9;background:#18351f;border:2px solid #315d35}@media(max-width:520px){main{padding:12px}button{flex:1 1 44%}.cell{min-height:44px}} -
Garden defenders +
Garden defenders

灵露花园

GENARRATIVE_REAL_E2E_VISIBLE

Goal: defend the garden and win every wave.

@@ -128,6 +132,7 @@ export function deterministicLaneDefenseInitialHtml() {
`; } @@ -374,12 +379,12 @@ function isUiPrototypeInspectionRequest(payload, context) { : false; return ( hasInputImage && - context.includes('resourceBar') && - context.includes('unitCardTray') && - context.includes('battlefieldGrid') && - context.includes('enemyEntryDirection') && - context.includes('waveStatus') && + context.includes('informationHud') && + context.includes('gameplaySurface') && + context.includes('objectiveEntities') && context.includes('primaryControls') && + context.includes('failureRestartFlow') && + context.includes('responsiveLayout') && context.includes('implementationClarity') && context.includes('originalTheme') ); @@ -397,6 +402,152 @@ function nativeResponse(response) { return { name: 'respond_to_user', arguments: { response } }; } +function goalContractCall() { + return nativeAction( + 'agent.goal_contract', + '冻结本轮可玩游戏交付目标与验收标准', + { + outcome: + '交付一个可完整游玩的原创植物塔防网页游戏,并让当前最终 revision 通过自动静态检查和桌面、移动双视口真实试玩。', + nonNegotiables: [ + '必须形成从开始、放置防御单位、敌人移动与受伤到胜利、下一关和重开的完整闭环', + '必须在无人确认、无人补充输入和无人 steer 的条件下完成', + ], + preferences: ['保持单页自包含并提供清晰的桌面与移动端操作反馈'], + forbiddenAssumptions: [ + '不得把静态页面、占位素材或未执行的试玩声明为完成', + ], + openQuestions: [], + acceptanceNodes: [ + { + criterionId: 'static-current-revision', + criterion: + '当前最终 revision 的游戏入口通过可执行 JavaScript、Canvas 与完整玩法合同静态检查', + required: true, + requiredEvidence: ['tool:command.run_limited'], + dependsOn: [], + }, + { + criterionId: 'playable-current-revision', + criterion: + '当前最终 revision 的完整塔防循环在桌面与移动视口均通过真实浏览器试玩', + required: true, + requiredEvidence: ['tool:preview.validate'], + dependsOn: ['static-current-revision'], + }, + ], + }, + ); +} + +function actionHistoryCall() { + return nativeAction( + 'agent.action_history', + '读取当前根 Run 的最终静态检查与真实试玩动作回执', + { + runId: null, + actionId: null, + tool: null, + status: 'ok', + limit: 10, + }, + ); +} + +function goalContractFingerprint(context) { + const marker = '[Root Goal Contract]'; + const start = context.lastIndexOf(marker); + if (start < 0) return null; + const tail = context.slice(start); + const end = tail.indexOf('\n\n'); + const goalContractContext = end < 0 ? tail : tail.slice(0, end); + const matches = new Set( + [ + ...goalContractContext.matchAll( + /"contractFingerprint"\s*:\s*"([0-9a-f]{64})"/giu, + ), + ].map((match) => match[1].toLowerCase()), + ); + return matches.size === 1 ? [...matches][0] : null; +} + +function finalAcceptanceEvidence(context, expectedRunId) { + const observation = latestToolObservation(context); + if ( + observation?.tool !== 'agent.action_history' || + observation?.status !== 'ok' || + typeof observation.detail !== 'string' + ) { + throw providerError('provider-final-action-history-observation-invalid'); + } + let detail; + try { + detail = JSON.parse(observation.detail); + } catch { + throw providerError('provider-final-action-history-detail-invalid'); + } + if ( + detail?.runId !== expectedRunId || + !Array.isArray(detail?.actions) || + detail?.count !== detail.actions.length + ) { + throw providerError('provider-final-action-history-identity-invalid'); + } + const evidenceByTool = new Map(); + for (const action of detail.actions) { + if ( + action?.agentId !== 'project-supervisor' || + action?.runId !== expectedRunId || + action?.status !== 'ok' || + typeof action?.actionId !== 'string' || + !/^action-[0-9a-f]{24}$/iu.test(action.actionId) || + !['command.run_limited', 'preview.validate'].includes(action?.tool) + ) { + continue; + } + evidenceByTool.set(action.tool, { + agentId: action.agentId, + runId: action.runId, + actionId: action.actionId, + }); + } + const staticSmoke = evidenceByTool.get('command.run_limited'); + const preview = evidenceByTool.get('preview.validate'); + if (!staticSmoke || !preview) { + throw providerError('provider-final-acceptance-evidence-missing'); + } + return { staticSmoke, preview }; +} + +function acceptanceUpdateCall(context, runId) { + const contractFingerprint = goalContractFingerprint(context); + if (!contractFingerprint) { + throw providerError('provider-goal-contract-fingerprint-missing'); + } + const evidence = finalAcceptanceEvidence(context, runId); + return nativeAction( + 'agent.acceptance_update', + '提交当前最终 revision 的静态检查与双视口试玩验收证据', + { + contractFingerprint, + evaluations: [ + { + criterionId: 'static-current-revision', + status: 'passed', + evidence: [evidence.staticSmoke], + summary: '当前最终 revision 已通过 game.static_smoke。', + }, + { + criterionId: 'playable-current-revision', + status: 'passed', + evidence: [evidence.preview], + summary: '当前最终 revision 已通过桌面与移动双视口真实试玩。', + }, + ], + }, + ); +} + function completedPlanCall(explanation, steps) { return { name: 'update_agent_plan', @@ -463,17 +614,6 @@ function chatTextResponse(sequence, model, content) { }; } -function delegateCall(agentId, task, acceptanceCriteria, expectedArtifacts) { - return nativeAction('agent.delegate', `把独立工作交给 ${agentId}`, { - agentId, - task, - acceptanceCriteria, - expectedArtifacts, - repairOfDelegationId: null, - runId: null, - }); -} - const projectMutationFunctionNames = new Set( [ 'blackboard.write', @@ -492,22 +632,6 @@ const projectMutationFunctionNames = new Set( ].map(runtimeFunction), ); -function isGameIndexArtifactContract(expectedArtifacts) { - return ( - Array.isArray(expectedArtifacts) && - expectedArtifacts.length === 1 && - expectedArtifacts[0] === 'game/index.html' - ); -} - -function isExplicitReadOnlyQualityTask(task) { - return ( - typeof task === 'string' && - task.includes('只读验收') && - task.includes('不要修改任何文件') - ); -} - function staticSmokeCall(reason) { return nativeAction('command.run_limited', reason, { commandId: 'game.static_smoke', @@ -540,15 +664,6 @@ function runStatusCall(reason) { }); } -function repairPatchCall(reason) { - return nativeAction('file.patch', reason, { - path: 'game/index.html', - oldText: hiddenCanvasCss, - newText: visibleCanvasCss, - expectedReplacements: 1, - }); -} - function fileReadCall(path, reason) { return nativeAction('file.read', reason, { path, @@ -566,6 +681,18 @@ function assetListCall(reason) { } function canvasAssetCall(agentId) { + if (agentId === 'art-director') { + return nativeAction('canvas.asset_generate', '生成统一视觉规范图', { + prompt: + '原创明亮花园植物塔防游戏的方形图标规范图。统一展示露华花守卫、棘刺芽守卫、雾影兽入侵者、晶苔地块、灵露资源和主要 UI 图标的轮廓、配色、材质与尺寸关系;纯色分区背景,适合后续角色、场景和 UI 共用,不使用任何现有游戏角色、Logo 或受保护视觉语言。', + outputPath: 'assets/art-spec.png', + aspectRatio: '1:1', + imageSize: '1K', + assetKind: 'icon-spec', + assetLabel: '游戏统一视觉规范图', + replaceExisting: false, + }); + } if (agentId === 'design-foundation') { return nativeAction('canvas.asset_generate', '生成横屏游戏界面原型', { prompt: @@ -635,6 +762,67 @@ function revisionBlockedObservation(context) { ); } +function projectRevisionDriftObservation(context, tool) { + const observation = latestToolObservation(context); + const detail = + typeof observation?.detail === 'string' ? observation.detail : ''; + const hasStructuredDetail = + detail.includes('projectRevisionDrift=true') && + detail.includes('expectedRevision=') && + detail.includes('currentRevision=') && + detail.includes('replanRequired=true'); + const detailUnavailable = + observation?.detail == null || detail === '[redacted sensitive context]'; + return ( + observation?.tool === tool && + observation?.status === 'blocked' && + observation?.summary === '并行项目变更使旧动作过期,旧动作未执行' && + (hasStructuredDetail || detailUnavailable) + ); +} + +function visualAssetBlockedObservation(context) { + const observation = latestToolObservation(context); + return ( + observation?.tool === 'runtime.visual_asset' && + observation?.status === 'blocked' && + typeof observation?.detail === 'string' && + observation.detail.includes('expectedPath=assets/ui-prototype.png') && + observation.detail.includes('requiredInspection=image.inspect') + ); +} + +function missingGeneratedVisualAssetObservation(context, agentId) { + const expected = { + 'art-director': { + path: 'assets/art-spec.png', + kind: 'icon-spec', + summary: '统一视觉规范图尚未按正式视觉流程生成并登记,不能完成任务', + }, + 'design-foundation': { + path: 'assets/ui-prototype.png', + kind: 'ui-prototype', + summary: '策划界面原型图尚未按正式视觉流程生成并登记,不能完成任务', + }, + 'art-asset-plan': { + path: 'assets/art-spritesheet.png', + kind: 'art-spritesheet', + summary: '首版美术素材图尚未按正式视觉流程生成并登记,不能完成任务', + }, + }[agentId]; + if (!expected) return false; + const observation = latestToolObservation(context); + return ( + observation?.tool === 'runtime.visual_asset' && + observation?.status === 'blocked' && + observation?.summary === expected.summary && + typeof observation?.detail === 'string' && + (observation.detail === '[redacted sensitive context]' || + (observation.detail.includes(`expectedPath=${expected.path}`) && + observation.detail.includes(`expectedKind=${expected.kind}`))) + ); +} + function specialistVerificationRepairContext(context) { return ( context.includes( @@ -646,17 +834,27 @@ function specialistVerificationRepairContext(context) { ); } +function verifiedDeliveryRepairContext(context) { + return ( + context.includes('当前 revision 已通过验证') && + context.includes('本次修复的原生工具目录只保留 respond_to_user') + ); +} + function transientCommandObservation(context) { const observation = latestToolObservation(context); - const detail = observation?.detail; + const detail = + typeof observation?.detail === 'string' + ? observation.detail.replaceAll('\\', '/') + : ''; return ( observation?.tool === 'command.run_limited' && ((observation?.status === 'blocked' && - (detail?.includes('repositoryContextDrift=true') || - (detail?.includes('projectRevisionDrift=true') && + (detail.includes('repositoryContextDrift=true') || + (detail.includes('projectRevisionDrift=true') && detail.includes('replanRequired=true')))) || (observation?.status === 'failed' && - detail?.includes('.agent/project.lock'))) + detail.includes('.agent/project.lock'))) ); } @@ -668,13 +866,6 @@ function successfulProjectMutationObservation(context) { ); } -function successfulCommandObservation(context) { - const observation = latestToolObservation(context); - return ( - observation?.tool === 'command.run_limited' && observation?.status === 'ok' - ); -} - function observationsIncludeAsset(context, path) { const observations = observationContext(context); return observations.includes('# 本地项目资产') && observations.includes(path); @@ -722,36 +913,20 @@ export function createDeterministicLaneDefenseRouter({ const readyTaskCompletionRetryCounts = new Map(); const readyTaskPreCompletionRetryCounts = new Map(); const readyTaskReplayAvailableRuns = new Set(); - const terminalRetryCounts = new Map(); - const terminalVerificationPendingRuns = new Set(); - const terminalReplayAvailableRuns = new Set(); - const terminalObservationFingerprints = new Map(); const agentCounts = new Map(); const stats = { requestCount: 0, contextCompactionRequestCount: 0, planningRequestCount: 0, finalReplyRequestCount: 0, - initialDelegationCount: 0, - followupDelegationCount: 0, + goalContractCount: 0, + actionHistoryCount: 0, + acceptanceUpdateCount: 0, + delegateActionCount: 0, runStatusCount: 0, sourceWriteCount: 0, - sourcePatchCount: 0, staticSmokeCount: 0, previewValidationCount: 0, - supervisorDirectMutationAttemptCount: 0, - delegationContractCount: 0, - delegationContractViolationCount: 0, - codePrototypeDelegationCount: 0, - codePrototypeExpectedArtifactCount: 0, - codePrototypeGameIndexArtifactDelegationCount: 0, - qualityReviewDelegationCount: 0, - qualityReviewReadOnlyDelegationCount: 0, - qualityReviewExpectedArtifactCount: 0, - artAssetPlanDelegationCount: 0, - artAssetPlanExpectedArtifactCount: 0, - artAssetPlanArtifactContractCount: 0, - qualityRevisionReplanCount: 0, manifestReadyTaskRunCount: 0, manifestReadyTaskCompletionCount: 0, manifestReadyTaskFileReadCount: 0, @@ -760,6 +935,7 @@ export function createDeterministicLaneDefenseRouter({ manifestReadyTaskPreviewValidationCount: 0, manifestReadyTaskCanvasGenerationCount: 0, canonicalCodeRunCount: 0, + canonicalCodeAssetListCount: 0, imageInspectionRequestCount: 0, interactionExecuteCount: 0, readyTaskRunsByAgent: {}, @@ -771,9 +947,7 @@ export function createDeterministicLaneDefenseRouter({ byAgent: {}, }; let responseSequence = 0; - let parentStage = 'initial-delegation'; - let initialBuilderRunId = null; - let repairBuilderRunId = null; + let parentStage = 'goal-contract'; function updateReadyTaskCounts(agentId, kind) { const current = stats.readyTaskCountsByAgent[agentId] ?? { @@ -784,53 +958,6 @@ export function createDeterministicLaneDefenseRouter({ stats.readyTaskCountsByAgent[agentId] = current; } - function consumeTerminalRetry(agentId, runId) { - const retryCount = terminalRetryCounts.get(runId) ?? 0; - if (retryCount >= 16) { - throw providerError(`provider-terminal-retry-exhausted:${agentId}`); - } - terminalRetryCounts.set(runId, retryCount + 1); - } - - function retryTerminalStaticVerification(agentId, runId, tools, reason) { - if (!tools.has(runtimeFunction('command.run_limited'))) { - throw providerError( - `provider-terminal-retry-verification-tool-missing:${agentId}`, - ); - } - consumeTerminalRetry(agentId, runId); - terminalReplayAvailableRuns.delete(runId); - terminalVerificationPendingRuns.add(runId); - stats.staticSmokeCount += 1; - return callsResponse(agentId, tools, [staticSmokeCall(reason)]); - } - - function consumeTerminalReplayAuthorization(agentId, runId) { - if (terminalVerificationPendingRuns.delete(runId)) return; - if (terminalReplayAvailableRuns.delete(runId)) return; - throw providerError(`provider-terminal-replay-unarmed:${agentId}`); - } - - function consumeUniqueTerminalObservation(agentId, runId, context) { - const observation = latestToolObservation(context); - const repairFingerprint = context.includes('当前专业合同明确要求只读交付') - ? 'read-only-delivery-repair' - : null; - if (!observation && !repairFingerprint) { - throw providerError(`provider-terminal-observation-missing:${agentId}`); - } - const fingerprint = observation - ? JSON.stringify(observation) - : repairFingerprint; - const seen = terminalObservationFingerprints.get(runId) ?? new Set(); - if (seen.has(fingerprint)) { - throw providerError(`provider-terminal-observation-duplicate:${agentId}`); - } - consumeTerminalRetry(agentId, runId); - seen.add(fingerprint); - terminalObservationFingerprints.set(runId, seen); - } - function recordReadyTaskRun(agentId, runId) { if (!deterministicManifestReadyAgentIds.includes(agentId)) { throw providerError(`provider-ready-agent-unsupported:${agentId}`); @@ -893,6 +1020,21 @@ export function createDeterministicLaneDefenseRouter({ (agentId === 'preview-playtest' ? 'preview.validate' : 'command.run_limited'); + if ( + tools.size === 1 && + tools.has('respond_to_user') && + verifiedDeliveryRepairContext(context) + ) { + readyTaskCompletionRetryArmedRuns.delete(runId); + readyTaskCompletionVerificationTools.delete(runId); + readyTaskReplayAvailableRuns.delete(runId); + return readyCallsResponse( + agentId, + runId, + tools, + readyTaskFinalizationCalls(context, agentId, tools), + ); + } if (readyTaskCompletionRetryArmedRuns.has(runId)) { const observation = latestToolObservation(context); if ( @@ -914,6 +1056,22 @@ export function createDeterministicLaneDefenseRouter({ readyTaskFinalizationCalls(context, agentId, tools), ); } + if ( + expectedVerificationTool === 'image.inspect' && + observation?.tool === 'image.inspect' && + ['blocked', 'failed'].includes(observation?.status) && + tools.has(runtimeFunction('image.inspect')) + ) { + if (retryCount >= 16) { + throw providerError( + `provider-ready-retry-verification-exhausted:${agentId}`, + ); + } + readyTaskCompletionRetryCounts.set(runId, retryCount + 1); + return readyCallsResponse(agentId, runId, tools, [ + imageInspectCall(), + ]); + } const transientCommandFailure = transientCommandObservation(context); if ( transientCommandFailure && @@ -936,6 +1094,36 @@ export function createDeterministicLaneDefenseRouter({ ); } const latestObservation = latestToolObservation(context); + if ( + missingGeneratedVisualAssetObservation(context, agentId) && + tools.has(runtimeFunction('canvas.asset_generate')) + ) { + if (retryCount >= 16) { + throw providerError( + `provider-ready-retry-verification-exhausted:${agentId}`, + ); + } + readyTaskCompletionRetryCounts.set(runId, retryCount + 1); + stats.manifestReadyTaskCanvasGenerationCount += 1; + return readyCallsResponse(agentId, runId, tools, [ + canvasAssetCall(agentId), + ]); + } + if ( + agentId === 'design-foundation' && + visualAssetBlockedObservation(context) && + tools.has(runtimeFunction('image.inspect')) + ) { + if (retryCount >= 16) { + throw providerError( + `provider-ready-retry-verification-exhausted:${agentId}`, + ); + } + readyTaskCompletionRetryCounts.set(runId, retryCount + 1); + readyTaskCompletionRetryArmedRuns.add(runId); + readyTaskCompletionVerificationTools.set(runId, 'image.inspect'); + return readyCallsResponse(agentId, runId, tools, [imageInspectCall()]); + } const successfulProjectMutation = successfulProjectMutationObservation(context); const standaloneSuccessfulVerification = @@ -1120,52 +1308,20 @@ export function createDeterministicLaneDefenseRouter({ publishAgentCounts(); for (const call of calls) { - if (call.name !== runtimeFunction('agent.delegate')) continue; - stats.delegationContractCount += 1; - const input = call.arguments?.input; - const hasExplicitExpectedArtifacts = Array.isArray( - input?.expectedArtifacts, - ); - const expectedArtifacts = hasExplicitExpectedArtifacts - ? input.expectedArtifacts - : []; - if (input?.agentId === 'code-prototype') { - stats.codePrototypeDelegationCount += 1; - stats.codePrototypeExpectedArtifactCount += expectedArtifacts.length; - if (isGameIndexArtifactContract(expectedArtifacts)) { - stats.codePrototypeGameIndexArtifactDelegationCount += 1; - } else { - stats.delegationContractViolationCount += 1; - } - } else if (input?.agentId === 'quality-review') { - stats.qualityReviewDelegationCount += 1; - stats.qualityReviewExpectedArtifactCount += expectedArtifacts.length; - if ( - hasExplicitExpectedArtifacts && - expectedArtifacts.length === 0 && - isExplicitReadOnlyQualityTask(input.task) - ) { - stats.qualityReviewReadOnlyDelegationCount += 1; - } else { - stats.delegationContractViolationCount += 1; - } - } else if (input?.agentId === 'art-asset-plan') { - stats.artAssetPlanDelegationCount += 1; - stats.artAssetPlanExpectedArtifactCount += expectedArtifacts.length; - if ( - hasExplicitExpectedArtifacts && - expectedArtifacts.length === 2 && - expectedArtifacts.includes('assets/manifest.art.json') && - expectedArtifacts.includes('assets/art-spritesheet.png') && - typeof input?.task === 'string' && - input.task.includes('生成') - ) { - stats.artAssetPlanArtifactContractCount += 1; - } else { - stats.delegationContractViolationCount += 1; - } - } else { - stats.delegationContractViolationCount += 1; + if (call.name === runtimeFunction('agent.goal_contract')) { + stats.goalContractCount += 1; + } else if (call.name === runtimeFunction('agent.action_history')) { + stats.actionHistoryCount += 1; + } else if (call.name === runtimeFunction('agent.acceptance_update')) { + stats.acceptanceUpdateCount += 1; + } else if (call.name === runtimeFunction('agent.delegate')) { + stats.delegateActionCount += 1; + } + if ( + agentId === 'code-prototype' && + call.name === runtimeFunction('asset.list') + ) { + stats.canonicalCodeAssetListCount += 1; } } } @@ -1178,177 +1334,134 @@ export function createDeterministicLaneDefenseRouter({ } function parentCalls(context, tools) { - const manifestDagWaitPrompt = - context.includes('当前正式 manifest DAG 仍有专业 task 在运行') && - tools.has(runtimeFunction('task.list')) && - tools.has(runtimeFunction('agent.run_status')); - if (manifestDagWaitPrompt) { - parentStage = 'await-manifest'; - stats.runStatusCount += 1; - return callsResponse('project-supervisor', tools, [ - nativeAction('task.list', '检查正式产物任务图是否已经收敛', {}), - runStatusCall('等待并读取并行专业任务的最新状态'), - ]); - } - const delegateRepairPrompt = - (context.includes('当前父 run 已进入只编排模式') && - context.includes('只保留 agent.delegate')) || - (parentStage === 'await-delegation-repair' && - tools.has(runtimeFunction('agent.delegate'))); - if (delegateRepairPrompt) { - if (parentStage !== 'await-delegation-repair') { - throw providerError('provider-repair-prompt-out-of-order'); + const goalContractTool = runtimeFunction('agent.goal_contract'); + if (tools.size === 1 && tools.has(goalContractTool)) { + if (parentStage !== 'goal-contract') { + throw providerError('provider-goal-contract-out-of-order'); } - parentStage = 'verify-repair'; - stats.followupDelegationCount += 1; - return callsResponse('project-supervisor', tools, [ - delegateCall( - 'code-prototype', - '修复最近一次真实浏览器验证证明的 canvas 不可见问题;保持现有 lane-defense-v1 交互、状态面、关卡推进和重开行为不变,并在修改后通过静态自检。', - [ - 'game/index.html 的 canvas 在桌面与移动视口可见且有非空像素', - 'lane-defense-v1 全部固定试玩断言继续通过', - '修改后的当前 revision 通过 game.static_smoke', - ], - ['game/index.html'], - ), - ]); + parentStage = 'await-manifest'; + return callsResponse('project-supervisor', tools, [goalContractCall()]); + } + + const advertisedProjectMutation = [...tools].some((tool) => + projectMutationFunctionNames.has(tool), + ); + const advertisedSafeExecution = [ + 'agent.goal_contract', + 'task.list', + 'agent.run_status', + 'command.run_limited', + 'preview.validate', + 'agent.action_history', + 'agent.acceptance_update', + ].some((tool) => tools.has(runtimeFunction(tool))); + if (advertisedProjectMutation && !advertisedSafeExecution) { + if ( + tools.has('respond_to_user') && + context.includes('当前父 run 已进入只编排模式') + ) { + return callsResponse('project-supervisor', tools, [ + nativeResponse('专业任务尚未收敛,继续等待 Runtime 完成门复核。'), + ]); + } + throw providerError('provider-supervisor-direct-mutation-denied'); } switch (parentStage) { - case 'initial-delegation': { - parentStage = 'claim-initial'; - stats.initialDelegationCount += 3; - return callsResponse('project-supervisor', tools, [ - delegateCall( - 'code-prototype', - '生成一个紧凑但完整的植物塔防网页游戏:支持开始、选择植物、放置防御单位、敌人移动和受伤、胜利、下一关与重开;写入 game/index.html,并在修改后通过静态自检。', - [ - 'game/index.html 提供完整 lane-defense-v1 状态面和六个可点击控件', - '敌人会移动并受伤,关卡可胜利、进入下一关并重开', - '修改后的当前 revision 通过 game.static_smoke', - ], - ['game/index.html'], - ), - delegateCall( - 'quality-review', - '执行只读验收:独立核对塔防交付合同是否覆盖植物选择、战斗、胜利、下一关、重开、桌面和移动视口;只返回合同验收重点,不要修改任何文件,也不要读取或依赖并行 code-prototype 尚未完成的项目产物。', - [ - '验收结论覆盖完整可玩循环和双视口', - '只读返回合同重点且项目 mutation 为零', - ], - [], - ), - delegateCall( - 'art-asset-plan', - '生成原创首版核心美术素材并写入正式资产清单;必须调用 canvas.asset_generate 生成 assets/art-spritesheet.png,并写入可解析的 assets/manifest.art.json,不得用占位文件代替。', - [ - 'assets/art-spritesheet.png 是可解码的原创 1:1 PNG', - 'assets/manifest.art.json 可解析并登记该图片', - '生成结果已通过资产列表核对', - ], - ['assets/manifest.art.json', 'assets/art-spritesheet.png'], - ), - ]); - } - case 'claim-initial': - parentStage = 'static-initial'; - stats.runStatusCount += 1; - return callsResponse('project-supervisor', tools, [ - runStatusCall('认领两份首轮专业回执'), - ]); - case 'static-initial': - parentStage = 'preview-initial'; - stats.staticSmokeCount += 1; - return callsResponse('project-supervisor', tools, [ - staticSmokeCall('验证初版当前 revision'), - ]); - case 'preview-initial': - parentStage = 'attempt-forbidden-repair'; - stats.previewValidationCount += 1; - return callsResponse('project-supervisor', tools, [ - previewCall('真实试玩并检查桌面与移动视口'), - ]); - case 'attempt-forbidden-repair': - if (observationContext(context).includes('浏览器验证已通过')) { - parentStage = 'respond'; - return parentCalls(context, tools); - } - parentStage = 'await-delegation-repair'; - stats.supervisorDirectMutationAttemptCount += 1; - return callsResponse('project-supervisor', tools, [ - repairPatchCall('根据浏览器失败诊断直接修复不可见 canvas'), - ]); - case 'verify-repair': - parentStage = 'await-manifest'; - stats.runStatusCount += 1; - return callsResponse('project-supervisor', tools, [ - runStatusCall('先认领返工后的专业交付,再验证最新 revision'), - ]); case 'await-manifest': - if (!tools.has(runtimeFunction('task.list'))) { - if (tools.has(runtimeFunction('command.run_limited'))) { - stats.staticSmokeCount += 1; - return callsResponse('project-supervisor', tools, [ - staticSmokeCall('刷新并行产物修改后的当前 revision 验证凭证'), - ]); - } - if (tools.has(runtimeFunction('preview.validate'))) { - parentStage = 'respond'; - stats.previewValidationCount += 1; - return callsResponse('project-supervisor', tools, [ - previewCall('对最终 current revision 完成双视口交互试玩'), - ]); - } - } if (!manifestTasksCompleted(context)) { + if ( + !tools.has(runtimeFunction('task.list')) || + !tools.has(runtimeFunction('agent.run_status')) + ) { + throw providerError('provider-manifest-wait-tools-invalid'); + } stats.runStatusCount += 1; return callsResponse('project-supervisor', tools, [ nativeAction('task.list', '检查正式产物任务图是否已经收敛', {}), runStatusCall('等待并读取并行专业任务的最新状态'), ]); } - parentStage = 'verify-final'; + parentStage = 'final-static-smoke'; return parentCalls(context, tools); - case 'verify-final': - parentStage = 'respond'; + case 'final-static-smoke': + if (!tools.has(runtimeFunction('command.run_limited'))) { + throw providerError('provider-final-static-smoke-tool-missing'); + } + parentStage = 'final-preview'; stats.staticSmokeCount += 1; - stats.previewValidationCount += 1; return callsResponse('project-supervisor', tools, [ staticSmokeCall('验证全部正式产物整合后的当前 revision'), + ]); + case 'final-preview': { + const observation = latestToolObservation(context); + if ( + observation?.tool !== 'command.run_limited' || + observation?.status !== 'ok' + ) { + throw providerError( + 'provider-final-static-smoke-observation-invalid', + ); + } + if (!tools.has(runtimeFunction('preview.validate'))) { + throw providerError('provider-final-preview-tool-missing'); + } + parentStage = 'final-action-history'; + stats.previewValidationCount += 1; + return callsResponse('project-supervisor', tools, [ previewCall('对最终 current revision 重跑完整真实试玩和双视口检查'), ]); - case 'respond': + } + case 'final-action-history': { + const observation = latestToolObservation(context); + if ( + observation?.tool !== 'preview.validate' || + observation?.status !== 'ok' + ) { + throw providerError('provider-final-preview-observation-invalid'); + } + if (!tools.has(runtimeFunction('agent.action_history'))) { + throw providerError('provider-final-action-history-tool-missing'); + } + parentStage = 'final-acceptance-update'; + return callsResponse('project-supervisor', tools, [ + actionHistoryCall(), + ]); + } + case 'final-acceptance-update': + if (!tools.has(runtimeFunction('agent.acceptance_update'))) { + throw providerError('provider-final-acceptance-update-tool-missing'); + } + { + const call = acceptanceUpdateCall( + context, + extractIdentity(context).runId, + ); + parentStage = 'final-respond'; + return callsResponse('project-supervisor', tools, [call]); + } + case 'final-respond': { + const observation = latestToolObservation(context); + if ( + observation?.tool !== 'agent.acceptance_update' || + observation?.status !== 'ok' + ) { + throw providerError('provider-final-acceptance-observation-invalid'); + } + if (!tools.has('respond_to_user')) { + throw providerError('provider-final-respond-tool-missing'); + } parentStage = 'done'; return callsResponse('project-supervisor', tools, [ - ...completedPlanCallsForContext( - context, - '可试玩项目和全部验证已经完成', - ), nativeResponse( - '可试玩塔防版本已经完成,并通过静态检查、完整交互试玩以及桌面和移动视口验证。', + '可试玩塔防版本已经完成,并通过当前 revision 静态检查、完整交互试玩以及桌面和移动视口验证。', ), ]); + } case 'done': - if (tools.has(runtimeFunction('task.list'))) { - parentStage = 'await-manifest'; - return parentCalls(context, tools); - } - if ( - tools.has(runtimeFunction('command.run_limited')) && - tools.has(runtimeFunction('preview.validate')) - ) { - parentStage = 'verify-final'; - return parentCalls(context, tools); - } - if (tools.has('respond_to_user')) { - return callsResponse('project-supervisor', tools, [ - nativeResponse( - '可试玩塔防版本已经完成,并通过正式产物检查、静态检查和双视口交互试玩。', - ), - ]); - } - throw providerError('provider-parent-done-tools-invalid'); + throw providerError('provider-parent-terminal-duplicate'); + case 'goal-contract': + throw providerError('provider-goal-contract-required-first'); default: throw providerError(`provider-parent-stage-invalid:${parentStage}`); } @@ -1369,12 +1482,6 @@ export function createDeterministicLaneDefenseRouter({ fileReadCall('memory/project.md', '读取长期项目约束'), ], ], - 'art-director': [ - [ - fileReadCall('game/game_design.md', '读取玩法规格以确定视觉方向'), - assetListCall('核对当前已有美术资产'), - ], - ], 'art-polish': [ [ fileReadCall( @@ -1428,6 +1535,43 @@ export function createDeterministicLaneDefenseRouter({ ); } + function artDirectorReadyCalls(runId, tools, context) { + const agentId = 'art-director'; + const phase = ensureReadyTaskRun(runId, agentId); + if (phase === 0) { + return readyCallsResponse(agentId, runId, tools, [ + assetListCall('核对当前已有美术资产'), + ]); + } + if (phase === 1) { + if (!tools.has(runtimeFunction('canvas.asset_generate'))) { + throw providerError('provider-ready-art-director-canvas-tool-missing'); + } + stats.manifestReadyTaskCanvasGenerationCount += 1; + return readyCallsResponse(agentId, runId, tools, [ + canvasAssetCall(agentId), + ]); + } + if (phase === 2) { + if ( + tools.has('respond_to_user') && + !tools.has(runtimeFunction('asset.list')) + ) { + return readyTaskCompleteResponse(agentId, runId, tools, context); + } + return readyCallsResponse(agentId, runId, tools, [ + assetListCall('核对统一视觉规范图已经生成并登记'), + ]); + } + if (phase === 3) { + return readyTaskCompleteResponse(agentId, runId, tools, context); + } + if (phase >= 4) { + return readyTaskCompleteResponse(agentId, runId, tools, context); + } + throw providerError(`provider-ready-art-director-stage-invalid:${phase}`); + } + function designFoundationReadyCalls(runId, tools, context) { const agentId = 'design-foundation'; const phase = ensureReadyTaskRun(runId, agentId); @@ -1590,93 +1734,6 @@ export function createDeterministicLaneDefenseRouter({ ); } - function initialArtDelegationCalls(runId, tools, context) { - const agentId = 'art-asset-plan'; - const existingKind = runKinds.get(runId); - if (!existingKind) { - runKinds.set(runId, 'initial-art-delegation'); - runPhases.set(runId, 0); - } else if (existingKind !== 'initial-art-delegation') { - throw providerError(`provider-initial-art-run-kind-invalid:${runId}`); - } - const phase = runPhases.get(runId) ?? 0; - const respond = (calls) => { - runPhases.set(runId, phase + 1); - return callsResponse(agentId, tools, calls); - }; - if (phase === 0) { - return respond([assetListCall('核对首版核心美术是否已经生成')]); - } - if (phase === 1) { - stats.sourceWriteCount += 1; - return respond([ - fileWriteCall( - 'assets/manifest.art.json', - `${JSON.stringify(deterministicArtManifest(true), null, 2)}\n`, - '写入登记首版核心图片的正式美术资产清单', - ), - ]); - } - if (phase === 2) { - if (!tools.has(runtimeFunction('canvas.asset_generate'))) { - throw providerError('provider-initial-art-canvas-tool-missing'); - } - return respond([canvasAssetCall(agentId)]); - } - if (phase === 3) { - return respond([ - fileReadCall('assets/manifest.art.json', '回读并核对正式美术清单'), - assetListCall('确认核心美术图片已生成并登记'), - ]); - } - if (phase === 4) { - terminalReplayAvailableRuns.add(runId); - return respond([ - ...completedPlanCallsForContext( - context, - '首版核心美术和正式资产清单已经完成', - ), - nativeResponse('首版核心美术图片与正式资产清单已经生成并核对。'), - ]); - } - if (successfulCommandObservation(context) && tools.has('respond_to_user')) { - consumeTerminalReplayAuthorization(agentId, runId); - return respond([ - ...completedPlanCallsForContext( - context, - '首版核心美术和正式资产清单已经完成并通过验证', - ), - nativeResponse('首版核心美术图片与正式资产清单已经生成并验证。'), - ]); - } - if ( - specialistVerificationRepairContext(context) || - revisionBlockedObservation(context) || - transientCommandObservation(context) - ) { - return retryTerminalStaticVerification( - agentId, - runId, - tools, - '补充验证首版核心美术和正式资产清单的当前 revision', - ); - } - if ( - tools.has('respond_to_user') && - context.includes('当前 revision 已通过验证') - ) { - consumeTerminalReplayAuthorization(agentId, runId); - return respond([ - ...completedPlanCallsForContext( - context, - '首版核心美术和正式资产清单已经完成并通过验证', - ), - nativeResponse('首版核心美术图片与正式资产清单已经生成并验证。'), - ]); - } - throw providerError('provider-initial-art-terminal-duplicate'); - } - function artAssetReadyCalls(runId, tools, context) { const agentId = 'art-asset-plan'; const phase = ensureReadyTaskRun(runId, agentId); @@ -1693,21 +1750,16 @@ export function createDeterministicLaneDefenseRouter({ const hasCanvas = tools.has(runtimeFunction('canvas.asset_generate')); const willGenerate = hasCanvas && !existing; const hasVisualAsset = existing || willGenerate; - const calls = [ + stats.sourceWriteCount += 1; + stats.manifestReadyTaskFileWriteCount += 1; + runData.set(runId, { existing, hasCanvas, willGenerate }); + return readyCallsResponse(agentId, runId, tools, [ fileWriteCall( 'assets/manifest.art.json', `${JSON.stringify(deterministicArtManifest(hasVisualAsset, 'manifest-ready'), null, 2)}\n`, '写入可解析的首版美术资产清单', ), - ]; - stats.sourceWriteCount += 1; - stats.manifestReadyTaskFileWriteCount += 1; - if (willGenerate) { - calls.push(canvasAssetCall(agentId)); - stats.manifestReadyTaskCanvasGenerationCount += 1; - } - runData.set(runId, { existing, hasCanvas, willGenerate }); - return readyCallsResponse(agentId, runId, tools, calls); + ]); } if (phase === 2) { const data = runData.get(runId) ?? { @@ -1715,11 +1767,17 @@ export function createDeterministicLaneDefenseRouter({ hasCanvas: false, willGenerate: false, }; + if (data.willGenerate) { + stats.manifestReadyTaskCanvasGenerationCount += 1; + return readyCallsResponse(agentId, runId, tools, [ + canvasAssetCall(agentId), + ]); + } const calls = [ fileReadCall('assets/manifest.art.json', '回读并核对美术清单 JSON'), ]; stats.manifestReadyTaskFileReadCount += 1; - if (data.willGenerate) { + if (data.existing) { calls.push(assetListCall('核对新图片已登记到项目资产清单')); } else { calls.push(staticSmokeCall('验证美术清单写入后的当前 revision')); @@ -1728,11 +1786,9 @@ export function createDeterministicLaneDefenseRouter({ } return readyCallsResponse(agentId, runId, tools, calls); } - if (phase === 3) { - return readyTaskCompleteResponse(agentId, runId, tools, context); - } - if (phase >= 4) { + if (phase >= 3) { if ( + phase >= 5 && !tools.has('respond_to_user') && !tools.has(runtimeFunction('command.run_limited')) && tools.has(runtimeFunction('file.write')) @@ -1747,6 +1803,51 @@ export function createDeterministicLaneDefenseRouter({ ), ]); } + if (completedReadyTaskRuns.has(runId)) { + return readyTaskCompleteResponse(agentId, runId, tools, context); + } + const data = runData.get(runId) ?? { + existing: false, + hasCanvas: false, + willGenerate: false, + }; + if ( + data.willGenerate && + projectRevisionDriftObservation(context, 'canvas.asset_generate') + ) { + if (!tools.has(runtimeFunction('canvas.asset_generate'))) { + throw providerError('provider-ready-art-canvas-tool-missing'); + } + const retryCount = readyTaskPreCompletionRetryCounts.get(runId) ?? 0; + if (retryCount >= 16) { + throw providerError('provider-ready-art-canvas-retry-exhausted'); + } + readyTaskPreCompletionRetryCounts.set(runId, retryCount + 1); + stats.manifestReadyTaskCanvasGenerationCount += 1; + return readyCallsResponse(agentId, runId, tools, [ + canvasAssetCall(agentId), + ]); + } + if (data.willGenerate) { + const observation = latestToolObservation(context); + if ( + observation?.tool === 'canvas.asset_generate' && + observation?.status === 'ok' + ) { + stats.manifestReadyTaskFileReadCount += 1; + return readyCallsResponse(agentId, runId, tools, [ + fileReadCall('assets/manifest.art.json', '回读并核对美术清单 JSON'), + assetListCall('核对新图片已登记到项目资产清单'), + ]); + } + if (phase === 3) { + stats.manifestReadyTaskFileReadCount += 1; + return readyCallsResponse(agentId, runId, tools, [ + fileReadCall('assets/manifest.art.json', '回读并核对美术清单 JSON'), + assetListCall('核对新图片已登记到项目资产清单'), + ]); + } + } return readyTaskCompleteResponse(agentId, runId, tools, context); } throw providerError(`provider-ready-art-stage-invalid:${phase}`); @@ -1770,6 +1871,7 @@ export function createDeterministicLaneDefenseRouter({ fileReadCall('assets/manifest.art.json', '读取美术资产清单'), fileReadCall('assets/manifest.audio.json', '读取声音事件清单'), fileReadCall('game/index.html', '读取当前入口以保留既有试玩行为'), + assetListCall('核对 Canvas 登记、图集与四类切片的权威资产身份'), ]); } if (phase === 2) { @@ -1859,7 +1961,6 @@ export function createDeterministicLaneDefenseRouter({ [ 'design-director', 'balance-director', - 'art-director', 'art-polish', 'audio-director', 'code-director', @@ -1868,6 +1969,9 @@ export function createDeterministicLaneDefenseRouter({ ) { return readOnlyReadyCalls(agentId, runId, tools, context); } + if (agentId === 'art-director') { + return artDirectorReadyCalls(runId, tools, context); + } if (agentId === 'design-foundation') { return designFoundationReadyCalls(runId, tools, context); } @@ -1907,223 +2011,6 @@ export function createDeterministicLaneDefenseRouter({ throw providerError(`provider-ready-agent-unsupported:${agentId}`); } - function codeCalls(runId, tools, context) { - if (!runKinds.has(runId)) { - if (!initialBuilderRunId) { - initialBuilderRunId = runId; - runKinds.set(runId, 'initial-builder'); - } else if (!repairBuilderRunId) { - repairBuilderRunId = runId; - runKinds.set(runId, 'repair-builder'); - } else { - throw providerError('provider-extra-builder-run'); - } - runPhases.set(runId, 0); - } - const kind = runKinds.get(runId); - const phase = runPhases.get(runId); - if (kind === 'initial-builder') { - if (phase === 0) { - runPhases.set(runId, 1); - stats.sourceWriteCount += 1; - return callsResponse('code-prototype', tools, [ - nativeAction('file.write', '写入完整可玩的塔防初版', { - path: 'game/index.html', - content: deterministicLaneDefenseInitialHtml(), - }), - ]); - } - if (phase === 1) { - runPhases.set(runId, 2); - stats.staticSmokeCount += 1; - return callsResponse('code-prototype', tools, [ - staticSmokeCall('验证初版源码与状态面'), - ]); - } - if (phase === 2) { - runPhases.set(runId, 3); - terminalReplayAvailableRuns.add(runId); - return callsResponse('code-prototype', tools, [ - ...completedPlanCallsForContext( - context, - '初版实现和静态验证已经完成', - ), - nativeResponse('初版塔防玩法与交互状态面已经生成,并通过静态自检。'), - ]); - } - if (phase >= 3) { - if ( - successfulCommandObservation(context) && - tools.has('respond_to_user') - ) { - consumeTerminalReplayAuthorization('code-prototype', runId); - return callsResponse('code-prototype', tools, [ - ...completedPlanCallsForContext( - context, - '初版实现和静态验证已经完成', - ), - nativeResponse( - '初版塔防玩法与交互状态面已经生成,并通过静态自检。', - ), - ]); - } - if ( - specialistVerificationRepairContext(context) || - revisionBlockedObservation(context) || - transientCommandObservation(context) - ) { - return retryTerminalStaticVerification( - 'code-prototype', - runId, - tools, - '重试验证初版源码与状态面', - ); - } - if ( - tools.has('respond_to_user') && - context.includes('当前 revision 已通过验证') - ) { - consumeTerminalReplayAuthorization('code-prototype', runId); - return callsResponse('code-prototype', tools, [ - ...completedPlanCallsForContext( - context, - '初版实现和静态验证已经完成', - ), - nativeResponse( - '初版塔防玩法与交互状态面已经生成,并通过静态自检。', - ), - ]); - } - } - } - if (kind === 'repair-builder') { - if (phase === 0) { - runPhases.set(runId, 1); - stats.sourcePatchCount += 1; - return callsResponse('code-prototype', tools, [ - repairPatchCall('修复真实浏览器发现的 canvas 可见性'), - ]); - } - if (phase === 1) { - runPhases.set(runId, 2); - stats.staticSmokeCount += 1; - return callsResponse('code-prototype', tools, [ - staticSmokeCall('验证返工后的源码'), - ]); - } - if (phase === 2) { - runPhases.set(runId, 3); - terminalReplayAvailableRuns.add(runId); - return callsResponse('code-prototype', tools, [ - ...completedPlanCallsForContext( - context, - '画布可见性返工和静态验证已经完成', - ), - nativeResponse( - '画布可见性已修复,原有完整试玩行为保持不变并通过静态自检。', - ), - ]); - } - if (phase >= 3) { - if ( - successfulCommandObservation(context) && - tools.has('respond_to_user') - ) { - consumeTerminalReplayAuthorization('code-prototype', runId); - return callsResponse('code-prototype', tools, [ - ...completedPlanCallsForContext( - context, - '画布可见性返工和静态验证已经完成', - ), - nativeResponse( - '画布可见性已修复,原有完整试玩行为保持不变并通过静态自检。', - ), - ]); - } - if ( - specialistVerificationRepairContext(context) || - revisionBlockedObservation(context) || - transientCommandObservation(context) - ) { - return retryTerminalStaticVerification( - 'code-prototype', - runId, - tools, - '重试验证返工后的源码', - ); - } - if ( - tools.has('respond_to_user') && - context.includes('当前 revision 已通过验证') - ) { - consumeTerminalReplayAuthorization('code-prototype', runId); - return callsResponse('code-prototype', tools, [ - ...completedPlanCallsForContext( - context, - '画布可见性返工和静态验证已经完成', - ), - nativeResponse( - '画布可见性已修复,原有完整试玩行为保持不变并通过静态自检。', - ), - ]); - } - } - } - throw providerError(`provider-builder-stage-invalid:${kind}:${phase}`); - } - - function qualityCalls(runId, tools, context) { - const phase = runPhases.get(runId) ?? 0; - if (phase === 0) { - runKinds.set(runId, 'quality-review'); - runPhases.set(runId, 1); - return callsResponse('quality-review', tools, [ - ...completedPlanCallsForContext(context, '独立验收重点已经核对'), - nativeResponse( - '验收必须以真实植物选择、放置、战斗推进、胜利、下一关、重开和双视口可见性为准。', - ), - ]); - } - if (phase === 1) { - if ( - !revisionBlockedObservation(context) && - !context.includes('当前专业合同明确要求只读交付') - ) { - throw providerError('provider-quality-terminal-duplicate'); - } - consumeUniqueTerminalObservation('quality-review', runId, context); - runPhases.set(runId, 2); - stats.qualityRevisionReplanCount += 1; - return callsResponse('quality-review', tools, [ - ...completedPlanCallsForContext( - context, - '项目 revision 更新后已重新核对独立验收重点', - ), - nativeResponse( - '已基于最新项目 revision 复核验收重点,仍需以真实植物选择、放置、战斗推进、胜利、下一关、重开和双视口可见性为准。', - ), - ]); - } - if ( - phase >= 2 && - tools.has('respond_to_user') && - (revisionBlockedObservation(context) || - context.includes('当前专业合同明确要求只读交付')) - ) { - consumeUniqueTerminalObservation('quality-review', runId, context); - return callsResponse('quality-review', tools, [ - ...completedPlanCallsForContext( - context, - '项目 revision 更新后已重新核对独立验收重点', - ), - nativeResponse( - '已基于最新项目 revision 复核验收重点,仍需以真实植物选择、放置、战斗推进、胜利、下一关、重开和双视口可见性为准。', - ), - ]); - } - throw providerError(`provider-quality-stage-invalid:${phase}`); - } - function finalReply(identity, context) { const key = `${identity.agentId}\0${identity.runId}`; if (finalReplyRuns.has(key)) { @@ -2135,6 +2022,9 @@ export function createDeterministicLaneDefenseRouter({ ) { throw providerError('provider-unexpected-text-request'); } + if (identity.agentId === 'project-supervisor' && parentStage !== 'done') { + throw providerError('provider-parent-final-reply-before-acceptance'); + } finalReplyRuns.add(key); const content = identity.agentId === 'project-supervisor' @@ -2175,6 +2065,18 @@ export function createDeterministicLaneDefenseRouter({ context.includes('用户这轮输入:') && tools.has('runtime_execute') ) { + if (payload.reasoning_effort !== 'max') { + throw providerError('provider-interaction-reasoning-effort-invalid'); + } + if (stats.interactionExecuteCount !== 0) { + throw providerError('provider-interaction-execute-duplicate'); + } + if ( + stats.manifestReadyTaskRunCount !== 0 || + stats.manifestReadyTaskCompletionCount !== 0 + ) { + throw providerError('provider-interaction-execute-out-of-order'); + } stats.planningRequestCount += 1; stats.interactionExecuteCount += 1; recordAgent('project-supervisor', 'planning'); @@ -2190,17 +2092,17 @@ export function createDeterministicLaneDefenseRouter({ model, JSON.stringify({ checks: { - resourceBar: true, - unitCardTray: true, - battlefieldGrid: true, - enemyEntryDirection: true, - waveStatus: true, + informationHud: true, + gameplaySurface: true, + objectiveEntities: true, primaryControls: true, + failureRestartFlow: true, + responsiveLayout: true, implementationClarity: true, originalTheme: true, }, issues: [], - summary: '确定性 UI 原型覆盖完整横屏塔防实现合同。', + summary: '确定性 UI 原型覆盖完整玩法界面实现合同。', }), ); } @@ -2253,15 +2155,6 @@ export function createDeterministicLaneDefenseRouter({ if (identity.agentId === 'project-supervisor') { return parentCalls(context, tools); } - if (identity.agentId === 'code-prototype') { - return codeCalls(identity.runId, tools, context); - } - if (identity.agentId === 'quality-review') { - return qualityCalls(identity.runId, tools, context); - } - if (identity.agentId === 'art-asset-plan') { - return initialArtDelegationCalls(identity.runId, tools, context); - } throw providerError(`provider-agent-unsupported:${identity.agentId}`); } catch (error) { stats.unexpectedRequestCount += 1; @@ -2376,6 +2269,7 @@ function createDeterministicCanvasFixture(apiKey) { canvasApiRequestCount: 0, canvasGenerationRequestCount: 0, canvasDownloadRequestCount: 0, + canvasSliceDownloadRequestCount: 0, canvasGeneratedAspectRatios: {}, }; let projectTitle = null; @@ -2473,6 +2367,137 @@ function createDeterministicCanvasFixture(apiKey) { : '未命名游戏原型'; return json(200, { data: { folder: { folderId, label: folderLabel } } }); } + if ( + request.method === 'POST' && + parsed.pathname === + '/api/external/v1/editor/icon-spritesheets/generations' + ) { + const idempotencyKey = request.headers['idempotency-key']; + if ( + typeof idempotencyKey !== 'string' || + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + idempotencyKey, + ) + ) { + request.resume(); + return json(400, { error: { message: 'invalid idempotency key' } }); + } + const body = await readJsonBody(request); + const aspectRatio = body?.aspectRatio === '16:9' ? '16:9' : '1:1'; + const width = aspectRatio === '16:9' ? 1280 : 1024; + const height = aspectRatio === '16:9' ? 720 : 1024; + generationSequence += 1; + stats.canvasGenerationRequestCount += 1; + stats.canvasGeneratedAspectRatios[aspectRatio] = + (stats.canvasGeneratedAspectRatios[aspectRatio] ?? 0) + 1; + const imageId = `deterministic-${generationSequence}`; + const objectKey = `generated/deterministic/${imageId}.png`; + const assetObjectId = `asset-object-${imageId}`; + const resourceId = `resource-${imageId}`; + const taskId = `task-${imageId}`; + images.set(imageId, { + aspectRatio, + width, + height, + bytes: deterministicPng(width, height, { + variant: generationSequence, + transparent: true, + }), + objectKey, + downloadKind: 'generation', + }); + const sliceNames = ['玩家主体', '目标与危险物', '场景与资源', '反馈特效']; + const iconImageSrcs = sliceNames.map((name, index) => { + const sliceImageId = `${imageId}-slice-${index + 1}`; + const sliceObjectKey = `generated/deterministic/${sliceImageId}.png`; + const sliceResourceId = `resource-${sliceImageId}`; + const sliceAssetObjectId = `asset-object-${sliceImageId}`; + images.set(sliceImageId, { + aspectRatio: '1:1', + width: 256, + height: 256, + bytes: deterministicPng(256, 256, { + variant: generationSequence * 10 + index + 1, + }), + objectKey: sliceObjectKey, + downloadKind: 'slice', + }); + return { + name, + imageSrc: `/${sliceObjectKey}`, + width: 256, + height: 256, + projectId, + taskId, + sourceResourceId: resourceId, + resource: { + resourceId: sliceResourceId, + projectId, + imageSrc: `/${sliceObjectKey}`, + objectKey: sliceObjectKey, + assetObjectId: sliceAssetObjectId, + taskId, + sourceResourceId: resourceId, + }, + asset: { + assetId: `asset-${sliceImageId}`, + assetObjectId: sliceAssetObjectId, + assetKind: 'art-spritesheet-slice', + projectId, + taskId, + }, + }; + }); + const operationId = `task-${imageId}`; + generationOperations.set(operationId, { + spritesheetImageSrc: `/${objectKey}`, + objectKey, + assetObjectId, + resourceId, + projectId, + taskId, + width, + height, + sourceType: 'generated', + prompt: + body?.iconDescriptions?.join('\n') ?? + 'deterministic spritesheet fixture', + actualPrompt: + body?.iconDescriptions?.join('\n') ?? + 'deterministic spritesheet fixture', + model: 'deterministic-canvas-v1', + provider: 'deterministic-loopback', + spritesheetResource: { + resourceId, + projectId, + imageSrc: `/${objectKey}`, + objectKey, + assetObjectId, + width, + height, + sourceType: 'generated', + taskId, + }, + spritesheetAsset: { + assetId: `asset-${imageId}`, + assetObjectId, + assetKind: 'art-spritesheet', + projectId, + taskId, + }, + iconImageSrcs, + }); + return json(202, { + data: { + operationId, + kind: 'editor_icon_spritesheet_generation', + status: 'queued', + statusUrl: `/api/external/v1/generations/${operationId}`, + pollAfterMs: 1, + updatedAtMicros: generationSequence, + }, + }); + } if ( request.method === 'POST' && parsed.pathname === '/api/external/v1/editor/images/generations' @@ -2499,7 +2524,11 @@ function createDeterministicCanvasFixture(apiKey) { const resourceId = `resource-${imageId}`; const assetKind = typeof body?.assetKind === 'string' ? body.assetKind : 'game-art'; - images.set(imageId, { ...image, objectKey }); + images.set(imageId, { + ...image, + objectKey, + downloadKind: 'generation', + }); const operationId = `task-${imageId}`; generationOperations.set(operationId, { imageSrc: `/${objectKey}`, @@ -2555,7 +2584,8 @@ function createDeterministicCanvasFixture(apiKey) { '/api/external/v1/generations/'.length, ); const result = generationOperations.get(operationId); - if (!result) return json(404, { error: { message: 'operation not found' } }); + if (!result) + return json(404, { error: { message: 'operation not found' } }); return json(200, { data: { operationId, @@ -2600,7 +2630,11 @@ function createDeterministicCanvasFixture(apiKey) { const imageId = parsed.pathname.slice('/signed/'.length, -'.png'.length); const image = images.get(imageId); if (!image) return json(404, { error: { message: 'image not found' } }); - stats.canvasDownloadRequestCount += 1; + if (image.downloadKind === 'slice') { + stats.canvasSliceDownloadRequestCount += 1; + } else { + stats.canvasDownloadRequestCount += 1; + } return { statusCode: 200, contentType: 'image/png', diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index ede836cf6..66982a3a4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -450,6 +450,15 @@ fn game_creator_codex_app_server_validate_llm_config( Ok(()) } +fn apply_game_creator_codex_app_server_reasoning_effort( + params: &mut serde_json::Value, + request: &LlmRunRequest, +) { + if let Some(effort) = game_creator_codex_cli_reasoning_effort(request) { + params["effort"] = serde_json::Value::String(effort.to_string()); + } +} + fn quoted_toml_string(value: &str) -> Result { serde_json::to_string(value).map_err(|error| { platform_llm::LlmError::InvalidConfig(format!("序列化 Codex app-server 配置失败:{error}")) @@ -970,9 +979,7 @@ impl CodexAppServerConnection { "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()); - } + apply_game_creator_codex_app_server_reasoning_effort(&mut params, &request); if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) { params["outputSchema"] = schema; } @@ -1646,6 +1653,15 @@ mod tests { ]) } + #[test] + fn codex_app_server_reasoning_effort_preserves_max_wire_value() { + let request = LlmRunRequest::single_turn("系统", "任务") + .with_response_reasoning_effort(platform_llm::LlmResponseReasoningEffort::Max); + let mut params = serde_json::json!({}); + apply_game_creator_codex_app_server_reasoning_effort(&mut params, &request); + assert_eq!(params["effort"], "max"); + } + #[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\"}"}]}"#; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index ca5ce15c3..8155044a6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -144,6 +144,7 @@ pub(in crate::agent) fn game_creator_codex_cli_reasoning_effort( platform_llm::LlmResponseReasoningEffort::Low => "low", platform_llm::LlmResponseReasoningEffort::Medium => "medium", platform_llm::LlmResponseReasoningEffort::High => "high", + platform_llm::LlmResponseReasoningEffort::Max => "max", }) } @@ -666,6 +667,16 @@ mod tests { ]) } + #[test] + fn codex_cli_reasoning_effort_preserves_max_wire_value() { + let request = LlmRunRequest::single_turn("系统", "任务") + .with_response_reasoning_effort(platform_llm::LlmResponseReasoningEffort::Max); + assert_eq!( + game_creator_codex_cli_reasoning_effort(&request), + Some("max") + ); + } + #[cfg(windows)] #[test] fn codex_cli_candidates_prefer_sorted_native_npm_targets_before_path() { 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 1c3ef53a9..db6594659 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 @@ -279,21 +279,12 @@ fn agent_interaction_system_prompt(agent_id: &str) -> String { ) } -fn build_agent_interaction_request_for_session( - root: &Path, +fn build_agent_interaction_llm_request( agent_id: &str, - session_id: &str, prompt: &str, -) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> { - let prompt = prompt.trim(); - if prompt.is_empty() { - return Err("交互内容不能为空".to_string()); - } - if !game_creator_agent_uses_interaction_kernel(agent_id) { - return Err(format!("Agent 不启用交互决策层:{agent_id}")); - } - let (llm, config_path, context) = - build_game_creator_role_agent_context_for_session(root, agent_id, Some(session_id))?; + context: &str, + llm: &GameCreatorLlmConfig, +) -> Result { let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?; let user_prompt = if context.trim().is_empty() { format!("用户这轮输入:\n{prompt}") @@ -311,6 +302,25 @@ fn build_agent_interaction_request_for_session( .with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS) .with_function_tools(function_tools) .with_tool_choice(platform_llm::LlmToolChoice::Auto); + apply_game_creator_llm_reasoning_effort(request, llm) +} + +fn build_agent_interaction_request_for_session( + root: &Path, + agent_id: &str, + session_id: &str, + prompt: &str, +) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> { + let prompt = prompt.trim(); + if prompt.is_empty() { + return Err("交互内容不能为空".to_string()); + } + if !game_creator_agent_uses_interaction_kernel(agent_id) { + return Err(format!("Agent 不启用交互决策层:{agent_id}")); + } + let (llm, config_path, context) = + build_game_creator_role_agent_context_for_session(root, agent_id, Some(session_id))?; + let request = build_agent_interaction_llm_request(agent_id, prompt, &context, &llm)?; Ok((llm, config_path, request)) } @@ -626,12 +636,16 @@ mod tests { #[test] fn interaction_request_crosses_neutral_provider_contract_without_shape_drift() { - let request = - LlmRunRequest::new(vec![LlmMessage::system("system"), LlmMessage::user("user")]) - .with_api_kind(LlmApiKind::OpenAiResponses) - .with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS) - .with_function_tools(agent_interaction_function_tools().expect("interaction tools")) - .with_tool_choice(platform_llm::LlmToolChoice::Auto); + let mut llm = GameCreatorLlmConfig::default(); + llm.api_kind = "openai_responses".to_string(); + llm.reasoning_effort = "max".to_string(); + let request = build_agent_interaction_llm_request( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "制作一个三消经营游戏", + "", + &llm, + ) + .expect("build interaction request"); let request = platform_llm::provider_request_from_llm_request("agent-interaction", request) .expect("neutral request"); assert_eq!( @@ -639,6 +653,10 @@ mod tests { Some(AGENT_INTERACTION_MAX_OUTPUT_TOKENS) ); assert_eq!(request.tools().len(), 3); + assert_eq!( + request.reasoning_effort(), + Some(agent_runtime_core::ProviderReasoningEffort::Max) + ); assert!(request.tools().iter().all(|tool| tool.strict())); assert_eq!( request.tool_choice(), @@ -646,6 +664,20 @@ mod tests { ); } + #[test] + fn interaction_request_propagates_invalid_reasoning_effort() { + let mut llm = GameCreatorLlmConfig::default(); + llm.reasoning_effort = "maximum".to_string(); + let error = build_agent_interaction_llm_request( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "制作一个三消经营游戏", + "", + &llm, + ) + .expect_err("invalid reasoning effort must fail before Provider request"); + assert!(error.contains("reasoning_effort")); + } + #[test] fn interaction_stream_sink_preserves_accumulation_delta_and_finish_reason() { let mut observed = Vec::new(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index 06dc6cc0d..71f601ca4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -61,6 +61,11 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ if let Some(blocker) = supervisor_orchestrator_mutation_block_at(root, agent_id, run_id, tool) { return blocker; } + if let Some(blocker) = + agent_runtime_autonomous_art_director_canvas_only_action_block(agent_id, task, tool) + { + return blocker; + } if let Some(blocker) = game_chat_delegated_art_agent_input_mutation_block( root, agent_id, @@ -640,3 +645,172 @@ pub(in crate::agent) fn agent_runtime_pending_reconciliation_observation( detail: Some(redact_agent_runtime_project_paths(root, error, 500)), } } + +#[cfg(test)] +mod canvas_only_execution_tests { + use super::*; + + #[tokio::test] + async fn art_director_canvas_only_execution_rejects_file_write_and_mixed_batch_without_side_effects( + ) { + const PARENT_RUN_ID: &str = "canvas-only-execution-parent"; + const CHILD_ID: &str = "art-director"; + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"canvas-only-execution-test-key"}}"#.to_string(), + ); + let temporary = tempfile::tempdir().expect("create canvas-only execution root"); + let root = temporary.path().join("project"); + init_local_game_project_at( + &root, + "canvas-only-execution-project", + "生成带统一视觉规范的完整游戏", + ) + .expect("init canvas-only execution project"); + + let parent_session = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve canvas-only parent session"); + let parent = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_session, + "生成带统一视觉规范的完整游戏", + PARENT_RUN_ID, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue canvas-only parent"); + let mut parent_state = agent_runtime_state_from_task_record(&parent); + parent_state.status = "running".to_string(); + parent_state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&root, &parent_state) + .expect("persist running canvas-only parent"); + + let task = format!( + "生成统一视觉规范图。{AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER}" + ); + let child_session = resolve_agent_conversation_session_id_at(&root, CHILD_ID, None, true) + .expect("resolve canvas-only child session"); + let child_run_id = autonomous_manifest_ready_task_run_id(PARENT_RUN_ID, CHILD_ID); + let child = append_unique_game_creator_agent_runtime_pending_task( + &root, + CHILD_ID, + &child_session, + &task, + &child_run_id, + "agent-ready-task-scheduler", + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(parent.agent_id.clone()), + parent_run_id: Some(parent.run_id.clone()), + delegation_id: None, + }), + ) + .expect("queue canvas-only child"); + let mut runtime = agent_runtime_state_from_task_record(&child); + runtime.status = "running".to_string(); + runtime.phase = "planning".to_string(); + write_game_creator_agent_runtime_state(&root, &runtime) + .expect("persist canvas-only child runtime"); + append_game_creator_agent_runtime_task(&root, &runtime) + .expect("persist running canvas-only child"); + update_manifest_task_status_at(&root, CHILD_ID, GameCreationAppTaskStatus::Running) + .expect("mark canvas-only child running"); + + let forbidden_path = "game/art-director-forbidden.txt"; + let forbidden_action = AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: Some("尝试写入非 Canvas 文件".to_string()), + input: serde_json::json!({ + "path": forbidden_path, + "content": "forbidden" + }), + }; + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before canvas-only rejection"); + let observation = execute_game_creator_agent_runtime_tool_action( + &root, + CHILD_ID, + &child_run_id, + &task, + &forbidden_action, + ) + .await; + assert_eq!(observation.status, "rejected", "{observation:?}"); + assert_eq!( + observation.summary, + "art-director 受控视觉任务拒绝非 Canvas 项目动作" + ); + assert!(!root.join(forbidden_path).exists()); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision after direct canvas-only rejection") + .revision, + revision_before.revision + ); + + let mixed_plan = AgentRuntimeToolPlan { + thinking_summary: "尝试混合文件写入与 Canvas 生成".to_string(), + plan_update: None, + plan: vec!["写入文件".to_string(), "生成视觉规范图".to_string()], + actions: vec![ + forbidden_action, + AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: Some("生成统一视觉规范图".to_string()), + input: serde_json::json!({ + "prompt": "生成统一视觉规范图", + "outputPath": AGENT_RUNTIME_ART_SPEC_PATH, + "assetKind": "icon-spec", + "aspectRatio": "1:1", + "imageSize": "1K", + "replaceExisting": false + }), + }, + ], + response: String::new(), + }; + let repository_fingerprint = build_repository_startup_context_at(&root) + .expect("build canvas-only repository context") + .fingerprint; + let prepared = prepare_game_creator_agent_runtime_provider_action_batch( + &root, + &runtime, + &task, + &mixed_plan, + &[], + &revision_before, + &repository_fingerprint, + ) + .await + .expect("prepare mixed canvas-only provider batch"); + assert!( + matches!( + &prepared, + AgentRuntimeProviderActionBatchPreparation::Aborted { + observation: AgentRuntimeToolObservation { + status, + summary, + .. + }, + .. + } if status == "blocked" + && summary == "art-director 受控视觉任务拒绝非 Canvas 项目动作" + ), + "mixed file.write + canvas batch must abort before execution: {prepared:?}" + ); + assert!(!root.join(forbidden_path).exists()); + assert!(!root.join(AGENT_RUNTIME_ART_SPEC_PATH).exists()); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision after mixed canvas-only rejection") + .revision, + revision_before.revision + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index 25a208d6c..f0bc0f716 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -271,6 +271,8 @@ pub(super) const AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_VERIFICATION_ONLY_REPAIR_ER "自主构建非只读专业 Agent 必须先验证本人 run 的项目修改"; pub(super) const AGENT_RUNTIME_AUTONOMOUS_READ_ONLY_MUTATION_ERROR_PREFIX: &str = "自主构建只读专业 Agent 禁止执行写入或副作用动作"; +pub(in crate::agent) const AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER: &str = + "唯一允许的项目写动作是 canvas.asset_generate"; fn autonomous_initial_collaboration_contract_error(detail: impl AsRef) -> String { format!( @@ -486,6 +488,49 @@ fn is_agent_runtime_autonomous_supervisor_delivery_convergence_plan( && action.input.get("delegation_id").is_none_or(serde_json::Value::is_null)) } +fn is_agent_runtime_autonomous_supervisor_request_bound_manifest_wait_plan( + plan: &AgentRuntimeToolPlan, +) -> bool { + let [task_list, run_status] = plan.actions.as_slice() else { + return false; + }; + let Some(task_list_input) = task_list.input.as_object() else { + return false; + }; + let Some(run_status_input) = run_status.input.as_object() else { + return false; + }; + plan.response.trim().is_empty() + && plan.plan_update.is_none() + && plan.plan.is_empty() + && task_list.tool.trim() == "task.list" + && task_list_input.is_empty() + && run_status.tool.trim() == "agent.run_status" + && run_status_input.len() == 3 + && run_status_input + .get("agentId") + .is_some_and(serde_json::Value::is_null) + && run_status_input + .get("scope") + .and_then(serde_json::Value::as_str) + == Some("all") + && run_status_input + .get("delegationId") + .is_some_and(serde_json::Value::is_null) +} + +fn is_agent_runtime_autonomous_supervisor_acceptance_control_plan( + plan: &AgentRuntimeToolPlan, +) -> bool { + plan.response.trim().is_empty() + && plan.plan_update.is_none() + && plan.actions.len() == 1 + && matches!( + plan.actions[0].tool.trim(), + "agent.action_history" | "agent.acceptance_update" + ) +} + fn is_agent_runtime_autonomous_preview_validation_plan(plan: &AgentRuntimeToolPlan) -> bool { matches!(plan.actions.as_slice(), [action] if action.tool.trim() == "preview.validate") } @@ -504,6 +549,35 @@ fn agent_runtime_autonomous_supervisor_plan_prepares_repair(plan: &AgentRuntimeT }) } +fn agent_runtime_game_chat_code_safe_default_repair_plan_matches( + plan: &AgentRuntimeToolPlan, + delivery: &StaticDelegateDeliveryRecord, +) -> bool { + let [action] = plan.actions.as_slice() else { + return false; + }; + plan.response.trim().is_empty() + && plan.plan_update.is_none() + && plan.plan.is_empty() + && action.tool.trim() == "agent.delegate" + && agent_runtime_tool_input_text(&action.input, &["agentId", "agent_id"]) + == delivery.target_agent_id + && agent_runtime_tool_input_text( + &action.input, + &["repairOfDelegationId", "repair_of_delegation_id"], + ) == delivery.delegation_id + && agent_runtime_tool_input_text(&action.input, &["runId", "run_id"]).is_empty() + && !agent_runtime_tool_input_text(&action.input, &["task"]).is_empty() + && agent_runtime_tool_input_string_list( + &action.input, + &["acceptanceCriteria", "acceptance_criteria", "criteria"], + ) == delivery.acceptance_criteria + && agent_runtime_tool_input_string_list( + &action.input, + &["expectedArtifacts", "expected_artifacts", "artifacts"], + ) == delivery.expected_artifacts +} + pub(crate) fn refresh_agent_runtime_autonomous_convergence_snapshot_after_provider_at( root: &Path, agent_id: &str, @@ -542,6 +616,7 @@ pub(super) fn validate_agent_runtime_autonomous_plan_liveness_at( observations: &[AgentRuntimeToolObservation], plan: &AgentRuntimeToolPlan, supervisor_requires_delegated_repair: bool, + supervisor_manifest_dag_in_progress_at_request: bool, ) -> Result<(), String> { if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && autonomous_manifest_dag_in_progress_at(root)? @@ -601,6 +676,7 @@ pub(super) fn validate_agent_runtime_autonomous_plan_liveness_at( )); } } + validate_game_chat_code_safe_default_repair_liveness_at(root, agent_id, run_id, plan)?; let has_current_playtest_receipt = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { let contract = read_autonomous_completion_contract(root, agent_id, run_id)?; @@ -621,19 +697,56 @@ pub(super) fn validate_agent_runtime_autonomous_plan_liveness_at( observations, plan, supervisor_requires_delegated_repair, + supervisor_manifest_dag_in_progress_at_request, has_current_playtest_receipt, ) } -pub(in crate::agent) fn autonomous_manifest_dag_in_progress_at( +pub(in crate::agent) fn validate_game_chat_code_safe_default_repair_liveness_at( root: &Path, -) -> Result { + agent_id: &str, + run_id: &str, + plan: &AgentRuntimeToolPlan, +) -> Result<(), String> { + if agent_id != "code-prototype" + || !static_delegate_parent_can_manage_receipts_at(root, agent_id, run_id)? + { + return Ok(()); + } + if let Some(delivery) = + game_chat_code_parent_safe_default_repair_delivery_at(root, agent_id, run_id)? + { + if agent_runtime_game_chat_code_safe_default_repair_plan_matches(plan, &delivery) { + return Ok(()); + } + return Err(format!( + "game-chat code-prototype 必须立即推进唯一安全默认返工;本轮只能对 delegationId={}、targetAgentId={} 提交同合同 agent.delegate,不得重复 route、读取、查询或请求 Provider 空转", + delivery.delegation_id, delivery.target_agent_id + )); + } + Ok(()) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(in crate::agent) enum AutonomousManifestDagState { + NotStartedOrStalled, + InProgress, + Completed, + Failed { + failed_task_ids: Vec, + has_active_children: bool, + }, +} + +pub(in crate::agent) fn autonomous_manifest_dag_state_at( + root: &Path, +) -> Result { let manifest = read_manifest_for_project(root)?; let current_root = current_autonomous_game_build_root_task_at(root)?.ok_or_else(|| { "无法解析当前自主构建根 Run 的可信 source,拒绝按 GUI 完整 DAG 回退".to_string() })?; if !autonomous_game_build_root_task_is_active(¤t_root) { - return Ok(false); + return Ok(AutonomousManifestDagState::NotStartedOrStalled); } let seed_task_ids = autonomous_manifest_seed_tasks_for_source(¤t_root.source) .into_iter() @@ -647,15 +760,43 @@ pub(in crate::agent) fn autonomous_manifest_dag_in_progress_at( let running = seed_tasks .iter() .any(|task| task.status == GameCreationAppTaskStatus::Running); - let failed = seed_tasks + let mut failed_task_ids = seed_tasks .iter() - .any(|task| task.status == GameCreationAppTaskStatus::Failed); + .filter(|task| task.status == GameCreationAppTaskStatus::Failed) + .map(|task| task.id.clone()) + .collect::>(); + failed_task_ids.sort(); let active_child = autonomous_manifest_parent_has_active_ready_task_at( root, ¤t_root.run_id, &seed_task_ids, )?; - Ok((running || active_child) && !failed) + if !failed_task_ids.is_empty() { + return Ok(AutonomousManifestDagState::Failed { + failed_task_ids, + has_active_children: running || active_child, + }); + } + if running || active_child { + return Ok(AutonomousManifestDagState::InProgress); + } + if seed_tasks.len() == seed_task_ids.len() + && seed_tasks + .iter() + .all(|task| task.status == GameCreationAppTaskStatus::Completed) + { + return Ok(AutonomousManifestDagState::Completed); + } + Ok(AutonomousManifestDagState::NotStartedOrStalled) +} + +pub(in crate::agent) fn autonomous_manifest_dag_in_progress_at( + root: &Path, +) -> Result { + Ok(matches!( + autonomous_manifest_dag_state_at(root)?, + AutonomousManifestDagState::InProgress + )) } fn autonomous_manifest_parent_has_active_ready_task_at( @@ -706,6 +847,7 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness( observations: &[AgentRuntimeToolObservation], plan: &AgentRuntimeToolPlan, supervisor_requires_delegated_repair: bool, + supervisor_manifest_dag_in_progress_at_request: bool, has_current_playtest_receipt: bool, ) -> Result<(), String> { let has_mutation = plan @@ -734,9 +876,18 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness( observations[index].status == "failed" && observations[index].summary == "浏览器验证未通过,请根据诊断修复后重试" }); + let has_current_playtest_acceptance_control = agent_id + == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && has_current_playtest_receipt + && is_agent_runtime_autonomous_supervisor_acceptance_control_plan(plan); + let has_request_bound_manifest_wait = agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && supervisor_manifest_dag_in_progress_at_request + && is_agent_runtime_autonomous_supervisor_request_bound_manifest_wait_plan(plan); // A Supervisor without a mutation must still hit the same first-mutation - // liveness gate as every other autonomous Agent. A concrete failed - // playtest is more specific and must reach its repair gate below. + // liveness gate as every other autonomous Agent. The only exception is + // the single-action evidence/update sequence after the current revision + // already owns a durable playtest receipt. A concrete failed playtest is + // more specific and must reach its repair gate below. if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && loop_index > AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT && verification_gate.mutation_revision.is_none() @@ -746,6 +897,8 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness( && plan.response.trim().is_empty() && !has_specialist_delegation && !latest_playtest_is_failed + && !has_current_playtest_acceptance_control + && !has_request_bound_manifest_wait { return Err(format!( "{AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX};Supervisor 尚未提交首次项目 mutation 或有效协作动作,禁止继续只规划、读取、验证或空转" @@ -954,6 +1107,86 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_read_only_delivery_pla )) } +fn agent_runtime_autonomous_art_director_canvas_only_tool_allowed(tool: &str) -> bool { + matches!( + tool, + "memory.read" + | "conversation.read" + | "asset.list" + | "project.index" + | "project.search" + | "project.diff" + | "git.inspect" + | "file.list" + | "file.read" + | "task.list" + | "agent.action_history" + | "agent.run_status" + | "command.output_read" + | "command.poll" + | "image.inspect" + | "canvas.asset_generate" + ) +} + +pub(in crate::agent) fn agent_runtime_autonomous_art_director_canvas_only_task( + agent_id: &str, + task: &str, +) -> bool { + agent_id == "art-director" + && task.contains(AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER) +} + +pub(in crate::agent) fn validate_agent_runtime_autonomous_art_director_canvas_only_plan( + agent_id: &str, + task: &str, + plan: &AgentRuntimeToolPlan, +) -> Result<(), String> { + if !agent_runtime_autonomous_art_director_canvas_only_task(agent_id, task) { + return Ok(()); + } + if let Some(forbidden) = plan.actions.iter().find(|action| { + !agent_runtime_autonomous_art_director_canvas_only_tool_allowed(action.tool.trim()) + }) { + return Err(format!( + "autonomous art-director 受控视觉任务拒绝动作 {};只允许读取、image.inspect 与唯一 canvas.asset_generate 素材事务", + forbidden.tool.trim() + )); + } + let canvas_count = plan + .actions + .iter() + .filter(|action| action.tool.trim() == "canvas.asset_generate") + .count(); + if canvas_count > 1 { + return Err(format!( + "autonomous art-director 受控视觉任务单轮包含 {canvas_count} 个 canvas.asset_generate,最多允许 1 个" + )); + } + Ok(()) +} + +pub(in crate::agent) fn agent_runtime_autonomous_art_director_canvas_only_action_block( + agent_id: &str, + task: &str, + tool: &str, +) -> Option { + if !agent_runtime_autonomous_art_director_canvas_only_task(agent_id, task) + || agent_runtime_autonomous_art_director_canvas_only_tool_allowed(tool.trim()) + { + return None; + } + Some(AgentRuntimeToolObservation { + tool: tool.trim().to_string(), + status: "rejected".to_string(), + summary: "art-director 受控视觉任务拒绝非 Canvas 项目动作".to_string(), + detail: Some(format!( + "tool={} · allowed=read,image.inspect,canvas.asset_generate", + tool.trim() + )), + }) +} + pub(in crate::agent) fn validate_agent_runtime_autonomous_specialist_response_delivery( agent_id: &str, run_id: &str, @@ -1973,6 +2206,7 @@ mod tests { &route_manifest, false, false, + false, ) .is_ok()); for (agent_id, plan) in [ @@ -1988,6 +2222,85 @@ mod tests { } } + #[test] + fn autonomous_art_director_visual_task_allows_one_canvas_and_rejects_other_actions() { + let task = format!( + "生成视觉规范。{AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER}" + ); + let action = |tool: &str| AgentRuntimeToolAction { + tool: tool.to_string(), + reason: None, + input: serde_json::json!({}), + }; + let allowed = AgentRuntimeToolPlan { + actions: vec![action("asset.list"), action("canvas.asset_generate")], + ..AgentRuntimeToolPlan::default() + }; + assert!( + validate_agent_runtime_autonomous_art_director_canvas_only_plan( + "art-director", + &task, + &allowed, + ) + .is_ok() + ); + assert!( + agent_runtime_autonomous_art_director_canvas_only_action_block( + "art-director", + &task, + "canvas.asset_generate", + ) + .is_none() + ); + + for tool in [ + "file.write", + "file.patch", + "file.delete", + "project.patchset", + "project.restore", + "command.run_limited", + "agent.delegate", + ] { + let rejected = AgentRuntimeToolPlan { + actions: vec![action(tool)], + ..AgentRuntimeToolPlan::default() + }; + assert!( + validate_agent_runtime_autonomous_art_director_canvas_only_plan( + "art-director", + &task, + &rejected, + ) + .is_err() + ); + assert!( + agent_runtime_autonomous_art_director_canvas_only_action_block( + "art-director", + &task, + tool, + ) + .is_some() + ); + } + + let duplicate_canvas = AgentRuntimeToolPlan { + actions: vec![ + action("canvas.asset_generate"), + action("canvas.asset_generate"), + ], + ..AgentRuntimeToolPlan::default() + }; + assert!( + validate_agent_runtime_autonomous_art_director_canvas_only_plan( + "art-director", + &task, + &duplicate_canvas, + ) + .is_err() + ); + } + #[test] fn autonomous_supervisor_without_playtest_still_hits_pre_mutation_liveness_gate() { let verification_gate = AgentRuntimeVerificationGate { @@ -2019,6 +2332,7 @@ mod tests { &plan, false, false, + false, ) .expect_err("Supervisor without a playtest must not bypass pre-mutation liveness"); assert!(error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX)); @@ -2069,8 +2383,190 @@ mod tests { &plan, false, false, + false, ) .expect_err("latest successful playtest must not retain an earlier failure exemption"); assert!(error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX)); } + + #[test] + fn autonomous_supervisor_current_playtest_allows_only_acceptance_control_actions() { + let verification_gate = AgentRuntimeVerificationGate { + schema_version: "test".to_string(), + project_id: "test".to_string(), + agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + run_id: "supervisor-current-playtest-acceptance".to_string(), + requires_verification: false, + mutation_revision: None, + verified_revision: None, + last_mutation_tool: None, + last_verification_tool: None, + last_verification_status: None, + static_smoke_verified_revision: None, + static_smoke_verified_game_index_sha256: None, + failed_playtest_revision: None, + updated_at: 0, + }; + let action = |tool: &str| AgentRuntimeToolAction { + tool: tool.to_string(), + reason: None, + input: serde_json::json!({}), + }; + + for tool in ["agent.action_history", "agent.acceptance_update"] { + let plan = AgentRuntimeToolPlan { + actions: vec![action(tool)], + ..AgentRuntimeToolPlan::default() + }; + validate_agent_runtime_autonomous_plan_liveness( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1, + 11, + &verification_gate, + &[], + &plan, + false, + false, + true, + ) + .unwrap_or_else(|error| { + panic!("{tool} should remain available for final acceptance: {error}") + }); + } + + let mixed_read = AgentRuntimeToolPlan { + actions: vec![action("agent.action_history"), action("file.read")], + ..AgentRuntimeToolPlan::default() + }; + let error = validate_agent_runtime_autonomous_plan_liveness( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1, + 11, + &verification_gate, + &[], + &mixed_read, + false, + false, + true, + ) + .expect_err("final acceptance exemption must not permit a mixed read batch"); + assert!(error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX)); + } + + #[test] + fn autonomous_supervisor_accepts_one_request_bound_manifest_wait_when_live_predicate_closes() { + let verification_gate = AgentRuntimeVerificationGate { + schema_version: "test".to_string(), + project_id: "test".to_string(), + agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + run_id: "supervisor-stale-manifest-wait".to_string(), + requires_verification: false, + mutation_revision: None, + verified_revision: None, + last_mutation_tool: None, + last_verification_tool: None, + last_verification_status: None, + static_smoke_verified_revision: None, + static_smoke_verified_game_index_sha256: None, + failed_playtest_revision: None, + updated_at: 0, + }; + let plan = AgentRuntimeToolPlan { + actions: vec![ + AgentRuntimeToolAction { + tool: "task.list".to_string(), + reason: None, + input: serde_json::json!({}), + }, + AgentRuntimeToolAction { + tool: "agent.run_status".to_string(), + reason: None, + input: serde_json::json!({ + "agentId": null, + "scope": "all", + "delegationId": null, + }), + }, + ], + ..AgentRuntimeToolPlan::default() + }; + assert!(is_agent_runtime_autonomous_supervisor_request_bound_manifest_wait_plan(&plan)); + + validate_agent_runtime_autonomous_plan_liveness( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1, + 0, + &verification_gate, + &[], + &plan, + false, + true, + false, + ) + .expect("request-bound wait should survive the live predicate closing once"); + + let wait_without_request_binding = validate_agent_runtime_autonomous_plan_liveness( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1, + 0, + &verification_gate, + &[], + &plan, + false, + false, + false, + ) + .expect_err("a request without a bound active observation must not keep waiting"); + assert!(wait_without_request_binding + .starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX)); + + for invalid_plan in [ + AgentRuntimeToolPlan { + actions: vec![plan.actions[0].clone()], + ..AgentRuntimeToolPlan::default() + }, + AgentRuntimeToolPlan { + actions: vec![plan.actions[1].clone(), plan.actions[0].clone()], + ..AgentRuntimeToolPlan::default() + }, + AgentRuntimeToolPlan { + actions: vec![ + plan.actions[0].clone(), + AgentRuntimeToolAction { + input: serde_json::json!({ + "agentId": null, + "scope": "all", + "delegationId": null, + "extra": true, + }), + ..plan.actions[1].clone() + }, + ], + ..AgentRuntimeToolPlan::default() + }, + ] { + let error = validate_agent_runtime_autonomous_plan_liveness( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1, + 0, + &verification_gate, + &[], + &invalid_plan, + false, + true, + false, + ) + .expect_err("request-bound wait exemption must reject any broader plan"); + assert!(error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX)); + } + let response_plan = AgentRuntimeToolPlan { + response: "已经完成".to_string(), + ..plan.clone() + }; + assert!( + !is_agent_runtime_autonomous_supervisor_request_bound_manifest_wait_plan( + &response_plan + ) + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index 94616c992..b87a1e16a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -845,8 +845,17 @@ pub(in crate::agent) fn static_delegate_completion_blocker_at_locked( agent_id: &str, run_id: &str, ) -> Option { - if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { - return None; + match static_delegate_parent_can_manage_receipts_at(root, agent_id, run_id) { + Ok(true) => {} + Ok(false) => return None, + Err(error) => { + return Some(AgentRuntimeToolObservation { + tool: "runtime.delegate_receipts".to_string(), + status: "blocked".to_string(), + summary: "无法确认当前父 Run 的专业 Agent 委派权限,不能收束当前任务".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }); + } } match static_delegate_completion_barrier_at(root, agent_id, run_id) { Ok(barrier) if barrier.is_clear() => None, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs index 905b8038e..80108596a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs @@ -473,6 +473,13 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch( None, )?; let command_id = game_creator_agent_runtime_tool_command_id(action.tool.trim()); + let art_director_canvas_only_block = + agent_runtime_autonomous_art_director_canvas_only_action_block( + &runtime.agent_id, + task, + action.tool.trim(), + ) + .map(|observation| AgentRuntimeToolPolicyBlock::Denied(observation.summary)); let game_chat_art_scope_block = game_chat_delegated_art_agent_input_mutation_block( root, &runtime.agent_id, @@ -493,7 +500,8 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch( } else { None }; - let local_policy_block = game_chat_art_scope_block + let local_policy_block = art_director_canvas_only_block + .or(game_chat_art_scope_block) .or(isolated_scope_block) .or_else(|| { command_id diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index 0ed74be4c..d42d96c2a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -9,6 +9,63 @@ enum AgentBackgroundContextMode { FinalReply, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(in crate::agent) struct AgentRuntimeToolPlanRequestSnapshot { + pub(super) supervisor_manifest_dag_in_progress: bool, +} + +fn parse_manifest_seed_task_counts(detail: &str) -> Option<[u64; 6]> { + let counts = detail + .lines() + .find_map(|line| line.strip_prefix("seedTaskCounts: "))?; + let mut parsed = std::collections::BTreeMap::new(); + for field in counts.split_ascii_whitespace() { + let (key, value) = field.split_once('=')?; + if parsed.insert(key, value.parse::().ok()?).is_some() { + return None; + } + } + let completed = *parsed.get("completed")?; + let running = *parsed.get("running")?; + let pending = *parsed.get("pending")?; + let waiting = *parsed.get("waiting")?; + let failed = *parsed.get("failed")?; + let total = *parsed.get("total")?; + if parsed.len() != 6 + || completed + .checked_add(running)? + .checked_add(pending)? + .checked_add(waiting)? + .checked_add(failed)? + != total + { + return None; + } + Some([completed, running, pending, waiting, failed, total]) +} + +fn prompt_observations_report_manifest_dag_in_progress( + observations: &[AgentRuntimeToolObservation], +) -> bool { + let Some(observation) = observations + .iter() + .rfind(|observation| observation.tool == "task.list") + else { + return false; + }; + if observation.status != "ok" || observation.summary != "已读取 manifest 任务图" { + return false; + } + let Some([_completed, running, _pending, _waiting, _failed, total]) = observation + .detail + .as_deref() + .and_then(parse_manifest_seed_task_counts) + else { + return false; + }; + total > 0 && running > 0 +} + #[cfg(target_os = "linux")] fn provider_command_exec_contract() -> &'static str { "command.exec 使用 {\"program\":\"受信任 PATH 中的裸可执行名\",\"args\":[\"逐项 argv\"],\"cwd\":\"可选项目内相对目录\",\"timeoutSeconds\":120};Linux 命令固定运行在 bubblewrap workspace-write、network-disabled 沙箱内,允许 bash -lc、管道、重定向和项目脚本,但不接受环境变量、宿主 executable 路径、mount 或网络策略输入" @@ -72,7 +129,16 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( observations: &[AgentRuntimeToolObservation], loop_index: usize, mcp_catalog: &GameCreatorMcpCatalog, -) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest, String), String> { +) -> Result< + ( + GameCreatorLlmConfig, + String, + LlmRunRequest, + String, + AgentRuntimeToolPlanRequestSnapshot, + ), + String, +> { let effective_task = autonomous_effective_root_task_at(root, agent_id, run_id, task)?; let (llm, config_path, context, repository_context_fingerprint, prompt_observations) = build_game_creator_background_agent_context( @@ -113,6 +179,11 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( let goal_contract_participant = root_control_authority || root_goal_contract_context != "null"; let autonomous_game_build = tool_policy.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD; + let request_snapshot = AgentRuntimeToolPlanRequestSnapshot { + supervisor_manifest_dag_in_progress: autonomous_game_build + && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && prompt_observations_report_manifest_dag_in_progress(&prompt_observations), + }; let autonomous_project_verify_available = !autonomous_game_build || agent_runtime_autonomous_project_verify_available(root); let mut allowed_tools = tool_policy.allowed_tools.clone(); @@ -215,7 +286,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( "file.list 使用 {{\"path\":\"\"}},path 为空字符串时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}};file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"完整文件内容\"}};file.patch 使用 {{\"path\":\"项目内相对路径\",\"oldText\":\"必须精确匹配的原文\",\"newText\":\"替换后的文本\",\"expectedReplacements\":1}};file.delete 使用 {{\"path\":\"项目内相对路径\"}},只删除项目内普通文件,不删除目录或任何 .agent 控制面文件。\n", "task.create 使用 {{\"taskId\":null,\"title\":\"任务标题\",\"group\":\"design|art|code|balance|audio|publishing\",\"role\":\"角色名\",\"dependencies\":[],\"artifacts\":[],\"acceptanceCriteria\":[\"验收标准\"],\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}},需要自定义 taskId 时把 null 替换为合法 ID;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};command.run_limited 使用 {{\"commandId\":\"game.static_smoke\"}}。\n", "canvas.asset_generate 使用 {{\"prompt\":\"图片描述\",\"outputPath\":null,\"aspectRatio\":null,\"imageSize\":null,\"assetKind\":null,\"assetLabel\":null,\"replaceExisting\":false}};需要指定时,aspectRatio 只允许 1:1|2:3|3:2|9:16|16:9,imageSize 只允许 0.5K|1K|2K,assetKind 只允许 {canvas_asset_kind_catalog}。replaceExisting 只能在带 repairOfDelegationId 的唯一返工委派中设为 true,普通生成必须为 false,并通过配置的 External Editor API 同时写入画布、同名素材库目录和本地 assets。\n", - "blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"acceptanceCriteria\":[\"可核对的语义验收条件\"],\"expectedArtifacts\":[],\"repairOfDelegationId\":null,\"runId\":null}},expectedArtifacts 无产物时传空数组且不接受 glob;返工时 repairOfDelegationId 指向已认领原 delivery 且 runId 必须为 null;agent.schedule_ready 使用 {{\"limit\":1}};agent.route_manifest 使用 {{\"strategy\":\"audit-existing-first|use-existing-art|generate-missing-art\",\"intentSummary\":\"Supervisor 自行理解的用户意图,仅 Supervisor 提交\",\"missingAssetSlots\":[]}};Supervisor 必须自行概括非空 intentSummary,并以 audit-existing-first 提交执行安全策略;code-prototype 先 asset.list 后只能按权威缺口提交 use-existing-art 或 generate-missing-art,且无需提交 intentSummary;agent.run_status 使用 {{\"agentId\":null,\"scope\":\"all\",\"delegationId\":null}},指定目标 Agent 或已认领 delegation 时把对应 null 替换为实际 ID;Project Supervisor 传 delegationId 时读取当前父 run 的未截断权威返工合同。\n", + "blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"acceptanceCriteria\":[\"可核对的语义验收条件\"],\"expectedArtifacts\":[],\"repairOfDelegationId\":null,\"runId\":null}},expectedArtifacts 无产物时传空数组且不接受 glob;返工时 repairOfDelegationId 指向已认领原 delivery 且 runId 必须为 null;agent.schedule_ready 使用 {{\"limit\":1}};agent.route_manifest 使用 {{\"strategy\":\"audit-existing-first|use-existing-art|generate-missing-art\",\"intentSummary\":\"Supervisor 自行理解的用户意图,仅 Supervisor 提交\",\"missingAssetSlots\":[]}};Supervisor 必须自行概括非空 intentSummary,并以 audit-existing-first 提交执行安全策略;code-prototype 先 asset.list 后只能按权威缺口提交 use-existing-art 或 generate-missing-art,且无需提交 intentSummary;agent.run_status 使用 {{\"agentId\":null,\"scope\":\"all\",\"delegationId\":null}},指定目标 Agent 或已认领 delegation 时把对应 null 替换为实际 ID;当前可信父 Run 传 delegationId 时读取自己已认领的未截断权威返工合同。\n", "当前请求中的每个 MCP 工具都以单独的动态函数广告;必须从实际广告函数中选择,并严格按该函数的 input schema 提交 arguments.input。server、tool、catalogFingerprint 和 toolFingerprint 由 Runtime 注入,禁止构造目录外包装调用。\n", "只有 conversation.read、asset.list、project.index、project.checkpoint、task.list、preview.start 的 arguments.input 使用空对象 {{}};其他函数必须提交实际广告 schema 的全部 required 字段。如果已有观察足够,必须调用 respond_to_user 交付最终回复。" ), @@ -392,7 +463,13 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( &llm, true, )?; - Ok((llm, config_path, request, repository_context_fingerprint)) + Ok(( + llm, + config_path, + request, + repository_context_fingerprint, + request_snapshot, + )) } pub(in crate::agent) fn build_game_creator_agent_background_final_reply_request( @@ -571,12 +648,13 @@ fn build_game_creator_background_agent_context( #[cfg(test)] mod tests { use crate::agent::{ - cancel_game_creator_agent_runtime_task_at, + autonomous_manifest_dag_in_progress_at, cancel_game_creator_agent_runtime_task_at, persist_game_chat_supervisor_workflow_decision_at, start_game_creator_supervisor_background_task_for_session_at, try_acquire_game_creator_agent_runtime_task_lock, GAME_CHAT_WORKFLOW_STRATEGY_AUDIT_EXISTING_FIRST, }; + use crate::{update_manifest_task_status_at, GameCreationAppTaskStatus}; use super::{ agent_runtime_root_source_at, append_unique_game_creator_agent_runtime_pending_task, @@ -590,6 +668,7 @@ mod tests { start_game_creator_agent_runtime_task_at, AgentRuntimeGoalContractAcceptanceNodeDraft, AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation, AgentRuntimeToolPlan, GameCreatorMcpCatalog, GameCreatorMcpCatalogTool, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT, AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, AGENT_RUNTIME_RESPOND_FUNCTION_NAME, AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, @@ -672,7 +751,7 @@ mod tests { summary: "结构化计划更新被 Runtime 拒绝".to_string(), detail: Some("计划状态回退".to_string()), }; - let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request( &root, &state.agent_id, &state.session_id, @@ -783,7 +862,7 @@ mod tests { servers: Vec::new(), tools: Vec::new(), }; - let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request( &root, agent_id, &state.session_id, @@ -841,7 +920,7 @@ mod tests { servers: Vec::new(), tools: Vec::new(), }; - let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request( &root, &state.agent_id, &state.session_id, @@ -910,17 +989,18 @@ mod tests { vec!["核对预加载说明".to_string()], ) .expect("start supervisor runtime state"); - let (_, _, supervisor_request, _) = build_game_creator_agent_background_tool_plan_request( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &supervisor_state.session_id, - &supervisor_state.run_id, - &supervisor_state.current_task, - &[], - 0, - &catalog, - ) - .expect("build supervisor planning request"); + let (_, _, supervisor_request, _, _) = + build_game_creator_agent_background_tool_plan_request( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &supervisor_state.session_id, + &supervisor_state.run_id, + &supervisor_state.current_task, + &[], + 0, + &catalog, + ) + .expect("build supervisor planning request"); let supervisor_system_prompt = &supervisor_request.messages[0].content; let supervisor_prompt = &supervisor_request.messages[1].content; let supervisor_identity_contract = @@ -1065,7 +1145,7 @@ mod tests { vec!["核对普通说明".to_string()], ) .expect("start ordinary runtime state"); - let (_, _, ordinary_request, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, ordinary_request, _, _) = build_game_creator_agent_background_tool_plan_request( &root, "code-prototype", &ordinary_state.session_id, @@ -1194,7 +1274,7 @@ mod tests { vec!["核对 MCP 调用协议".to_string()], ) .expect("start runtime state"); - let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request( &root, "code-prototype", &state.session_id, @@ -1348,7 +1428,7 @@ mod tests { servers: Vec::new(), tools: Vec::new(), }; - let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, &state.session_id, @@ -1370,6 +1450,135 @@ mod tests { assert!(!user_prompt.contains("Graph reset 才保留")); } + #[test] + fn supervisor_request_snapshot_preserves_prompt_visible_running_sibling_after_manifest_failure() + { + let temporary = + crate::tests::canonical_test_tempdir("provider-running-sibling-after-failure-"); + let root = temporary.path().join("project"); + init_local_game_project_at( + &root, + "running-sibling-after-failure", + "失败兄弟任务后的活跃观察绑定测试", + ) + .expect("project init"); + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let run_id = "provider-stale-manifest-observation-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous supervisor"); + let state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "生成一版可玩的游戏", + run_id, + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + "等待专业任务图完成", + vec!["读取专业任务图状态".to_string()], + ) + .expect("start autonomous supervisor"); + for task_id in [ + "design-director", + "design-foundation", + "balance-director", + "balance-seed", + "art-director", + "art-polish", + "audio-director", + ] { + update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Completed) + .expect("complete manifest fixture task"); + } + update_manifest_task_status_at(&root, "art-asset-plan", GameCreationAppTaskStatus::Failed) + .expect("fail manifest fixture task"); + update_manifest_task_status_at( + &root, + "audio-asset-plan", + GameCreationAppTaskStatus::Running, + ) + .expect("keep sibling manifest fixture task running"); + assert!( + !autonomous_manifest_dag_in_progress_at(&root) + .expect("read failure-closed live DAG predicate"), + "the regression requires a failed task to close the live predicate while a sibling remains running" + ); + let mut observations = vec![ + AgentRuntimeToolObservation { + tool: "task.list".to_string(), + status: "ok".to_string(), + summary: "已读取 manifest 任务图".to_string(), + detail: Some( + "readyTaskIds: (none)\nseedTaskCounts: completed=7 running=1 pending=7 waiting=0 failed=1 total=16\ntaskCounts: completed=7 running=1 pending=7 waiting=0 failed=1 total=16" + .to_string(), + ), + }, + AgentRuntimeToolObservation { + tool: "agent.run_status".to_string(), + status: "ok".to_string(), + summary: "已读取 2 个 Agent 状态".to_string(), + detail: Some( + "agentId: project-supervisor\nstatus: running\n\nagentId: code-prototype\nstatus: running" + .to_string(), + ), + }, + ]; + let catalog = GameCreatorMcpCatalog { + fingerprint: String::new(), + servers: Vec::new(), + tools: Vec::new(), + }; + + let (_, _, request, _, request_snapshot) = + build_game_creator_agent_background_tool_plan_request( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + &state.run_id, + &state.current_task, + &observations, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1, + &catalog, + ) + .expect("build request from the provider-visible running sibling observation"); + + assert!(request.messages[1].content.contains( + "seedTaskCounts: completed=7 running=1 pending=7 waiting=0 failed=1 total=16" + )); + assert!(request_snapshot.supervisor_manifest_dag_in_progress); + + observations.push(AgentRuntimeToolObservation { + tool: "task.list".to_string(), + status: "ok".to_string(), + summary: "已读取 manifest 任务图".to_string(), + detail: Some( + "readyTaskIds: (none)\nseedTaskCounts: completed=15 running=0 pending=0 waiting=0 failed=1 total=16\ntaskCounts: completed=15 running=0 pending=0 waiting=0 failed=1 total=16" + .to_string(), + ), + }); + let (_, _, _, _, settled_request_snapshot) = + build_game_creator_agent_background_tool_plan_request( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + &state.run_id, + &state.current_task, + &observations, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 2, + &catalog, + ) + .expect("build successor request from the terminal provider-visible observation"); + assert!( + !settled_request_snapshot.supervisor_manifest_dag_in_progress, + "a successor request must not inherit an older active observation" + ); + } + #[test] fn game_chat_code_prototype_receives_the_persisted_supervisor_workflow_authority() { let temporary = crate::tests::canonical_test_tempdir("provider-game-chat-authority-"); @@ -1432,7 +1641,7 @@ mod tests { servers: Vec::new(), tools: Vec::new(), }; - let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request( &root, "code-prototype", &code_state.session_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index 3754f1f80..2229daef8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -174,12 +174,16 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at let (run_profile, _) = agent_runtime_run_profile_identity_at(root, agent_id, run_id, None, None)?; let mcp_catalog = read_game_creator_mcp_catalog_at(root).await?; - let mut built_request = { + let (mut built_request, mut supervisor_manifest_dag_in_progress_at_request) = { let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( root, "runtime.provider_request.build.tool_plan", )?; - build_game_creator_agent_background_tool_plan_request( + let live_manifest_dag_in_progress_before = run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && autonomous_manifest_dag_in_progress_at(root)?; + let request = build_game_creator_agent_background_tool_plan_request( root, agent_id, session_id, @@ -188,7 +192,15 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at observations, loop_index, &mcp_catalog, - )? + )?; + let live_manifest_dag_in_progress_after = run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && autonomous_manifest_dag_in_progress_at(root)?; + let request_bound_manifest_dag_in_progress = live_manifest_dag_in_progress_before + || live_manifest_dag_in_progress_after + || request.4.supervisor_manifest_dag_in_progress; + (request, request_bound_manifest_dag_in_progress) }; let mut estimated_input_tokens = estimate_game_creator_llm_request_tokens(&built_request.2)?; let mut compaction = None; @@ -224,12 +236,19 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at return Ok(RequestedAgentRuntimeToolPlanOutcome::Superseded); } } - built_request = { + ( + built_request, + supervisor_manifest_dag_in_progress_at_request, + ) = { let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( root, "runtime.provider_request.rebuild.tool_plan", )?; - build_game_creator_agent_background_tool_plan_request( + let live_manifest_dag_in_progress_before = run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && autonomous_manifest_dag_in_progress_at(root)?; + let request = build_game_creator_agent_background_tool_plan_request( root, agent_id, session_id, @@ -238,7 +257,15 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at observations, loop_index, &mcp_catalog, - )? + )?; + let live_manifest_dag_in_progress_after = run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && autonomous_manifest_dag_in_progress_at(root)?; + let request_bound_manifest_dag_in_progress = live_manifest_dag_in_progress_before + || live_manifest_dag_in_progress_after + || request.4.supervisor_manifest_dag_in_progress; + (request, request_bound_manifest_dag_in_progress) }; estimated_input_tokens = estimate_game_creator_llm_request_tokens(&built_request.2)?; if estimated_input_tokens > built_request.0.auto_compact_token_limit { @@ -275,7 +302,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at applied_steer_cursor, )? }; - let (llm, config_path, mut request, repository_context_fingerprint) = built_request; + let (llm, config_path, mut request, repository_context_fingerprint, _) = built_request; let auto_compact_token_limit = llm.auto_compact_token_limit; let format_repair_attempts = if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { AGENT_RUNTIME_AUTONOMOUS_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS @@ -471,6 +498,17 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at error, ) })?; + validate_agent_runtime_autonomous_art_director_canvas_only_plan( + agent_id, + task, + &parsed.plan, + ) + .map_err(|error| { + AgentRuntimeToolPlanProtocolError::new( + AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics, + error, + ) + })?; if autonomous_scaffold_repair_active && source_payload.max_field_chars > AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_SOURCE_MAX_CHARS @@ -534,6 +572,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at observations, &parsed.plan, supervisor_requires_delegated_repair, + supervisor_manifest_dag_in_progress_at_request && repair_attempt == 0, ) { Ok(()) => Ok((parsed, source_payload)), Err(error) => Err(AgentRuntimeToolPlanProtocolError::new( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/run_status_observation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/run_status_observation.rs index 2a150f911..7ac22121c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/run_status_observation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/run_status_observation.rs @@ -12,7 +12,7 @@ pub(in crate::agent) fn mark_supervisor_delivery_claims_observed_for_pending_act observed_delegate_receipt_ids_from_run_status(observation.detail.as_deref())?; let observed_isolated_groups = observed_isolated_join_group_ids_from_run_status(observation.detail.as_deref())?; - if pending.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + if static_delegate_parent_can_manage_receipts_at(root, &pending.agent_id, &pending.run_id)? { mark_static_delegate_claim_observed_for_receipts_at( root, &pending.agent_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/structured_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/structured_plan.rs index d938862f3..0617863b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/structured_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/structured_plan.rs @@ -120,8 +120,9 @@ pub(crate) fn apply_agent_runtime_plan_update( update: &AgentRuntimePlanUpdate, ) -> Result { let update = sanitize_agent_runtime_plan_update(update)?; + let had_structured_plan = agent_runtime_has_structured_plan(runtime); let mut terminal_steps = std::collections::BTreeMap::new(); - if agent_runtime_has_structured_plan(runtime) { + if had_structured_plan { for step in &runtime.plan_steps { if matches!( step.status.as_str(), @@ -155,10 +156,12 @@ pub(crate) fn apply_agent_runtime_plan_update( .plan_steps .iter() .filter(|step| { - matches!( - step.status.as_str(), - AGENT_RUNTIME_PLAN_STATUS_COMPLETED | AGENT_RUNTIME_PLAN_STATUS_FAILED - ) && !incoming_titles.contains(step.title.as_str()) + had_structured_plan + && matches!( + step.status.as_str(), + AGENT_RUNTIME_PLAN_STATUS_COMPLETED | AGENT_RUNTIME_PLAN_STATUS_FAILED + ) + && !incoming_titles.contains(step.title.as_str()) }) .map(|step| (step.title.clone(), step.status.clone())) .collect::>(); @@ -175,7 +178,7 @@ pub(crate) fn apply_agent_runtime_plan_update( )); } - let unchanged = agent_runtime_has_structured_plan(runtime) + let unchanged = had_structured_plan && runtime.plan_explanation == update.explanation && runtime.plan_steps.len() == merged.len() && runtime @@ -190,11 +193,15 @@ pub(crate) fn apply_agent_runtime_plan_update( } let now = unix_timestamp(); - let previous_steps = runtime - .plan_steps - .iter() - .map(|step| (step.title.clone(), step.clone())) - .collect::>(); + let previous_steps = if had_structured_plan { + runtime + .plan_steps + .iter() + .map(|step| (step.title.clone(), step.clone())) + .collect::>() + } else { + std::collections::BTreeMap::new() + }; runtime.plan_steps = merged .into_iter() .enumerate() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs index 03750a4dc..08522fbf4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs @@ -165,6 +165,22 @@ fn game_chat_fast_path_action(tool: &str, input: serde_json::Value) -> AgentRunt } } +fn game_chat_fast_path_safe_default_repair_plan( + delivery: &StaticDelegateDeliveryRecord, +) -> AgentRuntimeToolPlan { + game_chat_fast_path_action( + "agent.delegate", + serde_json::json!({ + "agentId": delivery.target_agent_id, + "task": "不要追问用户;采用原回执中已授权的安全默认值,严格按原合同完成唯一一次返工。", + "acceptanceCriteria": delivery.acceptance_criteria, + "expectedArtifacts": delivery.expected_artifacts, + "repairOfDelegationId": delivery.delegation_id, + "runId": null, + }), + ) +} + fn game_chat_fast_path_fallback_write_plan_for_root( root: &Path, task: &str, @@ -2231,6 +2247,50 @@ pub(crate) fn game_chat_fast_path_plan_at( } } "code-prototype" => { + if !static_delegate_parent_can_manage_receipts_at( + root, + &runtime.agent_id, + &runtime.run_id, + )? { + return Err( + "game-chat code-prototype 父 Run 身份链不可信,拒绝管理专业美术回执" + .to_string(), + ); + } + let delegate_barrier = + static_delegate_completion_barrier_at(root, &runtime.agent_id, &runtime.run_id)?; + if delegate_barrier.ready_unclaimed_count > 0 { + return Ok(Some(game_chat_fast_path_action( + "agent.run_status", + serde_json::json!({ + "agentId": null, + "scope": "self", + "delegationId": null, + }), + ))); + } + if delegate_barrier.unobserved_claim_count > 0 { + return Err( + "game-chat 专业美术回执 claim 尚未由原 agent.run_status action 完整观察;拒绝发起新的 Provider 请求" + .to_string(), + ); + } + if let Some(error) = game_chat_code_parent_terminal_delivery_error_at( + root, + &runtime.agent_id, + &runtime.run_id, + )? { + return Err(error); + } + if let Some(delivery) = game_chat_code_parent_safe_default_repair_delivery_at( + root, + &runtime.agent_id, + &runtime.run_id, + )? { + return Ok(Some(game_chat_fast_path_safe_default_repair_plan( + &delivery, + ))); + } if agent_runtime_has_structured_plan(runtime) && runtime .plan_steps @@ -2309,8 +2369,11 @@ pub(crate) fn game_chat_fast_path_plan_at( { return Ok(None); } - if runtime.loop_iteration > 1 - || budget.elapsed_seconds >= GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS + if budget.elapsed_seconds >= GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS + || (runtime.loop_iteration > 1 + && current_route.is_some() + && game_chat_fast_path_has_visual_asset(root, "art-asset-plan") + && game_chat_fast_path_art_slice_paths(root).is_ok()) { return Ok(Some(game_chat_fast_path_fallback_write_plan_for_budget_at( root, &budget, task, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index e6389adbd..f868e2241 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -992,35 +992,59 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( && static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id) .is_none(); if autonomous_manifest_parent_can_wait { + let manifest_state_before_schedule = match autonomous_manifest_dag_state_at(&root) { + Ok(value) => value, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("读取自主构建 manifest 等待屏障失败:{error}"), + ); + } + }; // 已登记但损坏的派生视觉需要先由父 Run 规划修复,因此此时不再调度新的 // manifest child;但已经持久化并运行的 child 仍是当前 DAG 的活跃工作, // 父 Run 必须继续等待,不能提前落入 game-chat fixed-graph-stalled。 - let scheduled_ready_tasks = - if autonomous_registered_derived_visuals_block_manifest_scheduler_at( + // 同理,已有失败任务时只能等待已在途 child 收束或立即安全失败,不能再 + // 启动新的 pending sibling 并用它遮蔽原始失败。 + let manifest_scheduler_blocked = matches!( + &manifest_state_before_schedule, + AutonomousManifestDagState::Completed + | AutonomousManifestDagState::Failed { .. } + ) + || autonomous_registered_derived_visuals_block_manifest_scheduler_at( &root, &runtime, + ); + let scheduled_ready_tasks = if manifest_scheduler_blocked { + Vec::new() + } else { + match schedule_autonomous_game_build_ready_tasks_at( + &root, + &agent_id, + &runtime.run_id, + 3, ) { - Vec::new() - } else { - match schedule_autonomous_game_build_ready_tasks_at( - &root, - &agent_id, - &runtime.run_id, - 3, - ) { - Ok(tasks) => tasks, - Err(error) => { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - &format!("调度自主构建 manifest 任务失败:{error}"), - ); - } + Ok(tasks) => tasks, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("调度自主构建 manifest 任务失败:{error}"), + ); } - }; - let manifest_in_progress = if scheduled_ready_tasks.is_empty() { - match autonomous_manifest_dag_in_progress_at(&root) { + } + }; + let manifest_state = if matches!( + &manifest_state_before_schedule, + AutonomousManifestDagState::Failed { .. } + ) { + manifest_state_before_schedule + } else if scheduled_ready_tasks.is_empty() { + match autonomous_manifest_dag_state_at(&root) { Ok(value) => value, Err(error) => { return fail_game_creator_agent_background_context_at( @@ -1033,9 +1057,17 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( } } } else { - true + AutonomousManifestDagState::InProgress }; - if manifest_in_progress { + let manifest_parent_must_wait = matches!( + &manifest_state, + AutonomousManifestDagState::InProgress + | AutonomousManifestDagState::Failed { + has_active_children: true, + .. + } + ); + if manifest_parent_must_wait { let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &runtime) .unwrap_or_else(|| AgentRuntimeToolObservation { @@ -1064,6 +1096,21 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( } return AgentBackgroundTaskOutcome::WaitingForManifestTasks; } + if let AutonomousManifestDagState::Failed { + failed_task_ids, .. + } = manifest_state + { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!( + "自主构建 manifest 专业任务失败;failedTaskIds={}", + failed_task_ids.join(",") + ), + ); + } } match prepare_game_chat_single_round_convergence_at( &root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index 67856863f..cc12e1f9b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -202,11 +202,7 @@ fn queue_game_chat_fast_path_child( ) } -fn persist_game_chat_main_asset_audit_and_route( - root: &Path, - root_state: &AgentRuntimeState, - main_state: &mut AgentRuntimeState, -) { +fn persist_game_chat_main_asset_audit(root: &Path, main_state: &mut AgentRuntimeState) { assert_eq!(main_state.agent_id, "code-prototype"); main_state .recent_tool_calls @@ -225,6 +221,14 @@ fn persist_game_chat_main_asset_audit_and_route( .expect("persist game-chat main asset audit task"); write_game_creator_agent_runtime_state(root, main_state) .expect("persist game-chat main asset audit runtime"); +} + +fn persist_game_chat_main_asset_audit_and_route( + root: &Path, + root_state: &AgentRuntimeState, + main_state: &mut AgentRuntimeState, +) { + persist_game_chat_main_asset_audit(root, main_state); persist_game_chat_supervisor_workflow_decision_at( root, &root_state.agent_id, @@ -369,6 +373,49 @@ fn game_chat_main_art_child_fixture( (main, child, delegation_id) } +fn claim_game_chat_main_ready_deliveries_with_run_status( + root: &Path, + main: &AgentRuntimeState, +) -> (AgentRuntimePendingToolAction, AgentRuntimeToolObservation) { + let plan = game_chat_fast_path_plan_at(root, main, &main.current_task, unix_timestamp()) + .expect("build deterministic game-chat receipt claim plan") + .expect("ready game-chat delivery must produce a deterministic claim plan"); + assert_eq!(plan.actions.len(), 1); + assert_eq!(plan.actions[0].tool, "agent.run_status"); + let action = plan.actions[0].clone(); + let revision = read_game_creator_agent_runtime_project_revision(root) + .expect("read game-chat receipt claim revision"); + let repository_context_fingerprint = build_repository_startup_context_at(root) + .expect("build game-chat receipt claim repository context") + .fingerprint; + let pending = build_game_creator_agent_runtime_pending_tool_action( + root, + main, + &main.current_task, + &plan, + &[], + &revision, + &repository_context_fingerprint, + &action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ) + .expect("build game-chat receipt claim pending action"); + let observation = observe_agent_runtime_run_status( + root, + &main.agent_id, + &main.run_id, + Some(&pending.action_id), + &action.input, + ); + assert_eq!(observation.status, "ok", "{observation:?}"); + mark_supervisor_delivery_claims_observed_for_pending_action_at(root, &pending, &observation) + .expect("mark game-chat parent receipt claim observed"); + (pending, observation) +} + fn game_chat_main_without_asset_audit_fixture(root: &Path) -> String { init_local_game_project_at(root, "game-chat-no-audit", "缺失审计的单主任务") .expect("init no-audit project"); @@ -843,6 +890,492 @@ async fn game_chat_art_receipt_wakes_the_same_code_prototype_run() { ); } +#[test] +fn game_chat_code_prototype_ignores_suppressed_delivery_without_child_task() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let temporary = tempfile::tempdir().expect("create suppressed delivery recovery root"); + let root = temporary.path().join("project"); + init_local_game_project_at( + &root, + "game-chat-suppressed-delivery", + "忽略未启动 child 的 suppressed delivery 后重试", + ) + .expect("init suppressed delivery recovery project"); + let (root_state, mut main) = queue_game_chat_fast_path_child( + &root, + "game-chat-suppressed-delivery-root", + "审计已有美术并补齐真实缺口", + "code-prototype", + ); + main.status = "running".to_string(); + main.phase = "planning".to_string(); + main.current_task = "审计现有素材并按真实缺口委派美术".to_string(); + main.current_action = "恢复失败的美术委派".to_string(); + persist_game_chat_main_asset_audit_and_route(&root, &root_state, &mut main); + + let suppressed = new_static_delegate_delivery_with_contract( + &main.agent_id, + &main.session_id, + &main.run_id, + "game-chat-suppressed-before-child-action", + "delegation-game-chat-suppressed-before-child", + "art-director", + "game-chat-suppressed-missing-child-session", + "game-chat-suppressed-missing-child-run", + &["只写入受委派的 assets 产物并回执".to_string()], + &[AGENT_RUNTIME_ART_SPEC_PATH.to_string()], + None, + ); + let suppressed = create_or_read_static_delegate_delivery_at(&root, &suppressed) + .expect("create delivery before child startup failure"); + let suppressed = suppress_static_delegate_delivery_at(&root, &suppressed) + .expect("suppress delivery whose child never started"); + assert_eq!(suppressed.status, StaticDelegateDeliveryStatus::Suppressed); + assert!( + read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + "art-director", + "game-chat-suppressed-missing-child-run", + ) + .expect("check missing suppressed child") + .is_none(), + "fixture must preserve the dispatch-before-child-start failure window" + ); + + assert!( + static_delegate_parent_can_manage_receipts_at(&root, &main.agent_id, &main.run_id) + .expect("suppressed delivery must not invalidate the trusted parent"), + "trusted game-chat code parent must retain receipt capability" + ); + assert!( + static_delegate_completion_barrier_at(&root, &main.agent_id, &main.run_id) + .expect("read barrier with suppressed delivery") + .is_clear(), + "suppressed delivery must not block retry or completion" + ); + game_chat_fast_path_plan_at(&root, &main, &main.current_task, unix_timestamp()) + .expect("suppressed delivery without child must not fail the fast path"); + + let child_lane = try_acquire_game_creator_agent_runtime_task_lock(&root, "art-director") + .expect("acquire retried art child lane") + .expect("retried art child lane available"); + let retry_action_id = "game-chat-retry-after-suppressed-action"; + let retry = observe_agent_runtime_agent_delegate( + &root, + &main.agent_id, + &main.run_id, + Some(retry_action_id), + &serde_json::json!({ + "agentId": "art-director", + "task": "只补齐审计确认缺失的美术资产", + "acceptanceCriteria": ["只写入受委派的 assets 产物并回执"], + "expectedArtifacts": [AGENT_RUNTIME_ART_SPEC_PATH], + "repairOfDelegationId": null, + "runId": null + }), + ); + assert_eq!(retry.status, "ok", "{retry:?}"); + let retry_delegation_id = agent_runtime_delegation_id( + &main.agent_id, + &main.run_id, + "art-director", + retry_action_id, + ); + let retried_child = read_latest_game_creator_agent_runtime_task_by_delegation_id( + &root, + "art-director", + &retry_delegation_id, + ) + .expect("read retried art child") + .expect("retried art child exists"); + cancel_game_creator_agent_runtime_task_at( + &root, + &retried_child.agent_id, + &retried_child.run_id, + ) + .expect("cancel retried art child fixture"); + drop(child_lane); +} + +#[test] +fn game_chat_code_prototype_missing_parent_task_with_active_delivery_blocks_completion() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let temporary = tempfile::tempdir().expect("create missing parent task root"); + let root = temporary.path().join("project"); + let (main, child, _delegation_id, child_lane) = game_chat_main_art_child_fixture_with_lane( + &root, + "art-director", + &["art-spec", "core-spritesheet"], + true, + ); + let child_lane = child_lane.expect("delegated child lane is held"); + fs::remove_file(game_creator_agent_runtime_task_path(&root, &main.agent_id)) + .expect("remove code-prototype task journal fixture"); + + let capability = + static_delegate_parent_can_manage_receipts_at(&root, &main.agent_id, &main.run_id); + assert!( + capability + .as_ref() + .is_err_and(|error| error.contains("存在未收束静态委派")), + "missing parent task with an active delivery must fail closed: {capability:?}" + ); + let blocker = static_delegate_completion_blocker_at_locked(&root, &main.agent_id, &main.run_id) + .expect("missing parent task must block completion"); + assert_eq!(blocker.status, "blocked"); + assert!(blocker.summary.contains("无法确认当前父 Run")); + + cancel_game_creator_agent_runtime_task_at(&root, &child.agent_id, &child.run_id) + .expect("cancel missing-parent child fixture"); + drop(child_lane); +} + +#[test] +fn game_chat_code_prototype_claims_own_failed_art_delivery_and_fails_closed() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let temporary = tempfile::tempdir().expect("create game-chat failed art receipt root"); + let root = temporary.path().join("project"); + let (main, child, delegation_id, child_lane) = game_chat_main_art_child_fixture_with_lane( + &root, + "art-director", + &["art-spec", "core-spritesheet"], + true, + ); + let child_lane = child_lane.expect("delegated art child lane is held"); + let dispatched = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read dispatched failed-art delivery") + .expect("failed-art delivery exists"); + let failed_result = build_static_delegate_structured_result_at( + &root, + "failed", + &dispatched.expected_artifacts, + false, + None, + None, + None, + Some("External Editor 配置缺失,无法生成平台美术资源"), + ) + .expect("build failed art structured result"); + let ready = mark_static_delegate_delivery_ready_with_result_at( + &root, + &child.agent_id, + &child.session_id, + &child.run_id, + &delegation_id, + "failed", + "External Editor 配置缺失,无法生成平台美术资源", + failed_result, + ) + .expect("mark failed art delivery ready"); + assert_eq!(ready.status, StaticDelegateDeliveryStatus::Ready); + + let deterministic_claim = + game_chat_fast_path_plan_at(&root, &main, &main.current_task, unix_timestamp()) + .expect("build deterministic failed-art claim plan") + .expect("ready art delivery must be claimed before another Provider request"); + assert_eq!(deterministic_claim.actions.len(), 1); + assert_eq!(deterministic_claim.actions[0].tool, "agent.run_status"); + assert_eq!(deterministic_claim.actions[0].input["scope"], "self"); + + let claim_action = deterministic_claim.actions[0].clone(); + let project_revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read failed-art claim revision"); + let repository_context_fingerprint = build_repository_startup_context_at(&root) + .expect("build failed-art claim repository context") + .fingerprint; + let pending = build_game_creator_agent_runtime_pending_tool_action( + &root, + &main, + &main.current_task, + &deterministic_claim, + &[], + &project_revision_before, + &repository_context_fingerprint, + &claim_action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ) + .expect("build failed-art run_status pending action"); + let observation = observe_agent_runtime_run_status( + &root, + &main.agent_id, + &main.run_id, + Some(&pending.action_id), + &claim_action.input, + ); + assert_eq!(observation.status, "ok", "{observation:?}"); + let receipt_payload = + run_status_prefixed_payload(observation.detail.as_deref(), "readyDelegateReceipts: ") + .expect("parse failed art receipt prefix") + .expect("failed art receipt payload exists"); + let receipt_payload: serde_json::Value = + serde_json::from_str(receipt_payload).expect("parse failed art receipt JSON"); + let receipts = receipt_payload["receipts"] + .as_array() + .expect("failed art receipt array"); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0]["delegationId"], delegation_id); + assert_eq!(receipts[0]["status"], "failed"); + mark_supervisor_delivery_claims_observed_for_pending_action_at(&root, &pending, &observation) + .expect("mark trusted game-chat parent claim observed"); + let barrier = static_delegate_completion_barrier_at(&root, &main.agent_id, &main.run_id) + .expect("read failed art delivery barrier"); + assert_eq!(barrier.ready_unclaimed_count, 0); + assert_eq!(barrier.unobserved_claim_count, 0); + assert_eq!(barrier.repair_required_count, 1); + + let claimed_contract = observe_agent_runtime_run_status( + &root, + &main.agent_id, + &main.run_id, + Some("game-chat-code-failed-art-contract"), + &serde_json::json!({"delegationId": delegation_id}), + ); + assert_eq!(claimed_contract.status, "ok", "{claimed_contract:?}"); + let contract = claimed_contract + .detail + .as_deref() + .expect("claimed failed art contract detail"); + let contract: serde_json::Value = + serde_json::from_str(contract).expect("parse claimed failed art contract"); + assert_eq!( + contract["claimedDelegateContract"]["terminalStatus"], + "failed" + ); + assert_eq!( + contract["claimedDelegateContract"]["contractStatus"], + "needs-repair" + ); + + for (agent_id, run_id, rejected_delegation_id) in [ + ( + "quality-review", + main.run_id.as_str(), + delegation_id.as_str(), + ), + ( + main.agent_id.as_str(), + "game-chat-code-other-run", + delegation_id.as_str(), + ), + ( + main.agent_id.as_str(), + main.run_id.as_str(), + "delegation-000000000000000000000000", + ), + ] { + let rejected = observe_agent_runtime_run_status( + &root, + agent_id, + run_id, + Some("game-chat-code-cross-scope-contract"), + &serde_json::json!({"delegationId": rejected_delegation_id}), + ); + assert_eq!(rejected.status, "failed", "{rejected:?}"); + assert!(rejected.detail.is_none()); + assert!(rejected.summary.contains("当前可信父 run 或尚未认领")); + } + let unchanged = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read unchanged failed-art delivery") + .expect("failed-art delivery still exists"); + assert_eq!( + unchanged.status, + StaticDelegateDeliveryStatus::ClaimedByParent + ); + assert_eq!( + unchanged.claimed_by_action_id.as_deref(), + Some(pending.action_id.as_str()) + ); + + let terminal_error = + game_chat_fast_path_plan_at(&root, &main, &main.current_task, unix_timestamp()) + .expect_err("non-safe-default failed art delivery must stop before Provider planning"); + assert!(terminal_error.contains("专业美术子任务失败且不能自动返工")); + assert!(terminal_error.contains("art-director")); + + cancel_game_creator_agent_runtime_task_at(&root, &child.agent_id, &child.run_id) + .expect("cancel blocked art child fixture"); + drop(child_lane); +} + +#[tokio::test] +async fn game_chat_failed_art_delivery_stops_main_loop_without_provider_request() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let temporary = tempfile::tempdir().expect("create failed art main-loop root"); + let root = temporary.path().join("project"); + let (main, mut child, delegation_id, child_lane) = game_chat_main_art_child_fixture_with_lane( + &root, + "art-director", + &["art-spec", "core-spritesheet"], + true, + ); + let child_lane = child_lane.expect("failed art child lane is held"); + child.status = "failed".to_string(); + child.phase = "failed".to_string(); + child.current_action = "External Editor 配置缺失,无法生成平台美术资源".to_string(); + child.error = Some(child.current_action.clone()); + append_game_creator_agent_runtime_task(&root, &child) + .expect("persist failed art child terminal task"); + write_game_creator_agent_runtime_state(&root, &child) + .expect("persist failed art child terminal runtime"); + let dispatched = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read main-loop failed art delivery") + .expect("main-loop failed art delivery exists"); + let failed_result = build_static_delegate_structured_result_at( + &root, + "failed", + &dispatched.expected_artifacts, + false, + None, + None, + None, + Some("External Editor 配置缺失,无法生成平台美术资源"), + ) + .expect("build main-loop failed art result"); + mark_static_delegate_delivery_ready_with_result_at( + &root, + &child.agent_id, + &child.session_id, + &child.run_id, + &delegation_id, + "failed", + "External Editor 配置缺失,无法生成平台美术资源", + failed_result, + ) + .expect("mark main-loop failed art delivery ready"); + + let outcome = run_game_creator_agent_background_task_pass_with_context( + root.clone(), + main.agent_id.clone(), + main.current_task.clone(), + main.clone(), + AgentRuntimeContinuationContext::default(), + ) + .await; + assert!( + matches!(outcome, AgentBackgroundTaskOutcome::Finished), + "failed art delivery must terminate the main Run without Provider planning: {outcome:?}" + ); + let terminal = read_game_creator_agent_runtime_for_session_at( + &root, + &main.agent_id, + Some(&main.session_id), + ) + .expect("read terminal failed game-chat main Run") + .state; + assert_eq!(terminal.run_id, main.run_id); + assert_eq!(terminal.status, "failed"); + assert_eq!(terminal.phase, "failed"); + assert!(terminal.error.as_deref().is_some_and(|error| { + error.contains("专业美术子任务失败且不能自动返工") && error.contains("art-director") + })); + let records = + fs::read_to_string(root.join(".agent/agent.db")).expect("read failed art main-loop audit"); + assert!(!records.lines().any(|line| { + serde_json::from_str::(line) + .ok() + .is_some_and(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == main.run_id + }) + })); + + drop(child_lane); + crate::tests::wait_for_agent_runtime_terminal_and_lane_release_async( + &root, + &child.agent_id, + &child.run_id, + "failed", + "failed", + ) + .await; +} + +#[test] +fn game_chat_code_prototype_rejects_tampered_art_delivery_identity_before_claim() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let temporary = tempfile::tempdir().expect("create tampered art binding root"); + let root = temporary.path().join("project"); + let (main, child, delegation_id, child_lane) = game_chat_main_art_child_fixture_with_lane( + &root, + "art-director", + &["art-spec", "core-spritesheet"], + true, + ); + let child_lane = child_lane.expect("delegated tampered child lane is held"); + let dispatched = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read tampered child delivery") + .expect("tampered child delivery exists"); + let failed_result = build_static_delegate_structured_result_at( + &root, + "failed", + &dispatched.expected_artifacts, + false, + None, + None, + None, + Some("专业美术子任务失败"), + ) + .expect("build tampered child failed result"); + mark_static_delegate_delivery_ready_with_result_at( + &root, + &child.agent_id, + &child.session_id, + &child.run_id, + &delegation_id, + "failed", + "专业美术子任务失败", + failed_result, + ) + .expect("mark tampered child delivery ready"); + let mut tampered = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read ready delivery before identity tampering") + .expect("ready delivery exists before identity tampering"); + tampered.parent_session_id = "tampered-parent-session".to_string(); + fs::write( + root.join(format!( + ".agent/runtime/delegation-deliveries/{delegation_id}.json" + )), + serde_json::to_vec_pretty(&tampered).expect("serialize tampered delivery fixture"), + ) + .expect("persist tampered delivery fixture"); + + let error = game_chat_fast_path_plan_at(&root, &main, &main.current_task, unix_timestamp()) + .expect_err("tampered delivery identity must fail before receipt claim"); + assert!(error.contains("delivery 与可信父责任链不一致"), "{error}"); + assert!( + static_delegate_parent_can_manage_receipts_at(&root, &main.agent_id, &main.run_id).is_err(), + "tampered delivery must not degrade to an ordinary unauthorized parent" + ); + let blocker = static_delegate_completion_blocker_at_locked(&root, &main.agent_id, &main.run_id) + .expect("tampered delivery must block task completion"); + assert_eq!(blocker.status, "blocked"); + assert!(blocker.summary.contains("无法确认当前父 Run")); + let claim = observe_agent_runtime_run_status( + &root, + &main.agent_id, + &main.run_id, + Some("game-chat-tampered-delivery-claim"), + &serde_json::json!({ + "agentId": null, + "scope": "self", + "delegationId": null, + }), + ); + assert_eq!(claim.status, "failed", "{claim:?}"); + let delivery = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read unclaimed tampered delivery") + .expect("unclaimed tampered delivery exists"); + assert_eq!(delivery.status, StaticDelegateDeliveryStatus::Ready); + assert!(delivery.claimed_by_action_id.is_none()); + + cancel_game_creator_agent_runtime_task_at(&root, &child.agent_id, &child.run_id) + .expect("cancel tampered child fixture"); + drop(child_lane); +} + #[test] fn game_chat_main_agent_allows_only_one_same_contract_safe_default_repair() { let temporary = tempfile::tempdir().expect("create game-chat safe-default repair root"); @@ -881,13 +1414,15 @@ fn game_chat_main_agent_allows_only_one_same_contract_safe_default_repair() { .expect("read completed clarification child") .expect("completed clarification child exists"); publish_game_creator_agent_delegate_result(&root, &child_task, Some(&needs_input)); - claim_ready_static_delegate_receipts_at( - &root, - "code-prototype", - &main.run_id, - "game-chat-safe-default-claim", - ) - .expect("claim converted safe-default delivery"); + let (_claim_pending, claim_observation) = + claim_game_chat_main_ready_deliveries_with_run_status(&root, &main); + assert!( + claim_observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains(&delegation_id)), + "{claim_observation:?}" + ); let original = read_static_delegate_delivery_at(&root, &delegation_id) .expect("read original safe-default delivery") .expect("original safe-default delivery exists"); @@ -907,6 +1442,53 @@ fn game_chat_main_agent_allows_only_one_same_contract_safe_default_repair() { .as_ref() .and_then(|result| result.error.as_deref()) .is_some_and(|error| error.contains("game-chat-safe-default-repair.v1"))); + let repeated_route_plan = AgentRuntimeToolPlan { + thinking_summary: "重复提交既有资产路由".to_string(), + plan_update: None, + plan: Vec::new(), + actions: vec![AgentRuntimeToolAction { + tool: "agent.route_manifest".to_string(), + reason: Some("重复路由不应绕过返工门".to_string()), + input: serde_json::json!({ + "strategy": GAME_CHAT_ASSET_ROUTE_GENERATE_MISSING, + "missingAssetSlots": ["art-spec", "core-spritesheet"], + }), + }], + response: String::new(), + }; + let liveness_error = validate_game_chat_code_safe_default_repair_liveness_at( + &root, + &main.agent_id, + &main.run_id, + &repeated_route_plan, + ) + .expect_err("safe-default repair must reject repeated route/read plans"); + assert!( + liveness_error.contains("必须立即推进唯一安全默认返工"), + "{liveness_error}" + ); + + let deterministic_repair = + game_chat_fast_path_plan_at(&root, &main, &main.current_task, unix_timestamp()) + .expect("build trusted safe-default repair plan") + .expect("valid safe-default marker must produce a deterministic repair action"); + assert_eq!(deterministic_repair.actions.len(), 1); + assert_eq!(deterministic_repair.actions[0].tool, "agent.delegate"); + assert_eq!( + deterministic_repair.actions[0].input["agentId"].as_str(), + Some(original.target_agent_id.as_str()) + ); + assert_eq!( + deterministic_repair.actions[0].input["repairOfDelegationId"].as_str(), + Some(delegation_id.as_str()) + ); + validate_game_chat_code_safe_default_repair_liveness_at( + &root, + &main.agent_id, + &main.run_id, + &deterministic_repair, + ) + .expect("deterministic same-contract repair must pass the liveness gate"); let repair_action_id = "game-chat-safe-default-repair-action"; let repair = observe_agent_runtime_agent_delegate( @@ -914,14 +1496,7 @@ fn game_chat_main_agent_allows_only_one_same_contract_safe_default_repair() { "code-prototype", &main.run_id, Some(repair_action_id), - &serde_json::json!({ - "agentId": "art-director", - "task": "不要追问用户,采用安全默认视觉偏好继续交付", - "acceptanceCriteria": original.acceptance_criteria, - "expectedArtifacts": original.expected_artifacts, - "repairOfDelegationId": delegation_id, - "runId": null - }), + &deterministic_repair.actions[0].input, ); assert_eq!(repair.status, "ok", "{repair:?}"); let repair_delegation_id = agent_runtime_delegation_id( @@ -968,13 +1543,15 @@ fn game_chat_main_agent_allows_only_one_same_contract_safe_default_repair() { &repair_child_task, Some("已采用安全默认视觉偏好完成规范图"), ); - claim_ready_static_delegate_receipts_at( - &root, - "code-prototype", - &main.run_id, - "game-chat-safe-default-repair-claim", - ) - .expect("claim completed safe-default repair delivery"); + let (_repair_claim_pending, repair_claim_observation) = + claim_game_chat_main_ready_deliveries_with_run_status(&root, &main); + assert!( + repair_claim_observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains(&repair_delegation_id)), + "{repair_claim_observation:?}" + ); let duplicate = observe_agent_runtime_agent_delegate( &root, @@ -1069,6 +1646,176 @@ fn autonomous_parent_waits_for_active_child_while_registered_derived_visuals_nee assert_eq!(parent_state.phase, "waiting-for-manifest-tasks"); } +#[tokio::test] +async fn autonomous_supervisor_terminal_manifest_failure_fails_before_provider_request() { + const RUN_ID: &str = "autonomous-terminal-manifest-failure-parent"; + const TASK: &str = "生成完整小游戏并在专业任务失败时安全收口"; + const FAILED_TASK_ID: &str = "art-asset-plan"; + const READY_TASK_ID: &str = "design-director"; + let temporary = tempfile::tempdir().expect("create terminal manifest failure root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "terminal-manifest-failure", TASK) + .expect("init terminal manifest failure project"); + let session_id = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve terminal manifest failure Supervisor session"); + let record = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &session_id, + TASK, + RUN_ID, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue terminal manifest failure Supervisor"); + let mut state = agent_runtime_state_from_task_record(&record); + state.status = "running".to_string(); + state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&root, &state) + .expect("persist running terminal manifest failure Supervisor"); + write_game_creator_agent_runtime_state(&root, &state) + .expect("persist terminal manifest failure Supervisor state"); + + let mut manifest = read_manifest_for_project(&root).expect("read terminal failure manifest"); + for task in &mut manifest.tasks { + task.status = match task.id.as_str() { + FAILED_TASK_ID => GameCreationAppTaskStatus::Failed, + READY_TASK_ID => GameCreationAppTaskStatus::Pending, + _ => GameCreationAppTaskStatus::Completed, + }; + } + write_manifest(&root.join(".agent/manifest.json"), &manifest) + .expect("persist terminal failed manifest fixture"); + + let outcome = run_game_creator_agent_background_task_pass_with_context( + root.clone(), + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + TASK.to_string(), + state, + AgentRuntimeContinuationContext::default(), + ) + .await; + assert!( + matches!(outcome, AgentBackgroundTaskOutcome::Finished), + "terminal manifest failure must finish before requesting the Provider; outcome={outcome:?}" + ); + + let failed = + read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read terminal failed Supervisor") + .state; + assert_eq!(failed.run_id, RUN_ID); + assert_eq!(failed.status, "failed"); + assert_eq!(failed.phase, "failed"); + assert!(failed.error.as_deref().is_some_and(|error| { + error.contains("自主构建 manifest 专业任务失败") && error.contains(FAILED_TASK_ID) + })); + let manifest = read_manifest_for_project(&root).expect("read terminal failed manifest"); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == READY_TASK_ID) + .map(|task| task.status.clone()), + Some(GameCreationAppTaskStatus::Pending), + "a terminal manifest failure must not start another pending sibling" + ); + let records = fs::read_to_string(root.join(".agent/agent.db")) + .expect("read terminal manifest failure audit"); + assert!(!records.lines().any(|line| { + serde_json::from_str::(line) + .ok() + .is_some_and(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == RUN_ID + }) + })); +} + +#[tokio::test] +async fn autonomous_supervisor_waits_for_running_sibling_after_manifest_failure() { + const RUN_ID: &str = "autonomous-manifest-failure-running-sibling-parent"; + const TASK: &str = "生成完整小游戏并等待在途专业任务安全收束"; + const FAILED_TASK_ID: &str = "art-asset-plan"; + const RUNNING_TASK_ID: &str = "audio-asset-plan"; + let temporary = tempfile::tempdir().expect("create failed manifest running sibling root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "manifest-failure-running-sibling", TASK) + .expect("init failed manifest running sibling project"); + let session_id = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve failed manifest running sibling Supervisor session"); + let record = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &session_id, + TASK, + RUN_ID, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue failed manifest running sibling Supervisor"); + let mut state = agent_runtime_state_from_task_record(&record); + state.status = "running".to_string(); + state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&root, &state) + .expect("persist failed manifest running sibling Supervisor"); + write_game_creator_agent_runtime_state(&root, &state) + .expect("persist failed manifest running sibling Supervisor state"); + + let mut manifest = read_manifest_for_project(&root).expect("read failed sibling manifest"); + for task in &mut manifest.tasks { + task.status = match task.id.as_str() { + FAILED_TASK_ID => GameCreationAppTaskStatus::Failed, + RUNNING_TASK_ID => GameCreationAppTaskStatus::Running, + _ => GameCreationAppTaskStatus::Completed, + }; + } + write_manifest(&root.join(".agent/manifest.json"), &manifest) + .expect("persist failed manifest with running sibling fixture"); + + let outcome = run_game_creator_agent_background_task_pass_with_context( + root.clone(), + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + TASK.to_string(), + state, + AgentRuntimeContinuationContext::default(), + ) + .await; + assert!(matches!( + outcome, + AgentBackgroundTaskOutcome::WaitingForManifestTasks + )); + let waiting = + read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read Supervisor waiting for failed DAG sibling") + .state; + assert_eq!(waiting.run_id, RUN_ID); + assert_eq!(waiting.status, "running"); + assert_eq!(waiting.phase, "waiting-for-manifest-tasks"); + let records = fs::read_to_string(root.join(".agent/agent.db")) + .expect("read failed manifest running sibling audit"); + assert!(!records.lines().any(|line| { + serde_json::from_str::(line) + .ok() + .is_some_and(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == RUN_ID + }) + })); +} + fn prepare_autonomous_completion_evidence( root: &Path, state: &AgentRuntimeState, @@ -1517,6 +2264,32 @@ fn autonomous_visual_ready_tasks_only_require_images_when_editor_api_key_is_conf assert!(!autonomous_manifest_ready_task_requires_visual_asset( "art-asset-plan" )); + let art_director = new_game_creation_app_seed_tasks() + .into_iter() + .find(|task| task.id == "art-director") + .expect("art-director seed task"); + let task_text = render_autonomous_manifest_ready_task_background_prompt(&art_director); + let read_only_delivery = + agent_runtime_task_requires_read_only_delivery("art-director", &task_text); + assert!(read_only_delivery); + let forbidden_file_write = AgentRuntimeToolPlan { + actions: vec![AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: Some("无 External Editor API 时不得写入项目".to_string()), + input: serde_json::json!({ + "path": "assets/art-spec.png", + "content": "not-a-canvas-asset" + }), + }], + ..AgentRuntimeToolPlan::default() + }; + let error = validate_agent_runtime_autonomous_read_only_delivery_plan( + "art-director", + read_only_delivery, + &forbidden_file_write, + ) + .expect_err("art-director without External Editor API must remain read-only"); + assert!(error.contains("自主构建只读专业 Agent 禁止执行写入或副作用动作")); } let _config_guard = crate::tests::write_test_local_config( r#"{"editorApi":{"apiKey":"visual-ready-task-test-key"}}"#.to_string(), @@ -1533,6 +2306,18 @@ fn autonomous_visual_ready_tasks_only_require_images_when_editor_api_key_is_conf assert!(!autonomous_manifest_ready_task_requires_visual_asset( "code-prototype" )); + let art_director = new_game_creation_app_seed_tasks() + .into_iter() + .find(|task| task.id == "art-director") + .expect("art-director seed task"); + let task_text = render_autonomous_manifest_ready_task_background_prompt(&art_director); + assert!(task_text.contains("非只读视觉规范生成任务")); + assert!(task_text.contains("唯一允许的项目写动作是 canvas.asset_generate")); + assert!(task_text.contains("assets/art-spec.png(assetKind=icon-spec、aspectRatio=1:1)")); + assert!(!agent_runtime_task_requires_read_only_delivery( + "art-director", + &task_text + )); } #[test] @@ -2504,6 +3289,148 @@ fn game_chat_code_prototype_ignores_art_director_global_revision_before_its_own_ ); } +#[test] +fn game_chat_code_second_loop_waits_for_provider_before_asset_route() { + let temporary = tempfile::tempdir().expect("create unrouted game-chat code root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-unrouted-code", "制作星河收集小游戏") + .expect("init unrouted game-chat code project"); + register_game_chat_art_spec_fixture(&root); + register_game_chat_art_spritesheet_fixture(&root); + fs::write( + root.join("assets/manifest.art.json"), + game_chat_fast_path_art_manifest_content(), + ) + .expect("write unrouted complete art manifest"); + let (root_state, mut code_state) = queue_game_chat_fast_path_child( + &root, + "game-chat-unrouted-code-root", + "制作星河收集小游戏", + "code-prototype", + ); + code_state.loop_iteration = 2; + persist_game_chat_main_asset_audit(&root, &mut code_state); + let bound_at = read_game_creator_agent_runtime_run_profile_binding( + &root, + &root_state.agent_id, + &root_state.run_id, + ) + .expect("read unrouted game-chat root binding") + .expect("unrouted game-chat root binding exists") + .bound_at; + + assert!( + game_chat_fast_path_plan_at(&root, &code_state, &code_state.current_task, bound_at) + .expect("evaluate unrouted second loop") + .is_none(), + "asset.list 后尚未形成资产路由时,第二轮必须交给 Provider 决策与委派" + ); +} + +#[test] +fn game_chat_code_second_loop_waits_for_delegated_visual_contract() { + let temporary = tempfile::tempdir().expect("create routed game-chat code root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-routed-code", "制作星河收集小游戏") + .expect("init routed game-chat code project"); + let (root_state, mut code_state) = queue_game_chat_fast_path_child( + &root, + "game-chat-routed-code-root", + "制作星河收集小游戏", + "code-prototype", + ); + code_state.loop_iteration = 2; + persist_game_chat_main_asset_audit_and_route(&root, &root_state, &mut code_state); + let bound_at = read_game_creator_agent_runtime_run_profile_binding( + &root, + &root_state.agent_id, + &root_state.run_id, + ) + .expect("read routed game-chat root binding") + .expect("routed game-chat root binding exists") + .bound_at; + + assert!( + game_chat_fast_path_plan_at(&root, &code_state, &code_state.current_task, bound_at) + .expect("evaluate routed second loop without art") + .is_none(), + "资产路由已持久化但美术 child 尚未交付时,第二轮必须继续交给 Provider 执行委派" + ); +} + +#[test] +fn game_chat_code_second_loop_allows_fallback_after_visual_contract_is_ready() { + let temporary = tempfile::tempdir().expect("create ready game-chat code root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-ready-code", "制作星河收集小游戏") + .expect("init ready game-chat code project"); + register_game_chat_art_spec_fixture(&root); + register_game_chat_art_spritesheet_fixture(&root); + fs::write( + root.join("assets/manifest.art.json"), + game_chat_fast_path_art_manifest_content(), + ) + .expect("write ready complete art manifest"); + let (root_state, mut code_state) = queue_game_chat_fast_path_child( + &root, + "game-chat-ready-code-root", + "制作星河收集小游戏", + "code-prototype", + ); + code_state.loop_iteration = 2; + persist_game_chat_main_asset_audit_and_route(&root, &root_state, &mut code_state); + let bound_at = read_game_creator_agent_runtime_run_profile_binding( + &root, + &root_state.agent_id, + &root_state.run_id, + ) + .expect("read ready game-chat root binding") + .expect("ready game-chat root binding exists") + .bound_at; + + let plan = game_chat_fast_path_plan_at(&root, &code_state, &code_state.current_task, bound_at) + .expect("evaluate ready second loop") + .expect("verified visual contract may use deterministic fallback"); + assert_eq!(plan.actions.len(), 1); + assert_eq!(plan.actions[0].tool, "file.write"); + assert_eq!( + plan.actions[0].input.get("path"), + Some(&serde_json::json!(AGENT_RUNTIME_GAME_INDEX_PATH)) + ); +} + +#[test] +fn game_chat_code_soft_budget_still_fails_closed_without_visual_contract() { + let temporary = tempfile::tempdir().expect("create budgeted game-chat code root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-budgeted-code", "制作星河收集小游戏") + .expect("init budgeted game-chat code project"); + let (root_state, mut code_state) = queue_game_chat_fast_path_child( + &root, + "game-chat-budgeted-code-root", + "制作星河收集小游戏", + "code-prototype", + ); + persist_game_chat_main_asset_audit_and_route(&root, &root_state, &mut code_state); + let bound_at = read_game_creator_agent_runtime_run_profile_binding( + &root, + &root_state.agent_id, + &root_state.run_id, + ) + .expect("read budgeted game-chat root binding") + .expect("budgeted game-chat root binding exists") + .bound_at; + + let error = game_chat_fast_path_plan_at( + &root, + &code_state, + &code_state.current_task, + bound_at.saturating_add(GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS), + ) + .expect_err("soft budget must still fail closed when visual contracts are missing"); + assert!(error.contains("assets/art-spritesheet.png")); +} + #[test] fn game_chat_existing_game_requires_code_mutation_and_full_completion_before_delivery() { let temporary = tempfile::tempdir().expect("create existing game code root"); @@ -2773,6 +3700,65 @@ fn game_chat_trusted_single_main_repairs_javascript_smoke_and_completes_without_ ) .expect("persist failed static smoke receipt"); + update_agent_runtime_plan_steps( + &mut code_state, + vec!["记录开发者投递的后台任务".to_string()], + ); + activate_agent_runtime_plan_step(&mut code_state, 0, "执行 game.static_smoke"); + complete_agent_runtime_active_plan_step( + &mut code_state, + "failed", + &failed_smoke_observation.summary(), + ); + assert_eq!(code_state.plan_revision, 0); + assert_eq!( + code_state.plan_steps[0].status, + AGENT_RUNTIME_PLAN_STATUS_FAILED + ); + + apply_agent_runtime_plan_update( + &mut code_state, + &AgentRuntimePlanUpdate { + explanation: "首版 smoke 已返回可修复诊断,保持同一主 Run 继续修复与复验".to_string(), + steps: vec![ + AgentRuntimePlanUpdateStep { + step: "完成资产审计与缺口路由".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_COMPLETED.to_string(), + }, + AgentRuntimePlanUpdateStep { + step: "写入可运行游戏 scaffold".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_COMPLETED.to_string(), + }, + AgentRuntimePlanUpdateStep { + step: "修复静态 smoke 阻断".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS.to_string(), + }, + AgentRuntimePlanUpdateStep { + step: "重新执行静态 smoke".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_PENDING.to_string(), + }, + AgentRuntimePlanUpdateStep { + step: "完成浏览器试玩与视觉检查".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_PENDING.to_string(), + }, + AgentRuntimePlanUpdateStep { + step: "向父 Agent 交付证据回执".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_PENDING.to_string(), + }, + ], + }, + ) + .expect("replace failed legacy scaffold with first structured repair plan"); + assert_eq!(code_state.plan_revision, 1); + assert!(code_state + .plan_steps + .iter() + .all(|step| step.status != AGENT_RUNTIME_PLAN_STATUS_FAILED)); + assert!(code_state + .plan_steps + .iter() + .all(|step| step.title != "记录开发者投递的后台任务")); + let (history, count, truncated, output_truncated) = read_agent_runtime_action_history( &root, &code_state.agent_id, @@ -2857,6 +3843,27 @@ fn game_chat_trusted_single_main_repairs_javascript_smoke_and_completes_without_ ) .expect("persist same-run repair receipt"); + let bound_at = read_game_creator_agent_runtime_run_profile_binding( + &root, + &root_state.agent_id, + &root_state.run_id, + ) + .expect("read deterministic game-chat root binding") + .expect("deterministic game-chat root binding exists") + .bound_at; + let automatic_recheck = + game_chat_fast_path_plan_at(&root, &code_state, &code_state.current_task, bound_at) + .expect("evaluate repaired current revision") + .expect("same-run repair must automatically recheck current revision"); + assert_eq!(automatic_recheck.actions.len(), 1); + assert_eq!(automatic_recheck.actions[0].tool, "command.run_limited"); + assert_eq!( + automatic_recheck.actions[0].input.get("commandId"), + Some(&serde_json::json!("game.static_smoke")) + ); + assert!(automatic_recheck.plan_update.is_none()); + assert!(automatic_recheck.response.is_empty()); + let repaired_smoke = AgentRuntimeToolAction { tool: "command.run_limited".to_string(), reason: Some("重新验证已修复的当前 revision".to_string()), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 696410c77..0a9cd34ac 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -1492,6 +1492,11 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt( "{base}\n\n这是 autonomous-game-build 的只读试玩验收任务,不要修改项目文件。固定核心动作是且只能是 preview.validate;完成当前 revision 的桌面与移动试玩后直接交付验收结论,不要调用项目 mutation、其它预览动作或 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" ); } + if task.id == "art-director" && editor_api_key_is_configured() { + return format!( + "{base}\n\n这是 autonomous-game-build 的非只读视觉规范生成任务。{AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER};必须用固定合同生成并登记 assets/art-spec.png(assetKind=icon-spec、aspectRatio=1:1),该受控素材事务会同时提交当前 run 的 mutation 与验证凭证。禁止调用 file.write、file.patch、file.delete、project.patchset、project.restore 或写入其它路径。生成成功后直接交付视觉规范结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" + ); + } if autonomous_manifest_task_requires_project_mutation(&task.id) { let owner_prompt = render_autonomous_manifest_ready_task_owner_prompt(task); let code_visual_asset_requirement = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index 75ea76559..06825c6fc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -580,8 +580,13 @@ fn rendered_html_markup_and_stylesheets(content: &str) -> (String, String) { .unwrap_or(lower.len()); continue; } + if !html_tag_like_start(&lower, tag_start) { + cursor = tag_start + 1; + continue; + } let Some(tag_after) = html_tag_end(&lower, tag_start) else { - break; + cursor = tag_start + 1; + continue; }; let tag_end = tag_after - 1; let tag = &lower[tag_start..=tag_end]; @@ -7390,6 +7395,13 @@ pub(in crate::agent) fn validate_executable_inline_javascript_syntax( Ok(()) } +fn html_tag_like_start(content: &str, tag_start: usize) -> bool { + content + .as_bytes() + .get(tag_start + 1) + .is_some_and(|byte| byte.is_ascii_alphabetic() || matches!(byte, b'/' | b'!' | b'?')) +} + #[derive(Default)] struct JavascriptLiteralSpanCollector { strings: Vec>, @@ -17818,3 +17830,35 @@ mod javascript_projection_reachability_tests { assert!(!javascript_position_is_reachable(content, &ranges, decoy)); } } + +#[cfg(test)] +mod executable_inline_javascript_syntax_tests { + use super::*; + + #[test] + fn stray_unterminated_less_than_does_not_skip_later_inline_scripts() { + let html = r#" + + +

本关扣分规则:5 < 3 时按 3 计算

+ + +"#; + let error = validate_executable_inline_javascript_syntax(html) + .expect_err("a later script must still be syntax checked after a stray '<'"); + assert!(error.contains("不是有效 JavaScript"), "{error}"); + } + + #[test] + fn unterminated_less_than_alone_still_allows_a_valid_inline_script() { + let html = r#" + + +

a < b

+ + +"#; + validate_executable_inline_javascript_syntax(html) + .expect("stray '<' without '>' must not break valid syntax validation"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index 0324f485f..ebdae7dbd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -1066,7 +1066,7 @@ async fn game_chat_pure_continue_inherits_failed_root_semantics_and_manifest_pro servers: Vec::new(), tools: Vec::new(), }; - let (_, _, provider_request, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, provider_request, _, _) = build_game_creator_agent_background_tool_plan_request( &root, &recovered.agent_id, &recovered.session_id, @@ -1102,15 +1102,17 @@ async fn game_chat_pure_continue_inherits_failed_root_semantics_and_manifest_pro .expect("continued root must pass the scheduler contract gate"); assert_eq!(scheduled.len(), 1); assert_eq!(scheduled[0].state.agent_id, "code-prototype"); - // Scheduling starts the child worker asynchronously. Wait for its - // durable terminal projection and lane release before mutating the same - // project; a lane release alone can precede a late manifest projection. + // Scheduling starts the child worker asynchronously. The deliberately + // empty Provider config now fails closed after the asset audit yields to + // Provider planning. Wait for that durable terminal projection and lane + // release before mutating the same project; a lane release alone can + // precede a late manifest projection. let _ = crate::tests::wait_for_agent_runtime_manifest_projection_async( &root, "code-prototype", &autonomous_manifest_ready_task_run_id(&continuation.run_id, "code-prototype"), "failed", - "budget-exhausted", + "failed", GameCreationAppTaskStatus::Failed, ) .await; @@ -6746,6 +6748,13 @@ fn active_game_chat_child_keeps_dag_in_progress_after_completed_manifest_drift() ); update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Failed) .expect("fail the only game-chat manifest task"); + assert_eq!( + autonomous_manifest_dag_state_at(&root).expect("read failed DAG state with active child"), + AutonomousManifestDagState::Failed { + failed_task_ids: vec!["code-prototype".to_string()], + has_active_children: true, + } + ); assert!( !autonomous_manifest_dag_in_progress_at(&root).expect("read failed DAG with active child"), "a manifest failure must still fail closed" @@ -8100,6 +8109,7 @@ fn autonomous_playtest_liveness_only_enforces_the_latest_preview_result() { &superseded_failure, &response_plan, true, + false, true, ) .is_ok()); @@ -8122,6 +8132,7 @@ fn autonomous_playtest_liveness_only_enforces_the_latest_preview_result() { &response_plan, true, false, + false, ) .expect_err("latest failed preview must still require delegated repair"); assert!( 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 ef3faa7bc..0e898dc02 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 @@ -301,6 +301,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_llm_request_fingerprint( platform_llm::LlmResponseReasoningEffort::Low => "low", platform_llm::LlmResponseReasoningEffort::Medium => "medium", platform_llm::LlmResponseReasoningEffort::High => "high", + platform_llm::LlmResponseReasoningEffort::Max => "max", }); let text_verbosity = request.response_text_verbosity.map(|value| match value { platform_llm::LlmResponseTextVerbosity::Low => "low", @@ -1708,6 +1709,25 @@ mod tests { ); } + #[test] + fn provider_request_fingerprint_keeps_max_distinct_from_high() { + let base = LlmRunRequest::single_turn("系统", "任务"); + let high = game_creator_agent_runtime_llm_request_fingerprint( + &base + .clone() + .with_response_reasoning_effort(platform_llm::LlmResponseReasoningEffort::High), + ) + .expect("high request fingerprint"); + let max_request = + base.with_response_reasoning_effort(platform_llm::LlmResponseReasoningEffort::Max); + let max = game_creator_agent_runtime_llm_request_fingerprint(&max_request) + .expect("max request fingerprint"); + let repeated_max = game_creator_agent_runtime_llm_request_fingerprint(&max_request) + .expect("repeated max request fingerprint"); + assert_ne!(high, max); + assert_eq!(max, repeated_max); + } + #[test] fn codex_app_server_unknown_terminal_enters_reconciliation_without_retry() { let error = platform_llm::LlmError::Transport(format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs index 4f0152312..ec82d5121 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs @@ -245,6 +245,173 @@ fn trusted_game_chat_autonomous_parent_chain_at( Ok(Some(parent_binding)) } +fn trusted_game_chat_code_parent_task_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result, String> { + if parent_agent_id != "code-prototype" { + return Ok(None); + } + let Some(parent_task) = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + parent_agent_id, + parent_run_id, + )? + else { + return Ok(None); + }; + if parent_task.agent_id != parent_agent_id + || parent_task.run_id != parent_run_id + || trusted_game_chat_autonomous_parent_chain_at(root, &parent_task)?.is_none() + { + return Ok(None); + } + Ok(Some(parent_task)) +} + +pub(in crate::agent) fn static_delegate_parent_can_manage_receipts_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result { + if parent_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + return Ok(true); + } + let deliveries = + static_delegate_deliveries_for_parent_at(root, parent_agent_id, parent_run_id)?; + let active_deliveries = deliveries + .iter() + .filter(|delivery| delivery.status != StaticDelegateDeliveryStatus::Suppressed) + .collect::>(); + let Some(parent_task) = + trusted_game_chat_code_parent_task_at(root, parent_agent_id, parent_run_id)? + else { + if parent_agent_id == "code-prototype" && !active_deliveries.is_empty() { + return Err( + "game-chat code-prototype 存在未收束静态委派,但无法证明当前父 Run 身份链可信" + .to_string(), + ); + } + return Ok(false); + }; + for delivery in active_deliveries { + if !trusted_game_chat_autonomous_child_delivery_at(root, &parent_task, delivery, None)? { + return Err( + "game-chat code-prototype 父 Run 的静态委派 delivery 身份链不可信".to_string(), + ); + } + } + Ok(true) +} + +pub(in crate::agent) fn static_delegate_parent_can_manage_delivery_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + delivery: &StaticDelegateDeliveryRecord, +) -> Result { + if delivery.parent_agent_id != parent_agent_id || delivery.parent_run_id != parent_run_id { + return Ok(false); + } + if parent_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + return Ok(true); + } + let Some(parent_task) = + trusted_game_chat_code_parent_task_at(root, parent_agent_id, parent_run_id)? + else { + return Ok(false); + }; + trusted_game_chat_autonomous_child_delivery_at(root, &parent_task, delivery, None) +} + +pub(in crate::agent) fn game_chat_code_parent_terminal_delivery_error_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result, String> { + let Some(parent_task) = + trusted_game_chat_code_parent_task_at(root, parent_agent_id, parent_run_id)? + else { + return Ok(None); + }; + for delivery in claimed_static_delegate_deliveries_at(root, parent_agent_id, parent_run_id)? { + if !trusted_game_chat_autonomous_child_delivery_at(root, &parent_task, &delivery, None)? { + return Ok(Some( + "game-chat 专业美术回执不属于当前可信 code-prototype 父 Run,拒绝继续".to_string(), + )); + } + let Some(result) = delivery.structured_result.as_ref() else { + return Ok(Some(format!( + "game-chat 专业美术子任务 {} 缺少结构化终态合同,拒绝继续", + delivery.target_agent_id + ))); + }; + match result.contract_status { + StaticDelegateContractStatus::EvidenceReady => {} + StaticDelegateContractStatus::NeedsRepair + if delivery.repair_of_delegation_id.is_none() + && trusted_game_chat_safe_default_repair_delivery_at( + root, + &parent_task, + &delivery, + )? => {} + StaticDelegateContractStatus::NeedsRepair => { + return Ok(Some(format!( + "game-chat 专业美术子任务失败且不能自动返工:{};仅合法安全默认 marker 允许唯一一层同合同返工", + delivery.target_agent_id + ))); + } + StaticDelegateContractStatus::NeedsUserInput => { + return Ok(Some(format!( + "game-chat 专业美术子任务仍要求用户输入且未转换为安全默认返工:{}", + delivery.target_agent_id + ))); + } + } + } + Ok(None) +} + +pub(in crate::agent) fn game_chat_code_parent_safe_default_repair_delivery_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result, String> { + let Some(parent_task) = + trusted_game_chat_code_parent_task_at(root, parent_agent_id, parent_run_id)? + else { + return Ok(None); + }; + let barrier = static_delegate_completion_barrier_at(root, parent_agent_id, parent_run_id)?; + if barrier.repair_required_count == 0 { + return Ok(None); + } + let mut candidates = Vec::new(); + for delivery in claimed_static_delegate_deliveries_at(root, parent_agent_id, parent_run_id)? { + if delivery.repair_of_delegation_id.is_none() + && delivery.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsRepair + }) + && trusted_game_chat_safe_default_repair_delivery_at(root, &parent_task, &delivery)? + { + candidates.push(delivery); + } + } + if candidates.len() != barrier.repair_required_count { + return Err( + "game-chat code-prototype 存在待返工 delivery,但未全部通过安全默认 marker 与身份链校验" + .to_string(), + ); + } + if candidates.len() != 1 { + return Err( + "game-chat code-prototype 一次必须且只能推进一个安全默认返工 delivery".to_string(), + ); + } + Ok(candidates.pop()) +} + pub(crate) fn trusted_game_chat_autonomous_root_parent_at( root: &Path, parent_task: &AgentRuntimeTaskRecord, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs index b58d27a2b..71cca9f61 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs @@ -7,16 +7,12 @@ pub(in crate::agent) fn observe_claimed_static_delegate_contract_at( delegation_id: &str, ) -> AgentRuntimeToolObservation { let result = (|| -> Result { - if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { - return Err("只有 Project Supervisor 可以读取已认领委派合同".to_string()); - } let delivery = read_static_delegate_delivery_at(root, delegation_id)? - .ok_or_else(|| "指定委派合同不存在".to_string())?; - if delivery.parent_agent_id != agent_id - || delivery.parent_run_id != run_id + .ok_or_else(|| "指定委派合同不属于当前可信父 run 或尚未认领".to_string())?; + if !static_delegate_parent_can_manage_delivery_at(root, agent_id, run_id, &delivery)? || delivery.status != StaticDelegateDeliveryStatus::ClaimedByParent { - return Err("指定委派合同不属于当前 Supervisor 父 run 或尚未认领".to_string()); + return Err("指定委派合同不属于当前可信父 run 或尚未认领".to_string()); } let detail = serde_json::to_string(&serde_json::json!({ "claimedDelegateContract": { @@ -124,17 +120,15 @@ pub(crate) fn observe_agent_runtime_run_status( detail = format!("collaborationPolicy: {policy_status}\n\n{detail}"); } let claim_action_id = action_id.map(str::trim).filter(|value| !value.is_empty()); - let static_delegate_output_may_be_present = - if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { - static_delegate_run_status_may_include_receipts_at( - root, - agent_id, - run_id, - claim_action_id, - )? - } else { - false - }; + let can_manage_static_delegate_receipts = + static_delegate_parent_can_manage_receipts_at(root, agent_id, run_id)?; + let static_delegate_output_may_be_present = can_manage_static_delegate_receipts + && static_delegate_run_status_may_include_receipts_at( + root, + agent_id, + run_id, + claim_action_id, + )?; let isolated_join_payload_limit = if static_delegate_output_may_be_present { AGENT_RUNTIME_READY_ISOLATED_JOIN_MIXED_PAYLOAD_MAX_CHARS } else { @@ -169,7 +163,7 @@ pub(crate) fn observe_agent_runtime_run_status( detail = format!("claimedIsolatedJoins: {payload}\n\n{detail}"); } - let ready_delegate_receipts = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + let ready_delegate_receipts = if can_manage_static_delegate_receipts { let barrier = static_delegate_completion_barrier_at(root, agent_id, run_id)?; if barrier.ready_unclaimed_count > 0 && claim_action_id.is_none() { return Err("agent.run_status 认领专业 Agent 回执必须绑定 actionId".to_string()); @@ -219,7 +213,7 @@ pub(crate) fn observe_agent_runtime_run_status( })(); } - let claimed_delegate_deliveries = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + let claimed_delegate_deliveries = if can_manage_static_delegate_receipts { claimed_static_delegate_deliveries_at(root, agent_id, run_id)? } else { Vec::new() 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 6a9e44e4e..f373f8e80 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 @@ -1059,7 +1059,7 @@ fn runtime_tool_description(tool: &str) -> &'static str { "为 game-chat 提交结构化条件任务图路由;Supervisor 自行概括并持久化用户 intentSummary,Runtime 采用审计优先策略,code-prototype 审计后再提交复用或真实缺口生成路由。" } "agent.action_history" => "查询当前 Agent 的持久终态动作历史。", - "agent.run_status" => "读取自己或其他 Agent 的 Runtime 状态摘要;Project Supervisor 可按 delegationId 取回已认领的权威返工合同。", + "agent.run_status" => "读取自己或其他 Agent 的 Runtime 状态摘要;当前可信父 Run 可按 delegationId 取回自己已认领的权威返工合同。", _ => "执行一个受 Runtime 白名单和项目策略保护的工具动作。", } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 1206f9714..8a4ac0483 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -1050,7 +1050,7 @@ pub(crate) fn register_local_asset_entry( ) -> Result { let normalized_path = normalize_relative_path(local_path)?; let absolute_path = resolve_local_project_path(root, &normalized_path)?; - let (manifest_path, mut manifest) = read_or_create_manifest(root)?; + let manifest_path = root.join(".agent/manifest.json"); let kind = if kind.is_empty() { "asset" } else { kind }; let media_type = if media_type.is_empty() { "application/octet-stream" @@ -1060,31 +1060,32 @@ pub(crate) fn register_local_asset_entry( let mut source_for_record = source.clone(); source_for_record.prompt = None; - let (id, record_type) = if let Some(existing) = manifest - .assets - .iter_mut() - .find(|asset| asset.local_path == normalized_path) - { - existing.kind = kind.to_string(); - existing.media_type = media_type.to_string(); - existing.source = source; - (existing.id.clone(), "asset.update") - } else { - let id = format!( - "{id_prefix}-{}-{}", - unix_millis(), - manifest.assets.len() + 1 - ); - manifest.assets.push(GameCreationAppAssetManifestEntry { - id: id.clone(), - kind: kind.to_string(), - media_type: media_type.to_string(), - local_path: normalized_path.clone(), - source, - }); - (id, "asset.register") - }; - write_manifest(&manifest_path, &manifest)?; + let (id, record_type) = mutate_manifest_at(root, |manifest| { + if let Some(existing) = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == normalized_path) + { + existing.kind = kind.to_string(); + existing.media_type = media_type.to_string(); + existing.source = source; + Ok((existing.id.clone(), "asset.update")) + } else { + let id = format!( + "{id_prefix}-{}-{}", + unix_millis(), + manifest.assets.len() + 1 + ); + manifest.assets.push(GameCreationAppAssetManifestEntry { + id: id.clone(), + kind: kind.to_string(), + media_type: media_type.to_string(), + local_path: normalized_path.clone(), + source, + }); + Ok((id, "asset.register")) + } + })?; append_agent_db_record( root, serde_json::json!({ 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 4404da0e7..4b47ff186 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -150,8 +150,9 @@ pub(crate) fn parse_game_creator_llm_reasoning_effort( "low" => Ok(Some(platform_llm::LlmResponseReasoningEffort::Low)), "medium" => Ok(Some(platform_llm::LlmResponseReasoningEffort::Medium)), "high" => Ok(Some(platform_llm::LlmResponseReasoningEffort::High)), + "max" => Ok(Some(platform_llm::LlmResponseReasoningEffort::Max)), value => Err(format!( - "LLM reasoning_effort 无效:{value},请使用 default、low、medium 或 high" + "LLM reasoning_effort 无效:{value},请使用 default、low、medium、high 或 max" )), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index 573cccd63..4817ac216 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -1512,7 +1512,7 @@ pub(crate) fn suppress_static_delegate_deliveries_for_parent_terminal_at( Ok(()) } -pub(crate) fn claimed_static_delegate_deliveries_at( +pub(crate) fn static_delegate_deliveries_for_parent_at( root: &Path, parent_agent_id: &str, parent_run_id: &str, @@ -1522,15 +1522,26 @@ pub(crate) fn claimed_static_delegate_deliveries_at( let mut deliveries = list_static_delegate_deliveries_at(root)? .into_iter() .filter(|delivery| { - delivery.parent_agent_id == parent_agent_id - && delivery.parent_run_id == parent_run_id - && delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent + delivery.parent_agent_id == parent_agent_id && delivery.parent_run_id == parent_run_id }) .collect::>(); deliveries.sort_by(|left, right| left.delegation_id.cmp(&right.delegation_id)); Ok(deliveries) } +pub(crate) fn claimed_static_delegate_deliveries_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result, String> { + Ok( + static_delegate_deliveries_for_parent_at(root, parent_agent_id, parent_run_id)? + .into_iter() + .filter(|delivery| delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent) + .collect(), + ) +} + pub(crate) fn static_delegate_claim_exists_at( root: &Path, parent_agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index a49f17b04..b25c26e32 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -97,16 +97,42 @@ fn project_write_lock_open_error_is_contention(error: &std::io::Error) -> bool { false } +#[cfg(all(test, windows))] +#[test] +fn project_write_lock_treats_windows_target_races_as_contention() { + for code in [5, 32, 33] { + assert!( + project_write_lock_open_error_is_contention(&std::io::Error::from_raw_os_error(code)), + "Windows project lock error {code} must enter the bounded contention wait" + ); + } +} + +fn resolve_project_write_lock_path(root: &Path) -> Result { + let normalized = normalize_relative_path(PROJECT_WRITE_LOCK_PATH)?; + let (parent_relative, file_name) = normalized + .rsplit_once('/') + .ok_or_else(|| "项目写锁路径必须包含安全父目录".to_string())?; + let parent = resolve_local_project_path(root, parent_relative)?; + // create_new is the authority for the final lock component. On Windows a + // delete-pending lock can make a metadata preflight fail with ACCESS_DENIED + // before the existing bounded contention wait has a chance to run. + Ok(parent.join(file_name)) +} + pub(crate) fn acquire_project_write_lock( root: &Path, command_id: &str, ) -> Result { validate_project_root(root)?; - let path = resolve_local_project_path(root, PROJECT_WRITE_LOCK_PATH)?; + let mut path = resolve_project_write_lock_path(root)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("创建项目锁目录失败:{}: {error}", parent.display()))?; } + // Re-check the parent after creation so skipping metadata only for the final + // create_new target cannot weaken the normal ancestor link/reparse checks. + path = resolve_project_write_lock_path(root)?; let payload = serde_json::json!({ "commandId": command_id, "pid": std::process::id(), 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 85c449ad9..fd1d82f5e 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 @@ -356,9 +356,28 @@ pub(crate) fn record_command_run( pub(crate) fn read_manifest_for_project(root: &Path) -> Result { let (manifest_path, mut manifest) = read_or_create_manifest(root)?; + let canonical_manifest_installed = match fs::symlink_metadata(&manifest_path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err("manifest 必须是普通文件".to_string()); + } + Ok(_) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => { + return Err(format!( + "读取 manifest 元数据失败:{}: {error}", + manifest_path.display() + )); + } + }; + let original = manifest.clone(); ensure_manifest_seed_tasks(root, &mut manifest); - write_manifest(&manifest_path, &manifest)?; - Ok(manifest) + if canonical_manifest_installed && manifest == original { + return Ok(manifest); + } + mutate_manifest_at(root, |current| { + ensure_manifest_seed_tasks(root, current); + Ok(current.clone()) + }) } pub(crate) fn read_existing_manifest_for_project( @@ -422,13 +441,13 @@ pub(crate) fn ensure_manifest_has_seed_tasks( root: &Path, goal: Option<&str>, ) -> Result { - let (manifest_path, mut manifest) = read_or_create_manifest(root)?; - ensure_manifest_seed_tasks(root, &mut manifest); - if let Some(goal) = goal.map(str::trim).filter(|goal| !goal.is_empty()) { - manifest.goal = Some(goal.to_string()); - } - write_manifest(&manifest_path, &manifest)?; - Ok(manifest) + mutate_manifest_at(root, |manifest| { + ensure_manifest_seed_tasks(root, manifest); + if let Some(goal) = goal.map(str::trim).filter(|goal| !goal.is_empty()) { + manifest.goal = Some(goal.to_string()); + } + Ok(manifest.clone()) + }) } pub(crate) fn record_draft_task_progress( @@ -643,15 +662,14 @@ pub(crate) fn update_manifest_task_status_at( if task_id.is_empty() { return Err("任务 ID 不能为空".to_string()); } - let (manifest_path, mut manifest) = read_or_create_manifest(root)?; - ensure_manifest_seed_tasks(root, &mut manifest); - let Some(task) = manifest.tasks.iter_mut().find(|task| task.id == task_id) else { - return Err(format!("项目任务不存在:{task_id}")); - }; - task.status = status; - let updated = task.clone(); - write_manifest(&manifest_path, &manifest)?; - Ok(updated) + mutate_manifest_at(root, |manifest| { + ensure_manifest_seed_tasks(root, manifest); + let Some(task) = manifest.tasks.iter_mut().find(|task| task.id == task_id) else { + return Err(format!("项目任务不存在:{task_id}")); + }; + task.status = status; + Ok(task.clone()) + }) } pub(crate) fn create_manifest_task_at( @@ -821,6 +839,28 @@ pub(crate) fn read_or_create_manifest( Ok((manifest_path, manifest)) } +pub(crate) fn mutate_manifest_at( + root: &Path, + mutate: impl FnOnce(&mut GameCreationAppManifest) -> Result, +) -> Result { + if root.as_os_str().is_empty() { + return Err("项目目录不能为空".to_string()); + } + if !root.is_absolute() { + return Err("项目目录必须是绝对路径".to_string()); + } + let manifest_path = root.join(".agent/manifest.json"); + if let Some(parent) = manifest_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建 manifest 目录失败:{}: {error}", parent.display()))?; + } + let _write_lock = acquire_manifest_write_lock(&manifest_path)?; + let (_, mut manifest) = read_or_create_manifest(root)?; + let result = mutate(&mut manifest)?; + write_manifest_locked(&manifest_path, &manifest)?; + Ok(result) +} + fn manifest_backup_path(path: &Path) -> PathBuf { path.with_file_name(format!( ".{}.previous", @@ -1008,16 +1048,20 @@ fn write_manifest_with_lock_hook( where F: FnOnce(), { - validate_game_iteration_versions(&manifest.versions) - .map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?; - let payload = serde_json::to_string_pretty(manifest) - .map_err(|error| format!("序列化 manifest 失败:{error}"))?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("创建 manifest 目录失败:{}: {error}", parent.display()))?; } let _write_lock = acquire_manifest_write_lock(path)?; after_lock(); + write_manifest_locked(path, manifest) +} + +fn write_manifest_locked(path: &Path, manifest: &GameCreationAppManifest) -> Result<(), String> { + validate_game_iteration_versions(&manifest.versions) + .map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?; + let payload = serde_json::to_string_pretty(manifest) + .map_err(|error| format!("序列化 manifest 失败:{error}"))?; if manifest_storage_exists(path)? { let existing = read_manifest(path)?; if existing.versions.len() > manifest.versions.len() diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs index 0e6bdd66e..e72d29970 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs @@ -43,6 +43,116 @@ fn manifest_read_and_project_write_recover_previous_file() { fs::remove_dir_all(root).ok(); } +#[test] +fn manifest_read_installs_a_canonical_file_when_storage_is_missing() { + let root = unique_manifest_test_root("install-missing-canonical"); + let manifest_path = root.join(".agent/manifest.json"); + let backup_path = root.join(".agent/.manifest.json.previous"); + + let manifest = read_manifest_for_project(&root).expect("create missing manifest"); + assert_eq!(manifest.project_id, "local-project-draft"); + assert!(manifest_path.is_file()); + assert!(!backup_path.exists()); + assert_eq!( + read_manifest(&manifest_path).expect("read installed manifest"), + manifest + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn manifest_task_and_asset_mutations_are_serialized_across_the_full_transaction() { + let root = unique_manifest_test_root("serialized-task-asset-mutations"); + ensure_manifest_has_seed_tasks(&root, None).expect("seed manifest tasks"); + fs::create_dir_all(root.join("assets")).expect("create assets directory"); + fs::write(root.join("assets/concurrent.png"), b"asset").expect("write asset fixture"); + + let (locked_tx, locked_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let first_root = root.clone(); + let first = std::thread::spawn(move || { + mutate_manifest_at(&first_root, |manifest| { + let task = manifest + .tasks + .iter_mut() + .find(|task| task.id == "design-director") + .expect("design-director task"); + task.status = GameCreationAppTaskStatus::Completed; + locked_tx.send(()).expect("announce held manifest lock"); + release_rx.recv().expect("release held manifest lock"); + Ok(()) + }) + .expect("commit task status transaction"); + }); + + locked_rx.recv().expect("first transaction acquired lock"); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let second_root = root.clone(); + let second = std::thread::spawn(move || { + started_tx + .send(()) + .expect("announce asset transaction start"); + crate::assets::register_local_asset_entry( + &second_root, + "assets/concurrent.png", + "ui-prototype", + "image/png", + "test", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) + .expect("register concurrent asset"); + }); + started_rx.recv().expect("asset transaction started"); + release_tx.send(()).expect("release task transaction"); + first.join().expect("join task transaction"); + second.join().expect("join asset transaction"); + + let manifest = read_existing_manifest_for_project(&root).expect("read final manifest"); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == "design-director") + .map(|task| task.status.clone()), + Some(GameCreationAppTaskStatus::Completed) + ); + assert!(manifest + .assets + .iter() + .any(|asset| asset.local_path == "assets/concurrent.png")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn canonical_manifest_read_does_not_reenter_the_write_lock() { + let root = unique_manifest_test_root("canonical-read-with-write-lock-held"); + ensure_manifest_has_seed_tasks(&root, None).expect("seed manifest tasks"); + let manifest_path = root.join(".agent/manifest.json"); + let _write_lock = acquire_manifest_write_lock(&manifest_path).expect("hold manifest lock"); + + let manifest = read_manifest_for_project(&root).expect("read canonical manifest"); + assert_eq!( + manifest.tasks.len(), + new_game_creation_app_seed_tasks().len() + ); + + drop(_write_lock); + fs::remove_dir_all(root).ok(); +} + fn version_fixture( version_id: &str, parent_version_id: Option<&str>, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index 1ebf3f0a2..0a9f01982 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -2655,7 +2655,7 @@ fn project_supervisor_claimed_contract_query_is_exact_scoped_and_drives_repair() assert_eq!(wrong_parent.status, "failed"); assert!(wrong_parent .summary - .contains("不属于当前 Supervisor 父 run")); + .contains("不属于当前可信父 run 或尚未认领")); assert!(wrong_parent.detail.is_none()); let unclaimed_delegation_id = "project-supervisor-claimed-contract-unclaimed"; @@ -2704,7 +2704,9 @@ fn project_supervisor_claimed_contract_query_is_exact_scoped_and_drives_repair() &serde_json::json!({"delegationId": original_delegation_id}), ); assert_eq!(specialist_query.status, "failed"); - assert!(specialist_query.summary.contains("只有 Project Supervisor")); + assert!(specialist_query + .summary + .contains("不属于当前可信父 run 或尚未认领")); assert!(specialist_query.detail.is_none()); let mut invalid_repair_criteria = observed_criteria.clone(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs index 3fead9ed2..ef285d0e7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs @@ -894,6 +894,10 @@ async fn autonomous_supervisor_delegates_failed_playtest_repair_after_collaborat "自主构建总控委派试玩修复测试", ) .expect("project init"); + write_static_smoke_game_fixture_for_test( + &root, + "autonomous supervisor delegated playtest repair", + ); let run_id = "autonomous-supervisor-delegated-playtest-repair-run"; bind_game_creator_agent_runtime_run_profile_at( &root, @@ -1081,6 +1085,7 @@ fn supervisor_preview_tracks_specialist_revision_without_fake_mutation() { "总控验收专业产物验证门测试", ) .expect("project init"); + write_static_smoke_game_fixture_for_test(&root, "supervisor specialist preview gate"); let supervisor_run_id = "supervisor-specialist-preview-run"; let first_revision = prepare_agent_runtime_project_mutation_locked( &root, 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 2b70787dc..4179467de 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 @@ -1047,7 +1047,13 @@ fn llm_reasoning_effort_supports_provider_default_and_explicit_levels() { parse_game_creator_llm_reasoning_effort("high"), Ok(Some(platform_llm::LlmResponseReasoningEffort::High)) ); - assert!(parse_game_creator_llm_reasoning_effort("maximum").is_err()); + assert_eq!( + parse_game_creator_llm_reasoning_effort("max"), + Ok(Some(platform_llm::LlmResponseReasoningEffort::Max)) + ); + let error = parse_game_creator_llm_reasoning_effort("maximum") + .expect_err("maximum is not the max wire value"); + assert!(error.contains("max")); let mut llm = GameCreatorLlmConfig::default(); llm.reasoning_effort = "default".to_string(); @@ -1056,13 +1062,13 @@ fn llm_reasoning_effort_supports_provider_default_and_explicit_levels() { .expect("provider default request"); assert_eq!(request.response_reasoning_effort, None); - llm.reasoning_effort = "high".to_string(); + llm.reasoning_effort = "max".to_string(); let request = apply_game_creator_llm_reasoning_effort(LlmRunRequest::single_turn("system", "user"), &llm) - .expect("high reasoning request"); + .expect("max reasoning request"); assert_eq!( request.response_reasoning_effort, - Some(platform_llm::LlmResponseReasoningEffort::High) + Some(platform_llm::LlmResponseReasoningEffort::Max) ); } 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 0d66111ed..642927481 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 @@ -1455,6 +1455,15 @@ fn fake_llm_game_draft() -> LlmGameDraft { } } +fn write_static_smoke_game_fixture_for_test(root: &Path, marker: &str) { + let html = format!( + "{}\n\n", + fake_llm_game_draft().game_html + ); + fs::write(root.join("game/index.html"), html) + .expect("write complete static-smoke game fixture"); +} + fn fake_agent_handoffs() -> Vec { vec![ handoff( 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 7a8b75405..39fb4ea1c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -585,12 +585,8 @@ async fn background_agent_runtime_preview_start_respects_project_policy() { ))); assert!(!root.join(".agent/logs/preview.log").exists()); - cancel_game_creator_agent_runtime_task_at( - &root, - "code-prototype", - "code-preview-policy-run", - ) - .expect("cancel waiting preview policy task"); + cancel_game_creator_agent_runtime_task_at(&root, "code-prototype", "code-preview-policy-run") + .expect("cancel waiting preview policy task"); wait_for_agent_runtime_terminal_and_lane_release( &root, "code-prototype", diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs index 0855d3403..eacec8589 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs @@ -5686,6 +5686,73 @@ fn runtime_parallel_read_projection_waits_across_a_manifest_wave_project_write() fs::remove_dir_all(root).ok(); } +#[cfg(windows)] +#[test] +fn runtime_project_write_lock_waits_for_delete_pending_target() { + use std::os::windows::fs::OpenOptionsExt; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + FileDispositionInfo, SetFileInformationByHandle, FILE_DISPOSITION_INFO, + }; + + const DELETE_ACCESS: u32 = 0x0001_0000; + const GENERIC_READ: u32 = 0x8000_0000; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "delete pending project lock wait") + .expect("project init"); + let lock_path = root.join(PROJECT_WRITE_LOCK_PATH); + fs::write(&lock_path, "delete-pending-project-lock").expect("write lock fixture"); + + let delete_pending = fs::OpenOptions::new() + .access_mode(GENERIC_READ | DELETE_ACCESS) + .share_mode(FILE_SHARE_READ) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(&lock_path) + .expect("open delete-pending lock fixture"); + let disposition = FILE_DISPOSITION_INFO { DeleteFile: true }; + // SAFETY: delete_pending is live and disposition has the required layout. + assert_ne!( + unsafe { + SetFileInformationByHandle( + delete_pending.as_raw_handle().cast(), + FileDispositionInfo, + (&raw const disposition).cast(), + std::mem::size_of::() as u32, + ) + }, + 0, + "mark project lock delete-pending: {}", + std::io::Error::last_os_error() + ); + let create_error = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&lock_path) + .expect_err("delete-pending target must block the create_new contention path"); + assert!( + create_error.kind() == std::io::ErrorKind::PermissionDenied + || create_error.raw_os_error() == Some(5), + "delete-pending create_new should reproduce ACCESS_DENIED: {create_error}" + ); + let release = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(50)); + drop(delete_pending); + }); + + let acquired = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "runtime.parallel_read.project", + ) + .expect("runtime projection should outwait a delete-pending project lock"); + drop(acquired); + release.join().expect("release delete-pending lock fixture"); + assert!(!lock_path.exists()); + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn agent_runtime_file_write_waits_for_short_parallel_project_writer() { let root = unique_project_path(); 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 fa253f251..e260cb1a2 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 @@ -1625,6 +1625,10 @@ async fn autonomous_game_build_repairs_persisted_failed_playtest_after_context_c "自主构建总控持久试玩失败活性测试", ) .expect("project init"); + write_static_smoke_game_fixture_for_test( + &root, + "persisted failed playtest after context compaction", + ); write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) .expect("disable unrelated collaboration preflight"); let run_id = "autonomous-supervisor-persisted-playtest-liveness-run"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs index 40e30ae0a..78ce701e8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs @@ -1840,6 +1840,7 @@ async fn autonomous_game_build_repairs_new_revision_after_failed_playtest_with_v "自主构建总控新 revision 复验测试", ) .expect("project init"); + write_static_smoke_game_fixture_for_test(&root, "reverify new revision after failed playtest"); write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) .expect("disable unrelated collaboration preflight"); let run_id = "autonomous-supervisor-reverify-new-revision-run"; @@ -1992,6 +1993,7 @@ async fn autonomous_game_build_claims_ready_delivery_before_fourth_playtest_dele "自主构建总控 ready 回执收敛测试", ) .expect("project init"); + write_static_smoke_game_fixture_for_test(&root, "ready delivery initial revision"); let run_id = "autonomous-supervisor-ready-delivery-convergence-run"; bind_game_creator_agent_runtime_run_profile_at( &root, @@ -2059,11 +2061,7 @@ async fn autonomous_game_build_claims_ready_delivery_before_fourth_playtest_dele ) .expect("record specialist repair after failed playtest"); assert_eq!(repaired_revision, failed_revision + 1); - fs::write( - root.join("game/index.html"), - "ready delivery revision\n", - ) - .expect("write specialist repair fixture"); + write_static_smoke_game_fixture_for_test(&root, "ready delivery repaired revision"); let deliveries = [ ( @@ -2368,6 +2366,7 @@ async fn autonomous_game_build_claims_ready_delivery_before_fourth_playtest_dele &preview_plan, true, false, + false, ) .is_ok()); @@ -2395,6 +2394,7 @@ async fn autonomous_game_build_claims_ready_delivery_before_fourth_playtest_dele &stale_delegate_plan, true, false, + false, ) .expect_err("static-verified revision must reject another stale delegate"); assert!(stale_delegate_error @@ -3211,6 +3211,7 @@ async fn autonomous_game_build_verified_revision_forces_response_only_delivery() .expect("persist verified delivery plan"); write_game_creator_agent_runtime_state(&root, &runtime) .expect("write verified delivery runtime"); + write_static_smoke_game_fixture_for_test(&root, "verified specialist delivery"); advance_project_revision_for_test(&root, "code-prototype", child_run_id, "file.patch"); persist_project_verification_for_test( &root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index 0a9ca06bf..e3f8cf7fa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -27,7 +27,7 @@ pub(super) use super::super::{ wait_for_agent_runtime_confirmation, wait_for_agent_runtime_idle, wait_for_agent_runtime_terminal_and_lane_release, wait_for_captured_mock_request, write_agent_runtime_task_record_for_test, write_agent_runtime_verification_fixture, - write_test_local_config, + write_static_smoke_game_fixture_for_test, write_test_local_config, }; pub(super) use crate::{ 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 e6908bf47..39b8c96ad 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 @@ -583,6 +583,104 @@ fn structured_plan_update_validates_and_advances_monotonically() { assert!(structured_plan_completion_blocker(&runtime).is_none()); } +#[test] +fn first_structured_plan_replaces_terminal_legacy_scaffolding_steps() { + let mut runtime = default_game_creator_agent_runtime_state("code-prototype", "legacy-plan-run"); + runtime.plan = vec![ + "记录开发者投递的后台任务".to_string(), + "结构化修复步骤 2".to_string(), + ]; + runtime.plan_steps = vec![ + AgentRuntimePlanStep { + index: 0, + title: "记录开发者投递的后台任务".to_string(), + status: "active".to_string(), + detail: None, + updated_at: unix_timestamp(), + }, + AgentRuntimePlanStep { + index: 1, + title: "结构化修复步骤 2".to_string(), + status: "pending".to_string(), + detail: Some("legacy-detail-canary".to_string()), + updated_at: 1, + }, + ]; + runtime.active_plan_step_index = Some(0); + complete_agent_runtime_active_plan_step( + &mut runtime, + "failed", + "command.run_limited:failed · game.static_smoke 执行失败", + ); + assert_eq!(runtime.plan_revision, 0); + assert_eq!(runtime.plan_steps.len(), 2); + assert_eq!(runtime.plan_steps[0].status, "failed"); + + let first_structured_update = AgentRuntimePlanUpdate { + explanation: "根据 smoke 诊断进入同 Run 修复".to_string(), + steps: (1..=AGENT_RUNTIME_PLAN_STEP_LIMIT) + .map(|index| AgentRuntimePlanUpdateStep { + step: format!("结构化修复步骤 {index}"), + status: if index == 1 { "in_progress" } else { "pending" }.to_string(), + }) + .collect(), + }; + + assert!( + apply_agent_runtime_plan_update(&mut runtime, &first_structured_update) + .expect("first structured plan replaces legacy scaffolding") + ); + assert_eq!(runtime.plan_revision, 1); + assert_eq!(runtime.plan_steps.len(), AGENT_RUNTIME_PLAN_STEP_LIMIT); + assert_eq!(runtime.active_plan_step_index, Some(0)); + assert!(runtime + .plan_steps + .iter() + .all(|step| step.title != "记录开发者投递的后台任务")); + assert!(runtime + .plan_steps + .iter() + .all(|step| step.status != "failed")); + let same_title_step = runtime + .plan_steps + .iter() + .find(|step| step.title == "结构化修复步骤 2") + .expect("same-title structured step exists"); + assert_eq!(same_title_step.detail, None); + assert_ne!(same_title_step.updated_at, 1); +} + +#[test] +fn structured_plan_failed_step_remains_immutable_after_migration() { + let mut runtime = default_game_creator_agent_runtime_state("code-prototype", "failed-plan-run"); + runtime.plan_revision = 1; + runtime.plan_explanation = "已进入结构化计划".to_string(); + runtime.plan = vec!["不可改写的失败步骤".to_string()]; + runtime.plan_steps = vec![AgentRuntimePlanStep { + index: 0, + title: "不可改写的失败步骤".to_string(), + status: "failed".to_string(), + detail: Some("非验证类动作失败".to_string()), + updated_at: unix_timestamp(), + }]; + + let error = apply_agent_runtime_plan_update( + &mut runtime, + &AgentRuntimePlanUpdate { + explanation: "尝试错误重开".to_string(), + steps: vec![AgentRuntimePlanUpdateStep { + step: "不可改写的失败步骤".to_string(), + status: "in_progress".to_string(), + }], + }, + ) + .expect_err("structured failed step must remain immutable"); + + assert!(error.contains("不能改写已失败步骤")); + assert_eq!(runtime.plan_revision, 1); + assert_eq!(runtime.plan_steps[0].status, "failed"); +} + #[test] fn structured_plan_update_rejects_invalid_shapes_and_ignores_action_indexes() { let invalid_updates = [ diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs index 2b6dc6663..06d3209ce 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs @@ -1247,9 +1247,7 @@ fn agent_conversation_sessions_preserve_legacy_and_isolate_new_history() { let legacy_history_path = legacy_history.path.replace('\\', "/"); let new_history_path = new_history.path.replace('\\', "/"); assert!(legacy_history_path.ends_with("design-director.jsonl")); - assert!(new_history_path.ends_with(&format!( - "design-director/sessions/{new_session_id}.jsonl" - ))); + assert!(new_history_path.ends_with(&format!("design-director/sessions/{new_session_id}.jsonl"))); let legacy_context = render_local_conversation_prompt_context_for_session( &root, Some("design-director"), diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index d97a3ca63..348bb4d54 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -449,6 +449,7 @@ export const gameCreatorLlmReasoningEfforts = [ 'low', 'medium', 'high', + 'max', ] as const; export type GameCreatorLlmReasoningEffort = (typeof gameCreatorLlmReasoningEfforts)[number]; diff --git a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts index dbf21eed7..941667351 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts @@ -862,9 +862,10 @@ export function registerPublishedRuntimeSettingsTests() { screen.getByLabelText('LLM API Key').getAttribute('autocomplete'), ).toBe('off'); fireEvent.click(screen.getByRole('button', { name: /连接与工具/ })); - expect( - screen.getByLabelText('External Editor API Key'), - ).toHaveProperty('type', 'password'); + expect(screen.getByLabelText('External Editor API Key')).toHaveProperty( + 'type', + 'password', + ); expect( screen .getByLabelText('External Editor API Key') @@ -913,6 +914,12 @@ export function registerPublishedRuntimeSettingsTests() { 'value', '', ); + expect( + within(screen.getByLabelText('Generator LLM 推理档')).getByRole( + 'option', + { name: 'max' }, + ), + ).not.toBeNull(); expect( screen.getByLabelText('生成首版美术素材 (art/Asset) LLM 推理档'), ).toHaveProperty('value', ''); @@ -970,6 +977,11 @@ export function registerPublishedRuntimeSettingsTests() { 'value', 'medium', ); + expect( + within(screen.getByLabelText('LLM 推理档')).getByRole('option', { + name: 'max', + }), + ).not.toBeNull(); fireEvent.change(screen.getByLabelText('LLM API Key'), { target: { value: 'unit-new-secret-value' }, }); @@ -1195,9 +1207,10 @@ export function registerPublishedRuntimeSettingsTests() { '12000', ); fireEvent.click(screen.getByRole('button', { name: /连接与工具/ })); - expect( - screen.getByLabelText('External Editor Base URL'), - ).toHaveProperty('value', 'http://127.0.0.1:8082'); + expect(screen.getByLabelText('External Editor Base URL')).toHaveProperty( + 'value', + 'http://127.0.0.1:8082', + ); fireEvent.click( within(runtimeConfigDialog).getByRole('button', { name: '保存' }), ); diff --git a/docs/project-memory/plans/【实施计划】AGC无人值守游戏生成可靠性收口-2026-08-13.md b/docs/project-memory/plans/【实施计划】AGC无人值守游戏生成可靠性收口-2026-08-13.md index ab91bdf12..c8365a73f 100644 --- a/docs/project-memory/plans/【实施计划】AGC无人值守游戏生成可靠性收口-2026-08-13.md +++ b/docs/project-memory/plans/【实施计划】AGC无人值守游戏生成可靠性收口-2026-08-13.md @@ -241,7 +241,18 @@ git diff --check - 相关定向测试、类型检查、编码检查和 diff 检查通过。 - 使用真实 Provider 的空项目 smoke 成功;若现场外部服务不可用,则代码可以提交为“实现完成、真实现场验收未完成”,不得宣称目标已完全达成。 -## 9. 回滚边界 +## 9. 实施与验证记录(2026-08-15) + +在 `codex/agc-runtime-generation-reliability` 分支完成: + +- 可信 `code-prototype` 父 Run 可认领并观察直属美术 delivery、按 `delegationId` 精确读取合同;错误 Agent/Run、未知合同与身份篡改统一失败关闭。父 task/chain 无法证明但仍有活跃或已认领 delivery 时 completion 必须 blocked;Suppressed 且未形成 child 的失败前置记录不参与 capability、claim 与 completion barrier。 +- 普通失败或不合法安全默认 marker 认领后立即收束;合法 `game-chat-safe-default-repair.v1` marker 由 Runtime 直接生成唯一一层、同目标、同合同 `agent.delegate`,repairRequired 状态下重复 route/read/query 被 liveness 门拒绝。 +- Windows `.agent/project.lock` 不再对最终 `create_new` 目标做 metadata 预检,delete-pending 的 5/32/33 统一进入有界竞争等待;新增 delete-pending 回归,复现真实 `create_new` ACCESS_DENIED 后证明等待可收束。 +- 验证结果:`game_chat_` 113 通过、`autonomous_completion_contract` 107 通过、`safe_default` 4 通过、`static_smoke` 9 通过、`action_receipt` 10 通过、`project_execution_owner` 8 通过、runner 重启恢复与 Windows 锁回归通过;串行全量 Rust `1856 passed / 0 failed / 15 ignored`(含新增 HTML 内联语法 fail-open 回归);`platform-llm` 与 `agent-runtime-core` 全绿;前端 typecheck、`check:encoding`、`git diff --check`、变更文件 `rustfmt --check` 通过。 +- 真实 `gpt-5.6-sol / reasoningEffort=max` 隔离轮次:14 个 Provider 请求全部完成并闭合,无确认、追问、steer、路径/密钥/正文泄漏,Runner 与后代进程清理为零残留;在未配置 External Editor 的边界下 art-director 失败后由主 Run 认领并快速明确收束,未再次空转。 +- 待完成:用有效 External Editor 配置跑一轮完整 playable 真实验收;当前结果不能宣称现场完整生成已通过。 + +## 10. 回滚边界 - 默认路由可回退到原 `project-supervisor-gui`,但不能同时保留两套普通入口形成随机分流。 - 自动权限只由可信 source + root profile + binding fingerprint 共同启用;回滚时删除该窄例外,不修改全局策略默认值。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 648c30f4c..93996e1f8 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,28 @@ # 决策记录 +## 2026-08-15 AGC game-chat 主代码 Run 接管直属美术 delivery + +- 背景:真实 `gpt-5.6-sol / max` 验收中,`art-director` 失败后已形成 `ready + needs-repair` delivery,但认领、合同读取、claim observation 和完成 blocker 均硬编码为 Supervisor-only;实际直属父 Run `code-prototype` 无法消费回执,随后又发起 29 次 Provider 请求。 +- 决策:保留原 Supervisor 能力,并把 delivery 管理权限窄扩展给身份链完整的当前可信 game-chat `code-prototype` 父 Run。Runtime 在存在 ready 回执时确定性执行 `agent.run_status(scope=self)`;claim 未完整 Observed 前不请求 Provider;普通失败或不合法 marker 认领后立即终止。完整 `game-chat-safe-default-repair.v1` marker 不再交回 Provider 决策,而是由 Runtime 按原 Agent、原合同和原 delegationId 直接生成唯一一层 `agent.delegate`;repairRequired 状态的重复 route/read/query 由 liveness 门拒绝。 +- 安全边界:权限必须同时证明 game-chat 根 source/profile、root/parent/child task、父 binding fingerprint、delivery parent/session/run、delegationId 和目标 Agent;Agent ID、任务正文或错误关键词不能授权。身份损坏时 capability、run status 和 completion gate 全部失败关闭;父 task/chain 无法证明但仍有活跃或已认领 delivery 时也必须 blocked,不能静默当作“不适用”。Suppressed 且未形成 child 的失败前置记录不再参与 capability、claim 或 completion barrier,以保留同 action reopen / 新 action 重试语义。`code-prototype` 只能管理内部 delivery,正式用户 assistant 与根 completed 投影仍只允许根 `project-supervisor`。 +- 验证方式:覆盖合法认领与合同精确读取、错误 Agent/Run/delegation 拒绝、delivery 身份篡改阻断、唯一安全默认返工、普通失败零后续 Provider lifecycle,以及新的真实 Provider 空项目轮次。顺带收紧 `validate_executable_inline_javascript_syntax`:正文游离 `<` 不再吞掉后续 `', + body: '陶泥儿', }; test('旧 Vite marker 指向其它 API 时在启动后端前失败', async () => { diff --git a/docs/project-memory/plans/【计划】AGC开发态单窗口启动收口-2026-08-17.md b/docs/project-memory/plans/【计划】AGC开发态单窗口启动收口-2026-08-17.md new file mode 100644 index 000000000..bf26f3614 --- /dev/null +++ b/docs/project-memory/plans/【计划】AGC开发态单窗口启动收口-2026-08-17.md @@ -0,0 +1,20 @@ +# AGC 开发态单窗口启动收口计划 + +日期:`2026-08-17` + +## 目标 + +`npm run agc` 启动时只打开标题为“陶泥儿”的正式客户端窗口,不再自动额外打开 Agent 聊天开发窗口。 + +## 范围与边界 + +- 删除 Tauri setup 中仅 debug 生效的自动 developer 窗口调用,以及已无调用方的 developer 窗口构造代码与专属路由测试。 +- 保留普通 `client` 窗口、`--game-chat` 独立入口和显式 `supervisor-chat` 开发调试入口。 +- 不删除前端 `?agent-chat` 调试页面;它不再是 `npm run agc` 的自动入口。 +- 同步原生壳静态门禁、技术方案和长期决策记录,防止自动双窗口回归。 + +## 验收 + +1. Tauri 定向 Rust 测试和前端类型检查通过。 +2. 配置门禁、编码检查和差异检查通过。 +3. 实际运行 `npm run agc` 后,仅存在标题为“陶泥儿”的客户端窗口,不存在 Agent 聊天开发窗口。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b31810a79..31b749e1c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -14205,3 +14205,9 @@ - 直连 Runtime 的美术生成、代码生成和版本登记仍保持单次原子回合,不拆回 Supervisor 或 harness。为避免用户等待数分钟只看到“思考中”,Runtime 在每个用户可理解的安全阶段复用 `game-creator-agent-progress`:需求接收、陶泥儿美术包检查、规范图、背景图、图集及切片、代码生成、版本登记和预览刷新;普通直连工作台仅接受当前项目的事件并显示 `message`,不把内部执行协议、路径或凭据暴露到聊天。 - 资源画布不能把 `IntersectionObserver` 作为首屏唯一预览触发器。工作台进入资源管理时主动预取最多 12 个可预览的非音频、非版本、非占位资源;保留现有按可见性懒加载、队列优先级、并发上限、缓存上限、权限失败提示与详情/播放升级机制。这样不会一次读取大项目全部资源,但陶泥儿标准三张 PNG 和游戏代码能在正常首屏项目中无需打开详情直接显示。 - 验收:定向 hook 测试证明预取会直接调用本地 preview command 并进入 loaded;AppSurface 证明 direct 提交即时显示接收文案并消费阶段事件;2026-08-17 在当前 checkout 的普通 AGC 客户端打开 `gameagent-3291e57c`,未打开任何资源详情即观察到三份游戏代码摘要和三张陶泥儿 PNG(规范图、16:9 背景、核心图集)缩略图。 + +## 2026-08-17 AGC 开发态默认单窗口启动 + +- 决策:`npm run agc` 及普通 debug 启动只打开标题为“陶泥儿”的 `client` 客户端窗口,不再由 Tauri setup 自动创建 Agent 聊天开发窗口。普通创作、直连 Codex Runtime 和项目工作台行为均不受影响。 +- 保留:`?agent-chat` 仅作为显式前端调试路由;`supervisor-chat` 和 `--game-chat` 继续是独立的显式开发验收入口,不会随着普通启动同时弹出。 +- 守卫:原生壳配置检查必须拒绝恢复 `open_developer_window(app.handle())?` 自动调用;真实开发 smoke 以普通客户端单窗口为准。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index f9a75518f..56db608f3 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -27,11 +27,11 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 ## 目标 -在 Genarrative 内建设独立桌面 App:普通用户通过项目开发工作台中的陶泥儿对话、资源画布、运行状态和确认操作,让平台生成保存在本地的可运行 Web 游戏原型,并通过本地 HTTP server 预览;主窗口提供运行时配置入口,用于保存发布版 AppData / Tauri 配置目录里的 LLM 配置及受控开发者 External Editor 配置。普通客户素材画布使用平台登录态调用内部编辑器 API,不展示或要求填写画板 Base URL / API Key。任务明细、原始文件、命令日志和专业 Agent 调试控制仍放到开发构建的独立开发窗口。v1 的生成闭环仍以 Web 小游戏为主,同时允许用户打开已有 Godot 项目:用户选择的目录始终作为工作区根,`.agent/`、Session、Runtime、文件工具和外围资料都留在该根;客户端检查根目录及一层直接子目录中的普通文件 `project.godot`,将唯一命中的实际目录以工作区相对 `godotProjectRoot` 记录到 manifest。Agent 使用标准运行档继续修改,不创建 `game/`、`assets/`、`memory/`、`exports/` 平行目录;本期不扩展 Unity、Godot 内嵌预览、云同步或插件市场。 +在 Genarrative 内建设独立桌面 App:普通用户通过项目开发工作台中的陶泥儿对话、资源画布、运行状态和确认操作,让平台生成保存在本地的可运行 Web 游戏原型,并通过本地 HTTP server 预览;主窗口提供运行时配置入口,用于保存发布版 AppData / Tauri 配置目录里的 LLM 配置及受控开发者 External Editor 配置。普通客户素材画布使用平台登录态调用内部编辑器 API,不展示或要求填写画板 Base URL / API Key。任务明细、原始文件、命令日志和专业 Agent 调试控制只通过显式开发调试入口查看,不随普通客户端启动额外打开窗口。v1 的生成闭环仍以 Web 小游戏为主,同时允许用户打开已有 Godot 项目:用户选择的目录始终作为工作区根,`.agent/`、Session、Runtime、文件工具和外围资料都留在该根;客户端检查根目录及一层直接子目录中的普通文件 `project.godot`,将唯一命中的实际目录以工作区相对 `godotProjectRoot` 记录到 manifest。Agent 使用标准运行档继续修改,不创建 `game/`、`assets/`、`memory/`、`exports/` 平行目录;本期不扩展 Unity、Godot 内嵌预览、云同步或插件市场。 ## 技术选择 -- 桌面壳:新建 `apps/ai-game-creator-shell`,与现有 `apps/desktop-shell` 分离,避免把游戏创作本地能力塞进主站宿主壳;启动时先检查平台登录态,未登录只展示登录页,登录后进入单窗口客户端首页;正式用户窗口常驻左侧栏和顶部栏,并在首页、项目组、指南 / 反馈和项目开发页之间切换。发布配置只登记 `client` 用户窗口;debug 构建可由 Tauri setup 额外打开 `developer` 开发窗口,当前开发窗口路由到 `index.html?agent-chat` 承载单 Agent 对话,后续再扩展任务、文件、预览和日志面板。 +- 桌面壳:新建 `apps/ai-game-creator-shell`,与现有 `apps/desktop-shell` 分离,避免把游戏创作本地能力塞进主站宿主壳;启动时先检查平台登录态,未登录只展示登录页,登录后进入单窗口客户端首页;正式用户窗口常驻左侧栏和顶部栏,并在首页、项目组、指南 / 反馈和项目开发页之间切换。发布和 debug 启动都只登记并打开 `client` 用户窗口;`index.html?agent-chat` 仅保留为显式前端调试路由,不是 Tauri 自动启动入口。 - 平台后端:继续使用 `server-rs + Axum + SpacetimeDB`;本地开发启动独立客户端时,`agc` / Tauri dev 会先启动或复用配套 SpacetimeDB 与 `api-server`,再启动固定端口 Vite,并通过 `/api` 代理访问实际后端端口。 - 本地能力:使用 Tauri Rust command;正式用户 App 在项目运行工作台内承载 `127.0.0.1` 本地 HTTP preview,不再调用系统外部浏览器。运行容器只接受当前授权项目由 `PreviewRegistry` 返回的 loopback URL,release / dev CSP 都只为 `http://127.0.0.1:*` 开放 `frame-src`,并使用受限 iframe sandbox 隔离游戏脚本;远程 URL、`file://` 和任意手填地址均不得进入该容器。 - Agent Runtime:扩展 `server-rs/crates/platform-agent`,不引入 LangChain、AutoGen、Microsoft Agent Framework 或 OpenAI Agents SDK sidecar 作为核心。 @@ -43,7 +43,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 ## 开发态 Project Supervisor 纯聊天独立窗口 -- 入口边界:当前仅 Tauri dev 提供独立窗口,路由为 `index.html?supervisor-chat&projectPath=...`,其中 `projectPath` 传入 URL 编码后的项目绝对路径。窗口从现有 `index.html?agent-chat` 开发入口打开,不增加正式用户入口;重复打开同一项目只恢复并聚焦原窗口,切换项目时在同一窗口导航。 +- 入口边界:当前仅 Tauri dev 提供独立窗口,路由为 `index.html?supervisor-chat&projectPath=...`,其中 `projectPath` 传入 URL 编码后的项目绝对路径。该窗口仅通过显式开发调试动作打开,不增加正式用户入口,也不随 `npm run agc` 自动弹出;重复打开同一项目只恢复并聚焦原窗口,切换项目时在同一窗口导航。 - Runtime 边界:窗口固定对话 Agent 为 `project-supervisor`,新 Run 使用 `standard` profile,复用现有 active Session、External Runner、Agent Runtime、AppData 配置和持久 conversation;不新建平行会话库、Runner 或配置存储,也不把该测试入口隐式切成自动修改项目的自主构建模式。 - 界面边界:只显示持久消息区、输入框、必要的等待 / 错误状态、工具确认 / 用户追问卡片和设置入口;不显示 Agent picker、Session 面板、Goal 面板、完整 Runtime 面板或专业 Agent 协作栏。会话历史仍绑定 `project-supervisor` 的 active Session 持久化,不因隐藏 Session 控制面而变为临时聊天;`supervisor-chat` 窗口必须具备只读 Tauri event listen / unlisten capability,以实时接收 Runtime 更新。 - 产品边界:正式用户 `client` 窗口、登录后首页和项目开发流程保持不变,不暴露该开发验证面。 @@ -266,7 +266,7 @@ Agent Runtime 负责: ## Agent 能力清单 - 用户能力:项目开发工作台、陶泥儿聊天、上传文件、manifest 资源视图、客户端内运行表现层和专业 Agent 紧凑状态;正式用户窗口仍不展示原始任务 / 文件 / 日志、开发预览调试面板、能力清单或开发专用单 Agent 聊天入口。 -- 开发窗口能力:debug 构建额外打开 `developer` 窗口,走 `index.html?agent-chat`;开发者可选择 Agent、授权本地项目路径,并通过 `read_local_conversation` / `append_local_conversation_message` 读写 `.agent/conversations/agents/.jsonl`,通过 `agentLlm.` 调用该 Agent 的独立 LLM 路由做真实对话,用于单独调试某个 Agent 的长期对话上下文。这里的 `` 以 manifest taskId 为规范值,旧 `group-role` 别名只作为兼容输入映射到 taskId。 +- 开发调试能力:`index.html?agent-chat` 可作为显式前端调试路由;开发者可选择 Agent、授权本地项目路径,并通过 `read_local_conversation` / `append_local_conversation_message` 读写 `.agent/conversations/agents/.jsonl`,通过 `agentLlm.` 调用该 Agent 的独立 LLM 路由做真实对话,用于单独调试某个 Agent 的长期对话上下文。这里的 `` 以 manifest taskId 为规范值,旧 `group-role` 别名只作为兼容输入映射到 taskId。 - 命令能力:内置命令调用、权限 gate、执行日志;v1 只允许白名单受限命令,不执行任意 shell。 - 编排能力:任务拆分、任务图依赖、专业组调度、多智能体协作;Runtime V1 会为单 Agent 对话和生成 loop 中的角色 brief 写入独立 runtime state / event,先解决“每个 Agent 正在做什么、跑到哪一步、最近一次 task/run 是什么”的可观测性。 - 历史边界说明:下一条“后台任务能力”保留 V1.1 前的进程内演进记录,其中 App 内 tokio task、进程内 drain、旧工具箱和“不是独立 OS 进程”的描述均已失效;当前执行边界以上文独立 Runner 说明为准。 @@ -832,8 +832,8 @@ game-project/ - 开发模式可执行 `canvas.asset_import`,将项目内已有文件按画板来源导入 manifest;也可执行 `canvas.export_import`,把现有画板素材导出 ZIP 回流为本地项目资产。`game.generate_draft` 在配置 `editorApi.apiKey` 时会自动复用 External Editor API 的图片生成接口生成首版美术素材,不新增平行资产模型。 - 普通模式已在登录后的单窗口项目开发页渲染 GameAgent 工作台、`project-supervisor` 主聊天和专业 Agent 协作只读状态,不提供专业 Agent / child 单 Agent 对话;首页首条需求直接投递 active Supervisor Session,全新项目没有该 Session 时先通过既有 Session 命令创建并设为 active,已有项目恢复该 Session 与历史后继续交互。Tauri 主窗口承载当前授权项目的 loopback 游戏画面,但不承载开发 Agent picker、调度工具台或任意地址预览调试面板。 - 聊天生成草案后会尝试启动只读 `127.0.0.1:` 静态 HTTP server,把预览地址回写客户端项目运行工作台并切换到运行视图;不再调用系统外部浏览器。 -- 开发模式仅在 Vite dev 环境响应 `?dev` 或 `#dev`,Tauri dev 可额外打开 `developer` 窗口显示专业组、本地项目、预览 iframe 和内置命令日志;正式构建忽略 dev 参数,release 配置只登记一个用户窗口,登录后同窗口进入首页并在项目组 / 项目开发页之间切换。 -- `check:native-shells` 会运行 `ai-game-creator-shell:check` 和 `ai-game-creator-shell:build -- --no-bundle`,并静态检查 release 只登记 `client / index.html` 这一个用户窗口、开发窗口只在 debug 下打开,开发面板必须挂在 `devMode` 分支内,正式用户 App 的运行容器只接受 `http://127.0.0.1:*`,release / dev CSP 都只为该 loopback origin 开放 `frame-src`,Tauri 预览激活命令不得调用 opener,用户主流程不得调用旧工作区窗口切换 command。 +- 开发模式仅在 Vite dev 环境响应 `?dev` 或 `#dev`;`npm run agc` 与 release 都只打开 `client` 用户窗口,显式 `?agent-chat` 与 `supervisor-chat` 调试入口不随启动自动弹出。正式构建忽略 dev 参数,release 配置只登记一个用户窗口,登录后同窗口进入首页并在项目组 / 项目开发页之间切换。 +- `check:native-shells` 会运行 `ai-game-creator-shell:check` 和 `ai-game-creator-shell:build -- --no-bundle`,并静态检查 release 与 debug 启动都只登记 `client / index.html` 这一个默认窗口、禁止 Tauri setup 自动打开 developer 窗口、开发面板必须挂在 `devMode` 分支内,正式用户 App 的运行容器只接受 `http://127.0.0.1:*`,release / dev CSP 都只为该 loopback origin 开放 `frame-src`,Tauri 预览激活命令不得调用 opener,用户主流程不得调用旧工作区窗口切换 command。 - 共享契约提供 `GAME_CREATION_AGENT_CAPABILITIES` 和内置命令权限枚举;开发模式会展示能力列表。 - 共享契约提供 manifest task schema 和 ready-task 选择器,用于记录任务拆分、专业组、角色模板、依赖、产物、验收条件和当前可执行任务。 - 开发模式可读取、保存、删除短期记忆和长期记忆文件;普通用户通过聊天命令完成同类能力。 From f30e6e9f33fb2a7246de9f570fd32a0ee25fe236 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:35:13 +0800 Subject: [PATCH 09/20] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E5=88=9B=E4=BD=9C=E9=89=B4=E6=9D=83=E6=8F=90=E7=A4=BA=E5=8F=AF?= =?UTF-8?q?=E8=AF=BB=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分隔产品名称与直连运行时错误原因 同步更新直连错误投影测试 --- .../src/features/agent-runtime/model.ts | 7 +++++-- .../tests/agentRuntimeModel.test.ts | 10 +++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) 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 c92a54a1f..cd3c836bc 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 @@ -1783,7 +1783,10 @@ function directCodexFailureDetail(message: string) { } const safe = detail .replace(/https?:\/\/[^\s]+/gi, '[已隐藏链接]') - .replace(/(?:[A-Z]:\\|\\\\|\/(?:Users|home|var|tmp|private)\/)[^\s]+/gi, '[已隐藏路径]') + .replace( + /(?:[A-Z]:\\|\\\\|\/(?:Users|home|var|tmp|private)\/)[^\s]+/gi, + '[已隐藏路径]', + ) .replace(/\s+/g, ' ') .trim() .slice(0, 280); @@ -1866,7 +1869,7 @@ export function projectRuntimeVisibleError( other: '未完成本次执行,请查看项目文件是否已修改后再重试', }[directCodexAppServerKind]; if (detail) { - return `${subject}${detail}`; + return `${subject} ${detail}`; } } if (visibleMessage.includes('codex-app-server-terminal-unknown:')) { diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index 0c52b937a..503ac374c 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -944,7 +944,7 @@ describe('Agent Runtime Provider 状态投影', () => { '陶泥儿智能创作', true, ), - ).toBe('陶泥儿智能创作用量已达上限,请检查账户额度后重试'); + ).toBe('陶泥儿智能创作 用量已达上限,请检查账户额度后重试'); expect( projectRuntimeVisibleError( 'codex-app-server-terminal-unknown: 等待 turn/completed 超时', @@ -963,14 +963,18 @@ describe('Agent Runtime Provider 状态投影', () => { '陶泥儿智能创作', true, ), - ).toBe('陶泥儿智能创作:Codex 执行失败:Codex app-server turn 失败:HTTP 400'); + ).toBe( + '陶泥儿智能创作:Codex 执行失败:Codex app-server turn 失败:HTTP 400', + ); expect( projectRuntimeVisibleError( 'direct-codex-error:请求失败 https://provider.example/private C:\\Users\\private\\project', '陶泥儿智能创作', true, ), - ).toBe('陶泥儿智能创作:Codex 执行失败:请求失败 [已隐藏链接] [已隐藏路径]'); + ).toBe( + '陶泥儿智能创作:Codex 执行失败:请求失败 [已隐藏链接] [已隐藏路径]', + ); expect( projectRuntimeVisibleError( From 286c402a4b3fc17c4f67c3372898ac2b027127c6 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:03:50 +0800 Subject: [PATCH 10/20] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E8=AE=BE=E7=BD=AE=E5=BC=B9=E7=AA=97=E8=83=8C=E6=99=AF?= =?UTF-8?q?=E4=B8=8E=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置弹窗打开时锁定页面滚动并在关闭后恢复原状态 将弹窗、提示条和底部状态区改为不透明实色背景 补充设置弹窗滚动锁定与恢复的界面回归测试 --- .../runtime-config/RuntimeConfigDialog.tsx | 14 ++++++- apps/ai-game-creator-shell/src/styles.css | 40 ++++++++++++++----- .../appSurface/runtime-settings.suite.ts | 40 ++++++++++++++++++- 3 files changed, 81 insertions(+), 13 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index 2f8007f69..67c08ccb5 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -72,7 +72,7 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = { model: 'gpt-5.6-sol', apiKind: 'openai_responses', reasoningEffort: 'max', - stream: false, + stream: true, webSearchEnabled: false, contextWindowTokens: 128000, autoCompactTokenLimit: 64000, @@ -602,6 +602,18 @@ export function RuntimeConfigDialog({ useEscapeToClose(onClose); + useEffect(() => { + const htmlOverflow = document.documentElement.style.overflow; + const bodyOverflow = document.body.style.overflow; + document.documentElement.style.overflow = 'hidden'; + document.body.style.overflow = 'hidden'; + + return () => { + document.documentElement.style.overflow = htmlOverflow; + document.body.style.overflow = bodyOverflow; + }; + }, []); + useEffect(() => { void readRuntimeConfig(); // The dialog reads once on mount; subsequent reads are explicit user actions. diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index c6fe5d212..3db515b3f 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -3381,7 +3381,7 @@ h2 { padding: 11px 14px; border: 1px solid var(--platform-success-border); border-radius: 12px; - background: var(--platform-success-fill); + background: #edf8ef; box-shadow: var(--platform-panel-shadow); color: var(--platform-success-text); font-size: 12px; @@ -3391,7 +3391,7 @@ h2 { .runtime-settings-toast[data-tone='error'] { border-color: var(--platform-warm-border); - background: var(--platform-warm-bg); + background: #fff1ea; color: var(--platform-warm-text); } @@ -3411,6 +3411,11 @@ h2 { } .runtime-settings-panel { + --runtime-settings-fill: #fffdfa; + --runtime-settings-topbar-fill: #fffdf9; + --runtime-settings-nav-fill: #fdf7f1; + --runtime-settings-content-fill: #fffaf6; + --runtime-settings-subpanel-fill: #fffdfb; grid-template-rows: auto minmax(0, 1fr) auto; gap: 0; width: min(1080px, 100%); @@ -3420,7 +3425,7 @@ h2 { overflow: hidden; border-color: var(--platform-modal-border); border-radius: 18px; - background: var(--platform-modal-fill); + background: var(--runtime-settings-fill); box-shadow: var(--platform-panel-shadow); } @@ -3437,7 +3442,7 @@ h2 { min-height: 76px; padding: 15px 18px 14px 22px; border-bottom: 1px solid var(--platform-line-soft); - background: var(--platform-desktop-topbar-fill); + background: var(--runtime-settings-topbar-fill); } .runtime-settings-header > div { @@ -3488,7 +3493,7 @@ h2 { padding: 16px 12px; overflow-y: auto; border-right: 1px solid var(--platform-line-soft); - background: var(--platform-nav-fill); + background: var(--runtime-settings-nav-fill); } .runtime-settings-nav nav { @@ -3580,8 +3585,7 @@ h2 { min-height: 0; padding: 22px 24px 28px; overflow-y: auto; - background: var(--platform-desktop-panel-fill); - scrollbar-gutter: stable; + background: var(--runtime-settings-content-fill); } .runtime-settings-section-header { @@ -3618,7 +3622,7 @@ h2 { padding: 13px; border: 1px solid var(--platform-subpanel-border); border-radius: 12px; - background: var(--platform-subpanel-fill); + background: var(--runtime-settings-subpanel-fill); color: var(--platform-text-base); } @@ -3654,7 +3658,7 @@ h2 { overflow: hidden; border: 1px solid var(--platform-subpanel-border); border-radius: 12px; - background: var(--platform-subpanel-fill); + background: var(--runtime-settings-subpanel-fill); } .runtime-agent-card-summary { @@ -3723,7 +3727,7 @@ h2 { min-height: 66px; padding: 11px 18px 11px 22px; border-top: 1px solid var(--platform-line-soft); - background: var(--platform-desktop-topbar-fill); + background: var(--runtime-settings-topbar-fill); } .runtime-settings-footer .status-line { @@ -3744,7 +3748,7 @@ h2 { .runtime-settings-footer .status-line[data-tone='success'] { border-color: var(--platform-success-border); - background: var(--platform-success-fill); + background: #edf8ef; color: var(--platform-success-text); } @@ -4845,6 +4849,20 @@ iframe.preview-frame { color: var(--platform-button-secondary-text); } +.game-workbench-view-actions .game-workbench-play-button { + border-color: var(--platform-button-primary-border); + background: var(--platform-button-primary-fill); + color: var(--platform-button-primary-text); +} + +.game-workbench-view-actions .game-workbench-play-button:disabled { + border-color: var(--platform-surface-border); + background: var(--platform-button-secondary-fill); + color: var(--platform-text-muted); + cursor: not-allowed; + opacity: 0.6; +} + .game-resource-reorder-status { min-width: 0; color: #9a725f; diff --git a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts index 9ba8040b1..bb19d6b30 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts @@ -456,6 +456,44 @@ export function registerRuntimeSettingsTests() { expect(screen.queryByRole('dialog', { name: '运行时配置' })).toBeNull(); }); + it('locks page scrolling while runtime settings are open and restores it after close', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_game_creator_app_config') { + return { + path: '/home/test/AppData/game-creator.config.json', + config: { + agentMode: 'codex_app_server', + llm: { + apiKey: '', + baseUrl: 'https://llm.example.test/v1', + model: 'gpt-scroll-lock', + apiKind: 'openai_responses', + stream: true, + webSearchEnabled: false, + requestTimeoutMs: 180000, + maxRetries: 2, + retryBackoffMs: 500, + }, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + document.documentElement.style.overflow = 'auto'; + document.body.style.overflow = 'scroll'; + renderLauncherAt('/?launcher'); + + fireEvent.click(screen.getByRole('button', { name: '配置' })); + await screen.findByRole('dialog', { name: '运行时配置' }); + expect(document.documentElement.style.overflow).toBe('hidden'); + expect(document.body.style.overflow).toBe('hidden'); + + fireEvent.click(screen.getByRole('button', { name: '关闭 Agent 设置' })); + expect(document.documentElement.style.overflow).toBe('auto'); + expect(document.body.style.overflow).toBe('scroll'); + }); + it('configures MCP servers and reads the bounded Runner catalog', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -1227,7 +1265,7 @@ export function registerPublishedRuntimeSettingsTests() { model: 'gpt-5.6-sol', apiKind: 'openai_responses', reasoningEffort: 'max', - stream: false, + stream: true, webSearchEnabled: false, contextWindowTokens: 128000, autoCompactTokenLimit: 64000, From 94200a3cdb9d5357dabe405575925e8218b1239c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:11:22 +0800 Subject: [PATCH 11/20] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E7=9B=B4=E8=BF=9E?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E6=8C=81=E4=B9=85=E5=8C=96=E4=B8=8E=E7=94=9F?= =?UTF-8?q?=E6=88=90=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 持久化直连用户消息和最终安全回复,并在重开项目时恢复 补充直连聊天与工作台验收回归测试 直连美术生成和版本登记等待项目写锁,避免首轮生成被聊天保存阻断 同步直连 Runtime 实施计划 --- .../src-tauri/src/agent/direct_runtime.rs | 56 +++++- .../src/agent/generation/canvas_generation.rs | 104 ++++++++--- apps/ai-game-creator-shell/src/App.tsx | 169 ++++++++++++++++-- .../tests/appSurface/home.suite.ts | 103 ++++++++++- .../appSurface/project-development.suite.ts | 65 ++++++- ...计划】AGC直连Codex Runtime迁移-2026-08-15.md | 86 +++++++++ 6 files changed, 534 insertions(+), 49 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index a43e48470..ef4d53e30 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -47,6 +47,35 @@ struct DirectTaonierArtAssetIdentity { canvas_project_id: String, } +fn direct_taonier_art_generation_runtime_context( + root: &Path, + output_path: &str, + asset_kind: &str, +) -> Result { + let project_id = read_manifest(&root.join(".agent/manifest.json"))? + .project_id + .trim() + .to_string(); + if project_id.is_empty() { + return Err("直连美术生成缺少项目身份,已拒绝创建平台请求".to_string()); + } + let stage = match output_path { + DIRECT_CODEX_ART_SPEC_ASSET_PATH => "art-spec", + DIRECT_CODEX_BACKGROUND_ASSET_PATH => "game-background", + DIRECT_CODEX_SPRITESHEET_ASSET_PATH => "art-spritesheet", + _ => return Err("直连美术生成请求包含未声明的输出路径".to_string()), + }; + Ok(PlatformArtGenerationRuntimeContext { + agent_id: "direct-codex-art".to_string(), + task_id: format!("direct-codex-art-{stage}"), + session_id: project_id, + run_id: stage.to_string(), + source: "direct-codex".to_string(), + action_id: format!("direct-taonier-{stage}"), + action_fingerprint: format!("direct-taonier-art-v1:{asset_kind}:{stage}"), + }) +} + fn direct_taonier_art_asset_identity( root: &Path, expected_path: &str, @@ -281,11 +310,17 @@ async fn generate_direct_taonier_art_asset_at( asset_label: asset_label.to_string(), replace_existing: root.join(output_path).is_file(), }; - let generated = if require_slices { - generate_platform_art_asset_with_required_slices_at(root, prompt, &[], &options).await - } else { - generate_platform_art_asset_with_options_at(root, prompt, &[], &options).await - } + let runtime_context = + direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?; + let generated = generate_platform_art_asset_with_runtime_options_at( + root, + prompt, + &[], + &options, + require_slices, + &runtime_context, + ) + .await .map_err(|error| { format!("陶泥儿美术包生成失败({asset_label}),已在启动智能创作前终止:{error}") })?; @@ -375,7 +410,11 @@ async fn ensure_direct_taonier_art_package_at( .map(|path| (*path).to_string()) .collect()); } - emit_direct_game_creator_progress(root, "art.spritesheet", "正在生成核心图集并切分四类运行时素材"); + emit_direct_game_creator_progress( + root, + "art.spritesheet", + "正在生成核心图集并切分四类运行时素材", + ); generate_direct_taonier_art_asset_at( root, prompt, @@ -488,7 +527,10 @@ fn sync_direct_codex_project_outputs_at( } update_manifest_task_status_at(root, "code-prototype", GameCreationAppTaskStatus::Completed)?; if outputs_changed || manifest_requires_sync { - let _lock = acquire_project_write_lock(root, "direct-codex.version")?; + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "direct-codex.version", + )?; let version_revision = advance_agent_runtime_project_revision_locked(root)?; append_agent_game_iteration_version_at(root, version_revision)?; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 7cfddbb1b..0c1c99497 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -1306,6 +1306,59 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at( commit_prepared_platform_art_asset_strict_slices_at(root, prepared, options, |_| Ok(())) } +/// Generates a platform art asset with a durable request identity owned by a +/// caller outside the legacy Agent Runtime. Once the platform has accepted the +/// request, later invocations only recover that same operation; they never +/// create another billable generation for the same logical asset. +pub(in crate::agent) async fn generate_platform_art_asset_with_runtime_options_at( + root: &Path, + prompt: &str, + briefs: &[AgentGroupBrief], + options: &PlatformArtAssetGenerationOptions, + require_slices: bool, + runtime_context: &PlatformArtGenerationRuntimeContext, +) -> Result { + if require_slices && options.asset_kind != "art-spritesheet" { + return Err("严格游戏切片生成只允许 art-spritesheet 资产类型".to_string()); + } + { + // Direct Codex chat persists the user turn concurrently with the first + // platform-art request. Both operations are short-lived project writes; + // wait for the active writer instead of failing the whole generation on + // the expected startup race. + let recovery_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "canvas.asset_generate.runtime.recover", + )?; + recover_interrupted_strict_platform_art_transaction_locked_at(root, &recovery_lock)?; + } + let prepared = request_platform_art_asset_with_runtime_options_at( + root, + prompt, + briefs, + options, + Some(runtime_context), + ) + .await?; + let lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "canvas.asset_generate.runtime", + )?; + recover_interrupted_strict_platform_art_transaction_locked_at(root, &lock)?; + advance_agent_runtime_project_revision_locked(root)?; + let generated = if require_slices { + commit_prepared_platform_art_asset_strict_slices_at(root, prepared, options, |_| Ok(()))? + } else { + commit_prepared_platform_art_asset_at(root, prepared, options, |_| Ok(()))? + }; + remove_platform_art_generation_runtime_state_at( + root, + &runtime_context.agent_id, + &runtime_context.run_id, + )?; + Ok(generated) +} + pub(in crate::agent) async fn request_platform_art_asset_with_options_at( root: &Path, prompt: &str, @@ -6689,25 +6742,29 @@ mod canvas_generation_tests { request_sender .send(request.clone()) .expect("capture accepted recovery request"); - if request.starts_with("GET /api/external/v1/generations/accepted-operation-1 ") { + if request + .starts_with("GET /api/runtime/external-generation/jobs/accepted-operation-1 ") + { let body = serde_json::json!({ "data": { - "operationId": "accepted-operation-1", - "status": "completed", - "pollAfterMs": 0, - "result": { - "resource": { - "resourceId": "persisted-resource-1", - "projectId": "persisted-canvas-project", - "imageSrc": server_download_url + "job": { + "operationId": "accepted-operation-1", + "status": "completed", + "pollAfterMs": 0, + "result": { + "resource": { + "resourceId": "persisted-resource-1", + "projectId": "persisted-canvas-project", + "imageSrc": server_download_url + }, + "warning": { + "code": "unsupported-image-style", + "reason": "已保留可用原图" + }, + "sliceWarning": { + "reason": "测试切片告警" + } }, - "warning": { - "code": "unsupported-image-style", - "reason": "已保留可用原图" - }, - "sliceWarning": { - "reason": "测试切片告警" - } } } }) @@ -6741,14 +6798,10 @@ mod canvas_generation_tests { .expect("reject unexpected accepted recovery request"); } }); - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({ - "editorApi": { - "baseUrl": base_url, - "apiKey": "accepted-recovery-key" - } - }) - .to_string(), + let _platform_session = crate::platform_session::install_test_platform_session( + "accepted-recovery-user", + "accepted-recovery-key", + &base_url, ); let runtime_context = PlatformArtGenerationRuntimeContext { agent_id: "art-director".to_string(), @@ -6833,7 +6886,8 @@ mod canvas_generation_tests { 2, "accepted recovery must only poll and download" ); - assert!(requests[0].starts_with("GET /api/external/v1/generations/accepted-operation-1 ")); + assert!(requests[0] + .starts_with("GET /api/runtime/external-generation/jobs/accepted-operation-1 ")); assert!(requests[1].starts_with("GET /download.png ")); assert!(requests.iter().all(|request| !request.starts_with("POST "))); assert!(requests diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index d17f9e717..2fecc6007 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -131,6 +131,7 @@ import { persistSupervisorChatDraft, readInitialProjectPath, readSupervisorChatDraft, + type ProjectSupervisorComponentProps, type WorkspaceLauncherProps, writeRecentWorkspace, } from './features/app-shell/model'; @@ -254,6 +255,33 @@ const LEGACY_GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY = const GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY = 'genarrative.game-chat.auto-preview-authorization.v2'; const DIRECT_CODEX_PRODUCT_RUNTIME = true; +const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; + +function directCodexConversationMessageId( + turnId: string, + role: ChatMessage['role'], +) { + return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`; +} + +function isPersistableDirectCodexConversationMessage(message: ChatMessage) { + if (!message.runtimeOwned) { + return false; + } + const messageId = message.messageId?.trim() ?? ''; + const roleSuffix = `:${message.role}`; + if ( + !messageId.startsWith(DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX) || + !messageId.endsWith(roleSuffix) + ) { + return false; + } + const turnId = messageId.slice( + DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX.length, + -roleSuffix.length, + ); + return /^[a-z0-9][a-z0-9-]{5,159}$/iu.test(turnId); +} type GameChatAutoPreviewAuthorization = { afterRevision: number; @@ -579,6 +607,8 @@ type AppProps = { gameChatOnly?: boolean; allowAdvancedExternalEditorConfig?: boolean; initialSupervisorMessage?: string; + playRequest?: ProjectSupervisorComponentProps['playRequest']; + onPlayRequestHandled?: ProjectSupervisorComponentProps['onPlayRequestHandled']; onManifestChange?: ( projectPath: string, manifest: GameCreationAppManifest, @@ -601,6 +631,8 @@ export function App({ gameChatOnly = false, allowAdvancedExternalEditorConfig = false, initialSupervisorMessage = '', + playRequest = null, + onPlayRequestHandled, onManifestChange, onPreviewChange, onAgentRuntimeSummariesChange, @@ -678,6 +710,7 @@ export function App({ projectPath: initialProjectPath, prompt: initialSupervisorMessage.trim(), }); + const handledPlayRequestRef = useRef(null); function updateClientPreview( nextPreview: LocalPreviewResult | null, @@ -701,9 +734,25 @@ export function App({ ); const [chatAgentBusy, setChatAgentBusy] = useState(false); const [directCodexProgress, setDirectCodexProgress] = useState(''); + const directCodexConversationTurnSequenceRef = useRef(0); const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState< string | null >(null); + + function createDirectCodexConversationTurnId() { + directCodexConversationTurnSequenceRef.current += 1; + let randomId = ''; + try { + randomId = globalThis.crypto?.randomUUID?.().trim() ?? ''; + } catch { + // The timestamp and in-page sequence remain unique enough for a local + // conversation append when WebView crypto is unavailable. + } + if (/^[a-z0-9][a-z0-9-]{5,159}$/iu.test(randomId)) { + return randomId; + } + return `${Date.now().toString(36)}-${directCodexConversationTurnSequenceRef.current.toString(36)}`; + } const [projectSupervisorRuntime, setProjectSupervisorRuntime] = useState(null); const [projectSupervisorResponseStream, setProjectSupervisorResponseStream] = @@ -931,9 +980,9 @@ export function App({ ) => Promise) | null >(null); - const executeChatAgentReplyRef = useRef<(prompt: string) => Promise>( - async () => undefined, - ); + const executeChatAgentReplyRef = useRef< + (prompt: string, directConversationTurnId?: string) => Promise + >(async () => undefined); const agentConversationSavingRef = useRef(false); const agentConversationBackgroundBusyRef = useRef(false); const agentConversationLoadVersionRef = useRef(0); @@ -2107,7 +2156,8 @@ export function App({ projectSupervisorOnly && pendingMessages.every( (message) => - message.runtimeOwned || + (message.runtimeOwned && + !isPersistableDirectCodexConversationMessage(message)) || isTransientProjectOpenMessage(message, nextProjectPath), ) ) { @@ -2170,7 +2220,10 @@ export function App({ void (async () => { let wroteMessage = false; for (const [index, message] of pendingMessages.entries()) { - if (message.runtimeOwned) { + if ( + message.runtimeOwned && + !isPersistableDirectCodexConversationMessage(message) + ) { savedConversationCountRef.current = start + index + 1; continue; } @@ -2704,6 +2757,30 @@ export function App({ ]); } + useEffect(() => { + const nextProjectPath = localProject?.projectPath; + if ( + !projectSupervisorOnly || + !playRequest || + !nextProjectPath || + playRequest.projectPath !== nextProjectPath + ) { + return; + } + const requestKey = `${playRequest.projectPath}\n${playRequest.requestId}`; + if (handledPlayRequestRef.current === requestKey) { + return; + } + handledPlayRequestRef.current = requestKey; + onPlayRequestHandled?.(playRequest.requestId); + queueRunLocalShortcut(); + }, [ + localProject?.projectPath, + onPlayRequestHandled, + playRequest, + projectSupervisorOnly, + ]); + function queueStaticSmokeShortcut() { if (!requireChatProjectForUserAction()) { return; @@ -5874,7 +5951,10 @@ export function App({ } } - async function executeChatAgentReply(prompt: string) { + async function executeChatAgentReply( + prompt: string, + directConversationTurnId?: string, + ) { // Product default: send the conversation directly to Codex app-server. // The legacy Supervisor/harness path remains below for rollback and tests. if (directCodexProductRuntime) { @@ -5904,6 +5984,35 @@ export function App({ } } if (directProjectPath && directInvoke) { + const directAssistantMessageId = directConversationTurnId + ? directCodexConversationMessageId( + directConversationTurnId, + 'assistant', + ) + : undefined; + const appendDirectUserMessageIfMissing = ( + current: ChatMessage[], + ): ChatMessage[] => { + if (!directConversationTurnId) { + return current; + } + const directUserMessageId = directCodexConversationMessageId( + directConversationTurnId, + 'user', + ); + return current.some((message) => message.messageId === directUserMessageId) + ? current + : [ + ...current, + { + role: 'user' as const, + text: prompt, + runtimeOwned: true, + messageId: directUserMessageId, + updatedAt: Date.now(), + }, + ]; + }; setChatAgentBusy(true); setDirectCodexProgress('已提交需求,正在准备智能创作'); setProjectSupervisorRuntimeError(''); @@ -5917,11 +6026,14 @@ export function App({ ); if (localProjectPathRef.current === directProjectPath) { setMessages((current) => [ - ...current, + ...appendDirectUserMessageIfMissing(current), { role: 'assistant', text: reply, runtimeOwned: true, + ...(directAssistantMessageId + ? { messageId: directAssistantMessageId } + : {}), updatedAt: Date.now(), }, ]); @@ -5939,11 +6051,14 @@ export function App({ if (localProjectPathRef.current === directProjectPath) { setProjectSupervisorRuntimeError(visibleMessage); setMessages((current) => [ - ...current, + ...appendDirectUserMessageIfMissing(current), { role: 'assistant', text: visibleMessage, runtimeOwned: true, + ...(directAssistantMessageId + ? { messageId: directAssistantMessageId } + : {}), updatedAt: Date.now(), }, ]); @@ -5964,6 +6079,14 @@ export function App({ role: 'assistant', text: message, runtimeOwned: true, + ...(directConversationTurnId + ? { + messageId: directCodexConversationMessageId( + directConversationTurnId, + 'assistant', + ), + } + : {}), updatedAt: Date.now(), }, ]); @@ -6182,16 +6305,27 @@ export function App({ return; } supervisorChatShouldFollowLatestRef.current = true; + const directConversationTurnId = directCodexProductRuntime + ? createDirectCodexConversationTurnId() + : undefined; setMessages((current) => [ ...current, { role: 'user', text: latch.prompt, runtimeOwned: true, + ...(directConversationTurnId + ? { + messageId: directCodexConversationMessageId( + directConversationTurnId, + 'user', + ), + } + : {}), updatedAt: Date.now(), }, ]); - void executeChatAgentReplyRef.current(latch.prompt); + void executeChatAgentReplyRef.current(latch.prompt, directConversationTurnId); }, [ chatAgentBusy, directCodexProductRuntime, @@ -11175,6 +11309,9 @@ export function App({ if (supervisorChatOnly || gameChatOnly) { supervisorChatShouldFollowLatestRef.current = true; } + const directConversationTurnId = directCodexProductRuntime + ? createDirectCodexConversationTurnId() + : undefined; setChatInput(''); setMessages((current) => [ ...current, @@ -11182,10 +11319,18 @@ export function App({ role: 'user', text: prompt, runtimeOwned: true, + ...(directConversationTurnId + ? { + messageId: directCodexConversationMessageId( + directConversationTurnId, + 'user', + ), + } + : {}), updatedAt: Date.now(), }, ]); - void executeChatAgentReply(prompt); + void executeChatAgentReply(prompt, directConversationTurnId); } const visibleProfessionalAgentCards = agentStatusCards.filter( @@ -11311,14 +11456,18 @@ export function App({ directCodexProductRuntime ? false : projectSupervisorNeedsUserInput } onCancelConfirmation={cancelUiCommandConfirmation} + onCancelPendingCommand={handlePendingCommandCancel} onChatInputChange={setChatInput} onConfirmConfirmation={confirmUiCommand} + onConfirmPendingCommand={() => void handlePendingCommandConfirm()} onScroll={handleConversationScroll} onShowEarlierMessages={showEarlierConversationMessages} onSubmit={handleProjectSupervisorOnlySubmit} pendingConfirmation={ directCodexProductRuntime ? null : pendingUiConfirmation } + pendingCommand={directCodexProductRuntime ? pendingCommand : null} + projectPath={localProject?.projectPath ?? projectPath} transientReply={ directCodexProductRuntime ? directCodexProgress diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 1c0bbdca7..8409657f3 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1469,13 +1469,41 @@ export function registerHomeProjectCreationTests() { ([command]) => command === 'resume_game_creator_agent_runtime_tasks', ), ).toHaveLength(0); + const directConversationAppends = invoke.mock.calls.filter( + ([command, args]) => + command === 'append_local_conversation_message' && + (args as Record)?.agentId === null, + ); + expect(directConversationAppends).toHaveLength(2); + const directConversationMessages = directConversationAppends.map( + ([, args]) => args as Record, + ); + expect(directConversationMessages.map((args) => args.message)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: 'user', + content: expect.stringContaining('初始意图:art / 做素材'), + }), + expect.objectContaining({ + role: 'assistant', + content: 'DIRECT_CODEX_TEST_OK', + }), + ]), + ); + const directConversationMessageIds = directConversationMessages.map((args) => + String(args.messageId), + ); + expect(directConversationMessageIds).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^direct-codex:[a-z0-9-]+:user$/iu), + expect.stringMatching(/^direct-codex:[a-z0-9-]+:assistant$/iu), + ]), + ); expect( - invoke.mock.calls.filter( - ([command, args]) => - command === 'append_local_conversation_message' && - (args as Record)?.agentId === null, - ), - ).toHaveLength(0); + directConversationMessageIds[0].replace(/:(?:user|assistant)$/u, ''), + ).toBe( + directConversationMessageIds[1].replace(/:(?:user|assistant)$/u, ''), + ); expect(invoke).not.toHaveBeenCalledWith( 'generate_local_game_draft', expect.anything(), @@ -1549,15 +1577,45 @@ export function registerHomeProjectCreationTests() { 'failed-direct-project', '直连失败项目', ); + const persistedMessages: Array> = []; const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_local_game_manifest') { expect(args).toEqual({ projectPath }); return manifest; } + if (command === 'read_local_conversation') { + return { + path: `${projectPath}/.agent/conversations/project.jsonl`, + agentId: null, + sessionId: null, + messages: [...persistedMessages], + }; + } if (command === 'append_local_permission_log') { return {}; } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { deniedCommands: [], confirmCommands: [] }, + }; + } + if (command === 'append_local_conversation_message') { + const message = args?.message as Record; + persistedMessages.push({ + schemaVersion: 'game-creator-conversation.v1', + ...message, + messageId: String(args?.messageId ?? ''), + updatedAt: Number(message.updatedAt ?? persistedMessages.length + 1), + }); + return { + path: `${projectPath}/.agent/conversations/project.jsonl`, + agentId: null, + sessionId: null, + messages: [...persistedMessages], + }; + } if (command === 'chat_with_game_creator_direct_codex') { throw new Error('codex-app-server-error:unauthorized'); } @@ -1578,6 +1636,39 @@ export function registerHomeProjectCreationTests() { expect( await screen.findByText('陶泥儿智能创作 鉴权失败,请检查 API Key 或登录态'), ).not.toBeNull(); + await waitFor(() => { + expect(persistedMessages).toHaveLength(2); + }); + expect(persistedMessages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ role: 'user', content: '生成一个游戏' }), + expect.objectContaining({ + role: 'assistant', + content: '陶泥儿智能创作 鉴权失败,请检查 API Key 或登录态', + }), + ]), + ); + expect(JSON.stringify(persistedMessages)).not.toContain( + 'codex-app-server-error:unauthorized', + ); + cleanup(); + + render( + React.createElement(App, { + initialProjectPath: projectPath, + initialProjectManifest: manifest, + projectSupervisorOnly: true, + }), + ); + expect(await screen.findByText('生成一个游戏')).not.toBeNull(); + expect( + await screen.findByText('陶泥儿智能创作 鉴权失败,请检查 API Key 或登录态'), + ).not.toBeNull(); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ), + ).toHaveLength(1); }); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 46b3a5e37..543f98cb1 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -734,6 +734,10 @@ export function registerProjectWorkbenchFoundationTests() { }) as HTMLButtonElement; expect(runTab.disabled).toBe(false); expect(runTab.getAttribute('data-unavailable')).toBe('true'); + const playButton = screen.getByRole('button', { + name: '播放', + }) as HTMLButtonElement; + expect(playButton.disabled).toBe(true); fireEvent.click(runTab); expect(runTab.getAttribute('aria-selected')).toBe('false'); expect( @@ -3524,6 +3528,7 @@ export function registerProjectWorkbenchFoundationTests() { }, ); window.__TAURI__ = { core: { invoke } }; + const onPlay = vi.fn(); render( React.createElement(ProjectDevelopmentView, { @@ -3536,9 +3541,14 @@ export function registerProjectWorkbenchFoundationTests() { supervisor: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), + onPlay, }), ); + fireEvent.click(screen.getByRole('button', { name: '播放' })); + expect(onPlay).toHaveBeenCalledTimes(1); + expect(screen.getByLabelText('运行表现层')).not.toBeNull(); + const runTab = screen.getByRole('tab', { name: '运行', }) as HTMLButtonElement; @@ -3661,6 +3671,10 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.click(screen.getByRole('tab', { name: '运行' })); expect(screen.queryByTitle('远程预览拒绝测试 游戏运行画面')).toBeNull(); expect(screen.getByText('客户端运行画面尚未载入')).not.toBeNull(); + expect( + screen.getByText('点击顶部播放按钮后将在这里直接运行游戏'), + ).not.toBeNull(); + expect(screen.queryByText(/\/run|\/preview/)).toBeNull(); }); } @@ -3913,6 +3927,55 @@ export function registerUserSurfaceBoundaryTests() { } export function registerProjectSupervisorSurfaceTests() { + it('routes a top workbench play request into the existing game.run_local confirmation', async () => { + const projectPath = '/tmp/top-play-request'; + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: '顶部播放请求', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + return supervisorHarness.invoke(command, args); + }, + ); + const handled = vi.fn(); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + + render( + React.createElement(App, { + initialProjectPath: projectPath, + projectSupervisorOnly: true, + playRequest: { projectPath, requestId: 7 }, + onPlayRequestHandled: handled, + }), + ); + + expect( + await screen.findByText('game.run_local', {}, { timeout: 3_000 }), + ).not.toBeNull(); + expect(handled).toHaveBeenCalledWith(7); + expect( + screen.getByText(`运行自检并启动 ${projectPath}/game/`), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'start_local_game_preview', + expect.anything(), + ); + }); + it('keeps the direct Codex welcome surface free of legacy Supervisor status before the first message', async () => { const projectPath = '/tmp/launcher-empty-supervisor-game'; const manifest = createGameCreationAppManifest( @@ -9235,7 +9298,7 @@ export function registerProjectSupervisorSurfaceTests() { invoke.mock.calls.filter( ([command]) => command === 'read_project_permission_policy', ), - ).toHaveLength(policyReadCountBeforeChat); + ).toHaveLength(policyReadCountBeforeChat + 1); }); it('keeps legacy professional Agent results readable while runtime controls stay hidden', async () => { diff --git a/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md b/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md index 73d31e3b2..1f628a6a8 100644 --- a/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md +++ b/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md @@ -133,3 +133,89 @@ 1. direct Runtime 在不输出内部路径、Provider 或工具细节的前提下,复用 `game-creator-agent-progress` 发送安全的公开阶段:需求已接收、检查陶泥儿美术包、规范图、背景图、图集与切片、代码生成、项目版本登记和预览刷新。普通直连工作台按当前项目路径接收并即时显示该阶段;命令失败仍沿用现有安全错误映射,不把阶段进度伪装成已完成。 2. 资源管理首次进入时主动请求最多 12 个可预览的非音频、非版本、非占位资源。请求仍服从三并发、96 项队列、48 项/64 MiB 缓存和原有身份失效规则;后续资源继续通过 `IntersectionObserver` 按滚动加载,详情与播放请求可提升优先级。首屏预取不改变权限策略,读取被拒绝时卡片必须保留安全错误状态。 3. 验收必须同时证明:提交直连需求后立即可见“已提交需求,正在准备智能创作”,收到阶段事件后显示对应用户文案;真实普通 AGC 工作台首次打开既有项目时,无需进入详情即可显示游戏代码摘要以及陶泥儿规范图、场景背景图和核心图集的缩略图。 + +## 10. 平台已完成图集结果回收修复(2026-08-17) + +### 现场证据 + +- 空项目的规范图与场景背景图已通过当前平台登录态生成、登记;核心 `grid-2x2` 图集任务在平台侧进入完成态后,客户端显示“美术包生成失败”。 +- 失败文本表明客户端收到了 `completed`,却没有拿到可消费的 `result`。这不是未登录、未配置平台生图或背景图失败,也不能通过重新提交图集来处理,否则会造成重复扣点。 + +### 修复计划 + +1. 核对平台账号模式的 `/api/runtime/external-generation/jobs/{operationId}` 完成响应、通用外部 API 完成响应和持久化 `result_payload_json` 的真实包络;统一把完成结果映射为客户端可消费的 `result`,保持 `grid-2x2`、四类切片、资源和资产身份合同不变。 +2. 对已进入 `accepted/running/completed` 的本地图集账本只轮询并恢复同一个远端任务;仅 `prepared` 且未取得接受凭证时才以原幂等键重放。任何“结果暂不可读”都保留账本并进入可恢复状态,不发起新的付费请求。 +3. 后端增加 completed 队列结果的契约测试;原生端增加平台 completed 包络的回收、四切片严格验收和不重提回归;前端继续将内部错误转换为安全、可行动的中文提示。 +4. 先对现有失败项目执行同一任务的状态恢复,确认不会产生新的生成提交;再用当前客户端新建项目跑完整规范图、背景图、图集、四切片、代码、版本与预览闭环。真实验收未通过前,不把本次修复标记为完成。 + +## 11. 直连聊天记录持久化(2026-08-17) + +### 问题 + +- 普通直连创作会把用户输入、最终回复和失败提示都标记为运行时消息;项目对话保存器此前统一跳过这类消息。 +- 项目重新打开时已经会读取 `.agent/conversations/project.jsonl`,但直连问答从未写入该文件,因此客户端重启后只剩默认欢迎语。 + +### 现行契约 + +1. 每次直连提交生成一个仅用于项目对话的稳定回合标识,并把最终用户消息与最终助手消息分别写为 `direct-codex::user`、`direct-codex::assistant`。保存命令继续以 `messageId` 幂等,重试不得产生重复记录。 +2. 仅上述 role 与 ID 匹配的最终直连问答可以穿过运行时消息过滤并写入项目主对话;阶段进度、预览启动状态、流式中间内容及无稳定回合 ID 的运行时提示仍只保留在当前页面。 +3. 直连失败时,只保存已通过 `projectRuntimeVisibleError(...)` 转换的安全中文提示,禁止把 app-server、Provider、路径、认证或工具原始错误写入对话文件。 +4. 项目重新打开时继续复用既有主对话读取与 hydration。恢复聊天记录不代表 Codex app-server 的进程内 thread 能跨客户端恢复;下一条消息仍按现有直连会话策略发起。 + +### 验收 + +- AppSurface 覆盖成功与失败回合:用户消息和最终回复各保存一次、共享同一 turn 的不同 role ID;失败记录不含原始内部错误。 +- 重载同一项目时,`read_local_conversation` 返回已保存记录,聊天区恢复展示,不再次提交直连请求。 +- 真实桌面端验收:在同一项目完成一条直连问答,关闭并重新打开当前客户端后,用户输入和最终可见回复仍在项目聊天中。 + +### 并发写入约束 + +直连消息保存与首轮陶泥儿平台美术生成可能同时触发项目写锁。两者均为短时本地写入,直连美术生成的恢复锁、提交锁以及生成产物版本登记必须使用现有的有界等待锁;不能因为聊天记录保存占用项目锁的瞬时竞态而终止整轮游戏生成。 + +## 12. 顶部播放入口(2026-08-17) + +### 产品边界 + +1. 项目工作台顶部提供唯一面向普通用户的“播放”按钮;运行空态不再引导用户输入 `/run` 或 `/preview`。 +2. 播放按钮只负责进入运行视图并排入现有 `game.run_local` 确认流程,不新增一套预览启动实现,也不直接拼接或打开外部 URL。 +3. 现有聊天命令解析暂时保留为兼容入口和回归测试依据,但不再作为工作台产品引导。 + +### 运行合同 + +- 点击播放后仍需经过项目权限确认。 +- 确认后继续执行 `game.static_smoke`、`start_local_game_preview`、预览状态同步、manifest / run trace 刷新和 loopback iframe 安全校验。 +- 没有可运行原型时按钮禁用;运行视图仍由现有 `runAvailable` 判定控制。 + +### 验收 + +- 可运行项目的工作台顶部显示“播放”,点击后进入运行视图并排入 `game.run_local`。 +- 没有可运行原型时“播放”禁用。 +- 运行空态文案只提示点击顶部播放按钮,不出现 `/run` 或 `/preview`。 + +## 13. 直连 Codex 的 LLM 自主试玩验收与柔性美术合同(2026-08-17) + +### 13.1 分层边界 + +直连 Runtime 只把安全、权限、来源和不可逆副作用留在确定性门禁内:项目工作区与权限边界、凭据隔离、平台生成请求的幂等账本与未知结果恢复、持久文件事务、HTTP 完成包基本结构、图片下载/PNG 解码/可见像素、`source.kind=canvas`、资源身份与同 Canvas 项目关系。上述条件不满足时必须失败关闭,不能交给模型猜测或覆盖。 + +`grid-2x2`、固定四张切片、固定切片文件名、固定 `drawImage` 次数以及某一种 Canvas 代码形态不再是所有游戏的完成阻断合同。它们保留为“陶泥儿标准美术包”的推荐路径:平台返回完整透明主图集但切片后处理缺失时,仍可继续代码生成;已有切片可以被 Codex 优先使用,但客户端不得伪造切片或把缺失切片标记为已存在。只有平台主图集本身无法下载、解码、无可见像素、来源身份不可信或没有任何真实平台素材引用时才阻断。 + +### 13.2 同一 Codex thread 的有界自主验收 + +每个直连回合按以下最多三次 Codex turn 执行,不恢复 Supervisor、专业 Agent 或 harness: + +1. 首轮由 Codex 生成或修改当前工作区文件。 +2. 客户端启动受限本地预览,使用真实 Chromium 在 desktop 和 mobile 两个固定视口采集页面加载、Canvas、控制台/异常、失败请求、可见文本、截图和有限的真实交互探针;direct 链路不使用 `BrowserPlaytestScenario::GenericV1` 等固定玩法状态机,试玩结果只作为模型判断证据。 +3. 客户端把脱敏、结构化的浏览器证据发送给同一 Codex thread。Codex 必须读取实际文件和证据,发现问题就直接修改并说明修复;若文件发生变化,客户端重新试玩。最新结果仍失败时最多再发送一次明确整改反馈,之后安全失败,不登记完成版本。 + +最终回复必须包含:检查过的文件、真实启动/试玩动作、desktop/mobile 观察、陶泥儿平台素材如何被实际使用、已修复问题和剩余风险。客户端只展示摘要,不把绝对路径、Token、签名 URL、Provider 原文或内部队列信息传给用户。 + +### 13.3 最低使用证明与完成条件 + +客户端只要求 `game/index.html` 存在,并能证明至少一个已登记、真实、平台来源的图片路径被游戏源码引用;不统计 `drawImage` 次数,不要求四类切片或固定代码形态。游戏质量、布局、可玩性、素材是否真的出现在画面中由 Codex 结合真实浏览器截图和结果自行判断。浏览器基础设施失败、页面无法加载、Canvas 完全不可见或出现未处理异常仍作为安全失败证据;其它质量问题交给同 thread 有界整改。 + +### 13.4 验收要求 + +- 定向 Rust 测试覆盖:完整主图集无切片可通过;来源/身份/PNG 解码/同 Canvas 约束仍失败关闭;源码合理引用任一平台素材可登记;无平台素材引用不能完成;系统提示词包含自主试玩与结构化报告要求。 +- fake app-server 测试覆盖:连续 direct turn 复用同一 thread,浏览器证据和整改反馈形成后续 `turn/start`,无 Supervisor/child/harness。 +- 真实客户端验收覆盖:当前 checkout 的 AGC 新建或打开直连项目,真实本地预览在 desktop/mobile 运行并至少执行一次可见交互;截图与结构化证据写入项目 `.agent/runtime` 证据目录;完成回复展示可读验收摘要。 From 7f60a00c84da3446b50f6eac06e858875ff47d6b Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 17 Aug 2026 22:55:27 +0800 Subject: [PATCH 12/20] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E8=AE=A4=E8=AF=81=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为分支预览启用首次密码登录自动注册 为预览短信入口配置隔离 mock 验证码 补充预览部署门禁和认证验收文档 --- ...运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md | 3 +++ scripts/check-preview-deployer.mjs | 10 ++++++++++ scripts/jenkins-preview-deployer.sh | 9 +++++++++ 3 files changed, 22 insertions(+) diff --git a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md index 93d5fa629..878172c07 100644 --- a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md +++ b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md @@ -47,6 +47,8 @@ SpacetimeDB 2.7 CLI 发布到受控 Compose 网络地址时固定使用 `--yes=r 预览 Compose override 将 SpacetimeDB 内存上限设为 `2g`。基础 loadtest Compose 的 `896m` 是压测采样口径,当前完整模块首次发布和实例化会超过该上限;预览环境若沿用该值,容器会被 cgroup OOM 杀死并使模块上传中断。该覆盖只作用于分支预览实例,不修改生产或压测基线。 +每个预览实例使用独立 SpacetimeDB 空库,不继承生产账号和短信凭据。Jenkins 在实例私有 `api-server.env` 中开启预览专用认证:未注册的中国大陆手机号首次使用 6 到 128 位密码时自动创建预览账号;短信入口使用 `mock` provider 与固定预览验证码 `123456`,不向真实手机发送短信。该设置不写入公共 env 示例、生产配置或镜像层;实例重建会重建独立数据库,原预览账号不保留。 + ## Jenkins 参数与产物 固定 Job:`shared/Genarrative-Preview-Deployer`。 @@ -118,4 +120,5 @@ Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短 - 后端:输入校验、登录会话、Origin、Crumb、Jenkins `401/403/404/5xx`、queue 到 build 状态机、artifact schema、卸载所有权和幂等测试。 - 前端:登录、分支与可选 commit、自动刷新、排队/构建/成功/失败状态、内网链接、卸载确认和刷新恢复测试。 - Jenkins:两个分支依次发布后在不同端口并存;同一分支换 commit 优先复用端口;非分支 commit 被拒绝;卸载只删除目标实例并释放端口。 +- 预览认证:新手机号可以首次密码登录并重复使用同一密码;错误密码被拒绝;获取验证码后使用 `123456` 可完成登录;容器不包含生产短信凭据。 - 通用:`npm run check:encoding`、相关 typecheck/build/test、Rust 定向测试和 `git diff --check`。 diff --git a/scripts/check-preview-deployer.mjs b/scripts/check-preview-deployer.mjs index 5cee19342..860417d07 100644 --- a/scripts/check-preview-deployer.mjs +++ b/scripts/check-preview-deployer.mjs @@ -125,6 +125,16 @@ assertIncludes( 'restart: on-failure', '预览外部生成 worker 必须在运行时身份初始化竞态后自动重启。', ); +assertIncludes( + deployer, + 'GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true', + '预览独立空库必须允许首次密码登录自动创建预览账号。', +); +assertIncludes( + deployer, + "'SMS_AUTH_ENABLED=true',\n 'SMS_AUTH_PROVIDER=mock',\n 'SMS_AUTH_MOCK_VERIFY_CODE=123456'", + '预览短信登录必须使用固定 mock 验证码,不得继承生产短信密钥。', +); assertIncludes( deployer, '^preview-[0-9a-f]{16}$', diff --git a/scripts/jenkins-preview-deployer.sh b/scripts/jenkins-preview-deployer.sh index 0171628b8..9e61cebfc 100644 --- a/scripts/jenkins-preview-deployer.sh +++ b/scripts/jenkins-preview-deployer.sh @@ -336,6 +336,15 @@ const [file, internalSecret, jwtSecret] = process.argv.slice(2); let content = fs.readFileSync(file, 'utf8'); content = content.replace(/^GENARRATIVE_INTERNAL_API_SECRET=.*$/mu, `GENARRATIVE_INTERNAL_API_SECRET=${internalSecret}`); content = content.replace(/^GENARRATIVE_JWT_SECRET=.*$/mu, `GENARRATIVE_JWT_SECRET=${jwtSecret}`); +content += [ + '', + '# Jenkins 分支预览使用隔离数据库和预览专用认证,不继承生产账号或短信凭据。', + 'GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true', + 'SMS_AUTH_ENABLED=true', + 'SMS_AUTH_PROVIDER=mock', + 'SMS_AUTH_MOCK_VERIFY_CODE=123456', + '', +].join('\n'); fs.writeFileSync(file, content, {encoding: 'utf8', mode: 0o600}); NODE unset internal_secret jwt_secret From bdf7ed288aae3ba6a8c92bf36c28202916a4ad65 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:59:54 +0800 Subject: [PATCH 13/20] =?UTF-8?q?=E6=94=BE=E5=AE=BD=E7=9B=B4=E8=BF=9E?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E9=AA=8C=E6=94=B6=E5=B9=B6=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E7=B4=A0=E6=9D=90=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将固定四切片与固定 drawImage 降为推荐路径 - 在导航前注入 Canvas/WebGL 平台素材运行时观测 - 对缺少核心素材渲染证据的 desktop/mobile 视口回灌同一 Codex thread 整改 - 补充直连验收、浏览器脚本与真实 Chrome fixture 测试 - 同步 AGC 直连 Runtime 实施计划 --- .../game-creator.config.json | 2 +- .../src-tauri/src/agent/direct_runtime.rs | 2020 +++++++++++++++-- .../src-tauri/src/agent/generation.rs | 1 + .../src/agent/generation/canvas_generation.rs | 7 +- .../runtime_actions/response_stream_tests.rs | 4 - .../src-tauri/src/agent/runtime_driver.rs | 3 +- .../src-tauri/src/assets.rs | 474 +++- .../src-tauri/src/browser.rs | 4 +- .../src-tauri/src/browser/capture.rs | 43 + .../src-tauri/src/browser/cdp.rs | 86 +- .../src-tauri/src/browser/process.rs | 21 +- .../src-tauri/src/browser/tests.rs | 52 +- .../src-tauri/src/config.rs | 12 +- .../src-tauri/src/main.rs | 2 +- .../src-tauri/src/platform_session.rs | 56 + .../src/process_session/lifecycle.rs | 12 +- .../src-tauri/src/tests/configuration.rs | 38 +- .../src/features/agent-runtime/model.ts | 56 + .../features/app-shell/WorkspaceLauncher.tsx | 24 + .../src/features/app-shell/model.ts | 5 + .../ProjectSupervisorView.tsx | 37 + .../src/view/project-development/index.tsx | 19 +- .../tests/agentRuntimeModel.test.ts | 39 + ...计划】AGC直连Codex Runtime迁移-2026-08-15.md | 44 +- .../shared-memory/decision-log.md | 14 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 6 +- .../api-server/src/editor_generation_queue.rs | 3 +- .../src/external_generation_worker.rs | 67 +- 28 files changed, 2914 insertions(+), 237 deletions(-) diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index 2c566be25..19e45914c 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -6,7 +6,7 @@ "model": "gpt-5.6-sol", "apiKind": "openai_responses", "reasoningEffort": "max", - "stream": false, + "stream": true, "webSearchEnabled": false, "contextWindowTokens": 128000, "autoCompactTokenLimit": 64000, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index ef4d53e30..606f25217 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -1,4 +1,5 @@ use super::*; +use std::io::Write; use std::path::Path; const MAX_CONTEXT_FILE_BYTES: usize = 6 * 1024; @@ -12,6 +13,9 @@ const BUNDLED_AGC_IMPLEMENTATION_PLAN: &str = include_str!( const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png"; const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png"; const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png"; +const PLATFORM_GENERATION_SOURCE_PRESERVED_NO_RETRY_PREFIX: &str = + "platform-generation-source-preserved-no-retry:"; +#[cfg(test)] const DIRECT_CODEX_SPRITESHEET_SLICE_PATHS: [(&str, &str); 4] = [ ("player", "assets/art-spritesheet-slices/player.png"), ( @@ -37,8 +41,8 @@ const DIRECT_CODEX_GAME_OUTPUTS: [(&str, &str, &str); 3] = [ ("game/style.css", "game-style", "text/css"), ("game/game.js", "game-script", "text/javascript"), ]; -const DIRECT_CODEX_BUILTIN_SKILL: &str = "技能 direct-game-creation:先检查当前 game/ 入口和已有玩法,优先最小修改;需要生成新游戏时直接创建可运行的 game/index.html、style.css 和 game.js,保证 Canvas、输入、主要玩法、重开和移动端布局。游戏必须适配 16:9 横屏逻辑视口,页面本身不得出现横向或纵向滚动条。客户端会在本回合开始前通过陶泥儿平台按标准工作流生成并登记 `assets/art-spec.png`、场景背景 `assets/direct-game-background.png`、透明主图集 `assets/art-spritesheet.png` 及四个切片:`player.png`、`blocks-and-targets.png`、`obstacles-and-scene.png`、`feedback-effects.png`。规范图只能作为统一风格来源;你必须将背景图用于实际场景,并在 `game/game.js` 中加载四个切片、通过 Canvas `drawImage` 分别绘制玩家/可操作物、核心目标或道具、障碍或场景元素、反馈或界面装饰等核心视觉。不得用 emoji、纯色块或 CSS 图形代替这些核心视觉,也不得只在回复中提及图片路径。不要自行调用生图 API、不要伪造图片或修改 .agent/manifest.json;客户端会负责平台调用与资源登记。完成文件修改后运行可用的静态检查并通过 AGC 本地预览确认真实页面;执行环境没有命令或浏览器工具时必须如实说明。只使用当前工作区文件变更,不启动任何 Supervisor、child、harness 或隐藏返工。"; -const DIRECT_TAONIER_ART_WORKFLOW: &str = "陶泥儿游戏创作工作流:客户端先按标准三阶段生成并登记首版美术:`assets/art-spec.png` 是统一视觉规范,`assets/direct-game-background.png` 是 16:9 场景背景,`assets/art-spritesheet.png` 是基于规范图生成的透明核心图集;平台还会生成四份受合同保护的切片 `assets/art-spritesheet-slices/player.png`、`assets/art-spritesheet-slices/blocks-and-targets.png`、`assets/art-spritesheet-slices/obstacles-and-scene.png`、`assets/art-spritesheet-slices/feedback-effects.png`。你只负责在当前工作区实现游戏。项目结构固定为 `assets/` 存放已登记的真实媒体、`game/index.html` 为入口、`game/style.css` 与 `game/game.js` 为游戏代码、`memory/` 存放玩法记忆、`.agent/` 为客户端维护的项目状态。不得手写或修改 `.agent/manifest.json`,不得创建伪造 PNG、data URL、emoji 或纯 CSS 图形来代替核心美术。背景图必须实际用于游戏场景;四个切片必须由 `game/game.js` 加载,并分别通过 Canvas `drawImage` 渲染到可玩画面。只有完整平台美术包被实际使用、并且游戏代码存在时,本轮才可交付完成。"; +const DIRECT_CODEX_BUILTIN_SKILL: &str = "技能 direct-game-creation:先检查当前 game/ 入口和已有玩法,优先最小修改;需要生成新游戏时直接创建可运行的 game/index.html、style.css 和 game.js,保证 Canvas、输入、主要玩法、重开和移动端布局。游戏必须适配 16:9 横屏逻辑视口,页面本身不得出现横向或纵向滚动条。客户端会在本回合开始前通过陶泥儿平台准备已登记的真实美术资源。优先使用规范图、场景背景和可用图集/切片;如果平台只返回完整透明图集或某些切片缺失,不要伪造切片,也不要因此停止生成。至少让一个真实平台素材进入实际渲染路径,并通过真实浏览器试玩检查画面和交互。不要用 emoji、纯色块或 CSS 图形冒充平台核心美术,也不要只在回复中提及图片路径。不要自行调用生图 API、不要伪造图片或修改 .agent/manifest.json;客户端会负责平台调用与资源登记。完成文件修改后运行可用的静态检查并通过 AGC 本地预览确认真实页面;执行环境没有命令或浏览器工具时必须如实说明。收到客户端试玩证据后,必须读取实际文件并按证据继续修复;最终回复结构化列出检查文件、真实启动或试玩动作、desktop/mobile 观察、平台素材实际使用、已修复问题和剩余风险。只使用当前工作区文件变更,不启动任何 Supervisor、child、harness 或隐藏返工。"; +const DIRECT_TAONIER_ART_WORKFLOW: &str = "陶泥儿游戏创作工作流:客户端先准备并登记平台美术资源。项目结构固定为 `assets/` 存放已登记的真实媒体、`game/index.html` 为入口、`game/style.css` 与 `game/game.js` 为游戏代码、`memory/` 存放玩法记忆、`.agent/` 为客户端维护的项目状态。规范图用于统一视觉风格;背景、完整图集和平台返回的切片可按实际可用性接入。不得手写或修改 `.agent/manifest.json`,不得创建伪造 PNG、data URL、emoji 或纯 CSS 图形来代替真实平台素材。至少一个已登记平台图片必须被游戏源码引用并在页面中实际加载。"; #[derive(Clone, Debug, Eq, PartialEq)] struct DirectTaonierArtAssetIdentity { @@ -47,6 +51,159 @@ struct DirectTaonierArtAssetIdentity { canvas_project_id: String, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DirectCodexFailureStage { + ArtPreparation, + CodeGeneration, + BrowserValidation, + VersionRegistration, +} + +impl DirectCodexFailureStage { + fn id(self) -> &'static str { + match self { + Self::ArtPreparation => "art-preparation", + Self::CodeGeneration => "code-generation", + Self::BrowserValidation => "browser-validation", + Self::VersionRegistration => "version-registration", + } + } +} + +#[derive(Debug)] +struct DirectCodexTurnFailure { + stage: DirectCodexFailureStage, + error: String, +} + +impl DirectCodexTurnFailure { + fn new(stage: DirectCodexFailureStage, error: impl Into) -> Self { + Self { + stage, + error: error.into(), + } + } +} + +fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &str) -> &'static str { + let normalized = error.to_ascii_lowercase(); + if private_external_editor_credentials_storage_preparation_failed(error) { + return "请检查当前 Windows 用户对本机私有凭据目录的权限后重试"; + } + if private_external_editor_credentials_persistence_failed(error) { + return "请先在账户开发者凭据页面撤销刚创建但未保存的凭据,再重试"; + } + if error.contains("本机陶泥儿开发者 Key") { + return "请先在已登录的陶泥儿客户端发起一次直连创作,以创建仅保存在本机的开发者 Key"; + } + if normalized.contains("authentication-required") + || normalized.contains("unauthorized") + || normalized.contains("http 401") + { + return "登录态可能已失效,请重新登录陶泥儿后重试"; + } + if normalized.contains("permission-denied") || normalized.contains("http 403") { + return "当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试"; + } + if error.contains("项目正在被其他写操作占用") { + return "当前项目仍有写入正在结束,请稍后再次发送该需求"; + } + if error.contains("身份不唯一") + || error.contains("身份不匹配") + || error.contains("未找到身份完整的历史图集") + || error.contains("没有可见像素") + { + return "历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集"; + } + match stage { + DirectCodexFailureStage::ArtPreparation => { + "平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断" + } + DirectCodexFailureStage::CodeGeneration => { + "Codex 未完成本轮代码修改,请检查运行时配置后重试" + } + DirectCodexFailureStage::BrowserValidation => { + "游戏未通过真实试玩,请根据项目诊断修复后再次发送需求" + } + DirectCodexFailureStage::VersionRegistration => { + "产物尚未安全登记为版本,请检查项目目录后重试" + } + } +} + +fn direct_codex_failure_public_summary(error: &str) -> Option<&'static str> { + if private_external_editor_credentials_storage_preparation_failed(error) { + return Some("本机开发者凭据存储目录未安全初始化;未创建远端凭据"); + } + if private_external_editor_credentials_persistence_failed(error) { + return Some("本机开发者凭据已创建但未能安全保存"); + } + None +} + +fn direct_codex_failure_is_retryable(error: &str) -> bool { + ![ + "private-external-editor-credential-storage-preparation-failed", + "private-external-editor-credential-persistence-failed", + "身份不唯一", + "身份不匹配", + "未找到身份完整的历史图集", + "没有可见像素", + "合同发生变化", + ] + .iter() + .any(|marker| error.contains(marker)) +} + +fn record_direct_codex_turn_failure(root: &Path, failure: DirectCodexTurnFailure) -> String { + let summary = direct_codex_failure_public_summary(&failure.error) + .map(str::to_string) + .unwrap_or_else(|| redact_agent_runtime_error(root, &failure.error, 320)); + let summary = summary.split_whitespace().collect::>().join(" "); + let summary = if summary.trim().is_empty() { + "未提供可安全展示的详细原因".to_string() + } else { + summary + }; + let retryable = direct_codex_failure_is_retryable(&failure.error); + let recovery_hint = direct_codex_failure_recovery_hint(failure.stage, &failure.error); + let diagnostic = serde_json::json!({ + "schemaVersion": "direct-codex-diagnostic.v1", + "stage": failure.stage.id(), + "summary": summary, + "retryable": retryable, + "recoveryHint": recovery_hint, + "occurredAtUnixNanos": unix_timestamp_nanos().to_string(), + }); + let relative_path = format!( + ".agent/runtime/direct-codex-diagnostics/{}/failure.json", + unix_timestamp_nanos() + ); + let diagnostic_written = write_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative_path, + "直连 Codex 失败诊断", + &diagnostic, + 8 * 1024, + ) + .is_ok(); + let diagnostics_suffix = if diagnostic_written { + "已保存脱敏项目诊断" + } else { + "未能保存项目诊断" + }; + format!( + "direct-codex-failure:v1 stage={} retryable={} summary={};建议:{};{}", + failure.stage.id(), + retryable, + diagnostic["summary"] + .as_str() + .unwrap_or("未提供可安全展示的详细原因"), + recovery_hint, + diagnostics_suffix, + ) +} + fn direct_taonier_art_generation_runtime_context( root: &Path, output_path: &str, @@ -102,10 +259,22 @@ fn direct_taonier_art_asset_identity( .eq(expected_reference_resource_ids.iter().copied()) || expected_canvas_project_id .is_some_and(|expected| asset.source.canvas_project_id.as_deref() != Some(expected)) - || !root.join(&asset.local_path).is_file() { return None; } + let asset_path = resolve_local_project_path(root, &asset.local_path).ok()?; + if !asset_path.is_file() { + return None; + } + let bytes = std::fs::read(asset_path).ok()?; + let validated = validate_platform_art_png_bytes_with_limits( + &bytes, + &format!("陶泥儿平台素材 {}", asset.local_path), + ) + .ok()?; + if !validated.has_visible_pixels { + return None; + } Some(DirectTaonierArtAssetIdentity { resource_id: asset .source @@ -132,93 +301,7 @@ fn direct_taonier_art_asset_identity( }) } -fn direct_taonier_spritesheet_slice_contract_is_valid( - root: &Path, - spritesheet_resource_id: &str, - spritesheet_asset_object_id: &str, - spritesheet_canvas_project_id: &str, - art_spec_resource_id: &str, -) -> bool { - let Some(manifest) = std::fs::read(root.join("assets/art-spritesheet-slices/manifest.json")) - .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) - else { - return false; - }; - let Some(receipt) = std::fs::read(root.join(".agent/runtime/art-spritesheet-contract.json")) - .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) - else { - return false; - }; - DIRECT_CODEX_SPRITESHEET_SLICE_PATHS - .iter() - .all(|(usage, path)| { - root.join(path).is_file() - && manifest - .get("slices") - .and_then(serde_json::Value::as_array) - .is_some_and(|slices| { - slices.iter().any(|slice| { - slice.get("usage").and_then(serde_json::Value::as_str) == Some(*usage) - && slice.get("path").and_then(serde_json::Value::as_str) - == Some(*path) - }) - }) - && receipt - .get("slices") - .and_then(serde_json::Value::as_array) - .is_some_and(|slices| { - slices.iter().any(|slice| { - slice.get("usage").and_then(serde_json::Value::as_str) == Some(*usage) - && slice.get("path").and_then(serde_json::Value::as_str) - == Some(*path) - }) - }) - }) - && manifest.get("source").and_then(serde_json::Value::as_str) - == Some(DIRECT_CODEX_SPRITESHEET_ASSET_PATH) - && manifest - .get("sourceResourceId") - .and_then(serde_json::Value::as_str) - == Some(spritesheet_resource_id) - && manifest - .get("sourceAssetObjectId") - .and_then(serde_json::Value::as_str) - == Some(spritesheet_asset_object_id) - && manifest - .get("sourceCanvasProjectId") - .and_then(serde_json::Value::as_str) - == Some(spritesheet_canvas_project_id) - && manifest - .get("sourceReferenceResourceIds") - .and_then(serde_json::Value::as_array) - .is_some_and(|references| { - references.len() == 1 && references[0].as_str() == Some(art_spec_resource_id) - }) - && receipt.get("source").and_then(serde_json::Value::as_str) - == Some(DIRECT_CODEX_SPRITESHEET_ASSET_PATH) - && receipt - .get("sourceResourceId") - .and_then(serde_json::Value::as_str) - == Some(spritesheet_resource_id) - && receipt - .get("sourceAssetObjectId") - .and_then(serde_json::Value::as_str) - == Some(spritesheet_asset_object_id) - && receipt - .get("sourceCanvasProjectId") - .and_then(serde_json::Value::as_str) - == Some(spritesheet_canvas_project_id) - && receipt - .get("sourceReferenceResourceIds") - .and_then(serde_json::Value::as_array) - .is_some_and(|references| { - references.len() == 1 && references[0].as_str() == Some(art_spec_resource_id) - }) -} - -fn direct_taonier_art_package_is_valid(root: &Path) -> bool { +fn direct_taonier_art_base_is_valid(root: &Path) -> bool { let Some(art_spec) = direct_taonier_art_asset_identity( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, @@ -241,6 +324,25 @@ fn direct_taonier_art_package_is_valid(root: &Path) -> bool { ) else { return false; }; + let _ = (background, art_spec); + true +} + +fn direct_taonier_art_package_is_valid(root: &Path) -> bool { + let Some(art_spec) = direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + "/api/external/v1/editor/images/generations", + "spec", + &[], + None, + ) else { + return false; + }; + if !direct_taonier_art_base_is_valid(root) { + return false; + } let Some(spritesheet) = direct_taonier_art_asset_identity( root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, @@ -252,14 +354,8 @@ fn direct_taonier_art_package_is_valid(root: &Path) -> bool { ) else { return false; }; - let _ = background; - direct_taonier_spritesheet_slice_contract_is_valid( - root, - &spritesheet.resource_id, - &spritesheet.asset_object_id, - &spritesheet.canvas_project_id, - &art_spec.resource_id, - ) + let _ = (spritesheet, art_spec); + true } fn direct_asset_file_name(path: &str) -> &str { @@ -269,28 +365,337 @@ fn direct_asset_file_name(path: &str) -> &str { .unwrap_or(path) } -fn direct_game_sources_reference_taonier_art_package(root: &Path) -> bool { - let background_name = direct_asset_file_name(DIRECT_CODEX_BACKGROUND_ASSET_PATH); +fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec<&'static str> { let sources = ["game/index.html", "game/style.css", "game/game.js"] .iter() .filter_map(|relative_path| std::fs::read_to_string(root.join(relative_path)).ok()) .collect::>(); - let background_is_referenced = sources + let mut available_paths = Vec::new(); + if direct_taonier_art_base_is_valid(root) { + available_paths.push(DIRECT_CODEX_ART_SPEC_ASSET_PATH); + available_paths.push(DIRECT_CODEX_BACKGROUND_ASSET_PATH); + } + if direct_taonier_art_package_is_valid(root) { + available_paths.push(DIRECT_CODEX_SPRITESHEET_ASSET_PATH); + } + available_paths + .into_iter() + .filter(|path| { + sources.iter().any(|source| { + source.contains(path) || source.contains(direct_asset_file_name(path)) + }) + }) + .collect() +} + +fn direct_game_sources_reference_taonier_art_package(root: &Path) -> bool { + !direct_game_sources_referenced_taonier_assets(root).is_empty() +} + +fn direct_normalized_project_asset_path(path: &str) -> Option { + let normalized = path.trim().replace('\\', "/"); + let normalized = normalized.split(['?', '#']).next().unwrap_or_default(); + let normalized = normalized.trim_start_matches('/'); + (!normalized.is_empty() && !normalized.contains("..")).then(|| normalized.to_string()) +} + +const DIRECT_BROWSER_RENDERED_IMAGE_PATHS_PREFIX: &str = "[advisory] rendered-local-images:"; + +fn direct_browser_rendered_image_paths(viewport: &BrowserViewportValidationResult) -> Vec<&str> { + viewport + .diagnostics .iter() - .any(|source| source.contains(background_name)); - let game_script = std::fs::read_to_string(root.join("game/game.js")).ok(); - let core_slices_are_rendered = game_script.is_some_and(|source| { - DIRECT_CODEX_SPRITESHEET_SLICE_PATHS + .filter_map(|diagnostic| { + diagnostic.strip_prefix(DIRECT_BROWSER_RENDERED_IMAGE_PATHS_PREFIX) + }) + .flat_map(|paths| paths.split('|')) + .map(str::trim) + .filter(|path| !path.is_empty()) + .collect() +} + +fn direct_rendered_taonier_assets_in_viewport( + root: &Path, + viewport: &BrowserViewportValidationResult, +) -> Vec<&'static str> { + let referenced = direct_game_sources_referenced_taonier_assets(root); + referenced + .into_iter() + .filter(|asset_path| { + direct_browser_rendered_image_paths(viewport) + .iter() + .any(|rendered_path| { + direct_normalized_project_asset_path(rendered_path) + .as_deref() + .is_some_and(|rendered_path| { + rendered_path == *asset_path + || rendered_path.ends_with(&format!("/{asset_path}")) + }) + }) + }) + .collect() +} + +fn direct_browser_evidence_needs_art_repair( + root: &Path, + evidence: Option<&BrowserValidationResult>, +) -> bool { + let Some(evidence) = evidence else { + return false; + }; + evidence.passed + && evidence + .viewport_results .iter() - .all(|(_, path)| source.contains(direct_asset_file_name(path))) - && source.contains("drawImage") - && source.matches("drawImage(").count() >= DIRECT_CODEX_SPRITESHEET_SLICE_PATHS.len() - && (source.contains("new Image") - || source.contains("new window.Image") - || source.contains("document.createElement('img')") - || source.contains("document.createElement(\"img\")")) + .any(|viewport| direct_rendered_taonier_assets_in_viewport(root, viewport).is_empty()) +} + +/// The only output-shape proof the direct Runtime owns. It deliberately does +/// not prescribe a spritesheet layout, slice names, `drawImage` count, or a +/// particular canvas implementation: those are quality questions for the +/// same Codex thread after it has seen real browser evidence. +fn direct_game_output_completion_error(root: &Path) -> Option { + if !root.join("game/index.html").is_file() { + return Some("Codex 返回后未找到 game/index.html,项目未进入可运行状态".to_string()); + } + if !direct_game_sources_reference_taonier_art_package(root) { + return Some( + "游戏代码已生成,但未在源码中引用任何已登记的陶泥儿平台图片;不会将项目标记为完成" + .to_string(), + ); + } + None +} + +fn direct_acceptance_needs_repair( + root: &Path, + evidence: Option<&BrowserValidationResult>, + completion_error: Option<&str>, +) -> bool { + completion_error.is_some() + || evidence.is_none_or(|result| !result.passed) + || direct_browser_evidence_needs_art_repair(root, evidence) +} + +fn direct_recoverable_spritesheet_resource<'a>( + resources: &'a [serde_json::Value], + art_spec: &DirectTaonierArtAssetIdentity, +) -> Result, String> { + let mut candidates = resources.iter().filter(|resource| { + let kind = json_string_field(resource, "assetKind") + .or_else(|| json_string_field(resource, "kind")); + let source_resource_id = json_string_field(resource, "sourceResourceId"); + let referenced_spec_ids = resource + .pointer("/generationInputs/references") + .and_then(serde_json::Value::as_array) + .map(|references| { + references + .iter() + .filter_map(|reference| { + json_string_field(reference, "refId") + .or_else(|| json_string_field(reference, "resourceId")) + .or_else(|| json_string_field(reference, "id")) + }) + .collect::>() + }); + let bound_to_current_spec = match source_resource_id { + Some(source_resource_id) => source_resource_id == art_spec.resource_id, + // The platform's source-only fallback preserves the generated + // spritesheet as the primary resource and therefore has no + // sourceResourceId. Its immutable request reference is an + // equivalent identity only when it contains exactly the current + // spec and no other resource. + None => referenced_spec_ids.as_ref().is_some_and(|references| { + references.len() == 1 && references[0] == art_spec.resource_id + }), + }; + kind.as_deref() == Some("icon-spritesheet") + && json_string_field(resource, "projectId").as_deref() + == Some(art_spec.canvas_project_id.as_str()) + && bound_to_current_spec }); - background_is_referenced && core_slices_are_rendered + let Some(resource) = candidates.next() else { + return Ok(None); + }; + if candidates.next().is_some() { + return Err("陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复".to_string()); + } + if json_string_field(resource, "generationRoute") + .or_else(|| json_string_field(resource, "generation_route")) + .is_some_and(|route| route != "/api/external/v1/editor/icon-spritesheets/generations") + || json_string_field(resource, "generationKind") + .or_else(|| json_string_field(resource, "generation_kind")) + .is_some_and(|kind| kind != "icon-spritesheet") + { + return Err("匹配到的陶泥儿图集资源生成身份不匹配,已拒绝恢复".to_string()); + } + Ok(Some(resource)) +} + +async fn recover_direct_taonier_spritesheet_read_only_at( + root: &Path, + art_spec: &DirectTaonierArtAssetIdentity, +) -> Result { + let (api_base_url, api_key, _) = resolve_canvas_sync_api_credentials(None, None)?; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build() + .map_err(|error| format!("创建陶泥儿只读资源恢复客户端失败:{error}"))?; + let route = resolve_platform_editor_api_route(&format!( + "/api/external/v1/editor/projects/{}", + percent_encode_query_component(&art_spec.canvas_project_id) + )); + let response = client + .get(format!("{api_base_url}{route}")) + .bearer_auth(&api_key) + .send() + .await + .map_err(|error| format!("读取陶泥儿画布资源失败:{error}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!("读取陶泥儿画布资源失败:HTTP {}", status.as_u16())); + } + let payload = response + .json::() + .await + .map_err(|error| format!("解析陶泥儿画布资源失败:{error}"))?; + let project = payload + .get("project") + .or_else(|| payload.pointer("/data/project")) + .ok_or_else(|| "陶泥儿画布资源响应缺少 project".to_string())?; + let resources = project + .get("resources") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "陶泥儿画布资源响应缺少 resources".to_string())?; + let Some(resource) = direct_recoverable_spritesheet_resource(resources, art_spec)? else { + return Ok(false); + }; + let resource_id = json_string_field(resource, "resourceId") + .ok_or_else(|| "匹配到的陶泥儿图集资源缺少 resourceId".to_string())?; + let asset_object_id = json_string_field(resource, "assetObjectId") + .ok_or_else(|| "匹配到的陶泥儿图集资源缺少 assetObjectId".to_string())?; + let task_id = json_string_field(resource, "taskId") + .ok_or_else(|| "匹配到的陶泥儿图集资源缺少 taskId".to_string())?; + let download = resolve_canvas_resource_download(&client, &api_base_url, &api_key, resource) + .await? + .ok_or_else(|| "匹配到的陶泥儿图集资源缺少可下载媒体".to_string())?; + let validated = validate_platform_art_png_bytes_with_limits( + &download.bytes, + "恢复的陶泥儿 art-spritesheet", + )?; + if !validated.has_visible_pixels { + return Err("恢复的陶泥儿 art-spritesheet 没有可见像素".to_string()); + } + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "direct-codex.art-spritesheet-recovery", + )?; + let current_art_spec = direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + "/api/external/v1/editor/images/generations", + "spec", + &[], + Some(&art_spec.canvas_project_id), + ) + .ok_or_else(|| "只读恢复期间本地规范图合同发生变化,已拒绝写入图集".to_string())?; + if current_art_spec != *art_spec { + return Err("只读恢复期间本地规范图身份发生变化,已拒绝写入图集".to_string()); + } + let output = resolve_local_project_path(root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH)?; + if output.exists() { + return Err("本地图集文件已存在但合同不完整,已拒绝覆盖并保持失败状态".to_string()); + } + if let Some(parent) = output.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("创建陶泥儿图集目录失败:{error}"))?; + } + let mut output_file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&output) + .map_err(|error| format!("创建恢复的陶泥儿图集文件失败:{error}"))?; + output_file + .write_all(&download.bytes) + .and_then(|_| output_file.sync_all()) + .map_err(|error| { + let _ = std::fs::remove_file(&output); + format!("写入恢复的陶泥儿图集失败:{error}") + })?; + drop(output_file); + let register = register_local_asset_at( + root, + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + "art-spritesheet", + "image/png", + "platform-art-recovery", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some(art_spec.canvas_project_id.clone()), + resource_id: Some(resource_id), + asset_object_id: Some(asset_object_id), + task_id: Some(task_id), + prompt: json_string_field(resource, "actualPrompt") + .or_else(|| json_string_field(resource, "prompt")), + model: json_string_field(resource, "model"), + generation_route: Some( + "/api/external/v1/editor/icon-spritesheets/generations".to_string(), + ), + generation_kind: Some("icon-spritesheet".to_string()), + reference_resource_ids: vec![art_spec.resource_id.clone()], + }, + ); + if let Err(error) = register { + let _ = std::fs::remove_file(&output); + return Err(error); + } + emit_direct_game_creator_progress( + root, + "art.spritesheet.recovered", + "已从陶泥儿画布只读恢复核心图集", + ); + Ok(true) +} + +fn direct_spritesheet_source_preserved_failure(output_path: &str, error: &str) -> bool { + output_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH + && error + .trim_start() + .starts_with(PLATFORM_GENERATION_SOURCE_PRESERVED_NO_RETRY_PREFIX) +} + +fn clear_direct_taonier_generation_ledger_after_source_recovery( + root: &Path, + runtime_context: &PlatformArtGenerationRuntimeContext, +) -> Result<(), String> { + remove_platform_art_generation_runtime_state_at( + root, + &runtime_context.agent_id, + &runtime_context.run_id, + ) + .map_err(|error| format!("已恢复平台源图,但清理已完成生成账本失败:{error}")) +} + +/// A postprocess fallback has already completed the paid platform generation +/// and preserved its source resource. The direct Runtime may only make a +/// bounded, read-only attempt to recover that exact resource; it must never +/// turn this branch into another generation submission. +async fn recover_direct_taonier_spritesheet_after_source_preserved_failure_at( + root: &Path, + art_spec: &DirectTaonierArtAssetIdentity, +) -> Result { + const MAX_READ_ONLY_ATTEMPTS: usize = 3; + + for attempt in 0..MAX_READ_ONLY_ATTEMPTS { + if recover_direct_taonier_spritesheet_read_only_at(root, art_spec).await? { + return Ok(true); + } + if attempt + 1 < MAX_READ_ONLY_ATTEMPTS { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + } + Ok(false) } async fn generate_direct_taonier_art_asset_at( @@ -312,7 +717,7 @@ async fn generate_direct_taonier_art_asset_at( }; let runtime_context = direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?; - let generated = generate_platform_art_asset_with_runtime_options_at( + let generated = match generate_platform_art_asset_with_runtime_options_at( root, prompt, &[], @@ -321,9 +726,50 @@ async fn generate_direct_taonier_art_asset_at( &runtime_context, ) .await - .map_err(|error| { - format!("陶泥儿美术包生成失败({asset_label}),已在启动智能创作前终止:{error}") - })?; + { + Ok(generated) => generated, + Err(error) if direct_spritesheet_source_preserved_failure(output_path, &error) => { + let art_spec = direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + "/api/external/v1/editor/images/generations", + "spec", + &[], + None, + ) + .ok_or_else(|| { + "陶泥儿核心图集透明后处理失败,但当前规范图不再满足来源、下载或解码校验,已拒绝恢复源图" + .to_string() + })?; + let recovered = recover_direct_taonier_spritesheet_after_source_preserved_failure_at( + root, &art_spec, + ) + .await + .map_err(|recovery_error| { + format!( + "陶泥儿核心图集透明后处理失败后无法安全恢复已保留源图:{recovery_error};原始结果:{error}" + ) + })?; + if !recovered { + return Err(format!( + "陶泥儿核心图集透明后处理失败,平台已保留源图但在有界只读查询内未找到可安全恢复的唯一资源:{error}" + )); + } + clear_direct_taonier_generation_ledger_after_source_recovery(root, &runtime_context)?; + emit_direct_game_creator_progress( + root, + "art.spritesheet.source-recovered", + "透明后处理未完成,已安全恢复陶泥儿保留的核心源图", + ); + return Ok(()); + } + Err(error) => { + return Err(format!( + "陶泥儿美术包生成失败({asset_label}),已在启动智能创作前终止:{error}" + )); + } + }; if generated.asset.local_path != output_path { return Err(format!( "陶泥儿美术包生成返回后未形成可用的已登记平台素材合同 {output_path},已终止代码生成" @@ -344,7 +790,7 @@ async fn ensure_direct_taonier_art_package_at( .map(|path| (*path).to_string()) .collect()); } - let art_spec = match direct_taonier_art_asset_identity( + let existing_art_spec = direct_taonier_art_asset_identity( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, "icon-spec", @@ -352,7 +798,20 @@ async fn ensure_direct_taonier_art_package_at( "spec", &[], None, - ) { + ); + let existing_art_spec_and_background = existing_art_spec.as_ref().is_some_and(|art_spec| { + direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + "game-background", + "/api/external/v1/editor/images/generations", + "spec", + &[&art_spec.resource_id], + Some(&art_spec.canvas_project_id), + ) + .is_some() + }); + let art_spec = match existing_art_spec { Some(identity) => identity, None => { emit_direct_game_creator_progress(root, "art.spec", "正在生成统一视觉规范图"); @@ -410,6 +869,29 @@ async fn ensure_direct_taonier_art_package_at( .map(|path| (*path).to_string()) .collect()); } + if recover_direct_taonier_spritesheet_read_only_at(root, &art_spec).await? { + if direct_taonier_art_package_is_valid(root) { + emit_direct_game_creator_progress(root, "art.ready", "陶泥儿美术包已就绪"); + return Ok(DIRECT_CODEX_ART_ASSET_PATHS + .iter() + .map(|path| (*path).to_string()) + .collect()); + } + } + if existing_art_spec_and_background { + if !direct_taonier_art_base_is_valid(root) { + return Err("当前项目已有的陶泥儿规范图或背景图不再满足可下载、可解码和来源一致性要求,已终止代码生成".to_string()); + } + emit_direct_game_creator_progress( + root, + "art.ready", + "核心图集未能安全恢复,已使用现有陶泥儿规范图和背景图继续创作", + ); + return Ok(vec![ + DIRECT_CODEX_ART_SPEC_ASSET_PATH.to_string(), + DIRECT_CODEX_BACKGROUND_ASSET_PATH.to_string(), + ]); + } emit_direct_game_creator_progress( root, "art.spritesheet", @@ -422,11 +904,11 @@ async fn ensure_direct_taonier_art_package_at( "1:1", "art-spritesheet", "陶泥儿首版核心游戏美术图集", - true, + false, ) .await?; if !direct_taonier_art_package_is_valid(root) { - return Err("陶泥儿美术包不完整,已终止代码生成".to_string()); + return Err("陶泥儿美术包缺少可用的已登记主图,已终止代码生成".to_string()); } emit_direct_game_creator_progress(root, "art.ready", "陶泥儿美术包已就绪"); Ok(DIRECT_CODEX_ART_ASSET_PATHS @@ -467,21 +949,399 @@ fn direct_codex_output_fingerprint(root: &Path) -> String { format!("{:x}", hasher.finalize()) } +fn direct_browser_evidence_root(root: &Path, attempt: usize) -> Result { + let revision = read_game_creator_agent_runtime_project_revision(root) + .map(|value| value.revision) + .unwrap_or(0); + resolve_local_project_path( + root, + &format!(".agent/runtime/direct-codex-browser-validation/{revision}/attempt-{attempt}"), + ) +} + +fn direct_browser_viewport_evidence( + root: &Path, + result: &BrowserViewportValidationResult, +) -> String { + let screenshot = result + .screenshot_path + .strip_prefix(root) + .ok() + .and_then(|path| path.to_str()) + .map(|path| path.replace('\\', "/")) + .unwrap_or_else(|| "evidence-screenshot.png".to_string()); + let canvas_non_empty = result + .canvases + .iter() + .filter(|canvas| canvas.non_empty == Some(true)) + .count(); + let fatal_requests = result + .failed_requests + .iter() + .filter(|request| request.fatal) + .count(); + format!( + "{}: passed={}, readyState={}, canvas={}/{} nonEmpty, consoleErrors={}, exceptions={}, fatalRequests={}, screenshot={}", + match result.viewport { + BrowserValidationViewport::Desktop => "desktop", + BrowserValidationViewport::Mobile => "mobile", + }, + result.passed, + result.ready_state, + canvas_non_empty, + result.canvases.len(), + result.console_errors.len(), + result.exceptions.len(), + fatal_requests, + screenshot, + ) +} + +fn direct_browser_evidence_prompt( + root: &Path, + result: Option<&BrowserValidationResult>, + attempt: usize, + output_changed: bool, + completion_error: Option<&str>, +) -> String { + let completion_status = completion_error + .map(|error| { + format!( + "未通过:{}。这是客户端最低完成证明,不是可忽略的建议;请直接修改真实 `game/` 文件后再回复。", + redact_agent_runtime_error(root, error, 320) + ) + }) + .unwrap_or_else(|| { + "通过:已找到 `game/index.html`,且源码至少引用了一份已登记的陶泥儿平台图片。" + .to_string() + }); + let (browser_status, viewport_lines, details, advisory) = match result { + Some(result) => { + let viewport_lines = result + .viewport_results + .iter() + .map(|viewport| direct_browser_viewport_evidence(root, viewport)) + .collect::>(); + let art_observations = result + .viewport_results + .iter() + .map(|viewport| { + let viewport_name = match viewport.viewport { + BrowserValidationViewport::Desktop => "desktop", + BrowserValidationViewport::Mobile => "mobile", + }; + let rendered = direct_rendered_taonier_assets_in_viewport(root, viewport); + if rendered.is_empty() { + format!( + "{viewport_name}: 未在 Canvas/WebGL 渲染调用中观察到已登记陶泥儿图片;旁侧缩略图不能证明素材进入核心玩法,源码引用也不能代替运行时证据" + ) + } else { + format!( + "{viewport_name}: Canvas/WebGL 渲染调用观察到陶泥儿图片 {}", + rendered.join("、") + ) + } + }) + .collect::>(); + let advisory = result + .viewport_results + .iter() + .flat_map(|viewport| viewport.diagnostics.iter()) + .filter(|diagnostic| diagnostic.starts_with("[advisory]")) + .map(|diagnostic| redact_agent_runtime_project_paths(root, diagnostic, 240)) + .collect::>(); + let details = result + .viewport_results + .iter() + .flat_map(|viewport| { + let viewport_name = match viewport.viewport { + BrowserValidationViewport::Desktop => "desktop", + BrowserValidationViewport::Mobile => "mobile", + }; + let mut details = Vec::new(); + details.extend( + viewport + .diagnostics + .iter() + .filter(|diagnostic| !diagnostic.starts_with("[advisory]")) + .map(|diagnostic| { + format!( + "{viewport_name} diagnostic: {}", + redact_agent_runtime_error(root, diagnostic, 320) + ) + }), + ); + details.extend(viewport.console_errors.iter().take(4).map(|message| { + format!( + "{viewport_name} console.error: {}", + redact_agent_runtime_error(root, &message.text, 320) + ) + })); + details.extend(viewport.exceptions.iter().take(4).map(|exception| { + format!( + "{viewport_name} exception: {}", + redact_agent_runtime_error(root, &exception.text, 320) + ) + })); + details.extend( + viewport + .failed_requests + .iter() + .filter(|request| request.fatal) + .take(4) + .map(|request| { + format!( + "{viewport_name} fatal request: {} {} ({})", + request.method, + redact_agent_runtime_error(root, &request.url, 320), + redact_agent_runtime_error(root, &request.error_text, 220), + ) + }), + ); + details + }) + .collect::>(); + ( + format!("整体 passed={}", result.passed), + viewport_lines, + { + let mut details = details; + details.extend(art_observations); + details + }, + advisory, + ) + } + None => ( + "本次未启动 Chromium:最低完成证明尚未满足。不得把这当作试玩通过;修复后客户端才会启动真实 desktop/mobile 试玩。".to_string(), + Vec::new(), + Vec::new(), + Vec::new(), + ), + }; + format!( + "请在同一 Codex 会话中进行第 {attempt} 次自主验收。客户端只会把真实运行证据交给你,不能把未启动的浏览器、截图或结构化结果假装成已通过。\n\n[客户端最低完成证明]\n{completion_status}\n\n[结构化浏览器证据]\n{browser_status};代码文件相对指纹是否变化={output_changed}\n{}\n详细诊断与平台素材运行时观察:{}\n交互探针:{}\n证据文件留在项目 `.agent/runtime/direct-codex-browser-validation/` 下;如果原生文件工具能安全读取 PNG,可查看截图,否则只依据下列结构化数据,不要声称看过截图。\n\n请读取当前工作区的实际 `game/` 文件和可用 `assets/`,判断游戏是否真的可运行、画布是否有内容、素材是否实际进入画面、布局是否适合 desktop/mobile、主要交互是否有响应。若平台素材只作为旁侧缩略图或未观察到 Canvas/WebGL 实际渲染,请把已登记素材接入核心可玩画面后再回复;不得用纯色圆点、emoji 或 CSS 图形替代核心素材。发现任何问题就直接修改文件并再次自检;不要创建 Supervisor、child 或 harness。最终回复必须结构化列出:检查文件、真实启动/试玩动作、desktop/mobile 观察、平台素材实际使用、已修复问题、剩余风险。", + viewport_lines.join("\n"), + if details.is_empty() { + "无额外硬失败详情".to_string() + } else { + details.join("\n") + }, + if advisory.is_empty() { + "未找到可执行的可见控件".to_string() + } else { + advisory.join(";") + }, + ) +} + +async fn run_direct_browser_evidence_at( + root: &Path, + attempt: usize, +) -> Result { + let evidence_root = direct_browser_evidence_root(root, attempt)?; + let (preview, stop_sender) = start_local_game_preview_for_project(root)?; + let validation = validate_local_preview_in_browser_with_interaction( + BrowserValidationInput { + url: preview.url, + viewports: vec![ + BrowserValidationViewport::Desktop, + BrowserValidationViewport::Mobile, + ], + expected_text: Vec::new(), + settle_ms: 800, + fail_on_console_error: true, + playtest_scenario: None, + evidence_root, + }, + true, + ) + .await; + let _ = stop_sender.send(()); + validation +} + +fn write_direct_browser_acceptance_summary( + root: &Path, + evidence: &BrowserValidationResult, + accepted: bool, + attempts: usize, +) -> Result<(), String> { + let evidence_root = evidence + .evidence + .root + .strip_prefix(root) + .map_err(|_| "直连 Codex 浏览器证据目录不在当前项目内".to_string())?; + let evidence_directory = evidence_root + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .ok_or_else(|| "直连 Codex 浏览器证据目录缺少版本目录".to_string())?; + let relative_path = evidence_directory + .join("acceptance-summary.json") + .to_string_lossy() + .replace('\\', "/"); + let viewports = evidence + .viewport_results + .iter() + .map(|viewport| { + serde_json::json!({ + "viewport": match viewport.viewport { + BrowserValidationViewport::Desktop => "desktop", + BrowserValidationViewport::Mobile => "mobile", + }, + "passed": viewport.passed, + "readyState": viewport.ready_state, + "nonEmptyCanvasCount": viewport + .canvases + .iter() + .filter(|canvas| canvas.non_empty == Some(true)) + .count(), + "canvasCount": viewport.canvases.len(), + "consoleErrorCount": viewport.console_errors.len(), + "exceptionCount": viewport.exceptions.len(), + "fatalRequestCount": viewport + .failed_requests + .iter() + .filter(|request| request.fatal) + .count(), + "screenshot": viewport + .screenshot_path + .strip_prefix(root) + .ok() + .and_then(|path| path.to_str()) + .map(|path| path.replace('\\', "/")), + }) + }) + .collect::>(); + let diagnostics = evidence + .diagnostics + .iter() + .take(12) + .map(|detail| redact_agent_runtime_project_paths(root, detail, 320)) + .collect::>(); + let rendered_assets_by_viewport = evidence + .viewport_results + .iter() + .map(|viewport| { + serde_json::json!({ + "viewport": match viewport.viewport { + BrowserValidationViewport::Desktop => "desktop", + BrowserValidationViewport::Mobile => "mobile", + }, + "assets": direct_rendered_taonier_assets_in_viewport(root, viewport), + }) + }) + .collect::>(); + let source_report = evidence + .evidence + .report_path + .strip_prefix(root) + .ok() + .and_then(|path| path.to_str()) + .map(|path| path.replace('\\', "/")); + let summary = serde_json::json!({ + "schemaVersion": "direct-codex-browser-acceptance.v1", + "accepted": accepted, + "attempts": attempts, + "browserPassed": evidence.passed, + "viewports": viewports, + "diagnostics": diagnostics, + "renderedTaonierAssetsByViewport": rendered_assets_by_viewport, + "sourceReport": source_report, + }); + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative_path, + "直连 Codex 浏览器验收摘要", + &summary, + 32 * 1024, + ) +} + +fn render_direct_browser_acceptance_report( + root: &Path, + evidence: &BrowserValidationResult, +) -> String { + let referenced_assets = direct_game_sources_referenced_taonier_assets(root); + let viewports = evidence + .viewport_results + .iter() + .map(|viewport| { + let name = match viewport.viewport { + BrowserValidationViewport::Desktop => "desktop", + BrowserValidationViewport::Mobile => "mobile", + }; + let non_empty_canvas_count = viewport + .canvases + .iter() + .filter(|canvas| canvas.non_empty == Some(true)) + .count(); + format!( + "{name}: readyState={},非空 Canvas {non_empty_canvas_count}/{},控制台错误 {},异常 {},致命请求 {}", + viewport.ready_state, + viewport.canvases.len(), + viewport.console_errors.len(), + viewport.exceptions.len(), + viewport + .failed_requests + .iter() + .filter(|request| request.fatal) + .count(), + ) + }) + .collect::>(); + let interaction = evidence + .viewport_results + .iter() + .flat_map(|viewport| viewport.diagnostics.iter()) + .filter(|diagnostic| diagnostic.starts_with("[advisory]")) + .map(|diagnostic| redact_agent_runtime_project_paths(root, diagnostic, 180)) + .collect::>(); + let rendered_assets = evidence + .viewport_results + .iter() + .map(|viewport| { + let name = match viewport.viewport { + BrowserValidationViewport::Desktop => "desktop", + BrowserValidationViewport::Mobile => "mobile", + }; + let assets = direct_rendered_taonier_assets_in_viewport(root, viewport); + if assets.is_empty() { + format!("{name}: 未观察到平台素材进入 Canvas/WebGL 渲染") + } else { + format!("{name}: {}", assets.join("、")) + } + }) + .collect::>(); + format!( + "### 客户端真实试玩证据\n\n- 检查文件:`game/index.html`、`game/style.css`、`game/game.js` 及已登记陶泥儿图片。\n- 试玩动作:受限本地预览 + 真实 Chromium desktop/mobile 双视口;截图和完整报告已保存到项目 `.agent/runtime/direct-codex-browser-validation/`。\n- 双视口观察:{}。\n- 平台素材来源:已登记且可解码的陶泥儿图片;游戏源码引用:{}。\n- 平台素材运行时观察:{}。\n- 交互探针:{}。\n- 边界说明:客户端只证明来源、文件和浏览器运行基础;画面质量与玩法判断已由同一 Codex thread 依据上述证据复核。", + viewports.join(";"), + if referenced_assets.is_empty() { + "无(此状态不应被登记为完成)".to_string() + } else { + referenced_assets.join("、") + }, + rendered_assets.join(";"), + if interaction.is_empty() { + "未找到可执行的可见控件".to_string() + } else { + interaction.join(";") + }, + ) +} + fn sync_direct_codex_project_outputs_at( root: &Path, previous_output_fingerprint: Option<&str>, ) -> Result<(), String> { - if !root.join("game/index.html").is_file() { - return Err("Codex 返回后未找到 game/index.html,项目未进入可运行状态".to_string()); + if !direct_taonier_art_base_is_valid(root) { + return Err( + "游戏代码已生成,但缺少可用且已登记的陶泥儿平台美术资源;不会将项目标记为完成" + .to_string(), + ); } - if !direct_taonier_art_package_is_valid(root) { - return Err("游戏代码已生成,但缺少完整已登记的陶泥儿平台美术包(规范图、背景、核心图集与四类切片);不会将项目标记为完成".to_string()); - } - if !direct_game_sources_reference_taonier_art_package(root) { - return Err(format!( - "游戏代码已生成,但未在实际渲染中同时使用陶泥儿背景图 {} 与四类核心切片;不会将项目标记为完成", - DIRECT_CODEX_BACKGROUND_ASSET_PATH - )); + if let Some(error) = direct_game_output_completion_error(root) { + return Err(error); } let current_output_fingerprint = direct_codex_output_fingerprint(root); let outputs_changed = previous_output_fingerprint @@ -720,28 +1580,212 @@ pub(crate) async fn run_direct_game_creator_turn_at( return Err("聊天内容不能为空".to_string()); } emit_direct_game_creator_progress(root, "request.accepted", "已收到需求,正在准备智能创作"); - ensure_direct_taonier_art_package_at(root, prompt).await?; + match run_direct_game_creator_turn_inner(root, prompt).await { + Ok(reply) => Ok(reply), + Err(failure) => Err(record_direct_codex_turn_failure(root, failure)), + } +} + +async fn run_direct_game_creator_turn_inner( + root: &Path, + prompt: &str, +) -> Result { + if direct_taonier_art_base_is_valid(root) { + return run_direct_game_creator_turn_with_private_editor_credentials(root, prompt).await; + } + let external_editor_credentials = ensure_private_external_editor_api_credentials() + .await + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::ArtPreparation, error) + })?; + with_external_editor_api_credentials(external_editor_credentials, async { + run_direct_game_creator_turn_with_private_editor_credentials(root, prompt).await + }) + .await +} + +async fn run_direct_game_creator_turn_with_private_editor_credentials( + root: &Path, + prompt: &str, +) -> Result { + ensure_direct_taonier_art_package_at(root, prompt) + .await + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::ArtPreparation, error) + })?; let previous_output_fingerprint = direct_codex_output_fingerprint(root); let mut system_prompt = build_direct_codex_system_prompt(root); - system_prompt.push_str(&format!( - "\n本回合已由陶泥儿平台按标准合同生成并登记美术包:规范图 `/{}`、背景图 `/{}`、核心主图集 `/{}`,以及玩家 `/{}`、目标/道具 `/{}`、障碍/场景 `/{}`、反馈 `/{}` 四类切片。规范图只用于统一风格;游戏必须实际使用背景图和四个切片:`game/game.js` 要加载每个切片,并通过 Canvas `drawImage` 绘制对应核心类别。不得只在回复中提及路径。客户端将在回合结束时复核全部平台合同、文件存在、背景图被引用且四类切片进入实际 Canvas 渲染路径;任一条件不满足就不会把项目标记为完成。", - DIRECT_CODEX_ART_SPEC_ASSET_PATH, - DIRECT_CODEX_BACKGROUND_ASSET_PATH, - DIRECT_CODEX_SPRITESHEET_ASSET_PATH, - DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[0].1, - DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[1].1, - DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[2].1, - DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[3].1, - )); + system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实美术资源。请读取当前 `assets/` 与 `game/` 文件,选择实际存在且适合玩法的素材接入;不要假设四切片一定存在,也不要伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;你必须依据证据继续修复,并在最终回复中给出检查文件、试玩动作、双视口观察、平台素材实际使用、已修复问题和剩余风险。"); emit_direct_game_creator_progress(root, "codex.start", "美术素材已准备,正在生成游戏代码"); - let reply = direct_game_creator_codex_chat_at(root, system_prompt, prompt.to_string()) + let _initial_reply = + direct_game_creator_codex_chat_at(root, system_prompt.clone(), prompt.to_string()) + .await + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + })?; + let initial_output_fingerprint = direct_codex_output_fingerprint(root); + let mut completion_error = direct_game_output_completion_error(root); + let mut evidence_attempts = 0_usize; + let mut evidence: Option = None; + let mut browser_checked_output_fingerprint: Option = None; + if completion_error.is_none() { + emit_direct_game_creator_progress( + root, + "codex.validate", + "游戏代码已生成,正在通过真实浏览器试玩", + ); + evidence_attempts = 1; + evidence = Some( + run_direct_browser_evidence_at(root, evidence_attempts) + .await + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error) + })?, + ); + browser_checked_output_fingerprint = Some(initial_output_fingerprint.clone()); + } else { + emit_direct_game_creator_progress(root, "codex.validate", "正在检查游戏入口与平台素材引用"); + } + let repair_prompt = direct_browser_evidence_prompt( + root, + evidence.as_ref(), + 1, + initial_output_fingerprint != previous_output_fingerprint, + completion_error.as_deref(), + ); + emit_direct_game_creator_progress(root, "codex.review", "正在根据试玩证据自主检查游戏"); + let mut reply = direct_game_creator_codex_chat_at(root, system_prompt.clone(), repair_prompt) .await - .map_err(|error| format!("direct-codex-error:{error}"))?; - emit_direct_game_creator_progress(root, "codex.ready", "游戏代码已生成,正在登记项目版本"); - sync_direct_codex_project_outputs_at(root, Some(&previous_output_fingerprint)) - .map_err(|error| format!("Codex 已返回,但客户端登记生成产物失败:{error}"))?; + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + })?; + let repaired_fingerprint = direct_codex_output_fingerprint(root); + completion_error = direct_game_output_completion_error(root); + if completion_error.is_none() + && (browser_checked_output_fingerprint.as_deref() != Some(repaired_fingerprint.as_str()) + || evidence.as_ref().is_none_or(|result| !result.passed)) + { + evidence_attempts += 1; + emit_direct_game_creator_progress(root, "codex.revalidate", "正在复核自主修改后的游戏"); + evidence = Some( + run_direct_browser_evidence_at(root, evidence_attempts) + .await + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error) + })?, + ); + browser_checked_output_fingerprint = Some(repaired_fingerprint.clone()); + } else if completion_error.is_some() { + evidence = None; + } + if direct_acceptance_needs_repair(root, evidence.as_ref(), completion_error.as_deref()) { + let repair_prompt = direct_browser_evidence_prompt( + root, + evidence.as_ref(), + 2, + repaired_fingerprint != previous_output_fingerprint, + completion_error.as_deref(), + ); + emit_direct_game_creator_progress( + root, + "codex.repair", + "试玩未通过,正在进行最后一次自主修复", + ); + reply = direct_game_creator_codex_chat_at(root, system_prompt, repair_prompt) + .await + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + })?; + let final_fingerprint = direct_codex_output_fingerprint(root); + completion_error = direct_game_output_completion_error(root); + if completion_error.is_none() + && browser_checked_output_fingerprint.as_deref() != Some(final_fingerprint.as_str()) + { + evidence_attempts += 1; + emit_direct_game_creator_progress( + root, + "codex.final-validate", + "正在进行最终真实浏览器试玩", + ); + evidence = Some( + run_direct_browser_evidence_at(root, evidence_attempts) + .await + .map_err(|error| { + DirectCodexTurnFailure::new( + DirectCodexFailureStage::BrowserValidation, + error, + ) + })?, + ); + } else if completion_error.is_some() { + evidence = None; + } + } + if let Some(completion_error) = completion_error { + if let Some(evidence) = evidence.as_ref() { + write_direct_browser_acceptance_summary(root, evidence, false, evidence_attempts) + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error) + })?; + } + return Err(DirectCodexTurnFailure::new( + DirectCodexFailureStage::VersionRegistration, + format!("Codex 自主验收未通过,未登记完成版本:{completion_error}"), + )); + } + let Some(evidence) = evidence else { + return Err(DirectCodexTurnFailure::new( + DirectCodexFailureStage::BrowserValidation, + "Codex 自主试玩未产生真实浏览器证据,未登记完成版本", + )); + }; + if !evidence.passed { + write_direct_browser_acceptance_summary(root, &evidence, false, evidence_attempts) + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error) + })?; + return Err(DirectCodexTurnFailure::new( + DirectCodexFailureStage::BrowserValidation, + format!( + "Codex 自主试玩未通过,未登记完成版本:{}", + evidence + .diagnostics + .iter() + .take(4) + .cloned() + .collect::>() + .join(";") + ), + )); + } + if direct_browser_evidence_needs_art_repair(root, Some(&evidence)) { + write_direct_browser_acceptance_summary(root, &evidence, false, evidence_attempts) + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error) + })?; + return Err(DirectCodexTurnFailure::new( + DirectCodexFailureStage::BrowserValidation, + "Codex 自主试玩未证明陶泥儿平台素材进入核心 Canvas/WebGL 渲染,未登记完成版本" + .to_string(), + )); + } + emit_direct_game_creator_progress( + root, + "codex.ready", + "游戏已通过真实浏览器试玩,正在登记项目版本", + ); + sync_direct_codex_project_outputs_at(root, Some(&previous_output_fingerprint)).map_err( + |error| DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error), + )?; + write_direct_browser_acceptance_summary(root, &evidence, true, evidence_attempts).map_err( + |error| DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error), + )?; emit_direct_game_creator_progress(root, "project.ready", "项目版本已登记,正在刷新运行预览"); - Ok(reply) + Ok(format!( + "{}\n\n{}", + reply.trim(), + render_direct_browser_acceptance_report(root, &evidence) + )) } #[tauri::command] @@ -821,12 +1865,470 @@ mod tests { let prompt = build_direct_codex_system_prompt(Path::new(".")); assert!(prompt.contains("陶泥儿游戏创作工作流")); assert!(prompt.contains("`assets/`")); - assert!(prompt.contains(DIRECT_CODEX_ART_SPEC_ASSET_PATH)); - assert!(prompt.contains(DIRECT_CODEX_BACKGROUND_ASSET_PATH)); - assert!(prompt.contains(DIRECT_CODEX_SPRITESHEET_ASSET_PATH)); - assert!(prompt.contains(DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[0].1)); - assert!(prompt.contains(DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[3].1)); + assert!(prompt.contains("规范图用于统一视觉风格")); + assert!(prompt.contains("完整图集和平台返回的切片可按实际可用性接入")); assert!(prompt.contains("不得手写或修改 `.agent/manifest.json`")); + assert!(prompt.contains("最终回复结构化列出检查文件")); + assert!(prompt.contains("desktop/mobile 观察")); + } + + #[test] + fn missing_platform_art_reference_becomes_a_same_thread_repair_prompt() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-repair-project", "直连整改") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + std::fs::write( + root.path().join("game/index.html"), + "", + ) + .expect("index"); + std::fs::write(root.path().join("game/style.css"), "body {};").expect("style"); + std::fs::write( + root.path().join("game/game.js"), + "document.querySelector('#game').getContext('2d').fillRect(0, 0, 1, 1);", + ) + .expect("script"); + + let completion_error = direct_game_output_completion_error(root.path()) + .expect("missing platform image reference must require repair"); + let prompt = + direct_browser_evidence_prompt(root.path(), None, 1, true, Some(&completion_error)); + + assert!(direct_acceptance_needs_repair( + root.path(), + None, + Some(&completion_error) + )); + assert!(prompt.contains("同一 Codex 会话")); + assert!(prompt.contains("客户端最低完成证明")); + assert!(prompt.contains("未在源码中引用任何已登记的陶泥儿平台图片")); + assert!(prompt.contains("本次未启动 Chromium")); + assert!(prompt.contains("直接修改真实 `game/` 文件")); + } + + fn direct_browser_evidence_fixture( + root: &Path, + viewport_diagnostics: &[(&str, &[&str])], + ) -> BrowserValidationResult { + let viewport_results = viewport_diagnostics + .iter() + .map(|(viewport, diagnostics)| BrowserViewportValidationResult { + viewport: if *viewport == "mobile" { + BrowserValidationViewport::Mobile + } else { + BrowserValidationViewport::Desktop + }, + width: if *viewport == "mobile" { 390 } else { 1280 }, + height: if *viewport == "mobile" { 844 } else { 720 }, + final_url: "http://127.0.0.1:34567/".to_string(), + title: "fixture".to_string(), + ready_state: "complete".to_string(), + visible_text_summary: String::new(), + visible_text_character_count: 0, + dom_character_count: 0, + expected_text: Vec::new(), + console_errors: Vec::new(), + console_warnings: Vec::new(), + exceptions: Vec::new(), + failed_requests: Vec::new(), + canvases: Vec::new(), + blocked_popup_count: 0, + blocked_dialog_count: 0, + blocked_download_count: 0, + blocked_permission_count: 0, + blocked_service_worker_count: 0, + screenshot_path: root.join(format!("{viewport}.png")), + passed: true, + diagnostics: diagnostics + .iter() + .map(|value| (*value).to_string()) + .collect(), + }) + .collect(); + BrowserValidationResult { + schema_version: "fixture".to_string(), + url: "http://127.0.0.1:34567/".to_string(), + browser: BrowserIdentity { + kind: DiscoveredBrowserKind::Chrome, + product: "Chrome/fixture".to_string(), + protocol_version: "1.3".to_string(), + }, + passed: true, + viewport_results, + playtest: None, + diagnostics: Vec::new(), + evidence: BrowserValidationEvidencePaths { + root: root.join( + ".agent/runtime/direct-codex-browser-validation/fixture/evidence", + ), + report_path: root.join( + ".agent/runtime/direct-codex-browser-validation/fixture/evidence/validation.json", + ), + }, + completed_at_unix_ms: 1, + } + } + + #[test] + fn rendered_taonier_asset_observation_satisfies_both_viewports_and_is_reported() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-rendered-art", "运行时素材观察") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + write_minimal_direct_game_using_spritesheet(root.path()); + let rendered = format!( + "{DIRECT_BROWSER_RENDERED_IMAGE_PATHS_PREFIX}/assets/art-spritesheet.png?cache=fixture" + ); + let evidence = direct_browser_evidence_fixture( + root.path(), + &[ + ("desktop", &[rendered.as_str()]), + ("mobile", &[rendered.as_str()]), + ], + ); + + assert!(!direct_browser_evidence_needs_art_repair( + root.path(), + Some(&evidence) + )); + let report = render_direct_browser_acceptance_report(root.path(), &evidence); + assert!(report.contains("平台素材运行时观察"), "{report}"); + assert!( + report.contains("desktop: assets/art-spritesheet.png"), + "{report}" + ); + assert!( + report.contains("mobile: assets/art-spritesheet.png"), + "{report}" + ); + + write_direct_browser_acceptance_summary(root.path(), &evidence, true, 1) + .expect("write acceptance summary"); + let summary = std::fs::read_to_string(root.path().join( + ".agent/runtime/direct-codex-browser-validation/fixture/acceptance-summary.json", + )) + .expect("read acceptance summary"); + assert!(summary.contains("renderedTaonierAssetsByViewport")); + assert_eq!(summary.matches("assets/art-spritesheet.png").count(), 2); + } + + #[test] + fn source_reference_without_canvas_or_webgl_observation_requests_same_thread_repair() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-side-thumbnail", "旁侧缩略图整改") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + write_minimal_direct_game_using_spritesheet(root.path()); + let evidence = + direct_browser_evidence_fixture(root.path(), &[("desktop", &[]), ("mobile", &[])]); + + assert!(direct_game_output_completion_error(root.path()).is_none()); + assert!(direct_browser_evidence_needs_art_repair( + root.path(), + Some(&evidence) + )); + let prompt = direct_browser_evidence_prompt(root.path(), Some(&evidence), 1, true, None); + assert!( + prompt.contains("旁侧缩略图不能证明素材进入核心玩法"), + "{prompt}" + ); + assert!( + prompt.contains("请读取当前工作区的实际 `game/` 文件"), + "{prompt}" + ); + } + + #[test] + fn one_viewport_without_rendered_platform_art_still_requests_repair() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-mobile-art-gap", "移动端素材整改") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + write_minimal_direct_game_using_spritesheet(root.path()); + let rendered = + format!("{DIRECT_BROWSER_RENDERED_IMAGE_PATHS_PREFIX}/assets/art-spritesheet.png"); + let evidence = direct_browser_evidence_fixture( + root.path(), + &[("desktop", &[rendered.as_str()]), ("mobile", &[])], + ); + + assert!(direct_browser_evidence_needs_art_repair( + root.path(), + Some(&evidence) + )); + } + + #[test] + fn browser_diagnostics_are_redacted_before_same_thread_repair() { + let root = tempfile::tempdir().expect("temp dir"); + let browser_error = format!( + "Uncaught ReferenceError at {}?token=secret", + root.path().join("game/game.js").display() + ); + let evidence = BrowserValidationResult { + schema_version: "fixture".to_string(), + url: "http://127.0.0.1:34567/".to_string(), + browser: BrowserIdentity { + kind: DiscoveredBrowserKind::Chrome, + product: "Chrome/fixture".to_string(), + protocol_version: "1.3".to_string(), + }, + passed: false, + viewport_results: vec![BrowserViewportValidationResult { + viewport: BrowserValidationViewport::Desktop, + width: 1280, + height: 720, + final_url: "http://127.0.0.1:34567/".to_string(), + title: "fixture".to_string(), + ready_state: "complete".to_string(), + visible_text_summary: String::new(), + visible_text_character_count: 0, + dom_character_count: 0, + expected_text: Vec::new(), + console_errors: Vec::new(), + console_warnings: Vec::new(), + exceptions: vec![BrowserException { + text: browser_error, + source_url: None, + line_number: 1, + column_number: 1, + }], + failed_requests: Vec::new(), + canvases: Vec::new(), + blocked_popup_count: 0, + blocked_dialog_count: 0, + blocked_download_count: 0, + blocked_permission_count: 0, + blocked_service_worker_count: 0, + screenshot_path: root.path().join("desktop.png"), + passed: false, + diagnostics: vec!["canvas is empty".to_string()], + }], + playtest: None, + diagnostics: Vec::new(), + evidence: BrowserValidationEvidencePaths { + root: root.path().join("evidence"), + report_path: root.path().join("evidence/validation.json"), + }, + completed_at_unix_ms: 1, + }; + let prompt = direct_browser_evidence_prompt(root.path(), Some(&evidence), 2, false, None); + + assert!(prompt.contains("desktop exception:")); + assert!(prompt.contains("同一 Codex 会话")); + assert!(!prompt.contains("secret"), "{prompt}"); + assert!( + !prompt.contains(&root.path().display().to_string()), + "{prompt}" + ); + } + + #[test] + fn direct_failure_diagnostic_is_redacted_and_persisted_with_a_stable_stage() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断") + .expect("init project"); + let error = record_direct_codex_turn_failure( + root.path(), + DirectCodexTurnFailure::new( + DirectCodexFailureStage::ArtPreparation, + "读取陶泥儿画布资源失败:https://provider.example/private?token=secret C:\\Users\\private\\project authorization=Bearer secret", + ), + ); + + assert!(error + .starts_with("direct-codex-failure:v1 stage=art-preparation retryable=true summary=")); + assert!(error.contains(""), "{error}"); + assert!(error.contains(""), "{error}"); + assert!(!error.contains("secret"), "{error}"); + assert!(!error.contains("provider.example"), "{error}"); + + let diagnostics = root.path().join(".agent/runtime/direct-codex-diagnostics"); + let entries = std::fs::read_dir(&diagnostics) + .expect("diagnostic directory") + .filter_map(Result::ok) + .collect::>(); + assert_eq!(entries.len(), 1); + let diagnostic_path = entries[0].path().join("failure.json"); + let diagnostic = std::fs::read_to_string(diagnostic_path).expect("diagnostic sidecar"); + assert!(diagnostic.contains("\"stage\": \"art-preparation\"")); + assert!(diagnostic.contains("\"retryable\": true")); + assert!(diagnostic.contains("")); + assert!(!diagnostic.contains("secret")); + assert!(!diagnostic.contains("provider.example")); + } + + #[test] + fn direct_failure_diagnostic_marks_ambiguous_canvas_identity_as_not_retryable() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断") + .expect("init project"); + let error = record_direct_codex_turn_failure( + root.path(), + DirectCodexTurnFailure::new( + DirectCodexFailureStage::ArtPreparation, + "陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复", + ), + ); + + assert!( + error.contains("stage=art-preparation retryable=false"), + "{error}" + ); + assert!(error.contains("历史画布资源不满足安全恢复条件"), "{error}"); + } + + #[test] + fn direct_failure_diagnostic_keeps_private_credential_storage_failure_actionable() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断") + .expect("init project"); + let error = record_direct_codex_turn_failure( + root.path(), + DirectCodexTurnFailure::new( + DirectCodexFailureStage::ArtPreparation, + "private-external-editor-credential-storage-preparation-failed: 本机开发者凭据存储目录未安全初始化;未创建远端凭据", + ), + ); + + assert!( + error.contains("summary=本机开发者凭据存储目录未安全初始化;未创建远端凭据"), + "{error}" + ); + assert!(error.contains("请检查当前 Windows 用户对本机私有凭据目录的权限后重试")); + assert!(!error.contains("[redacted sensitive context]"), "{error}"); + } + + fn direct_art_spec_identity_fixture() -> DirectTaonierArtAssetIdentity { + DirectTaonierArtAssetIdentity { + resource_id: "taonier-resource-icon-spec".to_string(), + asset_object_id: "taonier-object-icon-spec".to_string(), + canvas_project_id: "taonier-project".to_string(), + } + } + + #[test] + fn direct_source_only_spritesheet_recovery_accepts_single_current_spec_reference() { + let art_spec = direct_art_spec_identity_fixture(); + let resources = vec![serde_json::json!({ + "resourceId": "taonier-resource-spritesheet", + "assetKind": "icon-spritesheet", + "projectId": "taonier-project", + "assetObjectId": "taonier-object-spritesheet", + "taskId": "taonier-task-spritesheet", + "generationInputs": { + "references": [{ "refId": "taonier-resource-icon-spec" }] + } + })]; + + let recovered = direct_recoverable_spritesheet_resource(&resources, &art_spec) + .expect("source-only identity must be accepted") + .expect("a single matching resource must be returned"); + + assert_eq!( + json_string_field(recovered, "resourceId").as_deref(), + Some("taonier-resource-spritesheet") + ); + } + + #[test] + fn direct_source_only_spritesheet_recovery_rejects_multiple_or_foreign_references() { + let art_spec = direct_art_spec_identity_fixture(); + let foreign_or_ambiguous = vec![ + serde_json::json!({ + "resourceId": "foreign-reference", + "assetKind": "icon-spritesheet", + "projectId": "taonier-project", + "generationInputs": { + "references": [{ "refId": "someone-else" }] + } + }), + serde_json::json!({ + "resourceId": "multiple-references", + "assetKind": "icon-spritesheet", + "projectId": "taonier-project", + "generationInputs": { + "references": [ + { "refId": "taonier-resource-icon-spec" }, + { "refId": "another-spec" } + ] + } + }), + ]; + + assert!( + direct_recoverable_spritesheet_resource(&foreign_or_ambiguous, &art_spec) + .expect("unrelated entries are not an identity conflict") + .is_none() + ); + } + + #[test] + fn direct_source_only_spritesheet_recovery_fails_closed_when_multiple_candidates_match() { + let art_spec = direct_art_spec_identity_fixture(); + let candidates = vec![ + serde_json::json!({ + "resourceId": "candidate-one", + "assetKind": "icon-spritesheet", + "projectId": "taonier-project", + "generationInputs": { + "references": [{ "refId": "taonier-resource-icon-spec" }] + } + }), + serde_json::json!({ + "resourceId": "candidate-two", + "assetKind": "icon-spritesheet", + "projectId": "taonier-project", + "generationInputs": { + "references": [{ "refId": "taonier-resource-icon-spec" }] + } + }), + ]; + + let error = direct_recoverable_spritesheet_resource(&candidates, &art_spec) + .expect_err("multiple source-only candidates must fail closed"); + assert!(error.contains("身份不唯一"), "{error}"); + } + + #[test] + fn direct_source_preserved_recovery_is_limited_to_the_canonical_spritesheet() { + let error = + format!("{PLATFORM_GENERATION_SOURCE_PRESERVED_NO_RETRY_PREFIX} postprocess failed"); + assert!(direct_spritesheet_source_preserved_failure( + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + &error, + )); + assert!(!direct_spritesheet_source_preserved_failure( + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + &error, + )); + assert!(!direct_spritesheet_source_preserved_failure( + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + "ordinary platform failure", + )); + } + + #[test] + fn source_preserved_recovery_clears_the_completed_generation_ledger() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-ledger-cleanup", "源图恢复账本清理") + .expect("init project"); + let pending = setup_platform_art_generation_runtime_accepted_for_recovery_test( + root.path(), + "art-spritesheet", + ) + .expect("accepted generation ledger"); + let runtime_context = platform_art_generation_runtime_context_from_pending(&pending); + let ledger = root + .path() + .join(".agent/runtime/canvas-generation-requests/art-director/art-spritesheet.json"); + assert!(ledger.is_file(), "{pending:?}"); + + clear_direct_taonier_generation_ledger_after_source_recovery(root.path(), &runtime_context) + .expect("clear completed source-preserved ledger"); + + assert!(!ledger.exists()); } fn register_direct_taonier_art_asset_fixture(root: &Path, local_path: &str, kind: &str) { @@ -847,7 +2349,19 @@ mod tests { Vec::new(), ), }; - std::fs::write(root.join(local_path), b"png").expect("visual asset"); + let mut png_bytes = Vec::new(); + let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 1, + 1, + image::Rgba([255, 96, 32, 255]), + )); + image + .write_to( + &mut std::io::Cursor::new(&mut png_bytes), + image::ImageFormat::Png, + ) + .expect("encode visual asset"); + std::fs::write(root.join(local_path), &png_bytes).expect("visual asset"); register_local_asset_at( root, local_path, @@ -871,17 +2385,7 @@ mod tests { } fn register_direct_taonier_art_package_fixture(root: &Path) { - std::fs::create_dir_all(root.join("assets")).expect("assets dir"); - register_direct_taonier_art_asset_fixture( - root, - DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", - ); - register_direct_taonier_art_asset_fixture( - root, - DIRECT_CODEX_BACKGROUND_ASSET_PATH, - "game-background", - ); + register_direct_taonier_art_base_fixture(root); register_direct_taonier_art_asset_fixture( root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, @@ -893,7 +2397,7 @@ mod tests { let absolute = root.join(path); std::fs::create_dir_all(absolute.parent().expect("slice parent")) .expect("slice directory"); - std::fs::write(&absolute, b"png").expect("slice png"); + std::fs::write(&absolute, tiny_visible_png()).expect("slice png"); serde_json::json!({ "name": usage, "usage": usage, @@ -936,6 +2440,66 @@ mod tests { .expect("receipt"); } + fn register_direct_taonier_art_base_fixture(root: &Path) { + std::fs::create_dir_all(root.join("assets")).expect("assets dir"); + register_direct_taonier_art_asset_fixture( + root, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + ); + register_direct_taonier_art_asset_fixture( + root, + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + "game-background", + ); + } + + fn tiny_visible_png() -> Vec { + let mut bytes = Vec::new(); + let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 1, + 1, + image::Rgba([255, 96, 32, 255]), + )); + image + .write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) + .expect("encode tiny PNG"); + bytes + } + + fn tiny_transparent_png() -> Vec { + let mut bytes = Vec::new(); + let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 1, + 1, + image::Rgba([255, 96, 32, 0]), + )); + image + .write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) + .expect("encode transparent PNG"); + bytes + } + + fn write_minimal_direct_game_using_spritesheet(root: &Path) { + std::fs::write( + root.join("game/index.html"), + "", + ) + .expect("index"); + std::fs::write(root.join("game/style.css"), "body {};").expect("style"); + std::fs::write( + root.join("game/game.js"), + "const art = new Image(); art.src = '/assets/art-spritesheet.png'; document.querySelector('#game').getContext('2d').drawImage(art, 0, 0);", + ) + .expect("script"); + } + #[test] fn direct_output_sync_registers_game_files_and_marks_prototype_completed() { let root = tempfile::tempdir().expect("temp dir"); @@ -1059,7 +2623,7 @@ mod tests { let error = sync_direct_codex_project_outputs_at(root.path(), None) .expect_err("missing TaoNier art package must block completion"); - assert!(error.contains("缺少完整已登记的陶泥儿平台美术包")); + assert!(error.contains("缺少可用且已登记的陶泥儿平台美术资源")); let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); assert!(manifest .assets @@ -1076,26 +2640,29 @@ mod tests { } #[test] - fn direct_output_sync_refuses_a_spritesheet_that_is_not_rendered() { + fn direct_output_sync_refuses_code_without_any_platform_art_reference() { let root = tempfile::tempdir().expect("temp dir"); init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步") .expect("init project"); register_direct_taonier_art_package_fixture(root.path()); std::fs::write( root.path().join("game/index.html"), - "", + "", ) .expect("index"); std::fs::write(root.path().join("game/style.css"), "body {}").expect("style"); std::fs::write( root.path().join("game/game.js"), - "const sprite = new Image(); sprite.src = '/assets/art-spritesheet-slices/player.png';", + "const canvas = document.querySelector('canvas'); canvas.getContext('2d').fillRect(0, 0, 10, 10);", ) .expect("script"); let error = sync_direct_codex_project_outputs_at(root.path(), None) - .expect_err("non-rendered spritesheet must block completion"); - assert!(error.contains("未在实际渲染中同时使用陶泥儿背景图")); + .expect_err("code without a platform art reference must block completion"); + assert!( + error.contains("未在源码中引用任何已登记的陶泥儿平台图片"), + "{error}" + ); let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); assert!(manifest .assets @@ -1104,14 +2671,115 @@ mod tests { } #[test] - fn direct_taonier_art_package_rejects_missing_required_slice() { + fn direct_output_sync_accepts_trusted_spec_and_background_without_a_historical_spritesheet() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步") + .expect("init project"); + register_direct_taonier_art_base_fixture(root.path()); + std::fs::write( + root.path().join("game/index.html"), + "", + ) + .expect("index"); + std::fs::write(root.path().join("game/style.css"), "body {}").expect("style"); + std::fs::write( + root.path().join("game/game.js"), + "const background = new Image(); background.src = '/assets/direct-game-background.png'; document.querySelector('#game').getContext('2d').drawImage(background, 0, 0); requestAnimationFrame(()=>{});", + ) + .expect("script"); + + sync_direct_codex_project_outputs_at(root.path(), None) + .expect("trusted TaoNier spec and background must be sufficient for direct completion"); + + let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); + assert_eq!(manifest.versions.len(), 1); + assert!(manifest + .assets + .iter() + .all(|asset| { asset.local_path != DIRECT_CODEX_SPRITESHEET_ASSET_PATH })); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .map(|task| task.status.clone()), + Some(GameCreationAppTaskStatus::Completed) + ); + } + + #[test] + fn direct_taonier_art_package_accepts_a_valid_platform_package_without_slices() { let root = tempfile::tempdir().expect("temp dir"); init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步") .expect("init project"); register_direct_taonier_art_package_fixture(root.path()); - std::fs::remove_file(root.path().join(DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[2].1)) - .expect("remove required slice"); + for (_, path) in DIRECT_CODEX_SPRITESHEET_SLICE_PATHS { + let _ = std::fs::remove_file(root.path().join(path)); + } + let _ = std::fs::remove_file( + root.path() + .join("assets/art-spritesheet-slices/manifest.json"), + ); + let _ = std::fs::remove_file( + root.path() + .join(".agent/runtime/art-spritesheet-contract.json"), + ); - assert!(!direct_taonier_art_package_is_valid(root.path())); + assert!(direct_taonier_art_package_is_valid(root.path())); + } + + #[test] + fn direct_output_sync_accepts_a_complete_spritesheet_without_slices() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + for (_, path) in DIRECT_CODEX_SPRITESHEET_SLICE_PATHS { + let _ = std::fs::remove_file(root.path().join(path)); + } + let _ = std::fs::remove_file( + root.path() + .join("assets/art-spritesheet-slices/manifest.json"), + ); + let _ = std::fs::remove_file( + root.path() + .join(".agent/runtime/art-spritesheet-contract.json"), + ); + write_minimal_direct_game_using_spritesheet(root.path()); + + sync_direct_codex_project_outputs_at(root.path(), None) + .expect("a valid complete spritesheet must be a sufficient direct art path"); + + let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); + assert_eq!(manifest.versions.len(), 1); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .map(|task| task.status.clone()), + Some(GameCreationAppTaskStatus::Completed) + ); + } + + #[test] + fn direct_taonier_art_package_rejects_a_corrupt_or_transparent_primary_png() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-art-integrity", "直连美术完整性") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + let spritesheet = root.path().join(DIRECT_CODEX_SPRITESHEET_ASSET_PATH); + + std::fs::write(&spritesheet, b"not-a-png").expect("write corrupt PNG"); + assert!( + !direct_taonier_art_package_is_valid(root.path()), + "a corrupt platform PNG must fail closed" + ); + + std::fs::write(&spritesheet, tiny_transparent_png()).expect("write transparent PNG"); + assert!( + !direct_taonier_art_package_is_valid(root.path()), + "a fully transparent platform PNG must fail closed" + ); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index a14024431..e6d8ca19f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -24,6 +24,7 @@ pub(crate) use canvas_generation::{ }; pub(in crate::agent) use canvas_generation::{ commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at, + generate_platform_art_asset_with_runtime_options_at, platform_art_generation_error_result_unknown, request_platform_art_asset_with_runtime_options_at, validate_platform_art_png_bytes_with_limits, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 0c1c99497..900c97c42 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -133,7 +133,10 @@ pub(crate) fn needs_platform_art_asset_generation(root: &Path, briefs: &[AgentGr } pub(crate) fn editor_api_key_is_configured() -> bool { - if editor_api_mode() == EditorApiMode::PlatformAccount { + if external_editor_api_credentials_override_is_active() { + return true; + } + if platform_editor_uses_account_routes() { return platform_session_is_available(); } load_game_creator_app_config() @@ -662,7 +665,7 @@ pub(crate) async fn submit_external_generation_request( ) -> Result { let mut request_body = serde_json::from_str::(request_body_json) .map_err(|error| format!("解析平台图片生成请求失败:{error}"))?; - if editor_api_mode() == EditorApiMode::PlatformAccount { + if platform_editor_uses_account_routes() { let inputs = request_body.as_object_mut().and_then(|body| { body.entry("generationInputs") .or_insert_with(|| serde_json::json!({})) 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 b6953e4a3..e7597e6ad 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 @@ -517,10 +517,6 @@ fn response_stream_finalization_commits_exactly_one_canonical_assistant() { #[test] fn non_stream_professional_final_reply_remains_queryable_after_later_project_revision() { let _config_guard = crate::tests::write_test_local_config("{}".to_string()); - assert!( - !GameCreatorLlmConfig::default().stream, - "the production default exercises the non-stream final-reply path" - ); let project = tempfile::tempdir().expect("create non-stream professional reply project"); let root = project.path(); init_local_game_project_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index 5b6c82f2e..4ddb4fe01 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -260,9 +260,8 @@ pub(crate) use entrypoints::{ chat_with_game_creator_role_agent_runtime_for_session_at, chat_with_game_creator_role_agent_stream_at, chat_with_game_creator_role_agent_stream_for_session_at, - configure_game_creator_manifest_invalidation_event_sink, + configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress, emit_game_creator_agent_runtime_update, game_creator_agent_runtime_update_event, - emit_direct_game_creator_progress, generate_local_game_draft_at, read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at, set_game_creator_agent_runtime_update_app_handle, diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 75f8bb086..6b7143b93 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -1,4 +1,363 @@ use super::*; +use std::future::Future; + +const PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_NAME: &str = "external-editor-api.json"; +const PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES: u64 = 8 * 1024; +const DIRECT_EXTERNAL_EDITOR_API_KEY_NAME: &str = "陶泥儿 AGC 直连客户端(本机)"; +const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE: &str = + "private-external-editor-credential-storage-preparation-failed"; +const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE: &str = + "private-external-editor-credential-persistence-failed"; + +/// A task-scoped External Editor credential used only by the direct Codex +/// runtime. It never changes the GUI account session and deliberately keeps +/// the key private: callers may use it to make authenticated requests but may +/// not format or serialize this value for diagnostics. +#[derive(Clone)] +pub(crate) struct ExternalEditorApiCredentials { + api_base_url: String, + api_key: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct PrivateExternalEditorApiKeyFile { + api_key: String, + #[serde(skip_serializing_if = "Option::is_none")] + base_url: Option, +} + +tokio::task_local! { + static EXTERNAL_EDITOR_API_CREDENTIALS_OVERRIDE: ExternalEditorApiCredentials; +} + +fn active_external_editor_api_credentials() -> Option { + EXTERNAL_EDITOR_API_CREDENTIALS_OVERRIDE + .try_with(Clone::clone) + .ok() +} + +pub(crate) async fn with_external_editor_api_credentials( + credentials: ExternalEditorApiCredentials, + operation: impl Future, +) -> T { + EXTERNAL_EDITOR_API_CREDENTIALS_OVERRIDE + .scope(credentials, operation) + .await +} + +fn private_external_editor_api_key_path() -> Result { + let home = std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .ok_or_else(|| "无法定位当前用户的私有开发者 Key 目录".to_string())?; + Ok(home + .join(".config") + .join("genarrative") + .join(PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_NAME)) +} + +pub(crate) fn normalize_external_editor_api_base_url(value: &str) -> Result { + let value = value.trim().trim_end_matches('/'); + let parsed = + url::Url::parse(value).map_err(|_| "陶泥儿 External Editor 地址无效".to_string())?; + if !matches!(parsed.scheme(), "http" | "https") + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || parsed.path() != "/" + { + return Err("陶泥儿 External Editor 地址必须是纯 HTTP(S) origin".to_string()); + } + let host = parsed + .host_str() + .ok_or_else(|| "陶泥儿 External Editor 地址缺少 host".to_string())?; + let production = matches!(host, "www.genarrative.world" | "dev.genarrative.world"); + let loopback = host == "localhost" + || host == "127.0.0.1" + || host + .parse::() + .is_ok_and(|ip| ip.is_loopback()); + if !production && !(cfg!(debug_assertions) && loopback && parsed.scheme() == "http") { + return Err("陶泥儿 External Editor 地址不在受信任白名单内".to_string()); + } + Ok(value.to_string()) +} + +fn normalize_external_editor_api_key(value: &str) -> Result { + let value = value.trim(); + if !value.starts_with("tnr_sk_") + || value.len() > 1024 + || value + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + { + return Err("本机陶泥儿开发者 API Key 无效,请重新登录客户端后重试".to_string()); + } + Ok(value.to_string()) +} + +fn private_external_editor_api_credentials_from_file_at( + path: &Path, +) -> Result, String> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!("读取本机陶泥儿开发者 Key 配置失败:{error}")); + } + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("本机陶泥儿开发者 Key 配置必须是普通文件".to_string()); + } + if metadata.len() > PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES { + return Err("本机陶泥儿开发者 Key 配置过大,已拒绝读取".to_string()); + } + #[cfg(windows)] + secure_windows_game_creator_path_for_current_user(path, false, false)?; + let content = fs::read_to_string(path) + .map_err(|error| format!("读取本机陶泥儿开发者 Key 配置失败:{error}"))?; + let parsed = serde_json::from_str::(&content) + .map_err(|_| "本机陶泥儿开发者 Key 配置格式无效,请重新登录客户端后重试".to_string())?; + let api_key = normalize_external_editor_api_key(&parsed.api_key)?; + let api_base_url = normalize_external_editor_api_base_url( + parsed + .base_url + .as_deref() + .unwrap_or(DEFAULT_CANVAS_SYNC_API_BASE_URL), + )?; + Ok(Some(ExternalEditorApiCredentials { + api_base_url, + api_key, + })) +} + +fn ensure_plain_private_external_editor_directory( + path: &Path, + label: &str, +) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!("本机陶泥儿开发者凭据{label}必须是普通目录")); + } + Ok(false) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let created = match fs::create_dir(path) { + Ok(()) => true, + Err(create_error) if create_error.kind() == std::io::ErrorKind::AlreadyExists => { + false + } + Err(create_error) => { + return Err(format!( + "创建本机陶泥儿开发者凭据{label}失败:{create_error}" + )); + } + }; + let metadata = fs::symlink_metadata(path).map_err(|metadata_error| { + format!("读取本机陶泥儿开发者凭据{label}失败:{metadata_error}") + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!("本机陶泥儿开发者凭据{label}必须是普通目录")); + } + Ok(created) + } + Err(error) => Err(format!("读取本机陶泥儿开发者凭据{label}失败:{error}")), + } +} + +/// Prepares the exact private directory before a one-time remote developer key +/// is requested. Existing directories are verified, not adopted; only the +/// directory created by this invocation may have its Windows owner initialized. +fn prepare_private_external_editor_api_credentials_parent_dir_at( + path: &Path, +) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| "本机陶泥儿开发者凭据配置缺少父目录".to_string())?; + let container = parent + .parent() + .ok_or_else(|| "本机陶泥儿开发者凭据配置缺少上级目录".to_string())?; + ensure_plain_private_external_editor_directory(container, "上级目录")?; + let parent_created = ensure_plain_private_external_editor_directory(parent, "目录")?; + #[cfg(windows)] + if parent_created { + initialize_windows_game_creator_directory_owner_for_current_user(parent)?; + } else { + secure_windows_game_creator_path_for_current_user(parent, true, false)?; + } + #[cfg(unix)] + if parent_created { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("收紧本机陶泥儿开发者凭据目录权限失败:{error}"))?; + } + Ok(()) +} + +pub(crate) fn private_external_editor_credentials_storage_preparation_failed(error: &str) -> bool { + error.starts_with(PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE) +} + +pub(crate) fn private_external_editor_credentials_persistence_failed(error: &str) -> bool { + error.starts_with(PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE) +} + +fn write_private_external_editor_api_credentials_at( + path: &Path, + credentials: &ExternalEditorApiCredentials, +) -> Result<(), String> { + prepare_private_external_editor_api_credentials_parent_dir_at(path)?; + let parent = path + .parent() + .ok_or_else(|| "本机陶泥儿开发者 Key 配置缺少父目录".to_string())?; + let parent_metadata = fs::symlink_metadata(parent) + .map_err(|error| format!("读取本机陶泥儿开发者 Key 目录失败:{error}"))?; + if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() { + return Err("本机陶泥儿开发者 Key 目录必须是普通目录".to_string()); + } + if path.exists() { + return Err("本机陶泥儿开发者 Key 已存在,拒绝覆盖".to_string()); + } + let body = serde_json::to_string_pretty(&PrivateExternalEditorApiKeyFile { + api_key: credentials.api_key.clone(), + base_url: Some(credentials.api_base_url.clone()), + }) + .map_err(|error| format!("序列化本机陶泥儿开发者 Key 配置失败:{error}"))?; + let temporary = parent.join(format!( + ".{PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_NAME}.tmp.{}.{}", + std::process::id(), + unix_millis(), + )); + let mut options = fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .map_err(|error| format!("创建本机陶泥儿开发者 Key 临时文件失败:{error}"))?; + let write_result = file + .write_all(format!("{body}\n").as_bytes()) + .and_then(|_| file.sync_all()); + drop(file); + if let Err(error) = write_result { + let _ = fs::remove_file(&temporary); + return Err(format!("写入本机陶泥儿开发者 Key 临时文件失败:{error}")); + } + #[cfg(windows)] + initialize_windows_game_creator_file_owner_for_current_user(&temporary)?; + match fs::hard_link(&temporary, path) { + Ok(()) => { + let _ = fs::remove_file(&temporary); + } + Err(error) => { + let _ = fs::remove_file(&temporary); + return Err(match error.kind() { + std::io::ErrorKind::AlreadyExists => { + "本机陶泥儿开发者 Key 已被另一个进程写入,请直接重试".to_string() + } + _ => format!("安全保存本机陶泥儿开发者 Key 失败:{error}"), + }); + } + } + #[cfg(windows)] + secure_windows_game_creator_path_for_current_user(path, false, true)?; + Ok(()) +} + +async fn create_private_external_editor_api_credentials_from_platform_session( +) -> Result { + let session = current_platform_session().ok_or_else(|| { + "authentication-required: 未找到本机陶泥儿开发者 Key;请先在客户端登录一次以创建本机 Key" + .to_string() + })?; + let api_base_url = normalize_external_editor_api_base_url(&session.api_base_url)?; + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("创建本机开发者 Key 客户端失败:{error}"))?; + let response = client + .post(format!("{api_base_url}/api/profile/api-keys")) + .bearer_auth(&session.access_token) + .json(&serde_json::json!({ "name": DIRECT_EXTERNAL_EDITOR_API_KEY_NAME })) + .send() + .await + .map_err(|error| { + format!("创建本机陶泥儿开发者 Key 未取得确定响应;不会自动重试:{error}") + })?; + let status = response.status(); + if !status.is_success() { + return Err(match status { + reqwest::StatusCode::UNAUTHORIZED => { + "authentication-required: 陶泥儿登录已失效,无法创建本机开发者 Key".to_string() + } + reqwest::StatusCode::FORBIDDEN => { + "permission-denied: 当前陶泥儿账号无权创建本机开发者 Key".to_string() + } + _ => format!("创建本机陶泥儿开发者 Key 失败:HTTP {}", status.as_u16()), + }); + } + let payload = response + .json::() + .await + .map_err(|_| "创建本机陶泥儿开发者 Key 响应格式无效".to_string())?; + let api_key = payload + .get("apiKey") + .or_else(|| payload.pointer("/data/apiKey")) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "创建本机陶泥儿开发者 Key 响应缺少一次性 Key".to_string())?; + Ok(ExternalEditorApiCredentials { + api_base_url, + api_key: normalize_external_editor_api_key(api_key)?, + }) +} + +/// Reads the user-private External Editor key used by the direct Runtime. If +/// it does not exist, an already authenticated GUI account may create exactly +/// one key and write it to private local storage. The GUI login token is never +/// copied into the file or passed to the later isolated Runtime. +pub(crate) async fn ensure_private_external_editor_api_credentials( +) -> Result { + let path = private_external_editor_api_key_path()?; + if let Some(credentials) = private_external_editor_api_credentials_from_file_at(&path)? { + return Ok(credentials); + } + prepare_private_external_editor_api_credentials_parent_dir_at(&path).map_err(|_| { + format!( + "{PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE}: 本机开发者凭据存储目录未安全初始化;未创建远端凭据" + ) + })?; + // Another local client may have completed the atomic write while this call + // prepared the directory. Re-read before creating a distinct remote key. + if let Some(credentials) = private_external_editor_api_credentials_from_file_at(&path)? { + return Ok(credentials); + } + let credentials = + create_private_external_editor_api_credentials_from_platform_session().await?; + if write_private_external_editor_api_credentials_at(&path, &credentials).is_err() { + return Err(format!( + "{PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE}: 本机开发者凭据已创建但未能安全保存;请在账户开发者凭据页面撤销后重试" + )); + } + Ok(credentials) +} + +pub(crate) fn platform_editor_uses_account_routes() -> bool { + active_external_editor_api_credentials().is_none() + && editor_api_mode() == EditorApiMode::PlatformAccount +} + +pub(crate) fn external_editor_api_credentials_override_is_active() -> bool { + active_external_editor_api_credentials().is_some() +} pub(crate) fn upload_local_asset_at( root: &Path, @@ -779,6 +1138,9 @@ pub(crate) async fn resolve_external_asset_signed_url( pub(crate) fn resolve_canvas_sync_api_base_url( api_base_url: Option, ) -> Result { + if let Some(credentials) = active_external_editor_api_credentials() { + return Ok(credentials.api_base_url); + } if editor_api_mode() == EditorApiMode::PlatformAccount { return current_platform_session() .map(|session| session.api_base_url) @@ -797,6 +1159,9 @@ pub(crate) fn resolve_canvas_sync_api_base_url( } pub(crate) fn resolve_canvas_sync_api_key(api_key: Option) -> Result { + if let Some(credentials) = active_external_editor_api_credentials() { + return Ok(credentials.api_key); + } if editor_api_mode() == EditorApiMode::PlatformAccount { return current_platform_session() .map(|session| session.access_token) @@ -817,6 +1182,9 @@ pub(crate) fn resolve_canvas_sync_api_credentials( api_base_url: Option, api_key: Option, ) -> Result<(String, String, Option), String> { + if let Some(credentials) = active_external_editor_api_credentials() { + return Ok((credentials.api_base_url, credentials.api_key, None)); + } if editor_api_mode() == EditorApiMode::PlatformAccount { let session = current_platform_session() .ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string())?; @@ -836,7 +1204,7 @@ pub(crate) fn resolve_canvas_sync_api_credentials( pub(crate) fn resolve_platform_editor_api_route(external_route: &str) -> String { resolve_platform_editor_api_route_for_mode( external_route, - editor_api_mode() == EditorApiMode::PlatformAccount, + platform_editor_uses_account_routes(), ) } @@ -859,28 +1227,30 @@ fn resolve_platform_editor_api_route_for_mode( pub(crate) fn resolve_platform_generation_status_route(operation_id: &str) -> String { resolve_platform_generation_status_route_for_mode( operation_id, - editor_api_mode() == EditorApiMode::PlatformAccount, + platform_editor_uses_account_routes(), ) } pub(crate) fn editor_api_authentication_error() -> String { - match editor_api_mode() { - EditorApiMode::PlatformAccount => { + match (platform_editor_uses_account_routes(), editor_api_mode()) { + (false, _) => "authentication-required: External Editor API Key 无效或权限不足".to_string(), + (true, EditorApiMode::PlatformAccount) => { "authentication-required: 陶泥儿登录已失效,请重新登录".to_string() } - EditorApiMode::ExternalDeveloper => { - "authentication-required: External Editor API Key 无效或权限不足".to_string() + (true, EditorApiMode::ExternalDeveloper) => { + unreachable!("external developer mode never uses account routes") } } } pub(crate) fn editor_api_authorization_error() -> String { - match editor_api_mode() { - EditorApiMode::PlatformAccount => { + match (platform_editor_uses_account_routes(), editor_api_mode()) { + (false, _) => "permission-denied: External Editor API Key 权限不足".to_string(), + (true, EditorApiMode::PlatformAccount) => { "permission-denied: 当前陶泥儿账号没有执行此操作的权限".to_string() } - EditorApiMode::ExternalDeveloper => { - "permission-denied: External Editor API Key 权限不足".to_string() + (true, EditorApiMode::ExternalDeveloper) => { + unreachable!("external developer mode never uses account routes") } } } @@ -1260,6 +1630,90 @@ mod tests { ); } + #[test] + fn private_external_editor_credentials_require_a_trusted_origin_and_key_shape() { + assert_eq!( + normalize_external_editor_api_base_url("https://dev.genarrative.world/") + .expect("trusted dev origin"), + "https://dev.genarrative.world" + ); + assert!(normalize_external_editor_api_base_url("https://untrusted.example.test").is_err()); + assert!( + normalize_external_editor_api_base_url("https://dev.genarrative.world/path").is_err() + ); + assert_eq!( + normalize_external_editor_api_key(" tnr_sk_fixture_123 ").expect("fixture API key"), + "tnr_sk_fixture_123" + ); + assert!(normalize_external_editor_api_key("plain-token").is_err()); + assert!(normalize_external_editor_api_key("tnr_sk_has whitespace").is_err()); + } + + #[test] + fn private_external_editor_credentials_storage_failure_markers_are_closed_and_stable() { + assert!( + private_external_editor_credentials_storage_preparation_failed( + "private-external-editor-credential-storage-preparation-failed: safe public detail" + ) + ); + assert!(private_external_editor_credentials_persistence_failed( + "private-external-editor-credential-persistence-failed: safe public detail" + )); + assert!( + !private_external_editor_credentials_storage_preparation_failed("ordinary failure") + ); + assert!(!private_external_editor_credentials_persistence_failed( + "ordinary failure" + )); + } + + #[cfg(windows)] + #[test] + fn newly_created_private_external_editor_credentials_directory_is_owned_by_token_user() { + let root = tempfile::tempdir().expect("temp dir"); + let path = root + .path() + .join("config") + .join("genarrative") + .join(PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_NAME); + + prepare_private_external_editor_api_credentials_parent_dir_at(&path) + .expect("prepare private credential directory"); + let parent = path.parent().expect("credential directory"); + secure_windows_game_creator_path_for_current_user(parent, true, false) + .expect("new credential directory must be owned by TokenUser"); + } + + #[tokio::test] + async fn direct_runtime_private_credentials_force_external_v1_routes_without_session_copy() { + let credentials = ExternalEditorApiCredentials { + api_base_url: "https://dev.genarrative.world".to_string(), + api_key: "tnr_sk_fixture_123".to_string(), + }; + with_external_editor_api_credentials(credentials, async { + assert!(external_editor_api_credentials_override_is_active()); + assert!(!platform_editor_uses_account_routes()); + assert_eq!( + resolve_canvas_sync_api_base_url(None).expect("scoped API base URL"), + "https://dev.genarrative.world" + ); + assert_eq!( + resolve_canvas_sync_api_key(None).expect("scoped API key"), + "tnr_sk_fixture_123" + ); + assert_eq!( + resolve_platform_editor_api_route("/api/external/v1/editor/projects"), + "/api/external/v1/editor/projects" + ); + assert_eq!( + resolve_platform_generation_status_route("operation-1"), + "/api/external/v1/generations/operation-1" + ); + }) + .await; + assert!(!external_editor_api_credentials_override_is_active()); + } + fn read_asset_test_request(stream: &mut std::net::TcpStream) { stream .set_read_timeout(Some(Duration::from_secs(2))) diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser.rs b/apps/ai-game-creator-shell/src-tauri/src/browser.rs index aded90b99..7b605276b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser.rs @@ -16,7 +16,9 @@ pub use model::{ BrowserValidationInput, BrowserValidationResult, BrowserValidationViewport, BrowserViewportValidationResult, DiscoveredBrowser, DiscoveredBrowserKind, }; -pub use process::validate_local_preview_in_browser; +pub use process::{ + validate_local_preview_in_browser, validate_local_preview_in_browser_with_interaction, +}; pub(crate) use playtest::browser_playtest_scenario_fingerprint; diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/capture.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/capture.rs index c9120792b..ee5048997 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/capture.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/capture.rs @@ -156,6 +156,7 @@ pub(super) struct PageSnapshot { pub(super) dom_character_count: usize, pub(super) expected_text_matches: Vec, pub(super) canvases: Vec, + pub(super) canvas_or_webgl_image_paths: Vec, pub(super) blocked_popup_count: u32, pub(super) blocked_download_count: u32, pub(super) blocked_permission_count: u32, @@ -612,6 +613,7 @@ pub(super) fn build_snapshot_script(expected_text: &[String]) -> Result text.includes(value)), canvases, + canvasOrWebglImagePaths: Array.from(new Set(security.canvasOrWebglImagePaths || [])).slice(0, 32), blockedPopupCount: security.blockedPopupCount || 0, blockedDownloadCount: security.blockedDownloadCount || 0, blockedPermissionCount: security.blockedPermissionCount || 0, @@ -702,6 +704,47 @@ pub(super) const RESTRICTION_SCRIPT: &str = r#" })(); "#; +pub(super) const RENDERED_IMAGE_CAPTURE_SCRIPT: &str = r#" +(() => { + const state = window.__GENARRATIVE_PREVIEW_VALIDATION__; + if (!state) return; + state.canvasOrWebglImagePaths = []; + const recordCanvasOrWebglImage = (candidate) => { + const source = candidate && (candidate.currentSrc || candidate.src); + if (typeof source !== 'string' || source.length === 0) return; + try { + const url = new URL(source, location.href); + if (url.origin !== location.origin || !url.pathname) return; + if (!state.canvasOrWebglImagePaths.includes(url.pathname) && state.canvasOrWebglImagePaths.length < 32) { + state.canvasOrWebglImagePaths.push(url.pathname); + } + } catch (_) {} + }; + const wrapImageRenderMethod = (prototype, method) => { + const original = prototype && prototype[method]; + if (typeof original !== 'function') return; + try { + Object.defineProperty(prototype, method, { + value: function(...args) { + for (const candidate of args) recordCanvasOrWebglImage(candidate); + return original.apply(this, args); + }, + configurable: true, + writable: true + }); + } catch (_) {} + }; + wrapImageRenderMethod(globalThis.CanvasRenderingContext2D?.prototype, 'drawImage'); + for (const prototype of [ + globalThis.WebGLRenderingContext?.prototype, + globalThis.WebGL2RenderingContext?.prototype + ]) { + wrapImageRenderMethod(prototype, 'texImage2D'); + wrapImageRenderMethod(prototype, 'texSubImage2D'); + } +})(); +"#; + fn remote_object_text(object: &RemoteObject) -> String { if let Some(value) = &object.value { if let Some(value) = value.as_str() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/cdp.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/cdp.rs index e56a8a0ad..71cc23fa0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/cdp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/cdp.rs @@ -14,7 +14,8 @@ use url::Url; use super::capture::{ build_snapshot_script, canvas_validation_diagnostic, sanitize_url, start_capture_tasks, - truncate_chars, BrowserCanvasSnapshot, PageSnapshot, RESTRICTION_SCRIPT, + truncate_chars, BrowserCanvasSnapshot, PageSnapshot, RENDERED_IMAGE_CAPTURE_SCRIPT, + RESTRICTION_SCRIPT, }; use super::evidence::write_atomic; use super::model::{ @@ -36,6 +37,7 @@ pub(super) async fn run_browser_validation( discovered: &DiscoveredBrowser, preview_url: &Url, input: &BrowserValidationInput, + advisory_interaction: bool, ) -> Result { browser .execute(SetDownloadBehaviorParams::new( @@ -51,7 +53,8 @@ pub(super) async fn run_browser_validation( let mut viewport_results = Vec::with_capacity(REQUIRED_VIEWPORTS.len()); let mut playtest = None; for viewport in REQUIRED_VIEWPORTS { - let outcome = validate_viewport(browser, preview_url, input, viewport).await?; + let outcome = + validate_viewport(browser, preview_url, input, viewport, advisory_interaction).await?; if outcome.playtest.is_some() { playtest = outcome.playtest; } @@ -116,6 +119,7 @@ async fn validate_viewport( preview_url: &Url, input: &BrowserValidationInput, viewport: BrowserValidationViewport, + advisory_interaction: bool, ) -> Result { let (width, height, mobile) = viewport.dimensions(); let page = browser @@ -139,6 +143,14 @@ async fn validate_viewport( page.evaluate_on_new_document(RESTRICTION_SCRIPT) .await .map_err(|error| format!("安装浏览器限制脚本失败:{error}"))?; + page.evaluate_on_new_document(RENDERED_IMAGE_CAPTURE_SCRIPT) + .await + .map_err(|error| { + format!( + "安装 {} 平台素材渲染观察失败:{error}", + viewport.file_stem() + ) + })?; let tasks = start_capture_tasks(&page, preview_url).await?; let navigation = tokio::time::timeout(BROWSER_TIMEOUT, page.goto(preview_url.as_str())) @@ -151,6 +163,12 @@ async fn validate_viewport( } tokio::time::sleep(Duration::from_millis(input.settle_ms)).await; + let advisory_interaction_diagnostic = if advisory_interaction { + Some(run_advisory_interaction_probe(&page).await) + } else { + None + }; + let playtest = if viewport == BrowserValidationViewport::Desktop { match input.playtest_scenario { Some(scenario) => Some(run_desktop_playtest(&page, scenario).await), @@ -245,7 +263,15 @@ async fn validate_viewport( if let Some(diagnostic) = canvas_validation_diagnostic(&canvases) { diagnostics.push(diagnostic.to_string()); } - + if !snapshot.canvas_or_webgl_image_paths.is_empty() { + diagnostics.push(format!( + "[advisory] rendered-local-images:{}", + snapshot.canvas_or_webgl_image_paths.join("|") + )); + } + if let Some(diagnostic) = advisory_interaction_diagnostic { + diagnostics.push(format!("[advisory] {diagnostic}")); + } Ok(BrowserViewportValidationOutcome { result: BrowserViewportValidationResult { viewport, @@ -269,9 +295,61 @@ async fn validate_viewport( blocked_permission_count: snapshot.blocked_permission_count, blocked_service_worker_count: snapshot.blocked_service_worker_count, screenshot_path, - passed: diagnostics.is_empty(), + passed: diagnostics.is_empty() + || diagnostics + .iter() + .all(|diagnostic| diagnostic.starts_with("[advisory]")), diagnostics, }, playtest, }) } + +async fn run_advisory_interaction_probe(page: &chromiumoxide::Page) -> String { + const PROBE: &str = r#"function() { + const candidates = Array.from(document.querySelectorAll( + 'button:not([disabled]), a[href], input[type="button"]:not([disabled]), input[type="submit"]:not([disabled])' + )); + const item = candidates.find((element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== 'none' && style.visibility !== 'hidden' && + rect.width > 0 && rect.height > 0 && + element.getAttribute('aria-disabled') !== 'true'; + }); + if (!item) return JSON.stringify({ ok: false, reason: 'no-visible-enabled-control' }); + item.scrollIntoView({ block: 'center', inline: 'center' }); + return JSON.stringify({ ok: true, tag: item.tagName.toLowerCase() }); + }"#; + let mut elements = match page.find_elements( + "button:not([disabled]), a[href], input[type=button]:not([disabled]), input[type=submit]:not([disabled])", + ) + .await + { + Ok(elements) => elements, + Err(_) => return "真实交互探针无法查询可见控件".to_string(), + }; + for element in elements.drain(..).take(8) { + let evaluated = match element.call_js_fn(PROBE, false).await { + Ok(value) => value, + Err(_) => continue, + }; + let Some(value) = evaluated.result.value else { + continue; + }; + let Some(text) = value.as_str() else { + continue; + }; + let Ok(probe) = serde_json::from_str::(text) else { + continue; + }; + if probe.get("ok").and_then(serde_json::Value::as_bool) != Some(true) { + continue; + } + return match element.click().await { + Ok(_) => "真实交互探针已点击一个可见启用控件".to_string(), + Err(_) => "真实交互探针发现控件但点击未完成".to_string(), + }; + } + "真实交互探针未发现可见启用控件".to_string() +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs index 0223b16c9..7366df54d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs @@ -34,6 +34,17 @@ pub(super) fn create_browser_process_temp_dir() -> Result { pub async fn validate_local_preview_in_browser( input: BrowserValidationInput, +) -> Result { + validate_local_preview_in_browser_with_interaction(input, false).await +} + +/// Runs the same restricted Chromium validation, optionally adding one bounded, +/// advisory click on a visible enabled control. The interaction is evidence for +/// the direct Codex reviewer; it is not a fixed gameplay contract and never +/// changes the deterministic browser safety checks. +pub async fn validate_local_preview_in_browser_with_interaction( + input: BrowserValidationInput, + advisory_interaction: bool, ) -> Result { let preview_url = validate_input(&input)?; prepare_evidence_root(&input.evidence_root)?; @@ -77,8 +88,14 @@ pub async fn validate_local_preview_in_browser( } }); - let validation = - run_browser_validation(&browser, &browser_executable, &preview_url, &input).await; + let validation = run_browser_validation( + &browser, + &browser_executable, + &preview_url, + &input, + advisory_interaction, + ) + .await; let close_result = browser .close() diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs index 086f410d2..ef4709782 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs @@ -12,7 +12,7 @@ use url::Url; use super::capture::{ canvas_validation_diagnostic, classify_canvas_pixel_probe, preview_fetch_enable_params, - BrowserCanvasSnapshot, + BrowserCanvasSnapshot, RENDERED_IMAGE_CAPTURE_SCRIPT, }; use super::discovery::system_browser_candidates; use super::evidence::browser_validation_result_for_report; @@ -70,6 +70,16 @@ fn canvas_snapshot( } } +#[test] +fn rendered_image_capture_script_observes_canvas_and_webgl_without_cross_origin_urls() { + assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("CanvasRenderingContext2D")); + assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("drawImage")); + assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("texImage2D")); + assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("texSubImage2D")); + assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("url.origin !== location.origin")); + assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("url.pathname")); +} + fn generic_state(sequence: u64, phase: BrowserPlaytestPhase, level: u64) -> PlayableWebGameState { PlayableWebGameState { sequence, @@ -2362,20 +2372,41 @@ async fn real_chrome_validation_smoke() { let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview"); let port = listener.local_addr().expect("preview address").port(); listener.set_nonblocking(true).expect("nonblocking preview"); + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 2, + 2, + image::Rgba([255, 96, 32, 255]), + )) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .expect("encode browser fixture PNG"); let (stop_tx, stop_rx) = mpsc::channel(); let server = thread::spawn(move || { - let html = br#"Browser Probe
Expected local preview
"#; + let html = br#"Browser Probe
Expected local preview
side only"#; while stop_rx.try_recv().is_err() { match listener.accept() { Ok((mut stream, _)) => { let mut request = [0_u8; 2048]; - let _ = stream.read(&mut request); + let count = stream.read(&mut request).unwrap_or_default(); + let request = String::from_utf8_lossy(&request[..count]); + let is_png = request.starts_with("GET /drawn.png ") + || request.starts_with("GET /side-only.png "); + let body = if is_png { + png.as_slice() + } else { + html.as_slice() + }; + let content_type = if is_png { + "image/png" + } else { + "text/html; charset=utf-8" + }; let headers = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - html.len() + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() ); let _ = stream.write_all(headers.as_bytes()); - let _ = stream.write_all(html); + let _ = stream.write_all(body); } Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { thread::sleep(Duration::from_millis(10)); @@ -2389,7 +2420,7 @@ async fn real_chrome_validation_smoke() { url: format!("http://127.0.0.1:{port}/"), viewports: REQUIRED_VIEWPORTS.to_vec(), expected_text: vec!["Expected local preview".to_string()], - settle_ms: 100, + settle_ms: 300, fail_on_console_error: true, playtest_scenario: None, evidence_root: evidence.path().join("evidence"), @@ -2425,5 +2456,12 @@ async fn real_chrome_validation_smoke() { ); assert_eq!(viewport_result.canvases[1].non_empty, Some(false)); assert_eq!(viewport_result.canvases[2].non_empty, Some(true)); + let rendered_diagnostic = viewport_result + .diagnostics + .iter() + .find(|diagnostic| diagnostic.starts_with("[advisory] rendered-local-images:")) + .expect("rendered image observation"); + assert!(rendered_diagnostic.contains("/drawn.png")); + assert!(!rendered_diagnostic.contains("/side-only.png")); } } 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 5a6922531..25343cd2f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -213,7 +213,7 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi model: None, api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(), reasoning_effort: DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT.to_string(), - stream: false, + stream: true, web_search_enabled: false, context_window_tokens: DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS, auto_compact_token_limit: DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT, @@ -835,6 +835,16 @@ pub(crate) fn initialize_windows_game_creator_file_owner_for_current_user( secure_windows_game_creator_path_for_current_user_with_owner_policy(path, false, true, true) } +/// Initialize ownership only for a directory that was created by the current +/// operation. Existing directories must use the strict verifier instead, so a +/// foreign-owned path is never silently adopted. +#[cfg(windows)] +pub(crate) fn initialize_windows_game_creator_directory_owner_for_current_user( + path: &Path, +) -> Result<(), String> { + secure_windows_game_creator_path_for_current_user_with_owner_policy(path, true, true, true) +} + #[cfg(windows)] pub(crate) fn windows_private_dacl_security_information( initialize_owner: bool, 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 5783fa333..651654b6f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1319,7 +1319,7 @@ impl Default for GameCreatorLlmConfig { model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(), api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(), reasoning_effort: DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT.to_string(), - stream: false, + stream: true, web_search_enabled: false, context_window_tokens: DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS, auto_compact_token_limit: DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT, diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs index 7b9ec1341..c76a3deb3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs @@ -182,6 +182,62 @@ pub(crate) fn platform_session_service_identity() -> Option { .map(|snapshot| format!("{}\nuser:{}", snapshot.api_base_url, snapshot.user_id)) } +#[cfg(test)] +static PLATFORM_SESSION_TEST_LOCK: OnceLock> = OnceLock::new(); + +#[cfg(test)] +pub(crate) struct TestPlatformSessionGuard { + _isolation: std::sync::MutexGuard<'static, ()>, + previous: Option, +} + +#[cfg(test)] +impl Drop for TestPlatformSessionGuard { + fn drop(&mut self) { + let mut current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *current = self + .previous + .take() + .expect("platform session test guard must restore its prior state"); + } +} + +/// Installs a scoped platform-account session for tests that must exercise the +/// production-default client authentication path. The previous process state +/// is restored on drop so the fixture cannot leak credentials or account +/// identity to another test. +#[cfg(test)] +pub(crate) fn install_test_platform_session( + user_id: &str, + access_token: &str, + api_base_url: &str, +) -> TestPlatformSessionGuard { + let isolation = PLATFORM_SESSION_TEST_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let previous = std::mem::take(&mut *current); + *current = PlatformSessionState { + generation: 1, + snapshot: Some(PlatformSessionSnapshot { + user_id: user_id.to_string(), + access_token: access_token.to_string(), + api_base_url: api_base_url.to_string(), + generation: 1, + }), + }; + drop(current); + TestPlatformSessionGuard { + _isolation: isolation, + previous: Some(previous), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs index 8a5e21147..ec5882bd2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs @@ -145,7 +145,9 @@ pub(super) fn process_session_command_builder( #[cfg(windows)] if is_npm_launch { let node_executable = launch.executable.to_string_lossy(); - let node_executable = node_executable.strip_prefix(r"\\?\").unwrap_or(&node_executable); + let node_executable = node_executable + .strip_prefix(r"\\?\") + .unwrap_or(&node_executable); command.env("npm_node_execpath", node_executable); command.env("NODE", node_executable); command.env("npm_config_node_gyp", ""); @@ -1187,9 +1189,7 @@ fn drain_process_session_output( let before = pending.len(); ansi.push(*byte, &mut pending); let visible_bytes = pending.len().saturating_sub(before); - if visible_bytes > 0 - && !matches!(pending.last(), Some(b'\n' | b'\r')) - { + if visible_bytes > 0 && !matches!(pending.last(), Some(b'\n' | b'\r')) { pending_logical_line_bytes = pending_logical_line_bytes.saturating_add(visible_bytes); if pending_logical_line_bytes > PROCESS_SESSION_MAX_PENDING_LINE_BYTES { @@ -1225,8 +1225,8 @@ fn drain_process_session_output( const PROCESS_SESSION_PTY_COLS: usize = 120; match pending.last() { Some(b'\r') => { - conpty_soft_wrap = pending_logical_line_bytes - >= PROCESS_SESSION_PTY_COLS; + conpty_soft_wrap = + pending_logical_line_bytes >= PROCESS_SESSION_PTY_COLS; if !conpty_soft_wrap { pending_logical_line_bytes = 0; } 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 778ae1180..0ef277145 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 @@ -107,14 +107,50 @@ fn agent_mode_defaults_to_codex_app_server_and_preserves_explicit_modes() { default_config.agent_mode, GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER ); + assert!( + default_config.llm.stream, + "新的全局 LLM 配置默认使用流式响应" + ); + + let inherited_agent_llm = + resolve_game_creator_llm_config_for_agent(&default_config, "design-director"); + assert!( + inherited_agent_llm.stream, + "未覆盖时 Agent 必须继承全局流式默认" + ); + + let mut explicitly_non_streaming_config = default_config.clone(); + explicitly_non_streaming_config.agent_llm.insert( + "design-director".to_string(), + GameCreatorLlmConfigFile { + stream: Some(false), + ..GameCreatorLlmConfigFile::default() + }, + ); + assert!( + !resolve_game_creator_llm_config_for_agent( + &explicitly_non_streaming_config, + "design-director", + ) + .stream, + "Agent 显式关闭流式请求时必须覆盖全局默认" + ); 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"); + fs::write( + &config_path, + r#"{"agentMode":"provider","llm":{"stream":false}}"#, + ) + .expect("write explicit provider mode and non-stream override"); 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!( + !config.llm.stream, + "已有配置显式关闭流式请求时必须保留该选择" + ); assert_eq!( normalize_game_creator_agent_mode(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER) .expect("app-server mode"), 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 cd3c836bc..9fbbb9059 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 @@ -1729,6 +1729,58 @@ export function isMudPointInsufficientRuntimeError(message: string) { ); } +function directCodexDiagnosticFailureDetail(message: string) { + const trimmed = message.trim(); + const match = + /^direct-codex-failure:v1 stage=(request|art-preparation|code-generation|browser-validation|version-registration) retryable=(true|false) summary=(.+?);建议:(.+?);(?:已保存脱敏项目诊断|未能保存项目诊断)$/u.exec( + trimmed, + ); + if (!match) { + return null; + } + const [, stage, retryable, rawSummary, rawHint] = match; + if (!stage || !retryable || !rawSummary || !rawHint) { + return null; + } + const redactMarkerForDisplay = (value: string) => + value + .replace(//gi, '[已隐藏链接]') + .replace(/\$PROJECT_ROOT|/g, '[已隐藏路径]') + .replace(/\[redacted-secret\]/gi, '[已隐藏凭据]'); + const summary = redactMarkerForDisplay(rawSummary) + .replace(/\s+/g, ' ') + .trim() + .slice(0, 320); + const hint = redactMarkerForDisplay(rawHint) + .replace(/\s+/g, ' ') + .trim() + .slice(0, 180); + const combined = `${summary} ${hint}`; + if ( + /(?:authorization|bearer|api[_ -]?key|token|secret|cookie|set-cookie|https?:\/\/|(?:^|[\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\)/i.test( + combined, + ) + ) { + return null; + } + const stageLabel = { + request: '智能创作请求失败', + 'art-preparation': '平台资源准备失败', + 'code-generation': '代码生成失败', + 'browser-validation': '真实试玩未通过', + 'version-registration': '项目版本登记失败', + }[stage]; + if (!stageLabel) { + return null; + } + if (!summary || !hint) { + return null; + } + return `${stageLabel}:${summary}。${hint}${ + retryable === 'true' ? '(可直接重试)' : '' + }`; +} + function directPlatformFailureDetail(message: string) { const trimmed = message.trim(); if (!trimmed) { @@ -1829,6 +1881,10 @@ export function projectRuntimeVisibleError( if (isMudPointInsufficientRuntimeError(message)) { return MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE; } + const directDiagnosticDetail = directCodexDiagnosticFailureDetail(message); + if (directDiagnosticDetail) { + return `${subject}:${directDiagnosticDetail}`; + } const directFailureDetail = directPlatformFailureDetail(message); if (directFailureDetail) { return `${subject}:${directFailureDetail}`; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 5ba4ce45b..d0b261e8a 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -39,6 +39,11 @@ export function WorkspaceLauncherShell({ const { recentProjectRows, rememberRecentWorkspace } = recentProjects; const [launcherView, setLauncherView] = useState(initialView); const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false); + const [playRequest, setPlayRequest] = useState<{ + projectPath: string; + requestId: number; + } | null>(null); + const playRequestIdRef = useRef(0); const [launcherNotice, setLauncherNotice] = useState<{ title: string; message: string; @@ -188,6 +193,20 @@ export function WorkspaceLauncherShell({ [applyManifestSnapshot, setCurrentProjectContext], ); + const requestCurrentProjectPlay = useCallback((nextProjectPath: string) => { + playRequestIdRef.current += 1; + setPlayRequest({ + projectPath: nextProjectPath, + requestId: playRequestIdRef.current, + }); + }, []); + + const handlePlayRequestHandled = useCallback((requestId: number) => { + setPlayRequest((current) => + current?.requestId === requestId ? null : current, + ); + }, []); + function showLauncherNotice(title: string) { setLauncherNotice({ title, @@ -289,6 +308,9 @@ export function WorkspaceLauncherShell({ preview={activeProjectPreview} agentRuntimeSummaries={activeProjectAgentRuntimeSummaries} agentResults={activeProjectAgentResults} + onPlay={() => + requestCurrentProjectPlay(currentProjectContext.projectPath) + } onManifestChange={syncActiveProjectManifest} onHomeOpen={() => setLauncherView('home')} onProjectsOpen={() => setLauncherView('projects')} @@ -301,6 +323,8 @@ export function WorkspaceLauncherShell({ initialSupervisorMessage={currentProjectContext.initialPrompt} orchestrationMode="single-supervisor" projectSupervisorOnly + playRequest={playRequest} + onPlayRequestHandled={handlePlayRequestHandled} onManifestChange={syncActiveProjectManifest} onPreviewChange={setActiveProjectPreview} onAgentRuntimeSummariesChange={ diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index 8bf9982b1..08a635840 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -38,6 +38,11 @@ export type ProjectSupervisorComponentProps = { initialSupervisorMessage?: string; orchestrationMode?: 'single-supervisor' | 'professional-dag'; projectSupervisorOnly?: boolean; + playRequest?: { + projectPath: string; + requestId: number; + } | null; + onPlayRequestHandled?: (requestId: number) => void; onManifestChange?: ( projectPath: string, manifest: GameCreationAppManifest, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 0e3fdcaa8..b58c35b7c 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -10,6 +10,7 @@ import type { import type { AgentStatusCard, ChatMessage, + PendingCommand, PendingUiConfirmation, } from '../../app/types'; import { @@ -21,6 +22,11 @@ import { } from '../agent-runtime'; import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation'; import { taskStatusLabels } from '../project-summary/projectSummary'; +import { + pendingCommandDetail, + pendingCommandTitle, +} from './pendingCommandPresentation'; +import { resolvePendingCommandProjectPath } from './projectCommandPolicy'; type RuntimePanelProps = ComponentProps; @@ -30,12 +36,16 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { hiddenConversationCount: number; needsUserInput: boolean; onCancelConfirmation: () => void; + onCancelPendingCommand: () => void; onChatInputChange: Dispatch>; onConfirmConfirmation: () => void; + onConfirmPendingCommand: () => void; onScroll: UIEventHandler; onShowEarlierMessages: () => void; onSubmit: FormEventHandler; pendingConfirmation: PendingUiConfirmation | null; + pendingCommand: PendingCommand | null; + projectPath: string; showProfessionalCollaboration?: boolean; transientReply: string; visibleMessages: ChatMessage[]; @@ -49,12 +59,16 @@ export function ProjectSupervisorView({ hiddenConversationCount, needsUserInput, onCancelConfirmation, + onCancelPendingCommand, onChatInputChange, onConfirmConfirmation, + onConfirmPendingCommand, onScroll, onShowEarlierMessages, onSubmit, pendingConfirmation, + pendingCommand, + projectPath, showProfessionalCollaboration = true, transientReply, visibleMessages, @@ -104,6 +118,29 @@ export function ProjectSupervisorView({ {directCodex ? null : ( )} + {pendingCommand ? ( +
+ + {pendingCommandTitle(pendingCommand)} + + {pendingCommandDetail( + pendingCommand, + resolvePendingCommandProjectPath( + pendingCommand, + { projectPath }, + projectPath, + ), + )} + + + + +
+ ) : null} {pendingConfirmation ? (
diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index ecfd267b9..ef0c5e017 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -273,6 +273,7 @@ export type ProjectDevelopmentViewProps = { supervisor: ReactNode; onHomeOpen: () => void; onProjectsOpen: () => void; + onPlay?: () => void; onManifestChange?: ( projectPath: string, manifest: GameCreationAppManifest, @@ -710,6 +711,7 @@ export default function ProjectDevelopmentView({ agentResults = emptyProjectAgentResults, supervisor, onManifestChange, + onPlay, }: ProjectDevelopmentViewProps) { const professionalDagVisible = orchestrationMode === 'professional-dag'; const [mode, setMode] = useState('resources'); @@ -2995,6 +2997,21 @@ export default function ProjectDevelopmentView({
+ {mode === 'resources' && !focusedResource && !assetCanvasRoute && @@ -3736,7 +3753,7 @@ export default function ProjectDevelopmentView({
)}
diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index 503ac374c..29ae4274e 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -956,6 +956,45 @@ describe('Agent Runtime Provider 状态投影', () => { ); }); + test('直连智能创作展示脱敏的阶段诊断与重试建议', () => { + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v1 stage=art-preparation retryable=true summary=读取陶泥儿画布资源失败:HTTP 503;建议:平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:平台资源准备失败:读取陶泥儿画布资源失败:HTTP 503。平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断(可直接重试)', + ); + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v1 stage=art-preparation retryable=false summary=陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复;建议:历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:平台资源准备失败:陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复。历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集', + ); + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v1 stage=art-preparation retryable=false summary=本机开发者凭据存储目录未安全初始化;未创建远端凭据;建议:请检查当前 Windows 用户对本机私有凭据目录的权限后重试;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:平台资源准备失败:本机开发者凭据存储目录未安全初始化;未创建远端凭据。请检查当前 Windows 用户对本机私有凭据目录的权限后重试', + ); + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v1 stage=art-preparation retryable=true summary=读取陶泥儿画布资源失败: [redacted-secret];建议:平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:平台资源准备失败:读取陶泥儿画布资源失败:[已隐藏链接] [已隐藏路径] [已隐藏凭据]。平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断(可直接重试)', + ); + }); + test('直连 Codex 失败保留安全原因并隐藏链接与路径', () => { expect( projectRuntimeVisibleError( diff --git a/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md b/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md index 1f628a6a8..d205aa1cc 100644 --- a/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md +++ b/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md @@ -105,9 +105,9 @@ ### 现行契约 -1. 直连 AGC 项目首次生成、或当前项目还没有完整已登记的陶泥儿美术包时,客户端必须按标准三阶段调用陶泥儿平台:先生成统一规范图 `assets/art-spec.png`,再以该规范图为唯一参考生成 16:9 场景背景图 `assets/direct-game-background.png`,最后以同一规范图生成透明主图集 `assets/art-spritesheet.png`。标准图集必须请求并回执 `sliceLayout=grid-2x2`,由平台按左上、右上、左下、右下四个固定槽位原子产出四份受合同保护的切片:`assets/art-spritesheet-slices/player.png`、`blocks-and-targets.png`、`obstacles-and-scene.png`、`feedback-effects.png`,以及对应切片清单与私有合同回执;不得继续把自由图集的连通域结果、客户端猜测等分裁剪或刚好四个部件冒充这四类素材。三份主图片都必须以 `source.kind=canvas` 登记到 manifest,且规范图、背景、图集必须属于同一 Canvas 项目;任一阶段失败或切片合同不完整都不得启动代码生成回合。 -2. 客户端把规范图、背景图、主图集和四份切片的相对路径、来源和用途作为不可变执行上下文提供给执行引擎。规范图只作为风格根;执行引擎必须将背景图实际用于游戏场景,并在 `game/game.js` 中加载四份切片,分别通过 Canvas `drawImage` 绘制玩家/可操作物、核心目标或道具、障碍或场景元素、反馈或界面装饰等核心画面元素;不能只在回复中声明使用,也不能用 emoji、纯 CSS 或 Canvas 色块替代这些核心视觉。 -3. 回合结束时客户端复核:三份平台主图片、四份切片、切片清单和合同回执均存在;manifest 来源、路由、规范图引用和同 Canvas 项目关系均成立;`game/index.html` 存在;游戏源码引用背景图,并在可执行 Canvas 渲染路径中加载四份切片且至少执行四次 `drawImage`。任一条件不满足就不登记 `code-prototype=completed`、不创建完成版本,并把明确原因返回给用户。 +1. 直连 AGC 项目首次生成、或当前项目还没有已登记陶泥儿美术时,客户端按标准三阶段优先调用陶泥儿平台:先生成统一规范图 `assets/art-spec.png`,再以该规范图为参考生成 16:9 场景背景图 `assets/direct-game-background.png`,最后以同一规范图生成透明主图集 `assets/art-spritesheet.png`。`sliceLayout=grid-2x2` 与四份受合同保护切片继续作为标准美术包的推荐路径;不得把客户端猜测裁剪或自由连通域结果冒充平台切片。平台返回的主图片必须以 `source.kind=canvas` 登记,资源身份、图片可解码性和同 Canvas 项目关系继续失败关闭;图集源图可信可用但透明化或切片后处理缺失时,按第 13 节安全复用源图并继续同一 Codex thread,不因固定切片缺失终止整个游戏。 +2. 客户端把实际可用的规范图、背景图、主图集与切片相对路径、来源和用途作为不可变执行上下文提供给唯一 Codex。规范图作为风格根,其余平台素材应进入核心可玩画面;Codex 可以根据游戏结构选择合理的图集、切片、Canvas 或 WebGL 组织方式,不要求固定文件组合、固定 `drawImage` 次数或单一代码形态。平台素材不能只在回复中声明使用或只显示为旁侧缩略图,核心视觉也不能退化为 emoji、纯 CSS 或纯色占位图。 +3. 回合结束时客户端只确定性复核安全与最低来源边界:可信平台图片、身份与同 Canvas 关系成立,`game/index.html` 存在,游戏源码至少引用一份已登记陶泥儿图片。随后由受限 Chromium 对 desktop/mobile 做真实运行、截图、异常、交互及 Canvas/WebGL 图片渲染观察,并把缺口回灌同一 Codex thread 有界整改。固定四切片、固定文件名和固定 `drawImage` 次数不再阻断完成;最终仍未观察到平台素材进入任一视口核心渲染时才拒绝登记版本。详细契约以第 13 节为准。 4. 资源画布增加“游戏代码”分区。HTML、CSS、JavaScript 和其它可执行源码进入该分区;玩法说明、配置和 Agent 文本回执仍归“文档”。已有资源布局若没有代码分区位置,将由现有布局 reconcile 自动补位。 5. 产品界面只展示“陶泥儿”“智能创作”等产品术语;Codex app-server 仅保留为内部执行实现和开发诊断名称,不在普通项目聊天、首页创建状态或设置说明中直接暴露。 6. 每次直连回合在游戏代码复核与登记成功后,必须在项目写锁内推进一次 durable project revision,并以该 revision 创建首个 `initial-*` 或后续 `agent-*` 正式版本。禁止修改 manifest 后沿用旧 revision;外层工作台会把同 revision 的不同 manifest 判为冲突并拒绝投影,表现为磁盘已有游戏代码和版本、客户端仍显示 0 项。 @@ -115,11 +115,11 @@ ### 验收 -- Rust 单测覆盖:缺规范图、背景、标准图集或任一四类切片时必须请求平台生成或拒绝完成;代码回收必须拒绝未分别引用背景图和四类切片的项目;只有同一平台规范根派生的完整图片合同、文件存在和渲染引用同时满足时才能完成 `code-prototype` 与初始版本登记。 +- Rust 单测覆盖:可信完整图集缺少固定切片仍可进入 Codex;只有规范图和背景图但历史图集不可安全恢复时不得重复发起付费生成;缺可信平台图片、来源身份不成立、PNG 不可解码或源码完全没有平台素材引用时继续拒绝完成。浏览器证据测试必须区分旁侧 `` 与 Canvas/WebGL 实际渲染,并证明 desktop/mobile 任一视口缺少核心素材观察都会回灌同 thread 整改。 - 资源投影与 AppSurface 测试覆盖:`game/index.html`、`game/style.css`、`game/game.js` 显示于“游戏代码”,不再显示于“文档”。 - 连续两次直连回合必须保留同一批游戏代码资产 ID,project revision 单调推进,并形成 `initial-* -> agent-*` 的父子版本链;外层资源管理在新 revision 到达后显示游戏代码和项目版本,不能停留在生成前快照。 - 已有项目修改测试必须证明系统提示词包含有界当前游戏源码、敏感行被过滤,基于该上下文的 file-change 可以命中;Codex 明确未修改文件时,revision 与版本数量保持不变。 -- 真实客户端验收必须在新建空项目中看到规范图、背景图和标准核心图集三张 `source.kind=canvas` PNG 位于“美术资源”,四类切片及其合同真实落盘,代码文件位于“游戏代码”,并在运行画面中实际加载背景图和四类切片。 +- 真实客户端验收必须在新建空项目中看到实际成功生成或安全恢复的 `source.kind=canvas` PNG 位于“美术资源”,代码文件位于“游戏代码”,并在 desktop/mobile 运行画面的核心 Canvas/WebGL 路径中观察到至少一份已登记陶泥儿素材;标准四切片存在时应优先使用,但不存在时不能伪造,也不能据此单独判定游戏失败。 ## 9. 直连创作阶段反馈与资源首屏预览(2026-08-17) @@ -198,24 +198,50 @@ 直连 Runtime 只把安全、权限、来源和不可逆副作用留在确定性门禁内:项目工作区与权限边界、凭据隔离、平台生成请求的幂等账本与未知结果恢复、持久文件事务、HTTP 完成包基本结构、图片下载/PNG 解码/可见像素、`source.kind=canvas`、资源身份与同 Canvas 项目关系。上述条件不满足时必须失败关闭,不能交给模型猜测或覆盖。 -`grid-2x2`、固定四张切片、固定切片文件名、固定 `drawImage` 次数以及某一种 Canvas 代码形态不再是所有游戏的完成阻断合同。它们保留为“陶泥儿标准美术包”的推荐路径:平台返回完整透明主图集但切片后处理缺失时,仍可继续代码生成;已有切片可以被 Codex 优先使用,但客户端不得伪造切片或把缺失切片标记为已存在。只有平台主图集本身无法下载、解码、无可见像素、来源身份不可信或没有任何真实平台素材引用时才阻断。 +`grid-2x2`、固定四张切片、固定切片文件名、固定 `drawImage` 次数以及某一种 Canvas 代码形态不再是所有游戏的完成阻断合同。它们保留为“陶泥儿标准美术包”的推荐路径:平台返回完整透明主图集但切片后处理缺失时,仍可继续代码生成;已有切片可以被 Codex 优先使用,但客户端不得伪造切片或把缺失切片标记为已存在。若项目已有同一画布、身份可信且可解码的规范图和背景图,但历史核心图集无法恢复,客户端必须直接复用这两张素材继续 Codex 代码生成与试玩,不重新发起图集付费生成,也不为只读恢复失败阻断本轮。只有已用平台图片本身无法下载、解码、无可见像素、来源身份不可信或最终没有任何真实平台素材引用时才阻断。 ### 13.2 同一 Codex thread 的有界自主验收 每个直连回合按以下最多三次 Codex turn 执行,不恢复 Supervisor、专业 Agent 或 harness: 1. 首轮由 Codex 生成或修改当前工作区文件。 -2. 客户端启动受限本地预览,使用真实 Chromium 在 desktop 和 mobile 两个固定视口采集页面加载、Canvas、控制台/异常、失败请求、可见文本、截图和有限的真实交互探针;direct 链路不使用 `BrowserPlaytestScenario::GenericV1` 等固定玩法状态机,试玩结果只作为模型判断证据。 -3. 客户端把脱敏、结构化的浏览器证据发送给同一 Codex thread。Codex 必须读取实际文件和证据,发现问题就直接修改并说明修复;若文件发生变化,客户端重新试玩。最新结果仍失败时最多再发送一次明确整改反馈,之后安全失败,不登记完成版本。 +2. 客户端先复核最低完成证明:`game/index.html` 是否存在,以及源码是否引用至少一个已登记的平台图片。该证明不满足时,不在版本登记阶段直接报错;客户端把脱敏的具体缺口回灌给同一 Codex thread,待其修复后才启动受限本地预览。该回灌不允许 Codex 修改 manifest、伪造素材或覆盖来源身份。 +3. 最低证明满足后,客户端启动受限本地预览,使用真实 Chromium 在 desktop 和 mobile 两个固定视口采集页面加载、Canvas、控制台/异常、失败请求、可见文本、截图和有限的真实交互探针;direct 链路不使用 `BrowserPlaytestScenario::GenericV1` 等固定玩法状态机,试玩结果只作为模型判断证据。 +4. 客户端把脱敏、结构化的浏览器证据及仍存在的最低完成证明缺口发送给同一 Codex thread。Codex 必须读取实际文件和证据,发现问题就直接修改并说明修复;若文件发生变化,客户端重新试玩。最新结果仍失败时最多再发送一次明确整改反馈,之后安全失败,不登记完成版本。 最终回复必须包含:检查过的文件、真实启动/试玩动作、desktop/mobile 观察、陶泥儿平台素材如何被实际使用、已修复问题和剩余风险。客户端只展示摘要,不把绝对路径、Token、签名 URL、Provider 原文或内部队列信息传给用户。 ### 13.3 最低使用证明与完成条件 -客户端只要求 `game/index.html` 存在,并能证明至少一个已登记、真实、平台来源的图片路径被游戏源码引用;不统计 `drawImage` 次数,不要求四类切片或固定代码形态。游戏质量、布局、可玩性、素材是否真的出现在画面中由 Codex 结合真实浏览器截图和结果自行判断。浏览器基础设施失败、页面无法加载、Canvas 完全不可见或出现未处理异常仍作为安全失败证据;其它质量问题交给同 thread 有界整改。 +客户端只要求 `game/index.html` 存在,并能证明至少一个已登记、真实、平台来源的图片路径被游戏源码引用;不统计 `drawImage` 次数,不要求四类切片或固定代码形态。该最低证明失败必须先进入同 thread 有界整改,而不是在浏览器成功后才由版本登记阶段突然终止;若最终仍失败,才拒绝登记。 + +受限 Chromium 还会对已登记平台图片采集运行时使用观察:图片是否在真实 Canvas / WebGL 渲染调用中出现,以及是否只作为页面可见图片出现。该观察不依赖固定切片、固定文件名、特定棋盘结构或字符串计数,也不替代 Codex 对截图和玩法质量的判断;但若只观察到旁侧预览、未观察到核心渲染路径,客户端必须把这一明确缺口回灌给同一 Codex thread 要求整改,不能把“源码引用”或“旁侧缩略图”表述为素材已进入玩法。浏览器基础设施失败、页面无法加载、Canvas 完全不可见或出现未处理异常仍作为安全失败证据;其它质量问题交给同 thread 有界整改。 ### 13.4 验收要求 - 定向 Rust 测试覆盖:完整主图集无切片可通过;来源/身份/PNG 解码/同 Canvas 约束仍失败关闭;源码合理引用任一平台素材可登记;无平台素材引用不能完成;系统提示词包含自主试玩与结构化报告要求。 - fake app-server 测试覆盖:连续 direct turn 复用同一 thread,浏览器证据和整改反馈形成后续 `turn/start`,无 Supervisor/child/harness。 - 真实客户端验收覆盖:当前 checkout 的 AGC 新建或打开直连项目,真实本地预览在 desktop/mobile 运行并至少执行一次可见交互;截图与结构化证据写入项目 `.agent/runtime` 证据目录;完成回复展示可读验收摘要。 + +### 13.5 透明后处理失败时的图集源图安全复用(2026-08-18) + +当陶泥儿 `art-spritesheet` 的远端生成已完成、源 PNG 已落在同一画布,但透明化或切片后处理失败时,直连 Runtime 可以在不重新提交、不重复扣费的前提下只读恢复该源图。恢复仅适用于规范图唯一绑定的核心图集:候选必须属于当前 Canvas、生成路由和种类匹配,且要么 `sourceResourceId` 精确指向当前规范图,要么只包含当前规范图的一项不可变参考。多个候选、外部参考、多参考、不可下载、非 PNG、透明或无可见像素一律失败关闭。 + +恢复成功后将完整图集作为可信平台素材继续交给同一 Codex thread;客户端不得伪造切片、不得把未生成的切片登记为存在。源图已确认由平台完成且只读恢复成功后,客户端清理同一 `agentId/runId` 的已完成生成账本;只读恢复失败、身份不唯一或结果仍未知时继续保留账本供对账,绝不删除后重发。若没有可安全恢复的历史图集、但规范图和背景图已经可信可用,直连生成可继续使用这两张素材并完成上述 LLM 自主验收,不再为只读恢复失败重发图集请求。 + +### 13.6 隔离验收进程的陶泥儿开发者凭据(2026-08-17) + +真实 CLI / Chromium 验收进程不能依赖已打开 GUI 的内存登录态,也不能复制 GUI 的 Cookie、access token 或 Runner 会话。直连 Runtime 首先只读取当前用户私有的 `~/.config/genarrative/external-editor-api.json`:其中保存一次性显示过的开发者 API Key,和可选的受信任陶泥儿 API origin;仓库、项目目录、`game-creator.config.json`、诊断、命令行参数和 Codex 系统提示词均不得出现该 Key。 + +当私有 Key 缺失但当前 GUI 已有陶泥儿账号会话时,客户端可按用户授权仅调用受保护的 `/api/profile/api-keys` 创建一个名称固定的本机直连 Key,并以原子写入和当前用户私有权限保存到上述文件。创建后,direct Runtime 的美术准备、只读图集恢复、素材换签、生成提交和轮询统一使用 External v1 路由及该 Key;不得混用账号 JWT 路由。若 Key 缺失且没有 GUI 会话,必须给出“先在客户端登录一次以创建本机开发者 Key”的可行动错误,而不是误报普通试玩失败。 + +开发者 Key 创建是唯一允许借用 GUI 登录态的受控设置动作;平台图像生成继续由既有幂等账本、同一 idempotency key 和未知结果恢复保护。真实验收必须至少用一个不带 GUI 内存登录态的新进程恢复同一项目,以证明该私钥链路可独立生成、下载并完成 Chromium desktop/mobile 试玩。 + +### 13.7 首次私钥引导的本机安全存储前置与可行动失败说明(2026-08-17) + +首次真实客户端验收表明,失败不是 GUI 登录态未同步:首次本机开发者凭据创建已经拿到远端响应,但 Windows 新建的私有目录 owner 是 `Administrators`,随后 TokenUser 私有 DACL 校验拒绝落盘。一次性显示的远端凭据无法恢复,旧顺序会留下孤儿凭据,因此不能用刷新登录态或自动重放掩盖。 + +1. 缺失本机开发者凭据时,客户端必须先准备并验证精确的私有存储目录,再请求远端创建凭据。仅当该精确目录由当前调用以原子创建成功时,才允许初始化 owner 和私有 DACL 为当前 TokenUser;已有目录一律按严格 owner/DACL 校验,owner 不匹配时失败关闭,不自动接管、改 ACL、覆盖或删除。 +2. 若本机目录预检失败,客户端返回稳定的“本机开发者凭据存储目录未安全初始化;未创建远端凭据”分类,不请求远端创建接口、不发起美术生成、不写 operation 或账本,也不刷新登录态或自动重试。 +3. 远端响应后原子写入仍可能因并发或磁盘故障失败;该极窄路径必须单独分类为“凭据已创建但未能安全保存”,提示用户在账户开发者凭据页面撤销后再试,不能自动创建第二把凭据。诊断和正式 UI 只展示上述安全摘要与恢复建议,不包含 access token、开发者凭据、响应正文、绝对路径或签名 URL。 +4. 验收先在当前失败项目上恢复:对遗留的空且 owner 不匹配目录做可恢复隔离后,再复用同一项目发送“继续完成此前三消游戏”。成功标准包括本机私钥存在但不读取其内容、平台素材与版本登记完成、desktop/mobile Chromium 试玩证据和隔离进程恢复;此前已创建但无法恢复的远端孤儿凭据作为明确剩余风险,绝不自动撤销。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0d37ae003..417ddb822 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -14235,3 +14235,17 @@ - 决策:`npm run agc` 及普通 debug 启动只打开标题为“陶泥儿”的 `client` 客户端窗口,不再由 Tauri setup 自动创建 Agent 聊天开发窗口。普通创作、直连 Codex Runtime 和项目工作台行为均不受影响。 - 保留:`?agent-chat` 仅作为显式前端调试路由;`supervisor-chat` 和 `--game-chat` 继续是独立的显式开发验收入口,不会随着普通启动同时弹出。 - 守卫:原生壳配置检查必须拒绝恢复 `open_developer_window(app.handle())?` 自动调用;真实开发 smoke 以普通客户端单窗口为准。 + +## 2026-08-17 直连 Codex 使用 LLM 自主试玩替代固定美术产物门 + +- 直连 Runtime 的确定性门禁只保护工作区权限、凭据隔离、平台生成幂等与账本、文件事务、图片下载/PNG 解码、平台来源身份和同 Canvas 关系。`grid-2x2`、固定四切片、固定文件名及固定 `drawImage` 次数降为陶泥儿标准推荐路径,不再阻断所有合理的游戏产物。 +- Codex 是唯一执行主体。客户端在同一 Codex thread 内最多追加两次结构化浏览器证据/整改 turn;受限 Chromium 负责 desktop/mobile 页面、Canvas、控制台/网络、截图和有限真实交互探针,禁止恢复 Supervisor、专业 Agent 或 harness,也不把固定玩法状态机当成 direct 完成合同。 +- 完成登记前至少要有 `game/index.html` 和一个已登记、真实、平台来源的图片被源码引用;游戏质量与视觉实际使用由 Codex 根据真实试玩证据自行判断,最终回复必须说明检查文件、试玩动作、双视口观察、素材使用、修复和剩余风险。 + +## 2026-08-17 AGC 直连平台资源失败诊断 + +- 普通客户端使用陶泥儿平台账号会话时,直连 Runtime 的只读画布恢复继续以 External Editor 形状构造请求,再统一经 `resolve_platform_editor_api_route` 映射到 `/api/editor/...` 与 `/api/assets/...`;不得把平台 access token 直接送到未映射的 `/api/external/v1/...` 路由,也不得为排障改用或落盘开发者 API Key。 +- 直连回合的请求准备、平台美术准备、Codex 代码生成、真实浏览器试玩和版本登记失败,统一投影为 `direct-codex-failure:v1`:仅含稳定阶段、脱敏摘要、是否可重试和下一步建议。每次失败尽力写入项目 `.agent/runtime/direct-codex-diagnostics//failure.json`,不保存 token、Cookie、完整 URL、绝对路径或原始服务端正文;写诊断失败不得遮蔽原失败。 +- 普通客户端必须展示上述安全摘要与建议,不能把可解释的资源恢复失败降级成“执行失败,请稍后重试”。平台账号失效提示重新登录;资源身份冲突、多个同源图集或透明图集明确要求先在资源画布核对,而非盲目重复生成或扣费。 +- 已有同一画布、身份可信且可解码的规范图与背景图时,历史核心图集的只读恢复只是可选增强:未找到该图集不得阻断直连 Codex 生成、浏览器试玩或版本登记,也不得触发重复付费生成;最终源码仍须实际引用至少一个已登记的平台图片。 +- 透明后处理失败但平台已保留源图时,只有同一画布身份的只读恢复成功后才清理对应 `agentId/runId` 生成账本;恢复失败、身份不唯一或结果未知继续保留 `accepted` / `operationId` 供对账,禁止因清理过早而重复扣费。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 03d646f58..4fce7e011 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -672,7 +672,7 @@ game-project/ - `npm run check:encoding` 与 `git diff --check`:覆盖中文文档、中文命令文案和补丁空白;用于避免乱码、尾随空白和无关格式漂移。 - `npm run ai-game-creator-shell:llm-status`:只检查 LLM 客户端配置是否就绪,不请求上游、不显示 API Key;用于本机联调前确认配置。发布版启动时会在 Tauri 应用配置目录生成默认 `game-creator.config.json`,仓库内 `apps/ai-game-creator-shell/game-creator.config.json` 只作为默认模板。 - `npm run agc:config`:提供终端交互式 LLM 配置向导,支持 OpenAI、DeepSeek、Anthropic、火山 Ark 和自定义 Provider,依次采集 Base URL、模型与隐藏输入的 LLM API Key,并写入与 GUI“配置”面板相同的系统 AppData `game-creator.config.json`。向导不采集普通画板 URL/Key;更新 LLM 主配置时必须保留其它现役配置,POSIX 下保持 AppData 目录 `0700`、配置文件 `0600`,通过同目录临时文件原子替换,保存后复用真实 `llm-status` 检查。禁止提供或接受 `--api-key`,避免密钥进入 shell history、进程参数、日志或测试报告。 -- `npm run ai-game-creator-shell:agent-run -- --no-wait /绝对项目路径 "游戏创作需求"`:使用真实 OpenAI-compatible 配置跑一次本地生成、落盘、自检和预览;用于人工验收真实 provider 路径。真实 provider 配置放在 Tauri 应用配置目录的 `game-creator.config.json` 中,至少设置 `llm.apiKey`,需要覆盖默认服务时设置 `llm.baseUrl`、`llm.model`;默认 API kind 为 `openai_responses`,旧 Chat Completions 兼容网关设置 `llm.apiKind` 为 `openai_chat`,Anthropic Messages 网关设置 `llm.apiKind` 为 `anthropic`,URL 会在 base URL 后拼 `/v1/messages`,例如 Minimax Anthropic base URL 可配置为 `https://api.minimaxi.com/anthropic`;真实网关长请求若在非流式响应前被 60 秒空闲连接切断,联调时设置 `llm.stream` 为 `true`。 +- `npm run ai-game-creator-shell:agent-run -- --no-wait /绝对项目路径 "游戏创作需求"`:使用真实 OpenAI-compatible 配置跑一次本地生成、落盘、自检和预览;用于人工验收真实 provider 路径。真实 provider 配置放在 Tauri 应用配置目录的 `game-creator.config.json` 中,至少设置 `llm.apiKey`,需要覆盖默认服务时设置 `llm.baseUrl`、`llm.model`;默认 API kind 为 `openai_responses`,旧 Chat Completions 兼容网关设置 `llm.apiKind` 为 `openai_chat`,Anthropic Messages 网关设置 `llm.apiKind` 为 `anthropic`,URL 会在 base URL 后拼 `/v1/messages`,例如 Minimax Anthropic base URL 可配置为 `https://api.minimaxi.com/anthropic`;`llm.stream` 默认开启,若某个兼容网关不支持流式响应,可在全局或对应 `agentLlm` 中显式设为 `false`。 ## 当前最小落地 @@ -702,7 +702,7 @@ game-project/ - 普通用户侧的生成、上传、运行、自检、预览状态 / 启动 / 打开 / 停止、记忆写入和画板资产导入都必须先完成 `/project` 初始化;未初始化时只提示设置本地项目,不落到默认临时目录。 - 终端可用 `npm run ai-game-creator-shell:llm-status` 检查 LLM 客户端配置是否就绪;桌面 App 主窗口“配置”面板可读写 Tauri 应用配置目录中的 `game-creator.config.json`,`/llm-status` / 生成入口读取同一份配置,CLI 开发入口无 AppHandle 时才回退读取仓库旁边的配置模板和 gitignored 本机覆盖文件;不请求上游、不显示 API Key,缺配置时以非零状态退出或在聊天里提示未就绪。 - 终端可用 `npm run ai-game-creator-shell:check` 跑 v1 开发验收:壳 typecheck、`platform-llm` 网关测试、共享契约测试、Tauri Rust 测试和无密钥本地 provider 端到端 smoke;已退役的 `platform-agent` 不再进入 workspace 或该门禁。 -- 终端可用 `npm run ai-game-creator-shell:agent-run -- /绝对项目路径 "游戏创作需求"` 跑一次真实 LLM 生成、落盘、`game.static_smoke` 和本地 HTTP 预览;发布 App 读取 Tauri 应用配置目录中的 `game-creator.config.json`,开发 CLI 无 AppHandle 时才读取仓库旁边的配置模板和 gitignored 本机覆盖文件,不把 API Key 写入仓库或项目文件。自动验证可加 `--no-wait`,例如 `npm run ai-game-creator-shell:agent-run -- --no-wait /tmp/genarrative-ai-game-test "像素风反弹弹幕厨房"`,生成预览 trace 后立即停止本地预览,避免终端卡在回车等待。默认 API kind 为 `openai_responses`;旧 Chat Completions 兼容网关设置 `llm.apiKind` 为 `openai_chat`,Anthropic Messages 网关设置 `llm.apiKind` 为 `anthropic`。真实 OpenAI-compatible 网关建议设置 `llm.stream` 为 `true` 跑 Planner 和 Generator,避免长请求非流式空闲断连。 +- 终端可用 `npm run ai-game-creator-shell:agent-run -- /绝对项目路径 "游戏创作需求"` 跑一次真实 LLM 生成、落盘、`game.static_smoke` 和本地 HTTP 预览;发布 App 读取 Tauri 应用配置目录中的 `game-creator.config.json`,开发 CLI 无 AppHandle 时才读取仓库旁边的配置模板和 gitignored 本机覆盖文件,不把 API Key 写入仓库或项目文件。自动验证可加 `--no-wait`,例如 `npm run ai-game-creator-shell:agent-run -- --no-wait /tmp/genarrative-ai-game-test "像素风反弹弹幕厨房"`,生成预览 trace 后立即停止本地预览,避免终端卡在回车等待。默认 API kind 为 `openai_responses`,且 `llm.stream` 默认开启;旧 Chat Completions 兼容网关设置 `llm.apiKind` 为 `openai_chat`,Anthropic Messages 网关设置 `llm.apiKind` 为 `anthropic`。不支持流式响应的兼容网关可显式设为 `false`。 - 终端可用 `npm run ai-game-creator-shell:agent-run:smoke` 跑一次无密钥本地端到端 smoke:脚本启动本机 OpenAI-compatible SSE 流式测试 provider,预置一个本地上传图片和一个本地上传音频,复用真实 `--agent-run`、Planner / Orchestrator / 角色 agent / Generator / Evaluator loop、本地落盘、`game.static_smoke` 和本地 HTTP 预览,并断言每次 provider 请求都使用 `stream: true`、Planner 与 Generator 分别命中自己的 `agentLlm` provider 配置、provider prompt 收到图片与音频资产上下文以及最近对话上下文、生成 HTML 引用这些资产、预览服务能用 `GET` 读取 `/assets/...`、用 `HEAD` 返回真实资源长度和对应 MIME、headless Chrome 打开预览后至少执行一帧游戏 JS,且通过确定性亮色探针采样证明 canvas 不是空白画布、`.agent/run.latest.json` 的 step group 覆盖 design / balance / art / audio / code / publishing 六组、第二轮会重跑 Evaluator 命中任务及其下游影响任务,未受影响角色 carry-over;随后脚本自动给 CLI 发送回车停止预览。该脚本只用于开发验证,不进入产品生成路径。 - `npm run ai-game-creator-shell:dev` 必须经受控启动器解析 AGC Vite 端口;Linux 默认使用用户端口段的 `start + 5`,非 Linux 保留 `3080` 兼容首选。启动器用动态 Tauri `build.devUrl`、`GENARRATIVE_AGC_VITE_PORT` 和 Vite CLI `--port` 保证 WebView、`beforeDevCommand`、配套后端预留和 Vite `strictPort` 对齐;不得复用无法证明 worktree 归属的现有 Vite,最终端口被竞态占用时直接失败。 - AI 游戏创作 App 的本地后端使用 gitignored 的 `server-rs/.spacetimedb/ai-game-creator/data`,不复用主站旧 standalone 数据目录。启动器从本地 `/v1/identity` 获取并持久化 API identity,再通过数据目录内 `0600` 的独立 `dev-cli/cli.toml` 发布模块;不得读取或覆盖开发者全局 SpacetimeDB 登录,也不得回退到每次变化的 `--anonymous` 身份。发布失败时 API 和 Vite 不得继续启动旧 schema,避免 `external_generation_job` 等缺表订阅进入持续重试。 @@ -712,7 +712,7 @@ game-project/ - `.agent/manifest.json` 会保存 6 个专业组下 16 个组内角色任务状态,当前覆盖 `Director`、`Gameplay`、`Difficulty`、`Asset`、`Polish`、`SFX`、`Code`、`Review`、`Preview`、`Playtest`、`Publish`;程序组内显式包含 `quality-review` 质量评审 gate,由 Evaluator trace 标记完成;开发窗口的专业组面板读取 manifest,而不是前端硬编码。 - 主窗口的 agent 状态列表以 manifest 角色任务为底表,再合并最近 run trace 中 `taskGraph.tasks` 的任务状态、同 taskId / group / role 的最新 step 状态、输入输出路径、错误摘要、lifecycleStatus 和 `activeTaskIds` / `carriedTaskIds` / `readyTaskIds` 编排标记;如果 trace 缺失或过期,只展示 manifest 的静态任务状态和“暂无最近运行证据”。 - 共享契约和 `platform-agent` 会按任务依赖与 `completed` 状态计算当前可执行任务,作为 v1 的最小编排选择器;每轮 `Orchestrator` 的 activeTaskIds、carriedTaskIds、repairRoutes 和 dependencyWaves 由 `platform-agent` 纯编排内核产出,`apps/ai-game-creator-shell` 只负责写入 `.agent/passes/pass-N/` 和执行本地工具;`Evaluator` 会在 `.agent/findings.md` 写出 `## Repair Routes` JSON,下一轮编排优先采用该结构化 taskIds,解析不到时才退回关键词路由;返工路由会按任务图自动扩展下游影响任务,例如美术资产变化会继续触发程序预览和运营包装重算。 -- `game.generate_draft` 使用 OpenAI-compatible LLM 配置生成结构化 JSON 草案,发布 App 的配置项来自 Tauri 应用配置目录中的 `game-creator.config.json`:`llm.apiKey`、`llm.baseUrl`、`llm.model`、`llm.apiKind`、`llm.stream`、`llm.requestTimeoutMs`、`llm.maxRetries`、`llm.retryBackoffMs`;默认 API kind 为 `openai_responses`,可设 `llm.apiKind=openai_chat` 切回旧 Chat Completions 兼容网关,或 `llm.apiKind=anthropic` 走 Anthropic Messages;`llm.stream=true` 时 Planner 和 Generator 使用流式请求;缺少配置或模型返回非法 JSON 时直接失败,不静默回退固定模板。 +- `game.generate_draft` 使用 OpenAI-compatible LLM 配置生成结构化 JSON 草案,发布 App 的配置项来自 Tauri 应用配置目录中的 `game-creator.config.json`:`llm.apiKey`、`llm.baseUrl`、`llm.model`、`llm.apiKind`、`llm.stream`、`llm.requestTimeoutMs`、`llm.maxRetries`、`llm.retryBackoffMs`;默认 API kind 为 `openai_responses`,`llm.stream` 默认开启,可设 `llm.apiKind=openai_chat` 切回旧 Chat Completions 兼容网关,或 `llm.apiKind=anthropic` 走 Anthropic Messages;显式 `llm.stream=false` 时 Planner 和 Generator 使用非流式请求;缺少配置或模型返回非法 JSON 时直接失败,不静默回退固定模板。 - 普通主窗口“配置”面板和聊天 `/config` 命令只维护 LLM、Agent LLM 与 MCP 等运行时配置,不展示画板 Base URL 或 Developer API Key;正式平台美术缺少登录态时引导重新登录,不引导填写 `editorApi`。主聊天 Agent、Planner、Orchestrator、Generator、Evaluator 和 16 个角色 agent 可在 `agentLlm` 中单独覆盖 LLM 路由,空项继承全局配置。仅独立 standalone game-chat release/显式高级自定义模式的隔离设置可维护 External v1 URL/Key,且不得被普通 release 自动读取。 - 聊天输入 `/llm-status` 会触发只读 `llm.config_check`,确认全局 LLM 以及各 agent resolved 后的 base_url、model、API 类型和 API Key 是否已从客户端配置读取;状态消息不会显示或保存 API Key;当前生成链路只要求 Planner / Generator 就绪。`/llm-routes` 复用同一检查结果,但输出按 agent 展开的路由清单和缺口摘要,用于确认哪些 agent 解析后走全局路由、哪些 agent 走单独 provider。 - `game.generate_draft` 的 LLM JSON 必须包含 `handoffs` 数组,覆盖 `design`、`balance`、`art`、`audio`、`code`、`publishing` 6 个专业组;每组必须给出 role、summary、outputs 和 next,缺组或交接内容不完整会判定为模型输出无效并进入返工。 diff --git a/server-rs/crates/api-server/src/editor_generation_queue.rs b/server-rs/crates/api-server/src/editor_generation_queue.rs index 240d85843..70f45dcf0 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -30,7 +30,8 @@ const EDITOR_GENERATION_QUEUE_PROVIDER: &str = "editor-generation-worker"; const MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES: usize = 512 * 1024; const EXTERNAL_API_GENERATION_DEDUPE_PREFIX: &str = "external-api-generation"; const EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX: &str = "editor-api-request-generation"; -const GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX: &str = "game-creator-client-generation"; +pub(crate) const GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX: &str = + "game-creator-client-generation"; pub(crate) const GAME_CREATOR_CLIENT_GENERATION_SOURCE: &str = "ai-game-creator-client"; #[derive(Debug, Serialize)] diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index 1f0da6635..b27804a9d 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -38,6 +38,7 @@ use crate::{ EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, EDITOR_IMAGE_EDIT_JOB_KIND, EDITOR_IMAGE_GENERATION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND, + GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX, }, editor_project::{ EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE, EDITOR_IMAGE_EDIT_QUEUE_PAYLOAD_VERSION, @@ -1278,7 +1279,7 @@ fn editor_generation_result_payload_json( compact_editor_generation_result(response.clone()), ); } - if is_external_api_generation_job(job) + if is_result_recovery_generation_job(job) && let Some(object) = payload.as_object_mut() { object.insert( @@ -1294,10 +1295,10 @@ fn editor_generation_result_payload_json( payload.to_string() } -fn is_external_api_generation_job(job: &ExternalGenerationJobRecord) -> bool { - job.dedupe_key - .trim() - .starts_with("external-api-generation:") +fn is_result_recovery_generation_job(job: &ExternalGenerationJobRecord) -> bool { + let dedupe_key = job.dedupe_key.trim(); + dedupe_key.starts_with("external-api-generation:") + || dedupe_key.starts_with(&format!("{GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX}:")) } fn is_editor_agent_generation_job(job: &ExternalGenerationJobRecord) -> bool { @@ -2604,6 +2605,62 @@ mod tests { } } + #[test] + fn game_creator_client_result_keeps_completed_grid_spritesheet_for_recovery() { + let mut job = external_generation_job_record_fixture(Some("lease-1")); + job.dedupe_key = format!( + "{GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX}:editor_icon_spritesheet_generation:fingerprint" + ); + job.job_kind = EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND.to_string(); + let response = json!({ + "data": { + "spritesheetImageSrc": "/api/assets/object/core-sheet.png", + "spritesheetWidth": 1024, + "spritesheetHeight": 1024, + "sliceLayout": "grid-2x2", + "spritesheetResource": { + "resourceId": "sheet-resource-1", + "objectKey": "users/user-1/core-sheet.png", + "imageSrc": "/api/assets/object/core-sheet.png" + }, + "spritesheetAsset": { + "assetId": "sheet-asset-1", + "objectKey": "users/user-1/core-sheet.png", + "imageSrc": "/api/assets/object/core-sheet.png" + }, + "iconImageSrcs": [ + {"name": "玩家", "objectKey": "users/user-1/player.png", "imageSrc": "/api/assets/object/player.png"}, + {"name": "目标", "objectKey": "users/user-1/targets.png", "imageSrc": "/api/assets/object/targets.png"}, + {"name": "场景", "objectKey": "users/user-1/scene.png", "imageSrc": "/api/assets/object/scene.png"}, + {"name": "反馈", "objectKey": "users/user-1/feedback.png", "imageSrc": "/api/assets/object/feedback.png"} + ] + } + }); + + let payload: Value = + serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) + .expect("游戏创作客户端完成结果应持久化为合法 JSON"); + + assert_eq!(payload["result"]["sliceLayout"], json!("grid-2x2")); + assert_eq!( + payload["result"]["iconImageSrcs"].as_array().map(Vec::len), + Some(4) + ); + assert_eq!( + payload["result"]["spritesheetResource"]["resourceId"], + json!("sheet-resource-1") + ); + assert_eq!( + payload["result"]["spritesheetAsset"]["assetId"], + json!("sheet-asset-1") + ); + assert_eq!( + payload["result"]["iconImageSrcs"][0]["objectKey"], + json!("users/user-1/player.png") + ); + assert!(!payload.to_string().contains("prompt")); + } + #[test] fn non_external_job_does_not_publish_query_result() { let job = external_generation_job_record_fixture(Some("lease-1")); From 1e9d74fa4a732d0d56a077dbba61914e19ed4ab6 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:46:03 +0800 Subject: [PATCH 14/20] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 登录页支持 release、dev 和 custom 服务器 - 持久化服务器选择并统一登录与平台会话请求地址 - 校验自定义服务器 origin 与 HTTPS 安全边界 - 补充前端、AppSurface 和 Rust 平台会话测试 --- .../src-tauri/src/platform_session.rs | 17 ++- .../src/app/AuthenticatedClient.tsx | 100 +++++++++++++ .../src/services/clientHttp.ts | 140 ++++++++++++++++-- .../src/services/platformSession.ts | 19 +-- apps/ai-game-creator-shell/src/styles.css | 28 +++- .../tests/appSurface/auth.suite.ts | 35 +++++ .../tests/clientHttp.test.ts | 87 ++++++++++- .../shared-memory/decision-log.md | 5 + 8 files changed, 396 insertions(+), 35 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs index c76a3deb3..359ad50f5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs @@ -126,13 +126,12 @@ fn normalize_platform_api_base_url(value: &str) -> Result { let host = parsed .host_str() .ok_or_else(|| "陶泥儿服务地址缺少 host".to_string())?; - let production = matches!(host, "www.genarrative.world" | "dev.genarrative.world"); let loopback = host == "localhost" || host == "127.0.0.1" || host .parse::() .is_ok_and(|ip| ip.is_loopback()); - if !production && !(cfg!(debug_assertions) && loopback && parsed.scheme() == "http") { + if parsed.scheme() == "http" && !(cfg!(debug_assertions) && loopback) { return Err("陶泥儿服务地址不在受信任白名单内".to_string()); } Ok(value.to_string()) @@ -342,6 +341,20 @@ mod tests { ); } + #[test] + fn custom_server_selection_accepts_https_origins_and_rejects_plain_http() { + assert_eq!( + normalize_platform_api_base_url("https://staging.example.com/"), + Ok("https://staging.example.com".to_string()) + ); + assert_eq!( + normalize_platform_api_base_url("http://127.0.0.1:8080/"), + Ok("http://127.0.0.1:8080".to_string()) + ); + assert!(normalize_platform_api_base_url("http://staging.example.com").is_err()); + assert!(normalize_platform_api_base_url("https://staging.example.com/api").is_err()); + } + #[test] fn frozen_platform_session_rejects_logout_account_switch_and_token_rotation() { let expected = PlatformSessionSnapshot { diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 80cfec5b6..d7efba85a 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -20,6 +20,15 @@ import { normalizeAuthPhoneInput, sendClientPhoneLoginCode, } from '../services/clientAuth'; +import { + AGC_DEVELOPMENT_API_BASE_URL, + AGC_RELEASE_API_BASE_URL, + type ClientServerPreset, + type ClientServerSelection, + getClientServerSelection, + normalizeClientServerBaseUrl, + setClientServerSelection, +} from '../services/clientHttp'; import { beginPlatformSessionTransition, clearCommittedPlatformSession, @@ -95,6 +104,46 @@ export function AuthenticatedClient({ const [loginBusy, setLoginBusy] = useState(false); const [codeBusy, setCodeBusy] = useState(false); const [codeCooldownSeconds, setCodeCooldownSeconds] = useState(0); + const initialServerSelection = getClientServerSelection(); + const [serverSelection, setServerSelection] = useState( + initialServerSelection, + ); + const [customServerUrl, setCustomServerUrl] = useState( + initialServerSelection.customBaseUrl, + ); + const selectedServerAddress = + serverSelection.preset === 'release' + ? AGC_RELEASE_API_BASE_URL + : serverSelection.preset === 'dev' + ? AGC_DEVELOPMENT_API_BASE_URL + : customServerUrl || '请输入自定义服务器地址'; + + function applyServerSelection() { + try { + const next = setClientServerSelection({ + preset: serverSelection.preset, + customBaseUrl: customServerUrl, + }); + setServerSelection(next); + return true; + } catch (error) { + setLoginStatus(error instanceof Error ? error.message : String(error)); + return false; + } + } + + function handleServerPresetChange(preset: ClientServerPreset) { + if (preset === 'custom') { + setServerSelection((current) => ({ ...current, preset })); + return; + } + const next = setClientServerSelection({ + preset, + customBaseUrl: customServerUrl, + }); + setServerSelection(next); + setLoginStatus(`已选择 ${preset} 服务器`); + } useEffect(() => { let disposed = false; @@ -229,6 +278,9 @@ export function AuthenticatedClient({ if (codeBusy || codeCooldownSeconds > 0) { return; } + if (!applyServerSelection()) { + return; + } const normalizedPhone = normalizeAuthPhoneInput(phone); if (!normalizedPhone) { setLoginStatus('请输入手机号'); @@ -265,6 +317,9 @@ export function AuthenticatedClient({ setLoginStatus('请输入密码'); return; } + if (!applyServerSelection()) { + return; + } setLoginBusy(true); setLoginStatus('正在登录'); const loginGeneration = beginPlatformSessionTransition(); @@ -324,6 +379,51 @@ export function AuthenticatedClient({

登录陶泥儿 GameAgent

登录后进入首页和本地项目工作区

+ + {serverSelection.preset === 'custom' ? ( + + ) : null}