文档先行:把 DirectProject 聊天真相源收敛的文档改到当前实现状态
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 5m13s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 4m30s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m39s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m43s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m8s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m10s
Project CI / Frontend tests (pull_request) Failing after 3m24s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m7s
Project CI / Native shell tests (pull_request) Successful in 6m8s

- 主规范把事件模型写成 ts-rs 导出的 tagged enum,说明 seq 不下发、事件不带回合身份,并补上 ts 绑定生成与 f64 时间戳约定
- ADR 补上 tagged enum 绑定、turn id 删除、正文只增不减与回合回收、前端卡片去掉 turnId、未知类型前端丢弃
- 里程碑与实施计划同步:文件改名到 direct_thread_wire.rs,新增删回合身份与生成绑定两步,验证命令加上 export_bindings
- 工具卡片技术方案在文首加修订段:数据来源层作废,表现层契约仍然有效,DirectRuntime 的 tool-calls.jsonl 写入保留
- 对话回合唯一投影里程碑改为 superseded,列出仍然有效与已作废的分界
This commit is contained in:
2026-09-16 19:54:05 +08:00
parent fdc48fe725
commit ed3369a494
6 changed files with 88 additions and 56 deletions
@@ -15,11 +15,14 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(
- **聊天投影** 在读取与渲染时生成,不落盘、不成为第二事实源;DirectProject 聊天框停止读取 `turn-stream.jsonl``tool-calls.jsonl`,也不再提供供前端读取的命令。DirectRuntime 自己那套进度事件与文件写入属于运行时账本,本轮保留不动。
- 页面重进的运行态只由 `subscribe` 的 bootstrap 事件重建,删除活动回合快照接管路径。
- 可见性判断留在前端聊天投影:后端历史分页只按原始条目切片,前端自己跳过不可显示条目并推进锚点。
- 运行态事件与历史切片使用同形条目信封(`turnId` / `itemId` / `seq` / `payload`),Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「原始条目 → 视图」投影函数
- 线上模型是 **ts-rs 导出的 tagged enum**`agent/direct_thread_wire.rs`),不是"一个大结构体加一堆可空字段":`DirectThreadItem` `itemType` 区分条目,`DirectThreadEvent` `type` 区分事件,前端直接消费生成的 TS 类型(改完 Rust 模型跑 `cargo test export_bindings`)。条目上的毫秒时间戳标 `#[ts(as = "f64")]`,因为 ts-rs 默认把 `u64` 映射成 `bigint`,而 Tauri 的 JSON 通道传的是 `number`
- 运行态事件与历史切片使用同形条目,Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「原始条目 → 视图」投影函数。
- 搬运层不生成展示形状:Thread Manager 只下发脱敏原始条目(`itemType` 原样透传),工具卡片的 `kind`、标题、折叠摘要都由前端生成。
- 条目身份只有一套:`itemId = call_id ?? id`。工具 item 的 app-server id 就是 `call_id`,而 `project.jsonl` 落盘的是原始 response item(两个 id 不同),所以在进队列前归一,不向 Thread Manager 与前端暴露第二个 id 概念。
- 合并只在前端,规则只保留「先到定形、后到补空白」:第一次见到的快照决定卡片形状,后续快照只补输出与状态,不做逐字段优先级表
- 活动回合的唯一判据是「出现过 `turn.started` 且未出现对应 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染
- 条目身份只有一套:进队列前归一成一个 `itemId`。工具条目在 `project.jsonl` 里带两个 id(调用 id 与 response item id,调用与输出共用前者),归一只在 Rust 边界做一次,Thread Manager 与前端都不暴露第二个 id 概念。
- 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;前端 state 里只有一个 `turnRunning` 布尔,没有 `turnId``subscribe` 返回的条目、增量、请求与队列锚点都不带 turn id
- 合并只在前端,规则只保留「先到定形、后到补空白」:第一次见到的快照决定卡片形状,后续快照只补输出与状态,不做逐字段优先级表。只有"后到信息一定更全"时才例外:正文取更长的一份、工具状态允许从 `running` 升级到终态、`updatedAt` 取较新的时间
- 前端不保留增量缓冲:`item.delta` 直接追加到运行态条目的正文(正文只增不减)。`turn.completed` 把当前回合的运行态条目并入历史再清空,条目既不消失也不重复。
- 活动回合的唯一判据是「出现过 `turn.started` 且未出现 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。
- 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。
- `notify` 是唯一唤醒来源:`subscribe` 的 bootstrap 事件本身就是该 subscriber 此刻要处理的事件(游标已在队尾),前端直接 reduce 它们,不额外补一次 `consume`,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。
- 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。
@@ -27,8 +30,11 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(
- 运行态事件必须自足:`item.started` / `item.completed` 携带与历史切片同形的完整**脱敏原始条目**,前端按归一后的 `itemId` 合并快照得到运行中与完成态;不提供按 `itemId` 单点取快照的接口。
- 思考正文以 `item.delta{kind:"reasoning"}` 流式下发(`item/reasoning/summaryTextDelta``item/reasoning/textDelta`)。这不放宽可见范围:同一段文本本来就已落进 `project.jsonl` 并在 `item.completed` 展示;plan 文本与命令输出仍只降级为活动状态。
- 首屏历史由 `subscribe` 返回的 `lastCompletedItemId` 锚定,再取最近切片;删除返回整份对话的历史命令。
- 生命周期锚点独立于 replay 队列保存,`subscribe` 必须返回最新的一条 `turn.started` / `turn.completed`,否则新订阅无法判定回合是否仍在运行。
- 删除范围包含前端对 `read_direct_turn_stream``read_direct_tool_calls``list_game_creator_direct_active_turns``read_direct_project_history`(整份历史)与 `game-creator-direct-turn-update` 事件的调用;保留分页用的历史切片读取,且该切片从文件尾反向扫描
- 生命周期锚点独立于 replay 队列保存(队列会回收 `cleanable` 事件,新订阅的游标又在队尾,回收后无法反推"最新回合是 started 还是 completed"`subscribe` 必须返回最新的一条 `turn.started` / `turn.completed`,否则新订阅无法判定回合是否仍在运行。
- 前端工具卡片形状是 `Omit<GameCreatorDirectToolCall, 'turnId'>`:聊天卡片不再有回合身份,`tool-calls.jsonl` 的持久化形状仍保留 `turnId`DirectRuntime 的账本没动)
- 未识别 item 类型由 Rust 原样透传(只带类型与身份,Rust 侧留 TODO),当前由前端投影丢弃:哪些类型可见属于前端决策,不回 Rust 加白名单。
- 删除范围包含前端对 `read_direct_turn_stream``read_direct_tool_calls``list_game_creator_direct_active_turns``read_direct_project_history`(整份历史)与 `game-creator-direct-turn-update` 事件的调用;保留分页用的历史切片读取(`read_direct_project_history_slice`),且该切片从文件尾反向扫描。
- 前端删掉 `directTurnStream` / `directToolCalls` / 活动回合快照接管 / 瞬时应答文本这些并行状态,聊天视图只由 reducer 状态投影(含工具卡片)。
- 失败与中止说明只在运行期显示,不写进 `project.jsonl`;页面重进后不再出现。
- 历史切片的 `firstItemId` 是分页锚点,始终取 `project.jsonl` 里的原始 item id,与归一后的条目身份分开计算。
- 审批与提问事件本次只作为同一条事件流 pass-through,不并入聊天 reducer 驱动的状态机,迁移面收敛在历史与运行态一致性上。
@@ -8,28 +8,30 @@
## 修改边界
- 允许修改:`agent/direct_thread_manager.rs``agent/direct_thread_raw_item.rs``agent/codex_app_server/``agent/direct_project_history.rs``main.rs` 命令注册、AGC 前端订阅与聊天投影、对应测试与 `docs/`
- 允许修改:`agent/direct_thread_wire.rs``agent/direct_thread_manager.rs``agent/codex_app_server/``agent/direct_project_history.rs``main.rs` 命令注册、`src/features/project-workspace/generated/`ts-rs 生成目录)、AGC 前端订阅与聊天投影、对应测试与 `docs/`
- 明确不修改:SpacetimeDB schema 与绑定、HTTP/OpenAPI、DirectRuntime 自己的进度事件与 `turn-stream.jsonl` / `tool-calls.jsonl` 写入、Codex durable thread 行为、审批弹层现有状态来源。
- 保持 `.env` 未提交修改,不触碰个人配置。
## 实现顺序
1. Rust 只搬运:`agent/direct_thread_raw_item.rs` 把 Codex 原始条目挑字段、脱敏、截断后下发,事件载荷与历史切片同形,不生成卡片形状。
2. 条目身份归一:`itemId = call_id ?? id`,同时从事件 envelope 与前端形状里删掉第二个 id 概念;历史切片的 `firstItemId` 继续取文件里的原始 item id。
3. 思考正文流式:`item/reasoning/summaryTextDelta``item/reasoning/textDelta` 产出 `item.delta{kind:"reasoning"}`plan 文本与命令输出保持活动状态
4. 前端收敛为单一 reducer`subscribe` 返回的 bootstrap 事件就是已暂存的运行态,游标已经在队尾,前端直接 reduce 这批事件即可(不需要再补一次 `consume`);此后只由 notify 唤醒 `consume`。合并规则只保留"先到定形、后到补空白",并删掉 `deltaText` 缓冲与回合结束后的运行态残留
5. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,历史读取改为从文件尾反向扫描;锚点按原始 item id 推进,切片无可见条目时自动连拉(上限 5 页)
6. App.tsx 接线:订阅 + 立即 consume + notify 唤醒,聊天视图改由 reducer 状态投影(含工具卡片),删除 Direct 回合事件订阅与 `directTurnStream` / `directToolCalls` 状态
7. 删除只服务旧读路径的命令与前端调用(`read_direct_project_history``read_direct_turn_stream``read_direct_tool_calls``list_game_creator_direct_active_turns`),DirectRuntime 自己的写入保留
8. 测试与文档收口:补 reducer 单测、解锁跳过的工具卡片用例、更新主规范并把冲突的实施计划与工具卡片文档改写为当前状态
1. Rust 只搬运:`agent/direct_thread_wire.rs` 把 Codex 原始条目挑字段、脱敏、截断后下发,事件载荷与历史切片同形,不生成卡片形状;线上模型是 ts-rs 导出的 tagged enum`DirectThreadItem` / `DirectThreadEvent` / bootstrap / consume / history slice),`at``#[ts(as = "f64")]`,改完模型跑 `cargo test export_bindings` 生成前端绑定
2. 条目身份归一:进队列前收敛成一个 `itemId`事件 envelope 与前端形状里都不出现第二个 id 概念;历史切片的 `firstItemId` 继续取文件里的原始 item id。
3. 删掉回合身份:`turn.started` 无载荷、`turn.completed{status}`,条目 / 增量 / 请求 / 生命周期锚点都不带 turn id;队列 `append` 直接收 `DirectThreadEvent``seq` 内部自算
4. 思考正文流式:`item/reasoning/summaryTextDelta``item/reasoning/textDelta` 产出 `item.delta{kind:"reasoning"}`plan 文本与命令输出保持活动状态
5. 前端收敛为单一 reducer`subscribe` 返回的 bootstrap 事件就是已暂存的运行态,游标已经在队尾,前端直接 reduce 这批事件即可(不需要再补一次 `consume`);此后只由 notify 唤醒 `consume`。合并规则只保留"先到定形、后到补空白"(正文只增不减、工具状态允许从 running 升级到终态),`item.delta` 直接追加到运行态条目正文,删掉 `deltaText` 缓冲,`turn.completed` 把运行态条目并入历史再清空
6. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,历史读取改为从文件尾反向扫描;锚点按原始 item id 推进,切片无可见条目时自动连拉(上限 5 页)
7. App.tsx 接线:订阅 + 立即 reduce bootstrap + notify 唤醒 consume,聊天视图改由 reducer 状态投影(含工具卡片),删除 Direct 回合事件订阅与 `directTurnStream` / `directToolCalls` 状态
8. 删除只服务旧读路径的命令与前端调用(`read_direct_project_history``read_direct_turn_stream``read_direct_tool_calls``list_game_creator_direct_active_turns`),DirectRuntime 自己的写入保留
9. 测试与文档收口:补 reducer 单测、解锁跳过的工具卡片用例、更新主规范并把冲突的实施计划与工具卡片文档改写为当前状态。
## 验证命令
1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_thread -- --nocapture`
2. `npx vitest run apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directThreadEvents.test.ts`
3. `npx vitest run apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`
4. TypeScript 类型检查与 ESLint(范围同前次 DirectProject 迁移)。
5. `npm run check:encoding``npm run check:doc-index``git diff --check`
2. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings`(生成 `src/features/project-workspace/generated/`
3. `npx vitest run apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directThreadEvents.test.ts`
4. `npx vitest run apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`
5. TypeScript 类型检查与 ESLint(范围同前次 DirectProject 迁移)。
6. `npm run check:encoding``npm run check:doc-index``git diff --check`
## 风险与回滚点
@@ -11,15 +11,16 @@
AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话历史**(`.agent/conversations/project.jsonl`)与 **运行态事件**Thread Manager `subscribe` / `consume` / `notify`)。Direct 回合事件、`turn-stream.jsonl``tool-calls.jsonl` 与活动回合快照都不再是聊天视图的输入。
边界固定为:Thread Manager 只是**搬运层**——把 Codex 原始条目挑字段、脱敏、截断后下发;工具卡片的形状、可见性与合并全部由前端投影完成。条目身份只有一个:Rust 在进队列前归一`call_id ?? id`,不再暴露第二 id 概念。
边界固定为:Thread Manager 只是**搬运层**——把 Codex 原始条目挑字段、脱敏、截断后下发;工具卡片的形状、可见性与合并全部由前端投影完成。线上模型是 ts-rs 导出的 tagged enum`agent/direct_thread_wire.rs`),条目身份只有一个:Rust 在进队列前归一成一个 `itemId`,不再暴露第二 id 概念,也不带任何回合身份
## 范围
- 运行态事件自足化:`item.started` / `item.completed` 携带与历史切片同形的脱敏**原始条目**,前端用同一个投影函数处理实时与回读。
- 条目身份归一:`itemId = call_id ?? id`(工具 item 的 app-server id 就是 `call_id`);历史切片另给 `firstItemId` 作为分页锚点,锚点始终是文件里的原始 item id
- 线上模型与提示词同源:条目与事件是 ts-rs 导出的 tagged enum,前端消费生成绑定(改 Rust 模型后跑 `cargo test export_bindings`);毫秒时间戳用 `#[ts(as = "f64")]` 对齐 Tauri JSON 通道的 `number`
- 条目身份归一:进队列前收敛成一个 `itemId`(工具条目在 `project.jsonl` 里带调用 id 与 response item id 两个值);历史切片另给 `firstItemId` 作为分页锚点,锚点始终是文件里的原始 item id。
- 思考正文流式:`item/reasoning/summaryTextDelta``item/reasoning/textDelta``item.delta{kind:"reasoning"}` 下发正文;plan 文本与命令输出仍只降级为活动状态。
- 历史读取以 `subscribe` 返回的 `lastCompletedItemId` 为首屏锚点,切片从**文件尾反向扫描**;不可显示条目由前端跳过并继续取页(上限 5 页)。
- 前端收敛为单一事件 reducer 与单一聊天投影;活动回合只由 `turn.started``turn.completed` 判定;回合结束后回收该回合运行态条目。
- 前端收敛为单一事件 reducer 与单一聊天投影;活动回合只由 `turn.started``turn.completed` 判定(事件不带 turn id,前端只有一个 `turnRunning` 布尔);`item.delta` 直接追加到运行态条目正文,不保留增量缓冲;`turn.completed`该回合运行态条目并入历史再清空
- 删除前端对 Direct 回合事件、`turn-stream.jsonl``tool-calls.jsonl`、活动回合快照的读取,以及只服务这些读取的命令与状态。
## 不在范围内
@@ -34,10 +35,12 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话
- 投影在前端:Rust 不生成 `kind` / 标题 / 折叠摘要,也不做合并。
- 合并只保留"先到定形、后到补空白":第一次见到的快照决定卡片形状,后续快照只补输出与状态;不做逐字段优先级表。
- 条目 id 只有一套:`call_id ?? id`;前端与 Thread Manager 都不再出现第二个 id。
- 条目 id 只有一套:归一到 `itemId`;前端与 Thread Manager 都不再出现第二个 id。
- 事件不带回合身份:`turn.started` 无载荷、`turn.completed{status}`Thread Manager 的生命周期锚点、条目、增量与请求都不带 turn id。
- 思考正文流式下发不放宽可见范围:被下发的就是此前已在 `item.completed` 展示、并已落进 `project.jsonl` 的同一段文本。
- 失败与中止说明只在运行期显示(不写 `project.jsonl`),页面重进后不再出现。
- 未知 item 类型由 Rust 原样透传(只有类型与身份),当前由前端投影丢弃。
- 未知 item 类型由 Rust 原样透传(只有类型与身份Rust 侧留 TODO),当前由前端投影丢弃。
- 前端聊天卡片的工具形状是 `Omit<GameCreatorDirectToolCall, 'turnId'>``tool-calls.jsonl` 的持久化形状与 DirectRuntime 的写入保持不变。
## 依赖与前置条件
@@ -52,12 +55,13 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话
- [ ] 进程存活期间的页面重进(含切走再切回)能恢复运行中回合,并允许终止。
- [ ] 历史分页在切片内全部是不可显示条目时仍能继续向前,不出现锚点停滞。
- [ ] 聊天视图不再读取 `turn-stream.jsonl` / `tool-calls.jsonl` / Direct 回合事件 / 活动回合快照;`read_direct_turn_stream``read_direct_tool_calls``list_game_creator_direct_active_turns` 不再被前端调用。
- [ ] 同一工具调用在实时与回读各只出现一张卡片( id 空间按归一身份对齐)。
- [ ] 同一工具调用在实时与回读各只出现一张卡片(两个 id 空间按归一后的 `itemId` 对齐)。
- [ ] 前端聊天状态里不再出现第二个 id 概念与任何回合身份字段;事件解析统一来自 ts-rs 生成绑定。
- [ ] 思考正文在回合进行中即可见,且不进入活动状态文本。
- [ ] 订阅过期后重新 `subscribe` 并原子替换状态,不重复渲染已完成的条目。
## 证据要求
- 自动化:Thread Manager 事件契约测试、原始条目搬运与脱敏测试、聊天 reducer 单测(bootstrap / consume / 过期重订阅 / 残回合 / 先到优先合并)、历史分页锚点测试、前端渲染测试(含此前跳过的工具卡片用例)。
- 自动化:Thread Manager 事件契约测试、原始条目搬运与脱敏测试、ts-rs 绑定生成(`cargo test export_bindings`)、聊天 reducer 单测(bootstrap / consume / 过期重订阅 / 残回合 / 先到优先合并 / 回合回收)、历史分页锚点测试、前端渲染测试(含此前跳过的工具卡片用例)。
- 运行时:真实 app-server 会话下的新回合、杀进程重开、页面重进、分页与终止。
- 边界:订阅过期、事件重复与乱序、不可显示切片、无 `turn.completed` 的残回合、工具输出超长截断与脱敏。
@@ -1,29 +1,32 @@
# 对话回合唯一投影
- Version: 2
- Status: implemented-awaiting-runtime-acceptance
- Version: 3
- Status: superseded
- Date: 2026-09-16
- Superseded by: `../【里程碑】DirectProject聊天真相源收敛-2026-09-16.md`
- Parent Spec: ../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
## 范围与评审
## 结论
里程碑修复回合展示和流写入的一致性;补充终态重进恢复、用户发送时间和完成后的过程折叠。评审确认:活动快照及 Direct 事件拥有生命周期,Provider 回放不创建 client 回合;JSONL 信封可选时间字段不污染原始 item,无须数据库或旧数据迁移;最终回复沿用 Runtime 的最后 assistant item 合同,失败提示不折叠。没有身份的旧记录不得做位置猜配
里程碑原先把「回合唯一投影」落在 Direct 回合事件 + `turn-stream.jsonl` + `tool-calls.jsonl` + 活动回合快照这条读路径上,方向已被推翻:聊天视图的输入只剩**项目对话历史**与**运行态事件**两项,Direct 回合事件、`turn-stream.jsonl``tool-calls.jsonl`、活动回合快照都不再是聊天视图的输入。后续实现与验收一律以 `../【里程碑】DirectProject聊天真相源收敛-2026-09-16.md``docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 为准
## 验收
## 仍然有效的部分
1. 一个 turn 只有一个呈现入口,用户消息不丢失。
2. item 增量、完成、持久化回读保持相同身份与固定顺序
3. 工具输入输出保留,重复快照不重复渲染
4. TypeScript、最小 Cargo 检查、编码和 diff 检查通过;用户要求不运行测试,实机新回合/重开/分页验收待确认
5. 已结束回合重进不显示提交中,真实运行回合可恢复;跨项目/新回合迟到快照无效
6. 用户消息时间可刷新恢复,旧无时间记录不造值;完成后中间正文和工具统一折叠,最终回复及失败提示保持可见
1. 一个回合只有一个呈现入口,用户消息不丢失;历史里带不了身份(没有 id)的旧记录不得做位置猜配
2. 同一 item 增量、完成、持久化回读使用同一个身份、同一个顺序;重复快照不重复渲染
3. 工具调用的输入输出保留,展开后仍显示「输入 / 输出」
4. 已结束回合重进不显示提交中;真实运行回合可恢复
5. 用户消息时间只来自条目自己的时间戳,旧无时间记录不造值;完成后中间正文与工具统一折叠,最终回复与失败提示保持可见
6. 失败与中止说明只在运行期显示,不写进 `project.jsonl`;页面重进后不再出现
依赖:既有项目历史和 v1 turn-stream / tool-calls DTO。未完成真实 UI 验收前不进入其它里程碑。
## 已作废的部分
- 由活动回合快照接管运行中回合:改为由 `subscribe` bootstrap 事件判定(事件序列里 `turn.started` 之后没有 `turn.completed` 即运行中)。
-`turnId` 归并回合并把工具卡片挂在回合上:事件与条目都不再带回合身份,前端只有一个 `turnRunning` 布尔,卡片形状去掉 `turnId`
-`turn-stream.jsonl``seq` 决定文本与工具的交替顺序:改为按运行态事件顺序 + 历史文件顺序投影。
- 前端订阅 `game-creator-direct-turn-update`:改为 `subscribe` + `notify` 唤醒 `consume`bootstrap 的事件直接 reduce,不再补一次 `consume`)。
## 当前证据
- 定向 TypeScript 类型检查、`cargo check --locked --bin genarrative-ai-game-creator-shell`、编码检查、文档索引检查`git diff --check` 通过
- 已补充回合归属、分页、重复快照、无流回退及 writer 完成/切段、持久快照单调性/跨回合裁剪用例;按用户要求未执行测试,不能作为已通过凭证
- 静态自审确认视图只剩统一回合列表,不再存在 mapped/unmapped/live 三个回合流出口;失败提示使用稳定 failure 身份。
- 真实新回合、历史重开、分页、失败/中断、工具展开输入输出仍待重启原生客户端后验收;仅本地提交,不推送。
- 本次增量已完成生命周期来源收敛、信封发送时间和完成过程折叠;定向 TypeScript、ESLint、Cargo check、文档索引通过。新增时间幂等/旧记录、终态分类及内容分区用例但未运行;主页进入项目、重新发送/切项目竞态和自动折叠仍待原生实机验收,仅本地提交,不推送。
- 里程碑版本的静态检查结论(TypeScript、Cargo、编码、文档索引、`git diff --check`)仍然成立;该增量按当时授权未运行测试
- 新路径的证据要求见 `../【里程碑】DirectProject聊天真相源收敛-2026-09-16.md` 的「验收标准」与「证据要求」,其中包含解锁此前跳过的工具卡片渲染用例
@@ -104,23 +104,29 @@ readHistory(threadId, { beforeItemId?, limit }) -> {
### 事件和顺序
Thread 内所有公开事件共用一个单调递增 seq;seq 允许跳号,前端不要求连续。事件 envelope 至少包含:
Thread 内所有公开事件共用一个单调递增 seq,但 **seq 只是 Thread Manager 的内部游标事实,不下发**:同一个 subscriber 的 `consume` 按队列顺序返回事件数组,数组顺序就是前端要处理的顺序,前端因此不需要 item 级 cursor 或第二套 reducer。
线上模型是 ts-rs 导出的 tagged enum`agent/direct_thread_wire.rs`),前端消费 `src/features/project-workspace/generated/` 里的生成绑定,改 Rust 模型后跑 `cargo test export_bindings` 重新生成;毫秒时间戳标 `#[ts(as = "f64")]`,因为 ts-rs 默认把 `u64` 映射成 `bigint`,而 Tauri 的 JSON 通道传的是 `number`
事件按 `type` 区分,条目按 `itemType` 区分:
```ts
{
seq: number,
type: string,
turnId: string,
itemId?: string,
payload: unknown,
}
type DirectThreadEvent =
| { type: 'turn.started' }
| { type: 'turn.completed'; status: string }
| { type: 'item.started'; item: DirectThreadItem }
| { type: 'item.completed'; item: DirectThreadItem }
| { type: 'item.delta'; itemId: string; kind: 'message' | 'reasoning'; delta: string }
| { type: 'request'; kind: 'approval.requested' | 'ask.requested' | 'request.resolved'; requestId: string | null };
```
进入 Thread Manager 的是已经完成安全过滤和协议标准化的公开 raw event,不是未经审查的 app-server JSON。事件可交错包含多个并发 item:`item.started``item.delta``item.completed`、approval/request/resolved 事件,以及 `turn.started``turn.completed` 生命周期事件。前端按 `turnId` / `itemId` 分发并 reduce,不需要 item 级 cursor 或第二套 reducer。
进入 Thread Manager 的是已经完成安全过滤和协议标准化的公开 raw event,不是未经审查的 app-server JSON。事件可交错包含多个并发 item:`item.started``item.delta``item.completed`、approval/request/resolved 事件,以及 `turn.started``turn.completed` 生命周期事件。前端按事件顺序 reduce,只用一个 reducer。
**事件不带回合身份。** DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;条目、增量、请求与生命周期锚点都不带 turn id。前端 state 里只有一个 `turnRunning` 布尔,历史条目也不记录回合身份。
一个 thread 同时最多有一个 active turn;一个 turn 内允许多个并发 item。`turn.completed` 必须在该 turn 的完成 item 均成功持久化后进入队列,前端据此结束运行态;不能用“不存在 unfinished item”猜测 turn 是否完成。
前端 reducer 的活动回合判定只有一条:state 中存在「出现 `turn.started`未出现对应 `turn.completed`」的 `turnId` 时,该 turn 才是活动回合,界面才允许显示忙碌态。`subscribe` bootstrap 没有这样的 `turnId`,就表示当前没有活动回合;Thread Manager 队列随进程消失,因此进程重启后历史里留下的半截回合一律按已结束渲染,前端不发明中断态,也不从历史条目反推忙碌态。
前端 reducer 的活动回合判定只有一条:事件序列中出现 `turn.started`其后没有 `turn.completed` 才是活动回合,界面才允许显示忙碌态。`subscribe` bootstrap 没有这样的序列,就表示当前没有活动回合;Thread Manager 队列随进程消失,因此进程重启后历史里留下的半截回合一律按已结束渲染,前端不发明中断态,也不从历史条目反推忙碌态。
生命周期锚点独立于 replay 队列保存:`turn.started` / `turn.completed` 事件即使已被队列前缀回收,`subscribe` 仍必须把最新的一条作为 bootstrap 事件返回。因此进程内任意时刻新建订阅,都能判定最新回合是运行中还是已结束,不依赖"未完成 item 恰好还在队列里"。
@@ -129,8 +135,10 @@ Thread 内所有公开事件共用一个单调递增 seq;seq 允许跳号,
条目形状的职责边界固定为三条:
1. **搬运层不生成展示形状**。Thread Manager 只下发 Codex 原始条目(`itemType` 原样透传,正文与工具明细脱敏后带上限截断),不生成工具卡片的 `kind`、标题、折叠摘要,也不判断哪些条目要显示。
2. **只有一个条目身份**`itemId = call_id ?? id`:工具 item 的 app-server id 就是 `call_id`codex-rs `thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`),`project.jsonl` 落盘的是原始 response item`id``call_id` 不同),所以在进队列前归一。Thread Manager 与前端都不得再出现第二个 id 概念。
3. **合并只在前端,且只保留"先到定形、后到补空白"**。第一次见到的快照决定卡片形状,后续快照只补输出与状态;同一调用只出现一张卡片。
2. **只有一个条目身份**工具条目在 `project.jsonl` 里带两个 id(调用 id 与 response item id,同一调用的调用与输出共用前者;codex-rs `thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`),所以在进队列前归一成一个 `itemId`。Thread Manager 与前端都不得再出现第二个 id 概念。
3. **合并只在前端,且只保留"先到定形、后到补空白"**。第一次见到的快照决定卡片形状,后续快照只补输出与状态;同一调用只出现一张卡片。只有"后到信息一定更全"时才例外:正文取更长的一份、工具状态允许从 `running` 升级到终态、`updatedAt` 取较新的时间。
前端不保留增量缓冲:`item.delta` 直接追加到运行态条目的正文(正文只增不减)。`turn.completed` 把当前回合的运行态条目并入历史再清空,条目既不消失也不重复;失败与中止说明只在运行期显示,不写进 `project.jsonl`
历史切片的 `firstItemId` 不是上述归一身份:分页锚点必须是 `project.jsonl` 里的原始 item id,由 Rust 从文件扫描单独算出。
@@ -1,5 +1,14 @@
# 【技术方案】GameAgent 对话工具调用卡片(Codex 风格)-2026-09-14
## 2026-09-16 修订(当前状态)
本方案的**卡片表现层**(折叠 / 展开、标题与摘要文案、耗时与时间显示、脱敏、无障碍、样式)仍然是有效契约;**数据来源层**已被 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 取代,边界改为:
- DirectProject 聊天框的工具卡片由**运行态事件 + 项目对话历史**在前端投影生成(`features/project-workspace/directThreadItemProjection.ts`),不再读取 `tool-calls.jsonl``read_direct_tool_calls` 不再是聊天视图的输入。
- 报文中不再有 `toolCalls` 增量字段与 `GameCreatorDirectTurnUpdateEvent` 这条实时链路:卡片形状由前端从脱敏原始条目生成,事件里只有 `item.started` / `item.completed` / `item.delta`(线上模型见 `agent/direct_thread_wire.rs`,由 ts-rs 导出绑定)。
- 卡片身份只有一个 `itemId`(工具条目在 `project.jsonl` 里带的两个 id 已在 Rust 边界归一),前端卡片形状是 `Omit<GameCreatorDirectToolCall, 'turnId'>`:聊天卡片不再有回合身份。
- 下面「### 1. 工具调用条目」「### 2. 实时事件」「### 3. 回读命令」三节描述的是 DirectRuntime 自己的账本(`tool-calls.jsonl` 的写入形状与脱敏规则仍然有效,DirectRuntime 保留),**不再是 DirectProject 聊天框的读路径**;「### 4. 前端合并与渲染」中按 `turnId` 归并、按 `turn-stream.jsonl``seq` 交替的规则已作废,改为按事件顺序 + 历史文件顺序投影。
## 一句话交付
把 GameAgent 右侧对话面板里的「执行命令 / 写文件 / 调工具」从一行中文进度文本,改成 Codex 桌面客户端那样的**可折叠卡片**(折叠态一行摘要,展开态看命令与文件明细),并且在**刷新页面、重开项目后仍然存在**。
@@ -55,7 +64,7 @@ toolCalls?: DirectTurnToolCall[] | null;
- 历史文件缺失 → 返回空数组,不报错。
- 单行损坏 → 逐行读字节并逐行解码,跳过该行继续,不整体失败;只有损坏字节与下一行黏成一行(例如写入被截断、缺失换行)时,被丢掉的也只是那**一行**,其后的合法记录必须继续读回(与 Codex item 流一样是"尽力而为"的展示数据,不是业务真相)。
### 4. 前端合并与渲染(回合唯一归属,连续工具成块)
### 4. 前端合并与渲染(回合唯一归属,连续工具成块)——已作废,见文首修订
- 加载对话时按 `turnId` 归并为唯一回合容器,用户消息保留在该回合前部。有 `turn-stream.jsonl` 时,文本与工具按 item `seq` 交替,连续工具合为一块,遇到文本另起一块;没有流的历史回合才采用“工具块 + 历史正文”。
- 回合完成后,中间文本及所有工具块统一收进默认关闭的“执行过程”;最终回复及失败提示留在外面。展开后仍按原顺序查看中间输出和工具详情;运行中不使用外层折叠区。用户消息的发送时间从消息自身的历史时间读取,不能拿工具起点补造。