diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs index d903e008b..dfb8f2f9b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs @@ -76,12 +76,20 @@ fn compact_history_images(value: &mut Value, remaining_bytes: &mut usize) { } } +/// 构造 `thread/inject_items` 载荷;**历史为空时返回 `None`**,调用方不得发空载荷。 +/// +/// codex app-server 0.155.1 起把 `items: []` 当协议错误拒绝(`items must not be empty`), +/// 而 CLI / 宿主探针路径(`--direct-codex-chat`)在全新项目上没有前端先写用户条目的步骤, +/// 于是「第一次对话」会直接失败并报「执行通道中断」。空历史本来就没有可注入的内容。 pub(super) fn build_direct_project_history_injection_params( history_root: &Path, thread_id: &str, -) -> Result { +) -> Result, platform_llm::LlmError> { let canonical_items = read_direct_project_history_items_at(history_root) .map_err(platform_llm::LlmError::InvalidRequest)?; + if canonical_items.is_empty() { + return Ok(None); + } let mut remaining_image_bytes = DIRECT_PROJECT_HISTORY_IMAGE_TOTAL_MAX_BYTES; let items = canonical_items .iter() @@ -100,13 +108,57 @@ pub(super) fn build_direct_project_history_injection_params( if let Some(error) = direct_project_history_injection_oversize_error(¶ms, payload_bytes) { return Err(platform_llm::LlmError::InvalidRequest(error)); } - Ok(params) + Ok(Some(params)) } #[cfg(test)] mod tests { - use super::{compact_history_images, DIRECT_PROJECT_HISTORY_IMAGE_OMITTED_TEXT}; + use super::{ + build_direct_project_history_injection_params, compact_history_images, + DIRECT_PROJECT_HISTORY_IMAGE_OMITTED_TEXT, + }; use serde_json::json; + use std::fs; + + // 回归:全新项目没有 `.agent/conversations/project.jsonl`,历史为空时**不得**构造 + // `items: []` 载荷——codex app-server 0.155.1 起会以 `items must not be empty` 拒绝, + // 表现为 CLI / 宿主探针的第一次对话直接「执行通道中断」。 + #[test] + fn empty_history_produces_no_injection_payload() { + let root = tempfile::tempdir().expect("temp dir"); + let params = build_direct_project_history_injection_params(root.path(), "thread-empty") + .expect("build params for empty history"); + assert!(params.is_none(), "空历史不得构造 thread/inject_items 载荷"); + } + + #[test] + fn existing_history_still_produces_an_injection_payload() { + let root = tempfile::tempdir().expect("temp dir"); + let conversations = root.path().join(".agent/conversations"); + fs::create_dir_all(&conversations).expect("create conversations dir"); + fs::write( + conversations.join("project.jsonl"), + concat!( + "{\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",", + "\"id\":\"codex-item-1\",\"content\":[{\"type\":\"input_text\",\"text\":\"先做一版\"}]}}\n" + ), + ) + .expect("write history"); + let params = build_direct_project_history_injection_params(root.path(), "thread-history") + .expect("build params for existing history") + .expect("有历史时必须构造载荷"); + assert_eq!( + params.get("threadId").and_then(|v| v.as_str()), + Some("thread-history") + ); + assert_eq!( + params + .get("items") + .and_then(|value| value.as_array()) + .map(Vec::len), + Some(1) + ); + } #[test] fn history_image_budget_omits_only_wire_preview_when_exhausted() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index f2058531c..c749de957 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -3377,19 +3377,22 @@ impl CodexAppServerConnection { } if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { if thread_created { - let params = - match build_direct_project_history_injection_params(history_root, &thread_id) { - Ok(params) => params, - Err(error) => { + match build_direct_project_history_injection_params(history_root, &thread_id) { + // 空历史没有可注入内容:app-server 会拒绝 `items: []`(items must not be empty), + // 新项目的第一次对话就发生在这里,因此必须跳过而不是发空载荷。 + Ok(None) => {} + Ok(Some(params)) => { + if let Err(error) = self.request("thread/inject_items", params).await { self.release_thread(snapshot, &thread_id).await; - return Err(error.into()); + return Err(DirectTurnRunFailure::from( + platform_llm::LlmError::Transport(error), + )); } - }; - if let Err(error) = self.request("thread/inject_items", params).await { - self.release_thread(snapshot, &thread_id).await; - return Err(DirectTurnRunFailure::from( - platform_llm::LlmError::Transport(error), - )); + } + Err(error) => { + self.release_thread(snapshot, &thread_id).await; + return Err(error.into()); + } } } } diff --git a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md index f20e8da83..5e2d24ac7 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md @@ -73,3 +73,12 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 - 运行时(待补):真实 app-server 会话下的新回合、杀进程重开、页面重进、分页与终止。 - 边界:订阅过期、回执竞态、不可显示切片、无 `turn.completed` 的残回合、工具输出超长截断与脱敏。 - 环境注意:`rehype-highlight` 已装齐后 `ChatMarkdownMessage` / `AgentMessageContent` 转绿;Node 26 下 vitest 的 jsdom 用例需要 `--localstorage-file` 才能拿到 `window.localStorage`(`clientApi.test.ts` / `chatPromptPolish.test.tsx`),已记入 `docs/project-memory/shared-memory/pitfalls.md`。 + +## 本轮核对(2026-09-28,真实 app-server 运行时:先把路修通) + +为了给两条运行时验收(杀进程重进、进程存活期间重进)建可复跑的证路,本轮先用既有夹具 `apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs`(真 AGC CLI → 真 app-server → loopback Responses fixture,无账号、无付费 Provider)跑通基线,结果先挖出一个**真实缺陷**并修掉: + +- **缺陷**:全新项目的第一次对话直接失败。夹具 `--cases completed` 下 AGC 进程 4 秒退出、`requests=[]`,stderr 只有 `Codex app-server JSON-RPC 失败:items must not be empty`。根因是 `thread_created` 时无条件发 `thread/inject_items`,而空项目没有 `.agent/conversations/project.jsonl`,载荷成了 `items: []`;codex app-server 0.155.1 起把空数组当协议错误。GUI 路径没暴露是因为前端会先写用户消息,CLI / 无前端宿主是裸的。 +- **修复**:`build_direct_project_history_injection_params()` 在历史为空时返回 `Ok(None)`,调用方跳过注入;新增单测「空历史不得构造载荷」「有历史仍构造 1 条」。`cargo test … direct_project_history_wire`(3 passed)与 `cargo fmt --check` 通过。 +- **复验**:同一条命令下夹具从「4 秒失败」变成「走到 loopback Provider:`requests=3`、`markers.ready=true`、AGC exit=0」,说明第一层已经修通。 +- **仍未完成(第二层)**:同一用例仍断言失败,执行账本落在 `phase=interrupted`(期望 `completed`),stderr 为 `agent.runner.failed: Codex app-server 连接终止 … stderrBytes=1242`,CLI 回执是「执行通道已断开,不能自动重放未确认操作」。现象指向 CLI 收尾阶段的关闭竞态,需要先拿到 app-server 的 stderr 原文(当前只落字节数与 sha256)。两条运行时验收**继续未勾选**。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4129f52ba..2adb0fe73 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9711,6 +9711,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(Windows 侧也要能核产物内部身份):新增 `scripts/pe-version-info.mjs`(纯 Node 解析 PE `RT_VERSION`,无第三方依赖),核对脚本下载 Windows 安装包后断言 `ProductName` 与 `FileVersion` 等于本渠道身份与本轮版本。理由与 mac 侧一致:文件名可以改,PE 版本资源是产物自己写的;两侧对称以后「产物里的渠道身份」不再只靠源码级用例。实测 `dev-win` 0.1.155 → `陶泥儿开发版`/`0.1.155`,`release-win` 0.1.150 → `陶泥儿 Release`/`0.1.150`。 - 事实(同日稍后):`dev-win` 的 0.1.155(commit `3702e0f8e`)在 14:26:13 发布完成,总号、渠道清单与旧协议指针三者对齐;该构建的源码早于 `assertArtifactVersionMatches()`,所以那条守卫仍待下一次真实渠道构建验证。 - 决策(旧协议指针也要线上核对):`agc/latest.json` 是全局单对象,只属于 dev 的 Windows 系统;核对任何非 dev 的 Windows 渠道时,脚本断言它仍指向 `/agc/dev-win/…`。本轮 `release-win` 通过(指针指向 `dev-win/0.1.155` 的安装包),把「release 不能写 dev 桥」这条从单测扩到线上状态。 + +## 2026-09-28 DirectProject 空历史不再发 `thread/inject_items` + +- 背景:用 `direct-execution-production-fixture.mjs`(真 AGC CLI → 真 app-server → loopback Responses fixture)跑基线时,全新项目的第一次对话直接失败:`Codex app-server JSON-RPC 失败:items must not be empty`,AGC 进程 4 秒退出、loopback Provider 一次都没被调用。根因是 `thread_created` 时无条件发 `thread/inject_items`,空项目没有 `.agent/conversations/project.jsonl`,载荷成了 `items: []`;codex app-server 0.155.1 起拒绝空数组。GUI 路径因为前端先写用户消息才没暴露,CLI / 无前端宿主没有这一步。 +- 决策:`build_direct_project_history_injection_params()` 在历史为空时返回 `Ok(None)`,调用方跳过注入(空历史本来就没有可注入内容);有历史时行为不变(仍按原载荷、原大小前置校验)。 +- 验证:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_project_history_wire` → 3 passed(新增「空历史不得构造载荷」「有历史仍构造 1 条」);同一条夹具命令从「4 秒失败、`requests=[]`」变成「`requests=3`、`markers.ready=true`、AGC exit=0」——但账本仍落 `interrupted`(app-server 收尾连接终止,`stderrBytes=1242`),是另一层问题,未在本次一并修。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/{direct_project_history_wire.rs,mod.rs}`、`docs/project-memory/shared-memory/pitfalls.md`、DirectProject 里程碑。 - 决策(口径回归):macOS 现行契约是 arm64 单架构(2026-09-21 决策),里程碑里「两个 macOS 平台键指向 universal 产物」的旧文字按现行决策改写;不得据此重新切回 universal,除非按该决策给出的恢复路径补齐按架构的 Node 运行时。 - 影响范围:`apps/ai-game-creator-shell/scripts/build-macos-ci.mjs`、新增 `apps/ai-game-creator-shell/scripts/macos-release-identity.mjs` 与其 `.test.mjs`、`apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs`、`jenkins/Jenkinsfile.ai-game-creator-shell-macos-build`、`scripts/check-agc-update-channel-manifests.mjs`、两份里程碑与本文件、pitfalls。 - 验证:`node --test apps/ai-game-creator-shell/scripts/*.test.mjs` → **110 passed / 0 failed**(其中定向批次 `macos-release-identity / prepare-macos-codex / verify-updater-signature / build-release / cargo-features` 60 passed;新增的 mac 身份回归用例直接喂线上那份 0.1.139 release 身份 plist,必须抛错;新增的产物版本守卫用例喂 `_0.1.153_` 残留安装包,必须抛错);`npm run check:production-ops`、`check:encoding`、`check:doc-index`、prettier、eslint、`git diff --check` 通过;只读核对对线上 `dev-win` 全 PASS(含 158 MiB 产物下载验签与旧协议 sha256 一致),对线上 `dev-mac` 精确报出上面两条 FAIL。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index dd1f5f25d..ce79fd386 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6106,3 +6106,11 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **判据**:只读核对要**打开产物看身份**,不能只看「地址存在 + 签名匹配」。`npm run check:agc-update-channel-manifests`(`AGC_UPDATE_VERIFY_DOWNLOAD=1`)现在会解出 mac 包的 `Info.plist`、以及 Windows 安装包的 PE `RT_VERSION`(`scripts/pe-version-info.mjs`),断言「产物内版本 == 清单版本」且「产物内产品名/identifier == 本渠道身份」,并额外断言**不同渠道的更新包不得字节相同**;本轮对线上取样得到三条 FAIL,正是这个缺陷。字节级佐证:`dev-mac/0.1.142/陶泥儿 Release.app.tar.gz` 与 `release-mac/0.1.139/陶泥儿 Release.app.tar.gz` 的 sha256 完全相同(`1dfc9deb79f7…`),说明 dev 分区里放的就是 release 那一次构建的产物。 - **处理(2026-09-28 已修)**:构建前按后缀清空 `macos/` 下的 `*.app.tar.gz`、`*.app.tar.gz.sig`、`*.dmg`、`*.dmg.sha256`;构建后读 `.app/Contents/Info.plist` 断言版本/identifier/产品名;生成清单后再断言 `release.artifact` 就是本轮那一个。守卫在 `apps/ai-game-creator-shell/scripts/macos-release-identity.mjs`,回归用例直接用线上那份 0.1.139 release 身份包(`node --test` 59 passed)。 - **教训**:凡是「按目录扫描挑产物」的发布步骤,都要么先清空同类产物、要么按本轮预期路径断言;只删「本轮要写的名字」等于把上一轮的坏包留在候选集里。现在共享入口 `generateUpdateManifest()` 也补了与平台无关的 `assertArtifactVersionMatches()`(文件名带 `_0.1.153_` 这类版本段时必须是本轮版本),所以即使将来某个流水线不再 `git clean -fdx`,旧安装包也会被拒绝而不是被发出去。还有一条更一般的:核对线上清单时,先看 decision-log 的现行口径(这里 macOS 已是 arm64 单架构),别拿过期里程碑文字当契约。 + +## 2026-09-28 DirectProject 空历史注入被新版 app-server 拒绝:新项目第一条消息直接「执行通道中断」 + +- **现象**:用 `apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs`(真 AGC CLI → 真 app-server → loopback Responses fixture,无账号/无付费 Provider)跑 `--cases completed` 时,AGC 进程 4 秒就退出、`requests=[]`,stderr 只有 `Codex app-server JSON-RPC 失败:items must not be empty` + `agent.runner.failed`,夹具因此报「AGC did not reach the loopback fixture」。 +- **根因**:`codex_app_server/mod.rs` 在 `thread_created` 时无条件调用 `thread/inject_items`,载荷由 `build_direct_project_history_injection_params()` 从 `.agent/conversations/project.jsonl` 构造;**全新项目该文件不存在 → `items: []`**,而 codex app-server 0.155.1 起把空数组当协议错误拒绝。GUI 路径之所以没暴露:前端会先调 `append_direct_project_conversation_message` 把用户消息写进历史,注入时至少有 1 条;`--direct-codex-chat` 这类无前端宿主(以及任何直接调用 `run_direct_game_creator_turn_at` 的夹具/CLI)没有这一步。 +- **处理(2026-09-28 已修)**:`build_direct_project_history_injection_params()` 在历史为空时返回 `Ok(None)`,调用方跳过 `thread/inject_items`;新增两条单测(空历史不得构造载荷、有历史仍构造 1 条)。 +- **复验**:同一条夹具命令下,AGC 进程从「4 秒失败、`requests=[]`」变成「走到 loopback Provider、`requests=3`、`markers.ready=true`、exit=0」——第一层缺陷确实修掉了。 +- **仍未解决(第二层,另一个问题)**:同一个夹具的 `completed` 用例仍断言失败,原因是执行账本 `phase=interrupted`(期望 `completed`),stderr 为 `agent.runner.failed: Codex app-server 连接终止 … exitStatus=unknown;stderrBytes=1242`,CLI 回执文本也是「执行通道已断开,不能自动重放未确认操作」。看起来是 CLI 收尾阶段的关闭竞态(账本被记成宿主终止),与空历史注入不是同一件事;要动它需要先抓 app-server 的 stderr 内容(应用目前只记字节数与摘要,不落原文)。