Compare commits

..

29 Commits

Author SHA1 Message Date
lhk229 45d7dc2a63 修复策划 Agent 历史思考位置
Project CI / Native shell tests (pull_request) Waiting to run
Project CI / Frontend tests (pull_request) Waiting to run
Project CI / Repository checks (pull_request) Waiting to run
Project CI / AI game creator shell web tests (pull_request) Waiting to run
Project CI / AI game creator shell Rust tests (pull_request) Has started running
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m8s
Project CI / Backend tests (pull_request) Has started running
按用户回合与响应顺序绑定持久化 reasoning,跨越 tool-only 响应。

合并同一正文对应的多段 reasoning,避免无归属内容堆积在消息列表底部。

补充策划 Runtime 顺序回归测试并更新里程碑文档。
2026-09-14 17:28:00 +08:00
lhk229 0a34f4e514 Merge remote-tracking branch 'origin/master' into opt/design_agent 2026-09-14 17:25:57 +08:00
kdletters 86b830cf2d AGC 壳 Rust 套件分片并行,客户端 Rust 关键路径压到 7 分钟以内
Project CI / AI game creator shell Rust tests (push) Has started running
Project CI / AI game creator shell Rust crates (push) Successful in 3m17s
Project CI / Native shell tests (push) Successful in 6m1s
Project CI / Backend tests (push) Successful in 6m39s
Project CI / Frontend tests (push) Successful in 3m59s
Project CI / Repository checks (push) Successful in 2m37s
Project CI / AI game creator shell web tests (push) Successful in 2m37s
run 2097 显示 AI game creator shell Rust tests 是客户端 CI 的关键路径(15 分 27 秒):
前置 5 分 30 秒(checkout + npm ci + Cargo fetch)、编译 1 分 39 秒、AGC 壳 bin target
的 2466 条单测一条命令串行 507 秒、agent-run smoke 51 秒。

- 新增 apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs:cargo test
  --no-run 编译一次拿到测试可执行文件,用 --list 取全部用例名,排序后按 index % shards
  切 4 片,每片作为独立进程执行 <bin> --exact <名单> --test-threads=1,并给每片独立
  TMPDIR。片并集必须等于 --list 全集且互斥(assertShardsCoverEveryTest),改分片规则
  不会静默漏跑。串行口径只从「整套串行」放宽到「片内串行」:当年 libtest 线程并行会
  互相干扰的是进程内后台锁与异步终态,进程分片不共享这些状态,因此不必放宽断言。
- package.json:ai-game-creator-shell:check:rust 拆成 :rust:crates(agent-runtime-core /
  orchestration / platform-llm / shared-contracts)与 :rust:shell(分片运行器),聚合
  脚本保持同序,ai-game-creator-shell:check 与本地全量入口语义不变。
- check-native-shells.mjs:分组由五个变六个(新增 agc-rust-crates、agc-rust-shell,
  移除 agc-rust);smoke 与壳测试同组以共享 AGC 壳的依赖预热。
- project-ci.yml:新增第 7 个 job AI game creator shell Rust crates(预热 server-rs 与
  两个无锁独立 crate 后跑四条 crate 测试);AI game creator shell Rust tests 只保留壳
  分片与 agent-run smoke,并只预热 AGC 壳自己那份 manifest(其 Cargo.lock 的 path 依赖
  已覆盖 platform-llm / platform-agent / agent-runtime-core / shared-contracts)。
  这两个 job 只用 cargo 与 node 内建模块,因此都不再执行 npm ci(各省 1~3 分钟)。
- project-ci-workflow.test.ts:新增纯 cargo job 免 npm ci、分片运行器覆盖校验、crate 级
  job 预热顺序等断言;同步运维文档、development-workflow、decision-log、pitfalls。

验证:分片运行器本地以 agent-runtime-core(7 条 → 2/2/2/1)与 platform-llm(146 条 →
49/49/48)验证分片、--exact 与片 TMPDIR 隔离,参数负例 exit 1;vitest
scripts/project-ci-workflow.test.ts 12 passed;contract 分组、AGC 与 mobile 壳
check-config、eslint、prettier、check:encoding(13400 文件)、check:doc-index 全绿。
Gitea master 分支保护需补 Project CI / AI game creator shell Rust crates (pull_request)。

Co-authored-by: DotCraft <273930855+dotcraft-ai@users.noreply.github.com>
2026-09-14 17:02:34 +08:00
kdletters f9bd0adae1 补齐客户端 CI 拆分的共享记忆:label 字面量契约与实测耗时
Project CI / Frontend tests (push) Successful in 5m6s
Project CI / Native shell tests (push) Successful in 7m1s
Project CI / Backend tests (push) Successful in 7m45s
Project CI / Repository checks (push) Successful in 2m58s
Project CI / AI game creator shell web tests (push) Successful in 2m10s
Project CI / AI game creator shell Rust tests (push) Successful in 14m39s
- pitfalls:新增「根门禁的 [check:native-shells] <label> 是别处按字面量校验的契约」,
  记录 run 2095 里 desktop-shell typecheck 的失败根因(label 被抽进公共打印函数后,
  check-config 的字面量断言落空)、处置与本地验证方式。
- decision-log:把「预计收敛到约 13 分钟量级」换成 run 2097 实测——六个 job 全绿、
  wall-clock 15 分 27 秒(拆分前 run 2094 为 22 分 15 秒),关键路径转移到
  AI game creator shell Rust tests,壳体串行套件 507 秒是硬底。

Co-authored-by: DotCraft <273930855+dotcraft-ai@users.noreply.github.com>
2026-09-14 16:47:35 +08:00
kdletters 11beb65c36 修复桌面壳对根门禁 label 字面量的断言
Project CI / Frontend tests (push) Successful in 4m24s
Project CI / Native shell tests (push) Successful in 7m34s
Project CI / Backend tests (push) Successful in 7m36s
Project CI / Repository checks (push) Successful in 2m45s
Project CI / AI game creator shell web tests (push) Successful in 2m15s
Project CI / AI game creator shell Rust tests (push) Successful in 15m27s
run 2095 的 Native shell tests 红了,失败点是 desktop-shell 的 typecheck:
apps/desktop-shell/scripts/check-config.mjs 断言根门禁脚本必须包含
`console.log('[check:native-shells] desktop-release-binary-artifact')` 字面量,
而拆分时我把 label 挪进了公共打印函数 runNativeShellGate(group, label, gate),
源码里不再有这个字面量,断言随即报
「root native shell gate must keep desktop release artifact check」。

- runNativeShellGate 收窄为 (group, gate):label 改回各门禁执行体里的字面量打印,
  分组能力、日志格式与 `--groups=` 语义都不变,外部按字面量做的快照断言继续有效。
- 在该函数旁注明不要把 label 抽成变量:apps/desktop-shell 与 apps/mobile-shell 的
  check-config 都会读取根脚本源码做字面量断言。

验证:node --check 通过;contract 分组通过(groups=contract + OK);mobile-shell
check-config exit 0;desktop-shell check-config 已越过第 740-777 行的根脚本断言
(本机只卡在本机不存在的 Tauri 生成产物目录,Linux CI 不受影响);
vitest scripts/project-ci-workflow.test.ts 11 passed;eslint 通过。

Co-authored-by: DotCraft <273930855+dotcraft-ai@users.noreply.github.com>
2026-09-14 16:26:10 +08:00
kdletters 20f109027a 按门禁组拆分客户端 CI,AGC 的 web / rust 两段并行
Project CI / Native shell tests (push) Failing after 3m36s
Project CI / Frontend tests (push) Successful in 4m13s
Project CI / Repository checks (push) Successful in 3m3s
Project CI / AI game creator shell web tests (push) Successful in 2m31s
Project CI / Backend tests (push) Successful in 7m49s
Project CI / AI game creator shell Rust tests (push) Successful in 14m10s
原生壳门禁原本挤在同一个 job 里串行执行,跑一遍 18 分 37 秒,其中 AI 游戏创作
壳独占约 15 分钟(壳内 Rust 套件 2451 条用例串行 441 秒),而微信 / 移动 / 桌面 /
H5 的全部门禁加起来不到 50 秒。长尾拖住短门禁,runner 也无法并行。

- scripts/check-native-shells.mjs 支持 `--groups=`(contract / shells / agc-web /
  agc-rust / release):每个步骤与静态断言归属且只归属一个分组,不带参数时仍按
  原顺序串行跑全部分组,本地 `npm run check:native-shells` 语义不变。
- 顺带修掉 H5 HostBridge 调用链扫描在 Windows 上恒红的缺陷:collectFiles 返回
  反斜杠路径而期望清单是 POSIX 写法,scannedFiles.has() 永远为假。新增
  normalizeScannedFilePath 只统一分隔符(不能复用会去后缀的 normalizeModulePath),
  在 Linux 上是恒等变换。
- package.json 增加 5 个分组脚本,并把 ai-game-creator-shell:check 拆成
  :check:web 与 :check:rust;聚合脚本保持 web && rust && agent-run:smoke 同序。
  agent-run smoke 会 spawn cargo,因此归入 agc-rust。
- .gitea/workflows/project-ci.yml 拆成 6 个 job:新增 native-shell-tests(contract +
  shells + release)、ai-game-creator-shell-web-tests、ai-game-creator-shell-rust-tests
  三个门禁 job,与 backend-tests / frontend-tests / repository-checks 并列。
- scripts/project-ci-workflow.test.ts 增加 3 条结构测试:分组恰好被一个 job 调用且
  与脚本内声明一致、CI 不再调用全量入口、AGC web/rust 拆分与聚合脚本等价、独立
  crate 预热必须发生在 AGC Rust 门禁之前。
- 同步运维文档、development-workflow、decision-log、pitfalls。

验证:`--groups=contract` 本地通过;vitest 11 passed;eslint 与 prettier 通过;
check:encoding 13329 文件通过;check:doc-index 103 份通过。shells / agc-web /
agc-rust / release 分组只能由 Linux CI 执行(Windows 上 spawnSync npm.cmd 报
EINVAL,属既有平台限制,非本次引入)。分支保护需补两个新 required context:
`Project CI / AI game creator shell web tests (pull_request)` 与
`Project CI / AI game creator shell Rust tests (pull_request)`。

Co-authored-by: DotCraft <273930855+dotcraft-ai@users.noreply.github.com>
2026-09-14 16:13:11 +08:00
kdletters 4e553bb15d 收窄项目写锁同进程复用判据为同线程重入
Project CI / Repository checks (push) Successful in 3m21s
Project CI / Frontend tests (push) Successful in 4m15s
Project CI / Backend tests (push) Successful in 6m13s
Project CI / Native shell tests (push) Successful in 18m37s
- 修复 write_lock.rs:advisory 复用判据从「本进程持锁」收窄为「同一线程重入或自主流水线」,新增按锁路径登记真实持锁线程的 PROJECT_WRITE_LOCK_THREAD_OWNERS,登记在 create_new 成功处、在 guard Drop 里按路径注销(guard 会被移到别的线程再 Drop)
- 恢复本进程其它线程写通道的串行化:一致快照读、project.diff / action_history、command.output_read、steer 序号分配、项目 revision 侧车、pending sidecar 复核与恢复安装重新等待并保持终态占用
- 调整 tests/project_tools.rs 的 agent_runtime_file_write_lock_failure_redacts_project_path:改为在另一条线程持锁,保持「别的写者持锁时 file.write 失败关闭且脱敏」的断言语义
- 调整 ui_editor/persistence.rs 的 recovery_install_respects_the_project_write_lock:同样改为在另一条线程持锁,保持占用失败断言
- 同步技术方案「2026-09-14 项目客户端占用锁收敛」段落,以及里程碑和实施计划的目标、验收标准、修改顺序、验证命令与未决事项
- 在 decision-log 记录复用判据收窄为同线程重入,并在 pitfalls 记录「同进程复用判据不能只看 pid」的现场、原因、处理与易错点
- 回答 AGC 生命周期文档「同 PID advisory guard 是否放过并行写」的未决项:确认会放过并行写,已收窄

Co-authored-by: DotCraft <273930855+dotcraft-ai@users.noreply.github.com>
2026-09-14 15:41:17 +08:00
lhk229 09a9fa7b10 Merge remote-tracking branch 'origin/master' into opt/design_agent
Project CI / Repository checks (pull_request) Successful in 3m13s
Project CI / Frontend tests (pull_request) Successful in 3m41s
Project CI / Backend tests (pull_request) Successful in 6m19s
Project CI / Native shell tests (pull_request) Failing after 17m39s
2026-09-14 15:17:11 +08:00
kdletters 7092689b36 收口 AGC 生命周期超时隔离与建项恢复
Project CI / Repository checks (push) Successful in 3m11s
Project CI / Frontend tests (push) Successful in 4m4s
Project CI / Backend tests (push) Successful in 6m14s
Project CI / Native shell tests (push) Failing after 14m20s
## 变更内容

在 master(0f829cd25)上用故障注入复现出四类"每走一步都会卡住"的生命周期残留,本轮按"超时后真正隔离并核对底层操作"收口,不再新增 operation 字段:

- 本地会话写入队列从"无限等上一次完成"改为带解围期限的闸门(60s)。Rust 侧 install/clear 本来就按 generation 单调拒绝更旧写入,所以渲染层只需保证新 generation 不被卡死的调用永久挡住;
- generation floor 读取不再把一次瞬时失败缓存成永久失败(原先 `??=` 缓存了已 reject 的 promise,导致同一渲染进程内后续登录/退出全部失败);
- 登录 UI 的 45 秒围栏只放弃等待、不放弃结果:本地运行时确实装好会话时界面跟随进入工作区,且迟到结果不会覆盖更新的登录尝试;
- 首页自动建项从 `deadlineMs: null` 改为 10 分钟兜底期限:到点解围并提示(底层创建继续在后台跑,迟到成功照常进项目),失败时保留 `scope.projectPath`、登记最近项目并新增「打开已创建的工作区」入口;底层创建未返回期间只挡"再建一个",不挡打开已有项目;
- 同步 `project.bootstrap` 权限文案断言、生命周期技术方案状态与 shared pitfalls。

## 验证

- `npm --prefix apps/ai-game-creator-shell run typecheck`(含 skill-pack、check-config)
- `vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(428/428,含新增 5 个故障注入回归;`project.bootstrap` 文案断言在此前 master 上确定性失败)
- 定向 `clientHttp`、`clientApi`、`clientOperation`、`recentProjectsModel`、`clientRuntimeErrorBoundary`、`sessionPreview`、`start-dev-stack`、`dev-port`、`start-tauri-dev`(全部通过)
- `npm run check:doc-index`、`npm run check:encoding`、`git diff --check`

原生 Runner/IPC 与真实 Provider 下的同一批时序未执行,本轮结论来自 deterministic surface 与 mock 故障注入。

Co-authored-by: DotCraft <273930855+dotcraft-ai@users.noreply.github.com>
2026-09-14 14:49:45 +08:00
lhk229 896cfad81e Merge remote-tracking branch 'origin/master' into opt/design_agent 2026-09-14 14:47:57 +08:00
lhk229 9f40f044a5 修复策划 Agent 推理展示生命周期
按项目与回合绑定 reasoning 事件

保留回合结束后的思考过程并在新回合清理

补充默认折叠的展开收起交互与定向测试

更新推理与正文分离里程碑进度
2026-09-14 14:47:33 +08:00
lhk229 f7c8b9f217 修复策划 Agent 正文流式收尾
让最终 DesignView 等待临时正文播放完成后再提交正式消息

为非流式返回复用策划 Agent 的前端伪流式显示

保持 reasoning、GameAgent 和其他 Agent 路径不变
2026-09-14 14:27:22 +08:00
lhk229 13e137191a 修复策划 Agent 事件监听时序
让策划 Agent 回合在调用 Runtime 前等待 design-agent-update 监听注册完成

覆盖普通消息、澄清、重试和阶段批准入口

保持 GameAgent、Direct/Codex 与退役入口行为不变
2026-09-14 14:11:43 +08:00
kdletters 0f829cd252 完成 DirectProject Phaser 迁移闭环 (#353)
Project CI / Repository checks (push) Failing after 2m44s
Project CI / Frontend tests (push) Failing after 2m53s
Project CI / Native shell tests (push) Failing after 6m39s
Project CI / Backend tests (push) Successful in 8m7s
## 变更

单 HTML → Phaser 4 + Vite 的迁移链路现在有明确且可执行的工程合同:Phaser/npm 目标固定走 DirectProject,旧 JSON Generator 继续只处理单文件 HTML。

- 新增受控 `project.bootstrap`,仅允许项目内 `game` 目录执行无参数 `npm install`,校验 package/lock、路径、npm 管理器并记录依赖指纹。
- `project.verify` 支持相对 `cwd`,构建前诊断缺失依赖,`cwd=game` 的 build 必须产出 `game/dist/index.html`。
- npm bootstrap 使用受控网络沙箱;通用 `command.exec npm install` 仍被拒绝。
- 同步 Runtime 工具广告、执行分发、审计、权限策略、DirectProject/Generator 提示、前端权限面板、TypeScript/Rust 契约和项目文档。
- Phaser 迁移提示覆盖 Scene、输入、敌人/守卫、波次、胜负、重开和桌面/移动双视口验收。

## 验证

- `npm run ai-game-creator-shell:typecheck`
- `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`
- `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check`
- `cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app`
- 定向 `project.verify` Rust 测试
- `npm run check:encoding`
- `npm run check:doc-index`
- `git diff --check`
- 真实 DirectProject:`npm install`、`npm run build` 成功,`game/dist/index.html` 存在。
- 浏览器试玩:桌面视口画布非空;移动视口触摸开火、键盘换道和重开逻辑均已观察确认。

真实项目没有纳入仓库提交;PR 只包含客户端合同、Runtime 能力和验证门禁。

Reviewed-on: #353
2026-09-14 13:57:24 +08:00
kdletters c146f7f99c 建立 AGC 稳定版生命周期基础合同 (#348)
Project CI / Repository checks (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
## 变更内容

这是基于 PR #346 的稳定版生命周期基础切换,承接已完成的 HTTP body timeout、Runner blocking worker、最近项目逐项刷新和首页跨页防重:

- 新增统一 `ClientOperation` 合同,固定 operationId、requestId、phase、deadline、scope、cancellable 和 stale 判定;
- 认证 refresh 投影 `auth-refresh` operation;登录、401 refresh、退出共用平台 session generation,并由 `auth-transition` 投影 Runner phase/成功/失败/不确定状态;
- 首页自动创建记录 draft、startMode、phase 和建项后的 project scope,页面卸载不会释放 operation;
- `.app/dev-stack.json` 增加 instanceId、repoRoot、服务级 dataDir/instanceId,AGC Vite marker 增加 repoRoot/processId/port;缺身份的旧状态拒绝 AGC 后端复用;
- 新增稳定版生命周期主规范、开发运维口径和 shared pitfalls。

## 验证

- `npm --prefix apps/ai-game-creator-shell run typecheck`
- `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/clientOperation.test.ts tests/clientApi.test.ts tests/clientHttp.test.ts tests/recentProjectsModel.test.ts tests/start-dev-stack.test.ts tests/dev-port.test.ts --reporter=dot`(61 passed,2 skipped)
- `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/appSurface.test.ts --reporter=dot`(423/423 passed)
- `node --check scripts/dev.mjs`
- `node --check apps/ai-game-creator-shell/scripts/start-dev-stack.mjs`
- `npm run check:doc-index`
- `npm run check:encoding`
- `git diff --check`

`scripts/dev.test.ts` 全量仍有 1 个 Windows 文件权限相关既有失败:bootstrap secret 测试在 Windows 上无法通过 chmod 模拟 0600;本变更未触及该逻辑。

Reviewed-on: #348
2026-09-14 13:56:56 +08:00
kdletters 818cfdba50 收敛项目客户端占用锁语义 (#344)
Project CI / Repository checks (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
本 PR 收敛项目锁的同进程重入语义,避免客户端内部调用等待自身持有的 `.agent/project.lock`。

变更:
- 同进程嵌套项目锁返回 advisory guard,不删除真实持有者锁。
- 跨进程占用、残留回收和权限分类保持不变。
- 增加回归测试与锁收敛里程碑/实施计划文档。

验证:
- 定向 Rust 锁测试通过。
- Runner owner 定向测试通过。
- cargo fmt --check 通过。
- npm run check:encoding 通过。
- git diff --check 通过。

后续里程碑:Runner 的 `.agent/runtime/execution-owner.lock` 迁移到统一项目占用锁仍需单独完成跨进程生命周期验证。

Reviewed-on: #344
2026-09-14 13:56:23 +08:00
lhk229 fd88e516b8 Merge branch 'master' into opt/design_agent
Project CI / Repository checks (pull_request) Successful in 2m51s
Project CI / Frontend tests (pull_request) Successful in 3m15s
Project CI / Backend tests (pull_request) Successful in 5m26s
Project CI / Native shell tests (pull_request) Successful in 21m10s
2026-09-14 13:24:15 +08:00
kdletters 86c1187ddc 放开 DirectProject Codex 沙箱权限 (#347)
Project CI / Repository checks (push) Successful in 3m5s
Project CI / Frontend tests (push) Successful in 3m39s
Project CI / Backend tests (push) Successful in 7m31s
Project CI / Native shell tests (push) Has been cancelled
## 变更内容

- DirectProject Codex app-server 使用 danger-full-access sandbox。
- 移除 workspaceWrite / writableRoots 与文件变更审批根白名单。
- DirectProject app-server 交互请求不再按 grant root 裁剪;首页只读对话、AGC 受控 MCP、凭据隔离和 Runtime 审计边界保持不变。
- 同步更新 DirectProject 提示词、技术方案和决策记录。

## 验证

- Rust 定向测试:49 passed,1 ignored。
- npm run check:encoding 通过。
- npm run check:doc-index 通过。
- git diff --check 通过。
- pre-push-master 门禁通过。

Reviewed-on: #347
2026-09-14 13:19:44 +08:00
kdletters 991b2a1104 修复 AGC 异步操作恢复闭环 (#346)
Project CI / Repository checks (push) Successful in 3m43s
Project CI / Backend tests (push) Successful in 6m16s
Project CI / Frontend tests (push) Successful in 3m54s
Project CI / Native shell tests (push) Successful in 20m0s
## 变更内容

AGC 登录、Runner 会话、最近项目检查和首页自动创建原先各自维护异步状态,响应体卡住、单目录变慢或切页会导致按钮长期 busy、项目列表整体不可用或重复创建项目。本 PR 将这些入口收口到可恢复的生命周期边界:

- 认证响应体读取增加独立超时,refresh singleflight 在失败后释放;
- Runner 会话安装/清除移到 blocking worker,登录/退出增加 45 秒 UI fence;
- 最近项目逐项检查并设置单项目超时,已完成行不受其它慢目录阻塞;
- 首页自动创建锁提升到 WorkspaceLauncher,切页后仍防重并保留状态;
- 新增异步闭环技术方案、body 卡住测试、逐行项目测试和跨页创建测试。

## 验证

- `npm --prefix apps/ai-game-creator-shell run typecheck`
- `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/clientHttp.test.ts tests/clientApi.test.ts tests/recentProjectsModel.test.ts --reporter=dot`
- `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/appSurface.test.ts --reporter=dot`
- `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`
- `npm run check:doc-index`
- `npm run check:encoding`
- `git diff --check`

`appSurface.test.ts` 390/390 通过;保留仓库既有 act/jsdom media warning。本 PR 未触发真实 Provider 或发布安装包。

Reviewed-on: #346
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-09-14 13:19:13 +08:00
lhk229 1b7bc95914 Merge remote-tracking branch 'origin/master' into opt/design_agent
Project CI / Repository checks (pull_request) Successful in 2m35s
Project CI / Frontend tests (pull_request) Successful in 3m20s
Project CI / Backend tests (pull_request) Successful in 8m8s
Project CI / Native shell tests (pull_request) Successful in 20m14s
2026-09-14 13:08:33 +08:00
lhk229 b4a9be4581 清理 reasoning 改造临时计划
删除已完成的五份实施计划

保留里程碑规范与最终验收证据
2026-09-14 13:03:21 +08:00
lhk229 e174b6dcf4 完成 reasoning 最终回归与验收
补充 Responses reasoning 与工具调用共存回归测试

补充策划 Runtime 重试清理 reasoning 测试

同步 platform-llm README 与策划 Agent 技术方案

记录第五轮验收证据与未验证边界
2026-09-14 13:01:19 +08:00
lhk229 e8f9929630 接通策划 Agent reasoning 展示
仅为策划 Provider 请求开启 reasoning 捕获

将流式与终态 reasoning 映射到已有折叠事件

补齐回合重试、错误和项目切换的状态清理测试
2026-09-14 12:46:30 +08:00
lhk229 deb327ce1f 锁定 Provider 适配层兼容行为
验证 neutral adapter 默认关闭 reasoning 捕获

补充带 reasoning 上游响应的正文流回归覆盖

确认 GameAgent 与 AGC Runtime 继续只消费正文字段
2026-09-14 12:31:29 +08:00
lhk229 28e4d6c627 Merge pull request '修复策划产物归类为文档' (#343) from opt/design-docs into master
Project CI / Repository checks (push) Successful in 3m11s
Project CI / Frontend tests (push) Successful in 3m45s
Project CI / Backend tests (push) Successful in 6m28s
Project CI / Native shell tests (push) Successful in 20m48s
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/343
2026-09-14 12:27:52 +08:00
lhk229 0635ddfdb1 实现 Provider reasoning 协议解析
解析 Chat 与 Responses 的 reasoning 字段和流式事件

保持正文、工具调用与 GameAgent 默认行为不变

补充 reasoning 分流、终态快照与兼容开关测试
2026-09-14 12:10:59 +08:00
lhk229 a7fa8e103a 修复策划产物归类为文档
Project CI / Repository checks (pull_request) Successful in 3m27s
Project CI / Frontend tests (pull_request) Successful in 4m7s
Project CI / Backend tests (pull_request) Successful in 6m50s
Project CI / Native shell tests (pull_request) Successful in 20m50s
将策划产物注册 kind 从 design-document 调整为 document

同步更新项目工具测试夹具
2026-09-14 03:57:20 +00:00
lhk229 6ab7047eff 建立 Provider 推理旁路契约
为 LlmStreamDelta 和 LlmRunResponse 增加独立 reasoning 字段

新增默认关闭的 reasoning 捕获开关并保持请求体不变

补齐 AGC、api-server 和 handoff 构造点及第一轮验收计划
2026-09-14 11:42:19 +08:00
lhk229 33336d6242 制定 Provider 推理与正文分离改造方案
新增 Issue #331 的分步里程碑方案

明确策划 Agent 与 GameAgent 的兼容边界

补充验收标准、风险与回滚点
2026-09-14 11:07:51 +08:00
201 changed files with 5962 additions and 7126 deletions
+202 -87
View File
@@ -27,9 +27,26 @@ env:
RUSTC_WRAPPER: ''
CARGO_BUILD_RUSTC_WRAPPER: ''
# job 声明顺序就是 runner 领取顺序,因此把最长尾的客户端 Rust 门禁排在前面,
# 让它在最少的等待下占用并发槽位;其余 job 按时长递减排列。
#
# 客户端(微信壳 / Expo 移动壳 / Tauri 桌面壳 / AI 游戏创作壳)门禁原先全部串在
# `Native shell tests` 一个 job 里,实测 18 分 37 秒。现在按门禁组拆成三个 job:
# `Native shell tests`(契约 + H5 / 微信 / 移动 / 桌面壳门禁 + 发布构建 smoke)、
# `AI game creator shell web tests`typecheck + 壳内测试)与
# `AI game creator shell Rust tests`AGC 壳 bin 单测分片并行 + agent-run smoke),
# 再把 AGC 壳依赖的共享 / 平台 crate 测试拆成 `AI game creator shell Rust crates`。
# 各自的命令与拆分前逐一对应,本地 `npm run check:native-shells` 仍是同一条串行序列。
#
# AGC 壳的 bin 单测(2466 条)按名单分 4 片、每片一个进程并行执行,片内保持
# `--test-threads=1`:当年线程并行会互相干扰的是进程内后台锁与异步终态,进程分片
# 天然隔离,每片另有独立 TMPDIR,因此不必再让整套用例串成一小时级的尾巴。
jobs:
repository-checks:
name: Repository checks
# 客户端 AGC 壳自身的 Rust 门禁:bin target 单测按名单分 4 片并行(片内仍保持
# `--test-threads=1`)加 agent-run smoke。这里不装 npm 依赖:壳 Rust 门禁与 smoke
# 只用 cargo 与 node 内建模块,也只需要 AGC 壳自己那份锁定依赖。
ai-game-creator-shell-rust-tests:
name: AI game creator shell Rust tests
runs-on: genarrative-ci
steps:
- name: Checkout full history from Gitea
@@ -41,76 +58,94 @@ jobs:
- name: Validate preinstalled CI job image and sandbox
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
- name: Resolve comparison base
- name: Prepare AI game creator shell Rust dependencies
shell: bash
run: |
set -euo pipefail
base_ref="$(node -e '
const fs = require("node:fs");
const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
process.stdout.write(event.pull_request?.base?.sha ?? event.before ?? "");
')"
if [[ -n "${base_ref}" && ! "${base_ref}" =~ ^0+$ ]]; then
git cat-file -e "${base_ref}^{commit}" 2>/dev/null || {
echo "comparison base commit is unavailable: ${base_ref}" >&2
# AGC 壳有独立 Cargo.lock,其 path 依赖已含 platform-llm、platform-agent、
# agent-runtime-core 与 shared-contracts,因此只锁这一份 manifest 就能覆盖壳测试
# 与 smoke 的全部第三方依赖;server-rs 那次预热归 crate 级 job,不在这里重复。
for attempt in $(seq 1 5); do
if cargo fetch --locked \
--target x86_64-unknown-linux-gnu \
--manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then
break
fi
if [[ "${attempt}" -eq 5 ]]; then
echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2
exit 1
}
else
base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)"
fi
resolved_base_ref="$(git rev-parse --verify "${base_ref}^{commit}" 2>/dev/null || true)"
head_ref="$(git rev-parse HEAD)"
if [[ "${resolved_base_ref}" == "${head_ref}" ]]; then
resolved_base_ref="$(git rev-parse --verify HEAD^ 2>/dev/null || true)"
fi
if [[ -z "${resolved_base_ref}" ]]; then
echo 'comparison base must resolve to a commit distinct from HEAD.' >&2
exit 1
fi
base_ref="${resolved_base_ref}"
if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \
&& ! git merge-base --is-ancestor "${base_ref}" HEAD; then
echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2
exit 1
fi
echo "SPACETIME_SCHEMA_BASE_REF=${base_ref}" >> "${GITHUB_ENV}"
fi
sleep $((attempt * 2))
done
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Run AI game creator shell Rust gates
run: npm run check:native-shells:agc-rust-shell
- name: Run repository checks
run: npm run check:repository-ci
frontend-tests:
name: Frontend tests
# AGC 壳依赖的共享 / 平台 crate 测试用的是 server-rs workspace 与两个无锁独立 crate
# 的 manifest,属另一套依赖图,因此单独一个 job 预热、单独跑。
ai-game-creator-shell-rust-crates:
name: AI game creator shell Rust crates
runs-on: genarrative-ci
steps:
- name: Checkout source from Gitea
- name: Checkout full history from Gitea
env:
GENARRATIVE_GITEA_FETCH_DEPTH: '1'
GENARRATIVE_GITEA_FETCH_DEPTH: '0'
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: genarrative-gitea-checkout
- name: Validate preinstalled CI job image and sandbox
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Prepare server-rs Rust dependencies
shell: bash
run: |
set -euo pipefail
for attempt in $(seq 1 5); do
if cargo fetch --locked \
--target x86_64-unknown-linux-gnu \
--manifest-path server-rs/Cargo.toml; then
break
fi
if [[ "${attempt}" -eq 5 ]]; then
echo 'server-rs Cargo dependency fetch failed after 5 attempts.' >&2
exit 1
fi
sleep $((attempt * 2))
done
- name: Run frontend and script tests
run: npm run test
- name: Prepare standalone Rust crate dependencies
shell: bash
run: |
set -euo pipefail
# agent-runtime-core / agent-runtime-orchestration 被 server-rs/Cargo.toml 的
# exclude 排除,不参与上面的 workspace 锁文件,因此上面那次锁定 fetch 覆盖不到它们;
# 而 `npm run ai-game-creator-shell:check:rust:crates` 会用
# `cargo test --manifest-path` 单独跑这两个 crate。不在这里预热的话,这两条测试
# 会在测试阶段自己 `Updating crates.io index`crates.io 一抖动整条 job 就红
# (见 #327 / PR #316 run 1950)。
# 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
# 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内,
# 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本
# 解析,不再触碰 registry index。
for manifest_path in \
server-rs/crates/agent-runtime-core/Cargo.toml \
server-rs/crates/agent-runtime-orchestration/Cargo.toml; do
for attempt in $(seq 1 5); do
if cargo fetch \
--target x86_64-unknown-linux-gnu \
--manifest-path "${manifest_path}"; then
break
fi
if [[ "${attempt}" -eq 5 ]]; then
echo "standalone crate dependency fetch failed after 5 attempts: ${manifest_path}" >&2
exit 1
fi
sleep $((attempt * 2))
done
done
- name: Run BgFilter worker smoke harness tests
run: npm run bgfilter-worker:smoke-test
- name: Validate production health patrol behavior
run: npm run check:production-health-patrol
- name: Validate production API release behavior
run: npm run check:production-api-release
- name: Validate production API deploy behavior
run: npm run check:production-api-deploy
- name: Run AI game creator shell shared crate gates
run: npm run check:native-shells:agc-rust-crates
backend-tests:
name: Backend tests
@@ -194,6 +229,8 @@ jobs:
- name: Check SpacetimeDB module
run: cargo check --locked -p spacetime-module --manifest-path server-rs/Cargo.toml
# 客户端的壳级与契约门禁:静态契约断言、H5 / 微信 / 移动 / 桌面壳运行时门禁,
# 以及依赖发布产物的构建 smoke。
native-shell-tests:
name: Native shell tests
runs-on: genarrative-ci
@@ -215,7 +252,6 @@ jobs:
run: |
set -euo pipefail
for manifest_path in \
server-rs/Cargo.toml \
apps/desktop-shell/src-tauri/Cargo.toml \
apps/ai-game-creator-shell/src-tauri/Cargo.toml; do
for attempt in $(seq 1 5); do
@@ -232,39 +268,118 @@ jobs:
done
done
- name: Prepare standalone Rust crate dependencies
shell: bash
run: |
set -euo pipefail
# agent-runtime-core / agent-runtime-orchestration 被 server-rs/Cargo.toml 的
# exclude 排除,不参与上面的 workspace 锁文件,因此上面那次锁定 fetch 覆盖不到它们;
# 而 check:native-shells 会经 agent-runtime-*:check 用 `cargo test --manifest-path`
# 单独跑这两个 crate。不在这里预热的话,这两条测试会在测试阶段自己
# `Updating crates.io index`crates.io 一抖动整条 native shell 作业就红
# (见 #327 / PR #316 run 1950)。
# 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
# 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内,
# 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本
# 解析,不再触碰 registry index。
for manifest_path in \
server-rs/crates/agent-runtime-core/Cargo.toml \
server-rs/crates/agent-runtime-orchestration/Cargo.toml; do
for attempt in $(seq 1 5); do
if cargo fetch \
--target x86_64-unknown-linux-gnu \
--manifest-path "${manifest_path}"; then
break
fi
if [[ "${attempt}" -eq 5 ]]; then
echo "standalone crate dependency fetch failed after 5 attempts: ${manifest_path}" >&2
exit 1
fi
sleep $((attempt * 2))
done
done
- name: Run native shell contract gates
run: npm run check:native-shells:contract
- name: Run native shell gates
run: npm run check:native-shells
run: npm run check:native-shells:shells
- name: Run native shell release build smoke
run: npm run check:native-shells:release
- name: Ensure native lockfiles are unchanged
run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock
frontend-tests:
name: Frontend tests
runs-on: genarrative-ci
steps:
- name: Checkout source from Gitea
env:
GENARRATIVE_GITEA_FETCH_DEPTH: '1'
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: genarrative-gitea-checkout
- name: Validate preinstalled CI job image and sandbox
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Run frontend and script tests
run: npm run test
- name: Run BgFilter worker smoke harness tests
run: npm run bgfilter-worker:smoke-test
- name: Validate production health patrol behavior
run: npm run check:production-health-patrol
- name: Validate production API release behavior
run: npm run check:production-api-release
- name: Validate production API deploy behavior
run: npm run check:production-api-deploy
repository-checks:
name: Repository checks
runs-on: genarrative-ci
steps:
- name: Checkout full history from Gitea
env:
GENARRATIVE_GITEA_FETCH_DEPTH: '0'
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: genarrative-gitea-checkout
- name: Validate preinstalled CI job image and sandbox
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
- name: Resolve comparison base
shell: bash
run: |
set -euo pipefail
base_ref="$(node -e '
const fs = require("node:fs");
const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
process.stdout.write(event.pull_request?.base?.sha ?? event.before ?? "");
')"
if [[ -n "${base_ref}" && ! "${base_ref}" =~ ^0+$ ]]; then
git cat-file -e "${base_ref}^{commit}" 2>/dev/null || {
echo "comparison base commit is unavailable: ${base_ref}" >&2
exit 1
}
else
base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)"
fi
resolved_base_ref="$(git rev-parse --verify "${base_ref}^{commit}" 2>/dev/null || true)"
head_ref="$(git rev-parse HEAD)"
if [[ "${resolved_base_ref}" == "${head_ref}" ]]; then
resolved_base_ref="$(git rev-parse --verify HEAD^ 2>/dev/null || true)"
fi
if [[ -z "${resolved_base_ref}" ]]; then
echo 'comparison base must resolve to a commit distinct from HEAD.' >&2
exit 1
fi
base_ref="${resolved_base_ref}"
if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \
&& ! git merge-base --is-ancestor "${base_ref}" HEAD; then
echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2
exit 1
fi
echo "SPACETIME_SCHEMA_BASE_REF=${base_ref}" >> "${GITHUB_ENV}"
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Run repository checks
run: npm run check:repository-ci
# 客户端的 AI 游戏创作壳前端门禁:typecheck 与壳内测试,不触碰 Cargo。
ai-game-creator-shell-web-tests:
name: AI game creator shell web tests
runs-on: genarrative-ci
steps:
- name: Checkout full history from Gitea
env:
GENARRATIVE_GITEA_FETCH_DEPTH: '0'
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: genarrative-gitea-checkout
- name: Validate preinstalled CI job image and sandbox
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Run AI game creator shell web gates
run: npm run check:native-shells:agc-web
@@ -108,8 +108,6 @@ const rustSharedContractSource = fs.readFileSync(
);
const allowedUncalledTauriCommands = [
'append_direct_project_conversation_message',
// TODO: Remove the retired binding command after the legacy runtime path is removed.
'bind_components',
'chat_with_game_creator_agent',
'check_ui_editor_font_glyph_coverage',
'create_ui_design_resource',
@@ -0,0 +1,426 @@
#!/usr/bin/env node
// AGC 壳 Rust 套件的分片运行器。
//
// 背景:AGC 壳的 Rust 单测集中在 src/main.rs 的 bin target(实测 2466 条),仓库口径用
// `--test-threads=1` 跑,理由是并行调度会让共享 Agent Runtime 后台锁与异步终态的用例在
// **同一进程内**互相干扰(见 development-workflow 的 Tauri suite 单线程口径)。代价是
// 整套用例串行跑满 507 秒,占掉 CI 上 `AI game creator shell Rust tests` job 的大头。
//
// 这里保留「片内串行」的既有口径,只把用例集合切成 N 片、让每片在**独立进程**里并行:
// 当年线程并行的两个根因(进程内全局锁、异步终态)在多进程下不存在,每片还会拿到自己的
// TMPDIR,避免 tempfile 目录互相踩。片并集必须等于全集、且不得重复,数量不符即失败,
// 防止分片规则改动后静默漏跑。
//
// 用法:
// node scripts/run-rust-shell-test-shards.mjs [--shards=4] [--concurrency=4]
// node scripts/run-rust-shell-test-shards.mjs --manifest=<Cargo.toml> --target-kind=lib --no-locked
//
// 参数:
// --shards=<n> 分片数,默认 4
// --concurrency=<n> 同时运行的片数,默认等于分片数
// --manifest=<path> Cargo.toml,默认 ../src-tauri/Cargo.toml(相对本脚本)
// --target-kind=<k> bin | lib,默认 bin(本地自测小 crate 时用 lib
// --bin=<name> bin target 名,默认 genarrative-ai-game-creator-shell
// --package=<name> target-kind=lib 时要跑的包名(配合 lib 目标使用)
// --no-locked 传给 cargo 时不带 --locked(只对没有提交 Cargo.lock 的 crate 需要)
// --shard-tmp-root=<path> 片专属 TMPDIR 的父目录,默认 <系统临时目录>/agc-rust-shards
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const shellRoot = path.resolve(scriptDirectory, '..');
function fail(message) {
console.error(`[rust-shards] ${message}`);
process.exit(1);
}
function parsePositiveInteger(name, rawValue) {
if (!/^[1-9][0-9]*$/.test(rawValue)) {
fail(`${name} must be a positive integer, received: ${rawValue}`);
}
return Number(rawValue);
}
const options = {
shards: 4,
concurrency: undefined,
manifestPath: path.join(shellRoot, 'src-tauri', 'Cargo.toml'),
targetKind: 'bin',
binName: 'genarrative-ai-game-creator-shell',
packageName: undefined,
locked: true,
shardTmpRoot: path.join(os.tmpdir(), 'agc-rust-shards'),
};
for (const rawArgument of process.argv.slice(2)) {
if (rawArgument === '--no-locked') {
options.locked = false;
continue;
}
const separatorIndex = rawArgument.indexOf('=');
if (!rawArgument.startsWith('--') || separatorIndex === -1) {
fail(`unexpected argument: ${rawArgument}`);
}
const name = rawArgument.slice(2, separatorIndex);
const value = rawArgument.slice(separatorIndex + 1);
switch (name) {
case 'shards':
options.shards = parsePositiveInteger('--shards', value);
break;
case 'concurrency':
options.concurrency = parsePositiveInteger('--concurrency', value);
break;
case 'manifest':
options.manifestPath = path.resolve(process.cwd(), value);
break;
case 'target-kind':
if (value !== 'bin' && value !== 'lib') {
fail(`--target-kind must be bin or lib, received: ${value}`);
}
options.targetKind = value;
break;
case 'bin':
options.binName = value;
break;
case 'package':
options.packageName = value;
break;
case 'shard-tmp-root':
options.shardTmpRoot = path.resolve(process.cwd(), value);
break;
default:
fail(`unexpected argument: ${rawArgument}`);
}
}
if (!fs.existsSync(options.manifestPath)) {
fail(`manifest does not exist: ${options.manifestPath}`);
}
const concurrency = options.concurrency ?? options.shards;
const crateRoot = path.dirname(options.manifestPath);
function buildCargoArguments(target) {
const cargoArguments = ['test'];
if (options.locked) {
cargoArguments.push('--locked');
}
cargoArguments.push('--manifest-path', options.manifestPath);
if (target.kind === 'lib') {
if (options.packageName !== undefined) {
cargoArguments.push('-p', options.packageName);
}
cargoArguments.push('--lib');
return cargoArguments;
}
cargoArguments.push('--bin', target.name);
return cargoArguments;
}
function formatDuration(milliseconds) {
return `${(milliseconds / 1000).toFixed(1)}s`;
}
// 编译一次,直接拿到测试可执行文件:后续每片都运行同一个二进制,不再各自调用 cargo,
// 免得 N 个 cargo 去争 package cache 与 target 目录锁。
function resolveTestExecutable() {
return new Promise((resolve, reject) => {
const cargoArguments = buildCargoArguments({
kind: options.targetKind,
name: options.binName,
});
cargoArguments.push('--no-run', '--message-format=json');
console.log(`[rust-shards] cargo ${cargoArguments.join(' ')}`);
const child = spawn('cargo', cargoArguments, {
cwd: crateRoot,
env: process.env,
stdio: ['ignore', 'pipe', 'inherit'],
});
let buffered = '';
const executables = [];
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
buffered += chunk;
const lines = buffered.split('\n');
buffered = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('{')) {
continue;
}
let message;
try {
message = JSON.parse(line);
} catch {
continue;
}
if (
message.reason === 'compiler-artifact' &&
typeof message.executable === 'string'
) {
executables.push(message.executable);
}
}
});
child.on('error', (error) => {
reject(new Error(`unable to start cargo: ${error.message}`));
});
child.on('close', (code) => {
if (code !== 0) {
reject(
new Error(
`cargo ${cargoArguments.join(' ')} failed with exit code ${code}`,
),
);
return;
}
const uniqueExecutables = [...new Set(executables)];
if (uniqueExecutables.length !== 1) {
reject(
new Error(
`expected exactly one test executable for the ${options.targetKind} target, found ${uniqueExecutables.length}: ${uniqueExecutables.join(', ')}`,
),
);
return;
}
resolve(uniqueExecutables[0]);
});
});
}
function listTestNames(executable) {
return new Promise((resolve, reject) => {
const child = spawn(executable, ['--list'], {
cwd: crateRoot,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
stdout += chunk;
});
child.stderr.on('data', (chunk) => {
stderr += chunk;
});
child.on('error', (error) => {
reject(new Error(`unable to list tests: ${error.message}`));
});
child.on('close', (code) => {
if (code !== 0) {
reject(
new Error(
`listing tests failed with exit code ${code}: ${stderr.trim()}`,
),
);
return;
}
const names = [];
for (const line of stdout.split('\n')) {
const match = /^(.*): test$/.exec(line.trim());
if (match !== null && match[1].length > 0) {
names.push(match[1]);
}
}
resolve(names);
});
});
}
function splitTestNames(testNames, shardCount) {
const sortedNames = [...testNames].sort();
const shards = Array.from({ length: shardCount }, () => []);
sortedNames.forEach((testName, index) => {
shards[index % shardCount].push(testName);
});
return shards;
}
function assertShardsCoverEveryTest(testNames, shards) {
const flattened = shards.flat();
if (flattened.length !== testNames.length) {
fail(
`shard split covered ${flattened.length} of ${testNames.length} tests; the split rule must be exhaustive`,
);
}
if (new Set(flattened).size !== flattened.length) {
fail(
'shard split selected the same test more than once; the split rule must be disjoint',
);
}
const missing = testNames.filter((testName) => !flattened.includes(testName));
if (missing.length > 0) {
fail(
`shard split is missing tests, for example: ${missing.slice(0, 5).join(', ')}`,
);
}
}
function runShard(executable, shardIndex, shardCount, shardTestNames) {
const label = `shard ${shardIndex + 1}/${shardCount}`;
const shardTmpDirectory = path.join(
options.shardTmpRoot,
`shard-${shardIndex + 1}`,
);
fs.rmSync(shardTmpDirectory, { recursive: true, force: true });
fs.mkdirSync(shardTmpDirectory, { recursive: true });
const startedAt = Date.now();
return new Promise((resolve) => {
const child = spawn(
executable,
['--exact', ...shardTestNames, '--test-threads=1'],
{
cwd: crateRoot,
env: {
...process.env,
TMPDIR: shardTmpDirectory,
TMP: shardTmpDirectory,
TEMP: shardTmpDirectory,
},
stdio: ['ignore', 'pipe', 'pipe'],
},
);
const failureLines = [];
let inFailureList = false;
let stderr = '';
const consumeLine = (rawLine) => {
const line = rawLine.replace(/\r$/, '');
if (line.includes('failures:')) {
inFailureList = true;
return;
}
if (inFailureList) {
if (line.trim().length === 0) {
inFailureList = false;
return;
}
failureLines.push(line.trim());
}
};
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
let stdoutBuffer = '';
child.stdout.on('data', (chunk) => {
stdoutBuffer += chunk;
const lines = stdoutBuffer.split('\n');
stdoutBuffer = lines.pop() ?? '';
for (const line of lines) {
consumeLine(line);
}
});
child.stderr.on('data', (chunk) => {
stderr += chunk;
});
child.on('error', (error) => {
resolve({
label,
ok: false,
durationMs: Date.now() - startedAt,
testCount: shardTestNames.length,
failures: [`unable to start test binary: ${error.message}`],
stderr,
});
});
child.on('close', (code) => {
resolve({
label,
ok: code === 0,
durationMs: Date.now() - startedAt,
testCount: shardTestNames.length,
failures: failureLines,
stderr,
});
});
});
}
async function runWithConcurrency(shards, runner) {
const results = new Array(shards.length);
let nextIndex = 0;
const workers = Array.from(
{ length: Math.min(concurrency, shards.length) },
async () => {
while (nextIndex < shards.length) {
const index = nextIndex;
nextIndex += 1;
results[index] = await runner(shards[index], index);
}
},
);
await Promise.all(workers);
return results;
}
async function main() {
const executable = await resolveTestExecutable();
const testNames = await listTestNames(executable);
if (testNames.length === 0) {
fail(
`no tests discovered in ${options.manifestPath} (${options.targetKind})`,
);
}
const shards = splitTestNames(testNames, options.shards);
assertShardsCoverEveryTest(testNames, shards);
console.log(
`[rust-shards] ${testNames.length} tests, ${shards.length} shard(s), concurrency ${Math.min(concurrency, shards.length)}`,
);
shards.forEach((shardTestNames, index) => {
console.log(
`[rust-shards] shard ${index + 1}/${shards.length}: ${shardTestNames.length} test(s)`,
);
});
const results = await runWithConcurrency(shards, (shardTestNames, index) =>
runShard(executable, index, shards.length, shardTestNames),
);
let failed = false;
for (const result of results) {
if (result.ok) {
console.log(
`[rust-shards] ${result.label} ok: ${result.testCount} test(s) in ${formatDuration(result.durationMs)}`,
);
continue;
}
failed = true;
console.error(
`[rust-shards] ${result.label} FAILED: ${result.testCount} test(s) in ${formatDuration(result.durationMs)}`,
);
for (const failure of result.failures) {
console.error(`[rust-shards] ${failure}`);
}
if (result.stderr.trim().length > 0) {
console.error(
`[rust-shards] stderr: ${result.stderr.trim().split('\n').slice(-20).join('\n[rust-shards] ')}`,
);
}
}
if (failed) {
process.exit(1);
}
console.log('[rust-shards] OK');
}
main().catch((error) => {
fail(error instanceof Error ? error.message : String(error));
});
@@ -84,6 +84,7 @@ function resolveBackendTargetsFromState(
requireAgcBackend = false,
expectedDatabase = backendDatabase,
expectedSpacetimeDataDir = backendSpacetimeDataDir,
expectedRepoRoot = repoRoot,
fallbackApiTarget = defaultApiTarget,
} = {},
) {
@@ -101,7 +102,25 @@ function resolveBackendTargetsFromState(
const hasMatchingDataDir =
Boolean(spacetimeDataDir) &&
spacetimeDataDir === resolve(expectedSpacetimeDataDir);
const hasMatchingBackend = hasMatchingDatabase && hasMatchingDataDir;
const instanceId =
typeof state?.instanceId === 'string' ? state.instanceId.trim() : '';
const hasMatchingRepoRoot =
typeof state?.repoRoot === 'string' &&
resolve(state.repoRoot) === resolve(expectedRepoRoot);
const hasMatchingInstance =
Boolean(instanceId) &&
[apiServer, spacetime, bgfilterWorker]
.filter(Boolean)
.every(
(service) =>
service.repoRoot &&
resolve(service.repoRoot) === resolve(expectedRepoRoot) &&
service.instanceId === instanceId,
);
const hasMatchingBackend =
hasMatchingDatabase &&
hasMatchingDataDir &&
(!requireAgcBackend || (hasMatchingRepoRoot && hasMatchingInstance));
const canReuseState = !requireAgcBackend || hasMatchingBackend;
const apiUrl =
canReuseState && isActive(apiServer) && apiServer.url
@@ -127,6 +146,8 @@ function resolveBackendTargetsFromState(
spacetimeDataDir,
hasMatchingDatabase,
hasMatchingDataDir,
hasMatchingRepoRoot,
hasMatchingInstance,
hasMatchingBackend,
};
}
@@ -1,6 +1,6 @@
处理代码任务时先用 project.search 定位,再用带行号的 file.read 获取足够上下文;单文件小改优先使用 file.patch;涉及多个文件时优先使用 project.patchset,它会自动创建 checkpoint,无需额外调用 project.checkpoint,并在成功后用返回的 checkpointId 调用 project.diff(includeContent=true) 审查整体变更;只有确认文件已废弃时才删除。
每次成功执行 file.write、file.patch、file.delete、project.patchset 或 project.restore,以及每次真正启动 command.execcommand.start,都会产生新的项目 revision最后一次修改后必须成功执行 project.verify、可验证 command.exec,或成功执行 command.run_limited 的 game.static_smoke,才能调用 respond_to_user 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。需要执行 package.json 中的验证脚本时,先读取 package.json,再把真实脚本名和读到的完整命令原样提交给 project.verifyscript 可以是 check、typecheck、test、lint、build,或使用 check:<name>、test:<name>(例如 test:unit)、lint:<name>、typecheck:<name>、build:<name>、verify:<name>、validate:<name> 形式的命名脚本,其中冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点;不得猜测或改写 expectedCommand。
每次成功执行 file.write、file.patch、file.delete、project.patchset 或 project.restore,以及每次真正启动 command.execcommand.start 或 project.bootstrap,都会产生新的项目 revisionDirectProject 的 npm/Phaser 工程先用 project.bootstrap {cwd:"game"} 执行受控无参数 npm install,再用 project.verify {cwd:"game",script:"build",expectedCommand:从 game/package.json 原样读取} 构建,并确认 game/dist/index.html 存在。最后一次修改后必须成功执行 project.verify、可验证 command.exec,或成功执行 command.run_limited 的 game.static_smoke,才能调用 respond_to_user 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。需要执行 package.json 中的验证脚本时,先读取 package.json,再把真实脚本名和读到的完整命令原样提交给 project.verifyscript 可以是 check、typecheck、test、lint、build,或使用 check:<name>、test:<name>(例如 test:unit)、lint:<name>、typecheck:<name>、build:<name>、verify:<name>、validate:<name> 形式的命名脚本,其中冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点;不得猜测或改写 expectedCommand。
每 6 轮只是一次进度 checkpoint 与停滞检测,不是上下文压缩或 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。真正的上下文压缩仅由 token 阈值或显式 compact 触发。Agent 私有记忆只能由本人写入,跨 Agent 共享稳定结论用 blackboard.write,给单个 Agent 留上下文用 agent.message。
@@ -1378,15 +1378,19 @@ fn codex_app_server_thread_start_params(
base_instructions: String,
use_model_provider: bool,
) -> serde_json::Value {
// DirectProject is an autonomous Codex session. The app-server sandbox
// remains the hard write boundary; approval prompts are not a second
// harness that can stall a turn. DirectHome/ToolHost stay passive.
// DirectProject is an autonomous Codex session with explicit full OS
// access; approval prompts are not a second harness that can stall a turn.
// DirectHome/ToolHost stay passive.
let approval_policy = "never";
let mut params = serde_json::json!({
"model": model,
"cwd": workspace_path,
"approvalPolicy": approval_policy,
"sandbox": if workspace_mode.allows_workspace_writes() { "workspace-write" } else { "read-only" },
"sandbox": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
"danger-full-access"
} else {
"read-only"
},
"ephemeral": true,
"baseInstructions": base_instructions
});
@@ -1404,7 +1408,6 @@ fn codex_app_server_turn_start_params(
thread_id: &str,
input: serde_json::Value,
model: &str,
workspace_path: &std::path::Path,
workspace_mode: CodexAppServerWorkspaceMode,
client_user_message_id: Option<&str>,
) -> serde_json::Value {
@@ -1415,14 +1418,12 @@ fn codex_app_server_turn_start_params(
"model": model,
"approvalPolicy": approval_policy,
});
if workspace_mode.allows_workspace_writes() {
// npm install/build must resolve project dependencies. Network access
// is enabled only for DirectProject; writableRoots keeps the file-write
// boundary at the real game workspace.
if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
// DirectProject is an explicitly user-selected local Codex session.
// Give the native Codex tools the full OS sandbox profile so they are
// not narrowed by a project-root writableRoots allowlist.
params["sandboxPolicy"] = serde_json::json!({
"type": "workspaceWrite",
"writableRoots": [workspace_path],
"networkAccess": true
"type": "dangerFullAccess"
});
}
if let Some(client_user_message_id) = client_user_message_id
@@ -1436,40 +1437,27 @@ fn codex_app_server_turn_start_params(
}
fn game_creator_codex_app_server_interaction_response(
workspace_path: &std::path::Path,
workspace_mode: CodexAppServerWorkspaceMode,
id: u64,
method: &str,
requested_grant_root: Option<&str>,
_method: &str,
_requested_grant_root: Option<&str>,
) -> serde_json::Value {
let direct_workspace = workspace_mode.allows_workspace_writes();
let file_change_within_workspace = game_creator_codex_file_change_request_is_allowed(
workspace_path,
method,
requested_grant_root,
);
if direct_workspace && file_change_within_workspace {
serde_json::json!({
if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
// Full-access DirectProject sessions do not use a file-root allowlist
// or a second approval gate. The declared sandbox policy is the only
// capability boundary for native Codex operations.
return serde_json::json!({
"id": id,
"result": { "decision": "accept" }
})
} else if direct_workspace && method == "item/fileChange/requestApproval" {
serde_json::json!({
"id": id,
"error": {
"code": -32602,
"message": "Genarrative AGC 只允许当前项目工作区的文件变更审批"
}
})
} else {
serde_json::json!({
"id": id,
"error": {
"code": -32601,
"message": "Genarrative AGC 拒绝 app-server 的交互、审批与工具请求"
}
})
});
}
serde_json::json!({
"id": id,
"error": {
"code": -32601,
"message": "Genarrative AGC 拒绝 app-server 的交互、审批与工具请求"
}
})
}
#[cfg(test)]
@@ -2734,7 +2722,6 @@ impl CodexAppServerConnection {
&thread_id,
input,
model,
&self.inner.workspace_path,
self.inner.workspace_mode,
direct_client_turn_id,
);
@@ -2842,6 +2829,8 @@ impl CodexAppServerConnection {
callback(&platform_llm::LlmStreamDelta {
accumulated_text: streamed_text.clone(),
delta_text: delta,
accumulated_reasoning: String::new(),
reasoning_delta: String::new(),
finish_reason: None,
});
}
@@ -3146,6 +3135,7 @@ fn parse_game_creator_codex_app_server_text(
} else {
String::new()
},
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: Some(thread_id.to_string()),
usage: None,
@@ -3293,7 +3283,6 @@ async fn read_game_creator_codex_app_server_stdout(
);
}
let response = game_creator_codex_app_server_interaction_response(
&inner.workspace_path,
inner.workspace_mode,
id,
method,
@@ -3635,82 +3624,6 @@ async fn read_game_creator_codex_app_server_stderr(
}
}
#[cfg(windows)]
fn game_creator_codex_workspace_path_key(path: &std::path::Path) -> String {
let value = path.to_string_lossy();
value
.strip_prefix("\\\\?\\")
.unwrap_or(value.as_ref())
.replace('/', "\\")
.trim_end_matches('\\')
.to_ascii_lowercase()
}
fn game_creator_codex_grant_root_is_within_workspace(
workspace: &std::path::Path,
grant_root: &str,
) -> bool {
fn canonicalize_with_missing_tail(path: &std::path::Path) -> Option<std::path::PathBuf> {
if !path.is_absolute()
|| path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return None;
}
let mut existing = path.to_path_buf();
let mut missing_tail = Vec::new();
while !existing.exists() {
missing_tail.push(existing.file_name()?.to_os_string());
if !existing.pop() {
return None;
}
}
let mut normalized = existing.canonicalize().ok()?;
for component in missing_tail.iter().rev() {
normalized.push(component);
}
Some(normalized)
}
let workspace = workspace
.canonicalize()
.unwrap_or_else(|_| workspace.to_path_buf());
let Some(grant_root) = canonicalize_with_missing_tail(std::path::Path::new(grant_root)) else {
return false;
};
#[cfg(windows)]
{
let workspace_key = game_creator_codex_workspace_path_key(&workspace);
let grant_key = game_creator_codex_workspace_path_key(&grant_root);
grant_key == workspace_key
|| grant_key
.strip_prefix(&workspace_key)
.is_some_and(|suffix| suffix.starts_with('\\'))
}
#[cfg(not(windows))]
{
grant_root == workspace || grant_root.starts_with(&workspace)
}
}
fn game_creator_codex_file_change_request_is_allowed(
workspace: &std::path::Path,
method: &str,
grant_root: Option<&str>,
) -> bool {
if method != "item/fileChange/requestApproval" {
return false;
}
// `grantRoot: null` means the already-declared turn sandbox root. It is
// valid only for file changes; it must never authorize another capability
// or a broader permission request.
grant_root.is_none()
|| grant_root.is_some_and(|grant_root| {
game_creator_codex_grant_root_is_within_workspace(workspace, grant_root)
})
}
async fn fail_game_creator_codex_app_server_connection(
inner: &Weak<CodexAppServerInner>,
error: String,
@@ -4481,7 +4394,6 @@ mod tests {
"home-thread",
serde_json::json!([{ "type": "text", "text": "你好" }]),
"fixture-model",
workspace,
CodexAppServerWorkspaceMode::DirectHome,
None,
);
@@ -4495,7 +4407,6 @@ mod tests {
"item/permissions/requestApproval",
] {
let response = game_creator_codex_app_server_interaction_response(
workspace,
CodexAppServerWorkspaceMode::DirectHome,
7,
method,
@@ -4513,14 +4424,10 @@ mod tests {
}
#[test]
fn direct_project_protocol_and_interactions_expose_only_the_real_game_workspace() {
fn direct_project_protocol_uses_full_access_without_a_root_allowlist() {
let temp = tempfile::tempdir().expect("temp dir");
let project_root = temp.path().join("project");
let assets = project_root.join("assets");
let agent = project_root.join(".agent");
std::fs::create_dir_all(&project_root).expect("project root");
std::fs::create_dir(&assets).expect("assets directory");
std::fs::create_dir(&agent).expect("agent directory");
let workspace =
resolve_direct_codex_game_workspace(&project_root).expect("resolve project workspace");
assert_eq!(
@@ -4536,83 +4443,40 @@ mod tests {
true,
);
assert_eq!(thread["cwd"], serde_json::json!(workspace));
assert_eq!(thread["sandbox"], "workspace-write");
assert_eq!(thread["sandbox"], "danger-full-access");
let turn = codex_app_server_turn_start_params(
"project-thread",
serde_json::json!([{ "type": "text", "text": "修复游戏" }]),
"fixture-model",
&workspace,
CodexAppServerWorkspaceMode::DirectProject,
Some("direct-turn-0001"),
);
assert_eq!(turn["clientUserMessageId"], "direct-turn-0001");
assert_eq!(
turn.pointer("/sandboxPolicy/writableRoots/0"),
Some(&serde_json::json!(workspace))
);
assert_eq!(
turn.pointer("/sandboxPolicy/networkAccess"),
Some(&serde_json::json!(true))
);
let authority_paths = [
turn.get("cwd"),
turn.pointer("/sandboxPolicy/writableRoots/0"),
];
assert!(
authority_paths
.iter()
.flatten()
.all(|value| value.as_str() == Some(workspace.to_string_lossy().as_ref())),
"writable params must be exactly the project workspace"
turn.pointer("/sandboxPolicy/type"),
Some(&serde_json::json!("dangerFullAccess"))
);
assert!(turn.pointer("/sandboxPolicy/writableRoots").is_none());
assert!(turn.pointer("/sandboxPolicy/networkAccess").is_none());
let workspace_string = workspace.to_string_lossy().into_owned();
for allowed_root in [None, Some(workspace_string.as_str())] {
for (id, method) in [
(9, "item/fileChange/requestApproval"),
(10, "item/commandExecution/requestApproval"),
(11, "item/permissions/requestApproval"),
(12, "item/tool/call"),
] {
let response = game_creator_codex_app_server_interaction_response(
&workspace,
CodexAppServerWorkspaceMode::DirectProject,
9,
"item/fileChange/requestApproval",
allowed_root,
id,
method,
Some("C:\\outside-project"),
);
assert_eq!(
response.pointer("/result/decision"),
Some(&serde_json::json!("accept"))
);
}
for forbidden_root in [assets, agent] {
let forbidden_root = forbidden_root.to_string_lossy().into_owned();
let response = game_creator_codex_app_server_interaction_response(
&workspace,
CodexAppServerWorkspaceMode::DirectProject,
10,
"item/fileChange/requestApproval",
Some(&forbidden_root),
);
assert_eq!(
response.pointer("/result/decision"),
Some(&serde_json::json!("accept")),
"project-root children must stay writable: {forbidden_root}"
);
}
for method in [
"item/commandExecution/requestApproval",
"item/permissions/requestApproval",
"item/tool/call",
] {
for requested_root in [None, Some(workspace_string.as_str())] {
let response = game_creator_codex_app_server_interaction_response(
&workspace,
CodexAppServerWorkspaceMode::DirectProject,
11,
method,
requested_root,
);
assert!(response.get("error").is_some());
assert!(response.get("result").is_none());
}
}
}
#[test]
@@ -4630,26 +4494,6 @@ mod tests {
assert!(resolve_direct_codex_game_workspace(&file_root).is_err());
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn direct_project_grant_root_comparison_remains_case_sensitive() {
let temp = tempfile::tempdir().expect("temp dir");
let project_root = temp.path().join("Project");
let child = project_root.join("assets");
let different_case = temp.path().join("project").join("assets");
std::fs::create_dir_all(&child).expect("child directory");
std::fs::create_dir_all(&different_case).expect("different-case directory");
assert!(game_creator_codex_grant_root_is_within_workspace(
&project_root,
project_root.to_string_lossy().as_ref()
));
assert!(!game_creator_codex_grant_root_is_within_workspace(
&project_root,
different_case.to_string_lossy().as_ref()
));
}
#[cfg(unix)]
#[test]
fn direct_project_rejects_a_non_directory_workspace() {
@@ -5246,51 +5090,25 @@ while IFS= read -r line; do :; done
}
#[test]
fn direct_file_change_approval_is_limited_to_workspace() {
let temp = tempfile::tempdir().expect("temp dir");
let workspace = temp.path().join("demo");
let child = workspace.join("assets");
let sibling = temp.path().join("demolition");
std::fs::create_dir_all(&child).expect("workspace child");
std::fs::create_dir(&sibling).expect("sibling");
assert!(game_creator_codex_file_change_request_is_allowed(
&workspace,
fn direct_project_interactions_accept_full_access_without_a_root_allowlist() {
for method in [
"item/fileChange/requestApproval",
None
));
assert!(game_creator_codex_grant_root_is_within_workspace(
&workspace,
workspace.to_string_lossy().as_ref()
));
assert!(game_creator_codex_grant_root_is_within_workspace(
&workspace,
child.to_string_lossy().as_ref()
));
assert!(!game_creator_codex_grant_root_is_within_workspace(
&workspace,
sibling.to_string_lossy().as_ref()
));
assert!(!game_creator_codex_file_change_request_is_allowed(
&workspace,
"item/fileChange/requestApproval",
Some(sibling.to_string_lossy().as_ref())
));
assert!(!game_creator_codex_grant_root_is_within_workspace(
&workspace,
sibling.join("missing").to_string_lossy().as_ref()
));
assert!(game_creator_codex_grant_root_is_within_workspace(
&workspace,
workspace.join("missing").to_string_lossy().as_ref()
));
assert!(!game_creator_codex_grant_root_is_within_workspace(
&workspace,
workspace
.join("..")
.join("outside")
.to_string_lossy()
.as_ref()
));
"item/commandExecution/requestApproval",
"item/permissions/requestApproval",
"item/tool/call",
] {
let response = game_creator_codex_app_server_interaction_response(
CodexAppServerWorkspaceMode::DirectProject,
1,
method,
Some("C:\\outside-project"),
);
assert_eq!(
response.pointer("/result/decision"),
Some(&serde_json::json!("accept"))
);
assert!(response.get("error").is_none());
}
}
#[test]
@@ -589,6 +589,7 @@ fn parse_game_creator_codex_cli_response(
} else {
String::new()
},
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id,
usage,
@@ -49,6 +49,18 @@ pub(crate) struct DesignView {
messages: Vec<DesignMessage>,
running: bool,
can_retry: bool,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_text: Option<String>,
reasoning_entries: Vec<DesignReasoningEntry>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DesignReasoningEntry {
id: String,
text: String,
#[serde(skip_serializing_if = "Option::is_none")]
message_id: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
@@ -65,6 +77,7 @@ pub(crate) struct DesignEvent {
}
fn design_view(session: &DesignSession, running: bool) -> DesignView {
let reasoning_entries = persisted_design_reasoning_entries(session);
DesignView {
session: DesignSessionSummary {
session_id: session.session_id.clone(),
@@ -82,9 +95,124 @@ fn design_view(session: &DesignSession, running: bool) -> DesignView {
&& session.turn.as_ref().is_some_and(|turn| turn.pending)
&& session.pending_approval.is_none()
&& session.pending_clarification.is_none(),
reasoning_text: reasoning_entries.last().map(|entry| entry.text.clone()),
reasoning_entries,
}
}
fn reasoning_text_from_history_item(item: &Value) -> Option<String> {
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
return None;
}
let mut text = String::new();
if let Some(summary) = item.get("summary").and_then(Value::as_array) {
for part in summary {
if let Some(value) = part.get("text").and_then(Value::as_str) {
text.push_str(value.trim());
}
}
}
if let Some(content) = item.get("content").and_then(Value::as_array) {
for part in content {
let part_type = part.get("type").and_then(Value::as_str).unwrap_or_default();
if matches!(
part_type,
"reasoning" | "reasoning_content" | "reasoning_text" | "analysis" | "thinking"
) {
if let Some(value) = part.get("text").and_then(Value::as_str) {
text.push_str(value.trim());
}
}
}
}
(!text.trim().is_empty()).then_some(text)
}
fn persisted_design_reasoning_entries(session: &DesignSession) -> Vec<DesignReasoningEntry> {
// Responses history contains tool-only provider responses. Their reasoning is
// followed by function calls and only the next provider response may contain
// visible assistant text, so pairing on the next `message` item makes the
// earlier reasoning look like an orphan and moves it to the bottom of the UI.
// Both persisted streams retain user-turn boundaries; pair reasoning and
// visible assistant messages by their response order within each turn.
let mut assistant_groups: Vec<Vec<String>> = vec![Vec::new()];
for message in &session.messages {
if message.role == "user" {
assistant_groups.push(Vec::new());
} else if message.role == "assistant" {
assistant_groups
.last_mut()
.expect("assistant group always exists")
.push(message.id.clone());
}
}
let mut entries = Vec::new();
let mut group_index = 0;
let mut assistant_index = 0;
let mut sequence = 0_u64;
let mut current_reasoning = Vec::new();
let mut pending_reasoning = Vec::new();
let mut saw_response_output = false;
for item in &session.history {
if item.get("role").and_then(Value::as_str) == Some("user") {
if !pending_reasoning.is_empty() || !current_reasoning.is_empty() {
pending_reasoning.append(&mut current_reasoning);
}
group_index += 1;
assistant_index = 0;
saw_response_output = false;
continue;
}
if item.get("type").and_then(Value::as_str) == Some("reasoning") {
if saw_response_output {
pending_reasoning.append(&mut current_reasoning);
saw_response_output = false;
}
if let Some(text) = reasoning_text_from_history_item(item) {
sequence += 1;
current_reasoning.push(DesignReasoningEntry {
id: item
.get("id")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| format!("reasoning-{sequence}")),
text,
message_id: None,
});
}
continue;
}
if item.get("role").and_then(Value::as_str) == Some("assistant")
|| item.get("type").and_then(Value::as_str) == Some("message")
{
pending_reasoning.extend(current_reasoning.drain(..));
let assistant_id = assistant_groups
.get(group_index)
.and_then(|ids| ids.get(assistant_index))
.cloned();
assistant_index += 1;
for mut entry in pending_reasoning.drain(..) {
entry.message_id = assistant_id.clone();
entries.push(entry);
}
saw_response_output = false;
} else if item.get("type").is_some() {
saw_response_output = true;
}
}
pending_reasoning.append(&mut current_reasoning);
let fallback_id = assistant_groups
.get(group_index)
.and_then(|ids| ids.last())
.cloned();
for mut entry in pending_reasoning {
entry.message_id = fallback_id.clone();
entries.push(entry);
}
entries
}
fn design_event(
root: &Path,
turn_id: &str,
@@ -104,6 +232,17 @@ fn design_event(
}
}
fn design_reasoning_event(
root: &Path,
turn_id: &str,
id: Option<&str>,
reasoning: String,
) -> DesignEvent {
let mut event = design_event(root, turn_id, "reasoning", id, None, None);
event.reasoning_text = Some(reasoning);
event
}
fn design_project_id(root: &Path) -> Result<String, String> {
validate_project_root(root)?;
Ok(read_existing_manifest_for_project(root)?.project_id)
@@ -462,6 +601,7 @@ fn build_design_request(
.with_tool_choice(platform_llm::LlmToolChoice::Auto)
.with_web_search(false);
apply_game_creator_llm_reasoning_effort(request, llm)
.map(|request| request.with_reasoning_capture(true))
}
// 调试队列只接收副本,写盘慢或失败时丢弃,不参与会话恢复。
@@ -548,6 +688,12 @@ async fn request_design_provider(
Some(String::new()),
None,
));
emit(design_reasoning_event(
root,
&turn_id,
Some(&message_id),
String::new(),
));
let result = if llm.stream {
let mut stream_sequence = 0_u64;
client
@@ -565,18 +711,30 @@ async fn request_design_provider(
"model": llm.model,
"deltaChars": delta.delta_text.chars().count(),
"accumulatedChars": delta.accumulated_text.chars().count(),
"reasoningDeltaChars": delta.reasoning_delta.chars().count(),
"reasoningAccumulatedChars": delta.accumulated_reasoning.chars().count(),
"deltaText": delta.delta_text,
"finishReason": delta.finish_reason,
}),
);
emit(design_event(
root,
&turn_id,
"text",
Some(&message_id),
Some(delta.accumulated_text.clone()),
None,
));
if !delta.delta_text.is_empty() || delta.finish_reason.is_some() {
emit(design_event(
root,
&turn_id,
"text",
Some(&message_id),
Some(delta.accumulated_text.clone()),
None,
));
}
if !delta.reasoning_delta.is_empty() {
emit(design_reasoning_event(
root,
&turn_id,
Some(&message_id),
delta.accumulated_reasoning.clone(),
));
}
})
.await
} else {
@@ -584,6 +742,14 @@ async fn request_design_provider(
};
match result {
Ok(response) => {
if !response.reasoning.is_empty() {
emit(design_reasoning_event(
root,
&turn_id,
Some(&message_id),
response.reasoning.clone(),
));
}
design_debug(
root,
"response",
@@ -606,6 +772,12 @@ async fn request_design_provider(
|| game_creator_agent_runtime_transient_provider_error_kind(&error, false)
.is_none()
{
emit(design_reasoning_event(
root,
&turn_id,
Some(&message_id),
String::new(),
));
return Err(detail);
}
tokio::time::sleep(Duration::from_millis(
@@ -654,8 +826,24 @@ async fn request_scripted_design_provider(
Some(String::new()),
None,
));
emit(design_reasoning_event(
root,
&turn_id,
Some(&message_id),
String::new(),
));
match fake_provider::take() {
Some(Ok(response)) => return Ok(response),
Some(Ok(response)) => {
if !response.reasoning.is_empty() {
emit(design_reasoning_event(
root,
&turn_id,
Some(&message_id),
response.reasoning.clone(),
));
}
return Ok(response);
}
Some(Err(error)) => {
let detail = redact_agent_runtime_error(
root,
@@ -666,10 +854,24 @@ async fn request_scripted_design_provider(
|| game_creator_agent_runtime_transient_provider_error_kind(&error, false)
.is_none()
{
emit(design_reasoning_event(
root,
&turn_id,
Some(&message_id),
String::new(),
));
return Err(detail);
}
}
None => return Err("假 Provider 脚本耗尽".into()),
None => {
emit(design_reasoning_event(
root,
&turn_id,
Some(&message_id),
String::new(),
));
return Err("假 Provider 脚本耗尽".into());
}
}
}
unreachable!()
@@ -1285,6 +1487,7 @@ mod tests {
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: "fake-design".into(),
text: text.into(),
reasoning: String::new(),
finish_reason: Some(if calls.is_empty() {
"stop".into()
} else {
@@ -1345,6 +1548,110 @@ mod tests {
.clone()
}
#[test]
fn design_request_enables_reasoning_capture_only_for_design_runtime() {
let session = new_design_session("project", "quality");
let request = build_design_request(&session, &pack(), &GameCreatorLlmConfig::default())
.expect("design request");
assert!(request.capture_reasoning);
}
#[test]
fn persisted_reasoning_follows_response_order_across_tool_only_responses() {
let mut session = new_design_session("project", "quality");
session.messages = vec![
DesignMessage {
id: "turn:user".into(),
role: "user".into(),
text: "需求".into(),
},
DesignMessage {
id: "call-1:tool".into(),
role: "tool".into(),
text: "读取资源".into(),
},
DesignMessage {
id: "turn:response:0".into(),
role: "assistant".into(),
text: "给出方案".into(),
},
];
session.history = vec![
json!({"role":"user", "content":"需求"}),
json!({"type":"reasoning", "id":"r1", "content":[{"type":"reasoning_text", "text":"第一段思考"}]}),
json!({"type":"function_call", "call_id":"call-1", "name":"read_resource", "arguments":"{}"}),
json!({"type":"reasoning", "id":"r2", "content":[{"type":"reasoning_text", "text":"第二段思考"}]}),
json!({"type":"message", "role":"assistant", "content":[{"type":"output_text", "text":"给出方案"}]}),
];
let entries = persisted_design_reasoning_entries(&session);
assert_eq!(
entries
.iter()
.map(|entry| (entry.id.as_str(), entry.message_id.as_deref()))
.collect::<Vec<_>>(),
vec![
("r1", Some("turn:response:0")),
("r2", Some("turn:response:0")),
]
);
}
#[tokio::test(flavor = "current_thread")]
async fn scripted_design_provider_emits_reasoning_without_persisting_it() {
let (_temp, root, _resources) = init_design_project();
let mut session = new_design_session("design-fake", "quality");
begin_design_turn(&mut session, "turn-reasoning");
let mut response = fake_response("reasoning", "正文", Vec::new());
response.reasoning = "先分析需求,再组织方案。".into();
let _fake = fake_provider::install(vec![Ok(response)], 0);
let mut events = Vec::new();
let response =
request_scripted_design_provider(&root, &mut session, &mut |event| events.push(event))
.await
.expect("scripted provider");
let reasoning_events = events
.iter()
.filter_map(|event| event.reasoning_text.as_deref())
.collect::<Vec<_>>();
assert_eq!(reasoning_events, vec!["", "先分析需求,再组织方案。"]);
assert_eq!(response.text, "正文");
assert!(session.history.is_empty());
}
#[tokio::test(flavor = "current_thread")]
async fn scripted_design_provider_retry_clears_previous_reasoning_attempt() {
let (_temp, root, _resources) = init_design_project();
let mut session = new_design_session("design-fake", "quality");
begin_design_turn(&mut session, "turn-reasoning-retry");
let mut response = fake_response("reasoning-retry", "重试后的正文", Vec::new());
response.reasoning = "重试后的推理".into();
let _fake = fake_provider::install(
vec![
Err(platform_llm::LlmError::Upstream {
status_code: 503,
message: "busy".into(),
}),
Ok(response),
],
1,
);
let mut events = Vec::new();
let response =
request_scripted_design_provider(&root, &mut session, &mut |event| events.push(event))
.await
.expect("scripted retry provider");
let reasoning_events = events
.iter()
.filter_map(|event| event.reasoning_text.as_deref())
.collect::<Vec<_>>();
assert_eq!(reasoning_events, vec!["", "", "重试后的推理"]);
assert_eq!(response.text, "重试后的正文");
assert!(session.history.is_empty());
}
#[tokio::test(flavor = "current_thread")]
async fn fake_provider_walks_five_phases_and_enters_consultant() {
let (_temp, root, resources) = init_design_project();
File diff suppressed because one or more lines are too long
@@ -16,7 +16,7 @@ JSON schema:
{ "group": "balance", "role": "Difficulty", "summary": "数值交接摘要", "outputs": ["game/balance.json"], "next": "交给程序组读取" },
{ "group": "art", "role": "Asset", "summary": "美术交接摘要", "outputs": ["assets/manifest.art.json"], "next": "进入画板或本地资产登记" },
{ "group": "audio", "role": "SFX", "summary": "音乐音效交接摘要", "outputs": ["assets/manifest.audio.json"], "next": "进入画板音频链路" },
{ "group": "code", "role": "Code", "summary": "程序交接摘要", "outputs": ["game/index.html"], "next": "交给 Playtest" },
{ "group": "code", "role": "Code", "summary": "程序交接摘要", "outputs": ["game/index.html", "game/game.js", "game/package.json", "game/vite.config.js"], "next": "先 project.bootstrap,再 project.verify(build)交给 Playtest" },
{ "group": "publishing", "role": "Publish", "summary": "运营交接摘要", "outputs": ["exports/README.md"], "next": "等待预览验收" }
],
"handoffSummary": "六组 agent 的交接摘要,每组一行",
@@ -24,6 +24,7 @@ JSON schema:
}
gameHtml 规则:
- 本次若目标是 Phaser/npmworkspaceMode 必须为 DirectProject;先写入完整 game/ 工程文件,再由受控项目工具安装与构建。
- 此 JSON 协议仅用于已有的单文件 HTML 项目;npm / Phaser 项目必须使用 DirectProject,不能通过 gameHtml 交付 package.json 或模块源码。
- 必须是单文件 HTML,不能加载远程脚本、远程图片、远程 CSS 或 CDN。
- 必须包含 canvas、canvas getContext、实际绘制调用、键盘或鼠标输入、requestAnimationFrame 主循环、目标、失败或胜利状态、R 或按钮重开。
@@ -409,6 +409,8 @@ where
(self.on_delta)(&platform_llm::LlmStreamDelta {
accumulated_text,
delta_text,
accumulated_reasoning: String::new(),
reasoning_delta: String::new(),
finish_reason,
});
}
@@ -499,6 +501,7 @@ mod tests {
provider: LlmProvider::OpenAiCompatible,
model: "interaction-test".to_string(),
text: text.to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: Some("interaction-response".to_string()),
usage: None,
@@ -1156,10 +1156,6 @@ mod tests {
assert!(with_canvas.contains("根据当前玩法需求编写规格和界面建议"));
assert!(with_canvas.contains("用途、数量、输出路径、尺寸、参考资源和是否需要 spritesheet"));
assert!(with_canvas.contains("再调用 canvas.asset_generate"));
assert!(with_canvas.contains("调用 canvas.asset_generate"));
assert!(with_canvas.contains("不要使用固定图片合同"));
assert!(with_canvas.contains("不修改 game/index.html"));
assert!(!with_canvas.contains("不调用 canvas.asset_generate"));
}
#[test]
@@ -801,7 +801,7 @@ fn agent_runtime_action_receipt_safe_detail_with_owner(
let initial_step = route.get("initialStep")?.as_str()?;
let render_mode = route.get("renderMode")?.as_str()?;
if resource_id.is_empty()
|| initial_step != "asset-separation"
|| initial_step != "visual-binding"
|| render_mode != "final-preview"
{
return None;
@@ -1192,6 +1192,7 @@ pub(in crate::agent) fn agent_runtime_public_action_input_summary(
| "project.patchset"
| "project.search"
| "project.verify"
| "project.bootstrap"
| "file.list"
| "file.read"
| "file.write"
@@ -1259,6 +1260,7 @@ pub(crate) fn agent_runtime_tool_action_fingerprint(
| "command.stdin"
| "command.terminate"
| "project.verify"
| "project.bootstrap"
)
.then(|| {
let metadata = command_sandbox_platform_metadata();
@@ -1468,11 +1470,23 @@ pub(crate) fn agent_runtime_tool_action_input_summary(
.and_then(|value| value.as_bool())
.unwrap_or(false)
),
"project.bootstrap" => {
format!(
"cwd={} · timeoutSeconds={}",
relative_path(&["cwd"]),
input
.get("timeoutSeconds")
.or_else(|| input.get("timeout_seconds"))
.and_then(|value| value.as_u64())
.unwrap_or(300)
)
}
"project.verify" => {
let expected_command = text(&["expectedCommand", "expected_command"]);
let command_chars = expected_command.chars().count();
format!(
"script={} · expectedCommandSha256={:x} · expectedCommandChars={} · timeoutSeconds={}",
"cwd={} · script={} · expectedCommandSha256={:x} · expectedCommandChars={} · timeoutSeconds={}",
relative_path(&["cwd"]),
text(&["script"]),
Sha256::digest(expected_command.as_bytes()),
command_chars,
@@ -184,6 +184,17 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
)
.await
}
"project.bootstrap" => {
observe_agent_runtime_project_bootstrap(
root,
agent_id,
run_id,
action_id,
&action_fingerprint,
&action.input,
)
.await
}
"project.checkpoint" => observe_agent_runtime_project_checkpoint(root),
"project.restore" => {
observe_agent_runtime_project_restore(root, agent_id, run_id, &action.input)
@@ -8,10 +8,15 @@ pub(crate) struct AgentRuntimeAutonomousSourcePayloadStats {
}
pub(in crate::agent) fn agent_runtime_autonomous_project_verify_available(root: &Path) -> bool {
let package_path = root.join("package.json");
fs::symlink_metadata(package_path)
.map(|metadata| metadata.file_type().is_file() && !metadata.file_type().is_symlink())
.unwrap_or(false)
[root.join("package.json"), root.join("game/package.json")]
.into_iter()
.any(|package_path| {
fs::symlink_metadata(package_path)
.map(|metadata| {
metadata.file_type().is_file() && !metadata.file_type().is_symlink()
})
.unwrap_or(false)
})
}
pub(in crate::agent) fn add_agent_runtime_autonomous_source_field(
@@ -345,7 +345,7 @@ fn build_game_creator_agent_background_tool_plan_request_at(
let project_tools_contract = if runtime_owner_artifact_validation_available {
"project.search 使用 {\"query\":\"要查找的字面文本\",\"path\":\"\",\"maxResults\":20,\"caseSensitive\":false},path 为空字符串时搜索整个项目,返回 path:line 和匹配行;当前固定 owner 的函数目录不广告 project.verifyproject.checkpoint 使用空对象,只用于多个 file.* 写动作前或需要独立回退点时创建本地 checkpointproject.patchset 会自动创建 checkpoint,不要为同一批变更额外调用 project.checkpointproject.restore 使用 {\"checkpointId\":\"checkpoint id\"}project.diff 使用 {\"checkpointId\":\"checkpoint id\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000}git.inspect 使用 {\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000},只读项目根 Git 状态和有界 diff,不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote。".to_string()
} else {
"project.search 使用 {\"query\":\"要查找的字面文本\",\"path\":\"\",\"maxResults\":20,\"caseSensitive\":false},path 为空字符串时搜索整个项目,返回 path:line 和匹配行;project.verify 使用 {\"script\":\"check|typecheck|test|lint|build\",\"expectedCommand\":\"从 package.json 读取的完整原始脚本\",\"timeoutSeconds\":120},只执行项目根 package.json 中同名 npm 脚本,expectedCommand 不一致时拒绝执行,确认策略以当前工具策略中 project.verify 的独立权限为准;project.checkpoint 使用空对象,只用于多个 file.* 写动作前或需要独立回退点时创建本地 checkpointproject.patchset 会自动创建 checkpoint,不要为同一批变更额外调用 project.checkpointproject.restore 使用 {\"checkpointId\":\"checkpoint id\"}project.diff 使用 {\"checkpointId\":\"checkpoint id\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000}git.inspect 使用 {\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000},只读项目根 Git 状态和有界 diff,不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote。".to_string()
"project.search 使用 {\"query\":\"要查找的字面文本\",\"path\":\"\",\"maxResults\":20,\"caseSensitive\":false},path 为空字符串时搜索整个项目,返回 path:line 和匹配行;project.bootstrap 使用 {\"cwd\":\"game\",\"timeoutSeconds\":300},只执行 game 目录无参数 npm install 并记录 package/lock 指纹;project.verify 使用 {\"script\":\"check|typecheck|test|lint|build\",\"expectedCommand\":\"对应 cwd 的 package.json 读取的完整原始脚本\",\"timeoutSeconds\":120,\"cwd\":\"game\"},只执行对应 cwd package.json 中同名 npm 脚本,build 必须确认 game/dist/index.htmlexpectedCommand 不一致时拒绝执行,确认策略以当前工具策略中 project.verify 的独立权限为准;project.checkpoint 使用空对象,只用于多个 file.* 写动作前或需要独立回退点时创建本地 checkpointproject.patchset 会自动创建 checkpoint,不要为同一批变更额外调用 project.checkpointproject.restore 使用 {\"checkpointId\":\"checkpoint id\"}project.diff 使用 {\"checkpointId\":\"checkpoint id\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000}git.inspect 使用 {\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000},只读项目根 Git 状态和有界 diff,不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote。".to_string()
};
let prompt = format!(
concat!(
@@ -115,6 +115,7 @@ fn persist_tool_plan_handoff_repair_chain(
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: llm.model.clone(),
text: text.to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: None,
usage: None,
@@ -146,6 +147,8 @@ fn stream_delta(delta_text: &str, accumulated_text: &str) -> platform_llm::LlmSt
platform_llm::LlmStreamDelta {
accumulated_text: accumulated_text.to_string(),
delta_text: delta_text.to_string(),
accumulated_reasoning: String::new(),
reasoning_delta: String::new(),
finish_reason: None,
}
}
@@ -1003,6 +1006,7 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: old_llm.model.clone(),
text: private_response.to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: None,
usage: None,
@@ -1116,6 +1120,7 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: old_llm.model.clone(),
text: text.to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: None,
usage: None,
@@ -1302,6 +1307,7 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: llm.model.clone(),
text: format!("capacity response {loop_iteration}"),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: None,
usage: None,
@@ -1436,6 +1442,7 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: llm.model.clone(),
text: text.to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: None,
usage: None,
@@ -1552,6 +1559,7 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: llm.model.clone(),
text: "cleanup handoff".to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: None,
usage: None,
@@ -1623,6 +1631,7 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: llm.model.clone(),
text: "terminal handoff".to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: None,
usage: None,
@@ -1718,6 +1727,7 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: llm.model.clone(),
text: "已成功但尚未消费的回复".to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: None,
usage: None,
@@ -28,6 +28,7 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> {
"project.index",
"project.search",
"project.verify",
"project.bootstrap",
"project.checkpoint",
"project.restore",
"project.diff",
@@ -83,6 +84,7 @@ pub(crate) fn agent_runtime_acceptance_evidence_tools() -> BTreeSet<&'static str
"project.index",
"project.search",
"project.verify",
"project.bootstrap",
"project.diff",
"git.inspect",
"project.patchset",
@@ -156,6 +156,7 @@ pub(super) const AGENT_RUNTIME_AUTONOMOUS_GAME_BUILD_AUTO_COMMAND_IDS: &[&str] =
"file.delete",
"project.patchset",
"project.verify",
"project.bootstrap",
"command.run_limited",
"preview.validate",
"canvas.asset_generate",
@@ -109,6 +109,7 @@ pub(crate) fn agent_runtime_tool_requires_repository_context_fingerprint_gate(to
| "project.index"
| "project.search"
| "project.verify"
| "project.bootstrap"
| "project.checkpoint"
| "project.patchset"
| "project.restore"
@@ -798,6 +798,7 @@ mod provider_reconciliation_diagnostic_tests {
let response = platform_llm::LlmRunResponse {
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: "test-model".to_string(),
reasoning: String::new(),
text: "C:\\private\\response".to_string(),
finish_reason: Some("completed".to_string()),
response_id: Some("response-1".to_string()),
@@ -857,6 +857,12 @@ pub(crate) async fn observe_agent_runtime_project_verify(
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
let cwd = input
.get("cwd")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or(".")
.to_string();
let timeout_seconds =
agent_runtime_tool_input_usize(input, &["timeoutSeconds", "timeout_seconds"])
.unwrap_or(AGENT_RUNTIME_PROJECT_VERIFY_DEFAULT_TIMEOUT_SECONDS);
@@ -896,6 +902,7 @@ pub(crate) async fn observe_agent_runtime_project_verify(
script.as_str(),
expected_command.as_str(),
timeout_seconds,
cwd.as_str(),
|| {
verification_state = Some(begin_agent_runtime_project_verification_locked(
root,
@@ -931,6 +938,7 @@ pub(crate) async fn observe_agent_runtime_project_verify(
"script": verification.script,
"expectedCommand": audit_expected_command,
"packageManager": verification.package_manager,
"cwd": verification.cwd_relative,
"status": verification.status,
"exitCode": verification.exit_code,
"timedOut": verification.timed_out,
@@ -1039,3 +1047,81 @@ pub(crate) async fn observe_agent_runtime_project_verify(
}
}
}
pub(crate) async fn observe_agent_runtime_project_bootstrap(
root: &Path,
agent_id: &str,
run_id: &str,
action_id: Option<&str>,
action_fingerprint: &str,
input: &serde_json::Value,
) -> AgentRuntimeToolObservation {
let cwd = input
.get("cwd")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let timeout = agent_runtime_tool_input_usize(input, &["timeoutSeconds", "timeout_seconds"])
.unwrap_or(300);
if cwd != "game" {
return AgentRuntimeToolObservation {
tool: "project.bootstrap".to_string(),
status: "failed".to_string(),
summary: "project.bootstrap 只允许 cwd=game".to_string(),
detail: None,
};
}
let _lock = match acquire_project_write_lock(root, "project.bootstrap") {
Ok(lock) => lock,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "project.bootstrap".to_string(),
status: "failed".to_string(),
summary: "project.bootstrap 无法取得项目执行锁".to_string(),
detail: Some(redact_agent_runtime_project_paths(root, &error, 240)),
}
}
};
let result = crate::project::run_project_bootstrap_at(root, timeout as u64).await;
match result {
Ok(value) if value.status == "completed" => {
if let Err(error) = append_agent_db_record(
root,
serde_json::json!({
"recordType":"agent.runtime.project.bootstrap", "agentId":agent_id, "runId":run_id,
"actionId":action_id, "actionFingerprint":action_fingerprint, "cwd":"game",
"packageSha256":value.package_sha256, "lockSha256":value.lock_sha256,
"status":value.status, "logPath":value.log_path, "output":value.output
}),
) {
return AgentRuntimeToolObservation {
tool: "project.bootstrap".to_string(),
status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(),
summary: "project.bootstrap 已安装,但审计记录无法落盘".to_string(),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
};
}
AgentRuntimeToolObservation {
tool: "project.bootstrap".to_string(),
status: "ok".to_string(),
summary: "game 依赖安装已完成".to_string(),
detail: Some(format!(
"cwd=game · packageSha256={} · lockSha256={}",
value.package_sha256,
value.lock_sha256.as_deref().unwrap_or("none")
)),
}
}
Ok(value) => AgentRuntimeToolObservation {
tool: "project.bootstrap".to_string(),
status: "failed".to_string(),
summary: "game 依赖安装失败".to_string(),
detail: Some(value.output),
},
Err(error) => AgentRuntimeToolObservation {
tool: "project.bootstrap".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
},
}
}
@@ -1009,7 +1009,8 @@ fn runtime_tool_description(tool: &str) -> &'static str {
}
"project.index" => "刷新并读取有界仓库启动上下文。",
"project.search" => "在项目文本文件中做有界字面量搜索。",
"project.verify" => "运行 package.json 原样声明的验证脚本。",
"project.verify" => "在项目或指定相对 cwd 中运行 package.json 原样声明的验证脚本,并检查构建产物",
"project.bootstrap" => "仅在项目 game 目录受控执行无参数 npm install,并记录依赖文件指纹。",
"project.checkpoint" => "创建项目本地 checkpoint。",
"project.restore" => "从 checkpoint 恢复当前项目。",
"project.diff" => "读取 checkpoint 与当前项目之间的有界差异。",
@@ -1154,10 +1155,18 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
}
}),
"project.verify" => json!({
"type": "object", "required": ["script", "expectedCommand", "timeoutSeconds"], "additionalProperties": false,
"type": "object", "required": ["script", "expectedCommand", "timeoutSeconds", "cwd"], "additionalProperties": false,
"properties": {
"script": { "type": "string", "minLength": 1 },
"expectedCommand": { "type": "string", "minLength": 1 },
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 600 },
"cwd": { "type": ["string", "null"], "pattern": "^[A-Za-z0-9._/-]+$" }
}
}),
"project.bootstrap" => json!({
"type": "object", "required": ["cwd", "timeoutSeconds"], "additionalProperties": false,
"properties": {
"cwd": { "type": "string", "const": "game" },
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 600 }
}
}),
@@ -647,9 +647,9 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<usize, String>
register_local_asset_at(
root,
&relative,
"design-document",
"document",
media_type,
"design-document",
"document",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
@@ -321,6 +321,44 @@ pub(crate) fn resolve_project_command_spec_at(
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))
}
/// Resolves the one privileged npm operation used to hydrate a DirectProject.
/// It intentionally bypasses the general command.exec npm allow-list: callers
/// must use the dedicated `project.bootstrap` action, which only accepts the
/// literal `npm install` in the project's `game` directory.
pub(crate) fn resolve_project_bootstrap_spec_at(
root: &Path,
timeout_seconds: u64,
) -> Result<ProjectCommandSpec, ProjectCommandError> {
validate_project_root(root)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?;
let cwd_relative = "game".to_string();
let cwd = resolve_local_project_path(root, &cwd_relative)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?;
validate_project_command_cwd_components(root, &cwd_relative)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?;
if !(PROJECT_COMMAND_MIN_TIMEOUT_SECONDS..=PROJECT_COMMAND_MAX_TIMEOUT_SECONDS)
.contains(&timeout_seconds)
{
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Validation,
format!("project.bootstrap timeoutSeconds 必须在 {PROJECT_COMMAND_MIN_TIMEOUT_SECONDS}-{PROJECT_COMMAND_MAX_TIMEOUT_SECONDS} 之间"),
));
}
let program = "npm".to_string();
let (executable, safe_path) = resolve_project_command_executable(root, &program)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?;
Ok(ProjectCommandSpec {
program,
executable,
safe_path,
arguments: vec!["install".to_string()],
cwd_relative,
cwd,
timeout_seconds,
verification_eligible: false,
})
}
fn resolve_project_command_spec_inner(
root: &Path,
program: &str,
@@ -1202,6 +1240,19 @@ pub(crate) fn prepare_project_command_launch_spec(
(OsString::from("NO_PROXY"), OsString::new()),
(OsString::from("PATH"), spec.safe_path.clone()),
];
if spec
.arguments
.first()
.is_some_and(|argument| argument == "install")
{
for (name, value) in &mut environment {
match name.to_string_lossy().as_ref() {
"npm_config_offline" => *value = OsString::from("false"),
"HTTP_PROXY" | "HTTPS_PROXY" | "ALL_PROXY" => *value = OsString::new(),
_ => {}
}
}
}
for name in ["SystemRoot", "PATHEXT", "RUSTUP_HOME"] {
if let Some(value) = std::env::var_os(name) {
environment.push((OsString::from(name), value));
@@ -1998,6 +2049,16 @@ pub(crate) async fn run_project_command_with_output_at(
.await
}
pub(crate) async fn run_project_bootstrap_command_at(
root: &Path,
timeout_seconds: u64,
) -> Result<ProjectCommandResult, ProjectCommandError> {
let spec = resolve_project_bootstrap_spec_at(root, timeout_seconds)?;
let launch = prepare_project_command_launch_spec(root, &spec)?;
let staged = stage_project_command_launch_spec(&spec, launch)?;
run_prepared_project_command_with_output_at(root, &spec, staged, None, || Ok(())).await
}
pub(crate) async fn run_prepared_project_command_with_output_at<F>(
root: &Path,
spec: &ProjectCommandSpec,

Some files were not shown because too many files have changed in this diff Show More