Merge pull request '退役 DirectProject 工具调用与回合流账本' (#560) from refactor/remove-toolcall.jsonl-legacy into master
Project CI / AI game creator shell Rust crates (push) Successful in 1m35s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m17s
Project CI / Backend tests (push) Successful in 4m8s
Project CI / Frontend tests (push) Successful in 1m49s
Project CI / Native shell tests (push) Successful in 6m21s
Project CI / Repository checks (push) Successful in 2m24s
Project CI / AI game creator shell web tests (push) Successful in 1m43s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 10m25s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 11m50s

Reviewed-on: #560
This commit was merged in pull request #560.
This commit is contained in:
2026-10-01 16:13:14 +08:00
18 changed files with 365 additions and 2556 deletions
@@ -10,7 +10,7 @@
## 背景
AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(实时)、`turn-stream.jsonl`(文本段与工具交替顺序)、`tool-calls.jsonl`(已脱敏工具卡片),重进页面时还要额外接管活动回合快照。同一段文本和同一张工具卡片因此存在多个来源,实时与回读会互相覆盖,恢复路径也只能靠"哪个源先到"决定。
AGC 项目开发聊天框当前同时从多处取数据:Direct 回合事件(实时)、另一份按顺序重建的回合投影、以及按读取期另算的工具卡片,重进页面时还要额外接管活动回合快照。同一段文本和同一张工具卡片因此存在多个来源,实时与回读会互相覆盖,恢复路径也只能靠"哪个源先到"决定。
`.agent/conversations/project.jsonl` 里的 Codex 原始条目本身已经带着顺序(`function_call` 与 `function_call_output` 按写入顺序落行),顺序信息并不是协议缺陷,而是在投影层被丢弃。
@@ -18,10 +18,10 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(
- AGC 项目开发对话的持久事实源只有 **项目对话历史**(`.agent/conversations/project.jsonl` 的原始条目);消息文本、工具卡片和它们的先后顺序都从它派生。
- 运行期间的回合状态只来自 **运行态事件**(Thread Manager 的 subscribe / consume / notify);`notify` 只做唤醒,不携带状态。
- **聊天投影** 在读取与渲染时生成,不落盘、不成为第二事实源;DirectProject 聊天框停止读取 `turn-stream.jsonl` 与 `tool-calls.jsonl`,也不再提供供前端读取的命令。DirectRuntime 自己那套进度事件与文件写入属于运行时账本,本轮保留不动。
- **聊天投影** 在读取与渲染时生成,不落盘、不成为第二事实源;聊天视图只由项目对话历史与运行态事件驱动,不再有供前端读取的旁路命令。
- 页面重进的运行态只由 `subscribe` 的 bootstrap 事件重建,删除活动回合快照接管路径。
- 可见性判断留在前端聊天投影:后端历史分页只按原始条目切片,前端自己跳过不可显示条目并推进锚点。
- 线上模型是 **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`。
- 线上模型是 **ts-rs 导出的 tagged enum**(`agent/thread_manager/wire.rs`),不是"一个大结构体加一堆可空字段":`ThreadItem` 用 `itemType` 区分条目,`ThreadEvent` 用 `type` 区分事件,前端直接消费生成的 TS 类型(改完 Rust 模型跑 `cargo test export_bindings`)。条目上的毫秒时间戳标 `#[ts(as = "f64")]`,因为 ts-rs 默认把 `u64` 映射成 `bigint`,而 Tauri 的 JSON 通道传的是 `number`。
- 运行态事件与历史切片使用同形条目,Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「原始条目 → 视图」投影函数。
- 两侧的过滤口径必须完全一致,包含「哪些条目根本不是本项目的聊天条目」:Codex app-server 回显的用户消息(`userMessage` / 非 AGC 的 `role=user`)在落盘侧被过滤,在运行态事件侧也必须被过滤(`direct_thread_visible_item`)。少一侧就会出现「实时比历史多出两条同文本用户条目、各自开出一个耗时 0 秒的假回合,重进页面又正常」这类只有其中一侧的事实源缺陷。
- 搬运层不生成展示形状:Thread Manager 只下发脱敏原始条目(`itemType` 原样透传),工具卡片的 `kind`、标题、折叠摘要都由前端生成。
@@ -43,7 +43,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(
- 思考正文以 `item.delta{kind:"reasoning"}` 流式下发(`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta`)。这不放宽可见范围:同一段文本本来就已落进 `project.jsonl` 并在 `item.completed` 展示;plan 文本与命令输出仍只降级为活动状态。
- 首屏历史由 `subscribe` 返回的 `lastCompletedItemId` 锚定,再取最近切片;删除返回整份对话的历史命令。锚点是切片**新端(较新一侧)的边界且含该条**(命令参数 `throughItemId`):比锚点更新的条目只从运行态事件来,历史切片与实时流因此不重叠;向后翻页仍用切片返回的 `firstItemId` 作为 `beforeItemId`(不含锚点)。订阅回执到达之前不读首屏,也不退化成"取文件尾"(那会把回执之后才完成的条目也拉进历史)。
- 生命周期锚点独立于 replay 队列保存(队列会回收 `cleanable` 事件,新订阅的游标又在队尾,回收后无法反推"最新回合是 started 还是 completed"),`subscribe` 必须返回最新的一条 `turn.started` / `turn.completed`,否则新订阅无法判定回合是否仍在运行。
- 前端工具卡片形状是 `Omit<GameCreatorDirectToolCall, 'turnId'>`:聊天卡片不再有回合身份,`tool-calls.jsonl` 的持久化形状仍保留 `turnId`(DirectRuntime 的账本没动)。
- 前端工具卡片形状是 `Omit<GameCreatorDirectToolCall, 'turnId'>`:聊天卡片不再有回合身份。
- 未识别 item 类型由 Rust 原样透传(只带类型与身份,Rust 侧留 TODO),当前由前端投影丢弃:哪些类型可见属于前端决策,不回 Rust 加白名单。
- 删除范围包含前端对 `read_direct_turn_stream`、`read_direct_tool_calls`、`read_direct_project_history`(整份历史)与 `game-creator-direct-turn-update` 事件的调用;保留分页用的历史切片读取(`read_direct_project_history_slice`),且该切片从文件尾反向扫描。`list_game_creator_direct_active_turns` 有意保留:它服务首页跨页面的「运行中的项目」列表,不是聊天框读路径。
- 前端删掉 `directTurnStream` / `directToolCalls` / 活动回合快照接管 / 瞬时应答文本这些并行状态,聊天视图只由 reducer 状态投影(含工具卡片)。
@@ -53,13 +53,12 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(
## 备选方案与取舍
1. **保留 `tool-calls.jsonl` 作为"读侧已脱敏"缓存**:省一次脱敏与截断,但它成为与项目对话历史并行的第二事实源,卡片状态与顺序会和实时事件分叉。选择按读取期投影,必要时在进程内缓存。
1. **保留一份"读侧已脱敏"的持久缓存**:省一次脱敏与截断,但它会成为与项目对话历史并行的第二事实源,卡片状态与顺序会和实时事件分叉。选择按读取期投影,必要时在进程内缓存。
2. **保留 Direct 回合事件作为实时传输**:迁移量小,但同一段文本仍有两条实时链路,reducer 必须处理互相覆盖,正是本次要消除的问题。
3. **让后端分页按"可显示消息数"切片**:界面能少写循环,代价是 Rust 需要理解 UI 可见性,界面规则一变就要同步改后端。
## 影响
- 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再由 DirectProject 聊天框读取。
- 工具卡片的脱敏与截断必须在读取期执行一次,不能因为"原始条目已在磁盘"就把未脱敏内容直接渲染到界面。
- 回合结束语义务必由 `turn.completed` 判定(失败时同一事件带 `failure` 载荷,不新增事件类型);缺少该事件的残留回合不得被渲染成运行中。
- 两条已知边界,都**不**在本次补路径,且已被 [`【ADR】DirectProject命令接单化-2026-09-23`](./【ADR】DirectProject命令接单化-2026-09-23.md) 取代(§3、§2):① 宿主进程被强杀(`kill -9`)时没有任何 `Drop` 会执行,但队列随进程消失,新进程的订阅 bootstrap 因此不会看到"有开始没结束",界面不会卡在忙碌态;② `turn.started` 之前的失败按发生位置分流——接单**之前**的是拒单,根本不产生回合(不写用户条目、不写失败诊断),接单**之后**的由这一轮的占用对象统一收口成 `turn.completed`,不存在"有回合却没有事件解释"的路径。
@@ -69,5 +68,5 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(
- **调用级拒绝**(同一 `clientTurnId` 并发复用 / 项目已有另一条回合在跑 / 权限策略拒绝 / 目录锚不定 / 输入校验 / 环境与凭据未就绪)不属于回合失败:这一轮没有开始,只把原因回给命令边界(界面出运行错误横幅),不写失败诊断、不发 `failed` 事件、不进交付报告。此前它们与回合失败混在同一层、共用同一份错误文本,现在分流只认 typed 判据。
- 「活动回合的唯一判据」约束的是**原生回合**:界面上的「本地已发出、原生还没认领」是投影的展示态(`DirectChatTurn.state = 'awaiting-start'`),由本地在途用户条目身份派生,不构成第二套原生生命周期,也不参与 `turnRunning` 的判定。
- 三层数据流、变量归属与一次发送的时序写在代码里:`apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表在 `apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。改判据时同步这两处与对应测试。
- 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `turn-stream.jsonl` / `tool-calls.jsonl`。
- 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致。
- id 空间已用源码核对:codex-rs `app-server-protocol/src/protocol/thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`,而 `project.jsonl` 落盘的是原始 response item。真实 app-server 会话核对仍列为运行时验收项。
@@ -8,20 +8,20 @@
## 修改边界
- 允许修改:`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 行为、审批弹层现有状态来源。
- 允许修改:`agent/thread_manager/wire.rs`、`agent/thread_manager/mod.rs`、`agent/codex_app_server/`、`agent/direct_project_history.rs`、`main.rs` 命令注册、ts-rs 生成目录、AGC 前端订阅与聊天投影、对应测试与 `docs/`。
- 明确不修改:SpacetimeDB schema 与绑定、HTTP/OpenAPI、Codex durable thread 行为、审批弹层现有状态来源;DirectRuntime 的进度事件与账本本轮不动(该账本随后已随 issue #553 整体删除)。
- 保持 `.env` 未提交修改,不触碰个人配置。
## 实现顺序
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` 生成前端绑定。
1. Rust 只搬运:`agent/thread_manager/wire.rs` 把 Codex 原始条目挑字段、脱敏、截断后下发,事件载荷与历史切片同形,不生成卡片形状;线上模型是 ts-rs 导出的 tagged enum(`ThreadItem` / `ThreadEvent` / 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`。唯一例外是回执竞态:Rust 注册完 subscriber 就开始通知,而前端要等回执才知道 `subscriptionId`,这段时间到达的通知只能记欠账,回执到达后立刻补一次 `consume`(否则整轮最后一个事件之后可能再无通知,事件会卡死在队列里)。合并规则只保留"先到定形、后到补空白"(正文只增不减、工具状态允许从 running 升级到终态),`item.delta` 直接追加到运行态条目正文,删掉 `deltaText` 缓冲,`turn.completed` 把运行态条目并入历史再清空。
6. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,历史读取改为从文件尾反向扫描;切片的新端边界由这个锚点给出(含该条,命令参数 `throughItemId`),首屏读取等订阅回执里的锚点,回执到达前不发请求、也不退化成「取文件尾」;之后锚点按原始 item id 推进(`beforeItemId`,不含锚点)。一次翻页操作在前端连拉,直到合并后聊天投影出现新回合(新的用户气泡)或 `hasMore=false`,每个操作上限 5 页;锚点未推进(`items` 为空 / `firstItemId` 为 null / 与请求锚点相同)时立即停止。可见性口径只在 `features/project-workspace/directHistoryPaging.ts` 实现一份,首屏与「显示更早」共用。
7. App.tsx 接线:订阅 + 立即 reduce bootstrap + notify 唤醒 consume,聊天视图改由 reducer 状态投影(含工具卡片),删除 Direct 回合事件订阅与 `directTurnStream` / `directToolCalls` 状态。
8. 删除只服务旧读路径的命令与前端调用(`read_direct_project_history`、`read_direct_turn_stream`、`read_direct_tool_calls`),DirectRuntime 自己的写入保留。`list_game_creator_direct_active_turns` 是唯一的例外并有意保留:它服务首页跨页面的「运行中的项目」列表(`WorkspaceLauncher` / `directActiveTurns.ts`),不是聊天框读路径。
8. 删除只服务旧读路径的命令与前端调用(`read_direct_project_history`、`read_direct_turn_stream`、`read_direct_tool_calls`)。`list_game_creator_direct_active_turns` 是唯一的例外并有意保留:它服务首页跨页面的「运行中的项目」列表(`WorkspaceLauncher` / `directActiveTurns.ts`),不是聊天框读路径。DirectRuntime 自己的账本写入本轮保留,随后已随 issue #553 整体删除。
9. 测试与文档收口:补 reducer 单测、解锁跳过的工具卡片用例、更新主规范并把冲突的实施计划与工具卡片文档改写为当前状态。
## 验证命令
@@ -11,7 +11,7 @@
AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话历史**(`.agent/conversations/project.jsonl`)与 **运行态事件**(Thread Manager `subscribe` / `consume` / `notify`)。Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl` 与活动回合快照都不再是聊天视图的输入。
边界固定为:Thread Manager 只是**搬运层**——把 Codex 原始条目挑字段、脱敏、截断后下发;工具卡片的形状、可见性与合并全部由前端投影完成。线上模型是 ts-rs 导出的 tagged enum(`agent/direct_thread_wire.rs`),条目身份只有一个:Rust 在进队列前归一成一个 `itemId`,不再暴露第二个 id 概念,也不带任何回合身份。
边界固定为:Thread Manager 只是**搬运层**——把 Codex 原始条目挑字段、脱敏、截断后下发;工具卡片的形状、可见性与合并全部由前端投影完成。线上模型是 ts-rs 导出的 tagged enum(`agent/thread_manager/wire.rs`),条目身份只有一个:Rust 在进队列前归一成一个 `itemId`,不再暴露第二个 id 概念,也不带任何回合身份。
## 范围
@@ -27,7 +27,7 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话
- 审批、提问与用户输入请求的状态机迁移;本次事件只作同一条流的 pass-through。
- 跨进程回合账本、按回合统计与持久 turn ledger。
- DirectRuntime 自己的进度事件与该运行时仍在使用的 `turn-stream.jsonl` / `tool-calls.jsonl` 写入:它们属于运行时的账本,本轮只切 DirectProject 聊天框的读路径。
- DirectRuntime 自己的进度事件与该运行时的账本:本轮只切 DirectProject 聊天框的读路径;该账本(`turn-stream.jsonl` / `tool-calls.jsonl`)随后已随 issue #553 整体删除。
- 旧项目磁盘上既有投影文件的清理、迁移或回填。
- 非 DirectProject 运行时、Codex durable thread 语义、SpacetimeDB 与 HTTP 契约。
@@ -40,7 +40,7 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话
- 思考正文流式下发不放宽可见范围:被下发的就是此前已在 `item.completed` 展示、并已落进 `project.jsonl` 的同一段文本。
- 失败与中止说明只在运行期显示(不写 `project.jsonl`),页面重进后不再出现。
- 未知 item 类型由 Rust 原样透传(只有类型与身份,Rust 侧留 TODO),当前由前端投影丢弃。
- 前端聊天卡片的工具形状是 `Omit<GameCreatorDirectToolCall, 'turnId'>`;`tool-calls.jsonl` 的持久化形状与 DirectRuntime 的写入保持不变。
- 前端聊天卡片的工具形状是 `Omit<GameCreatorDirectToolCall, 'turnId'>`。
- 「可显示」的判据取**前端回合反馈**:一次翻页操作连拉到「合并后聊天投影的回合数增加」为止。工具卡片与思考文本虽然能通过 `projectDirectThreadItem`,但可能整页落进已渲染回合的折叠「执行过程」,不构成用户可见反馈;口径只在 `directHistoryPaging.ts` 里实现一份,首屏与「显示更早」共用。
- 首屏切片的**新端边界**只认 `subscribe` 回执里的 `lastCompletedItemId`(含该条):回执到达之前不读首屏,也不退化成「取文件尾」;锚点缺失(订阅不可用 / 失败 / 历史为空)时才按文件尾取尾屏,手动重读保持按当前文件尾取尾屏的恢复语义。
@@ -1,5 +1,13 @@
# 决策记录
## 2026-09-30 退役 DirectProject 工具调用与回合流账本(issue #553)
- 背景:`tool-calls.jsonl` / `turn-stream.jsonl` 是只写不读的账本——读命令在 2026-09-16 随聊天真相源收敛删除后,唯一"消费方"是 `game-creator-direct-turn-update` 事件,而全仓已无监听方;工具卡片的真实读路径早已是「项目对话历史 + 运行态事件」的前端投影。issue #553 还暴露了同一时期绝对路径脱敏误伤 HTML 结束标签的问题。
- 决策:整体删除 DirectRuntime 的 `turn-stream.jsonl` / `tool-calls.jsonl` 写入器、`DirectToolCallCollector`、回合流节流器、`DirectCodexTurnObservation::ToolCall` 与 `GameCreatorDirectTurnUpdateEvent`;`update_active_turn` 保留,首页「运行中的项目」快照不变。工具卡片只由读取期在 `agent/thread_manager/wire.rs` 脱敏、截断的历史条目投影。
- 边界:不在运行时做磁盘清理;旧项目里可能残留的文件不迁移、不读取,历史由 Git 保存。
- 未修(另开):`redact_absolute_path_tokens` 的无语境绝对路径扫描仍会把 HTML 结束标签、嵌套 JSON 转义里的 `/` 误判成路径,是 issue #553 的根因;本次只删死路径,未改扫描器。
- 验证:`cargo test --bin genarrative-ai-game-creator-shell agent:: -- --test-threads=1`(929 passed / 0 failed / 5 ignored)、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。
## 2026-09-30 release 渠道移除产品名与包名后缀
- 决策:`release` 渠道的正式产品名统一为 `陶泥儿`,Windows NSIS、macOS DMG / updater 归档等由 Tauri `productName` 派生的包名不再包含 `Release` 文本;`identifier=world.genarrative.ai-game-creator.release` 与 `release-win` 更新分区保持不变。
@@ -24,7 +24,7 @@
- **处理(本次)**:`sanitize_error_context` 改为 `split_inclusive('\n')` 逐段处理、原样保留行终止符(CRLF 仍归一成 LF,私钥块整行吞掉的旧口径不变)。逐段脱敏因此与整段脱敏同形,Markdown 结构不再依赖 delta 的切法。
- **仍未修**:`/` 开头的 delta 片段被判成绝对路径(上面 ②)。要修必须把「上一个字符」带进边界判定(正文增量可从 `direct_project_history` 的累计文本取,思考增量目前没有累计器),属于跨 delta 状态的取舍,本次未一并改。
- **判据/取证**:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell direct_thread_delta`——新增 `direct_thread_delta_sanitization_preserves_line_breaks` 钉住「逐段脱敏 == 整段脱敏」,去掉 `split_inclusive` 即红;真实文本的回归用渲染侧夹具复核(修复前 table/li/h2 全 0,修复后与整段脱敏一致:1 个 table / 3 个 th / 4 个 h2)。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/generation/prompt_context.rs`、`.../agent/direct_thread_wire.rs`、`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts`、`#384`。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/generation/prompt_context.rs`、`.../agent/thread_manager/wire.rs`、`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts`、`#384`。
## 2026-09-29 Game Agent 读工具被项目相对路径规则拦住
@@ -4,10 +4,10 @@
本方案的**卡片表现层**(折叠 / 展开、标题与摘要文案、耗时与时间显示、脱敏、无障碍、样式)仍然是有效契约;**数据来源层**已被 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 取代,边界改为:
- DirectProject 聊天框的工具卡片由**运行态事件 + 项目对话历史**在前端投影生成(`features/project-workspace/directThreadItemProjection.ts`),不再读取 `tool-calls.jsonl`;`read_direct_tool_calls` 命令与 Rust 侧 `read_direct_tool_calls_at` 回读函数已删除,该文件现在只有 DirectRuntime 的写入。
- 报文中不再有 `toolCalls` 增量字段与 `GameCreatorDirectTurnUpdateEvent` 这条实时链路:卡片形状由前端从脱敏原始条目生成,事件里只有 `item.started` / `item.completed` / `item.delta`(线上模型见 `agent/direct_thread_wire.rs`,由 ts-rs 导出绑定)。
- DirectProject 聊天框的工具卡片由**运行态事件 + 项目对话历史**在前端投影生成(`src/view/project-development/chat/conversation/directThreadItemProjection.ts`);工具条目在读取期由 `agent/thread_manager/wire.rs` 统一脱敏与截断,不再有独立的工具调用账本或回读命令。
- 报文中不再有 `toolCalls` 增量字段与 `GameCreatorDirectTurnUpdateEvent` 这条实时链路:卡片形状由前端从脱敏原始条目生成,事件里只有 `item.started` / `item.completed` / `item.delta`(线上模型见 `agent/thread_manager/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` 交替的规则已作废,改为按事件顺序 + 历史文件顺序投影。
- DirectRuntime 曾经的 `tool-calls.jsonl` / `turn-stream.jsonl` 账本、写入器与进度事件已整体删除(2026-09-30,issue #553);本方案原先描述这些落盘与事件契约的「工具调用条目」「实时事件」「回读命令」三节一并移除,改为按事件顺序 + 历史文件顺序投影。
## 一句话交付
@@ -44,60 +44,24 @@
- 同一用户消息从本地发送转为正式条目时必须保留原发送时间,不能因去重丢掉该时间而改用启动应答后的观测时间。回合明确结束时,即使当前 live 集合为空,也应将终态边界关联到已知的本轮用户条目;不得仅按“历史最后一项”猜测或向无关旧回合补时间。
- 生命周期事件可携带已有用户条目的 `userItemId`,用于把时间边界精确关联到同一条历史消息(不产生新的回合 ID 或持久化字段)。恢复时该关联随 Thread Manager 原事件重放;带旧用户身份的终态不能收口另一条新请求。缺少身份的旧事件不据时间猜测归属。
## 背景与现状(已核实)
## 背景与现状(2026-09-14 立项时点)
> 以下记录立项时点的现状,用来解释这次改造的动机;其中「工具调用不留痕」「没有结构化工具调用」等结论都已被后续实现与 2026-09-16 修订取代,**当前状态以文首修订与 ADR 为准**。
- 数据来源:Codex app-server 会推 `item/started` / `item/completed`,item 里带完整信息(`commandExecution.command`、`fileChange.changes[].path` 等)。
- 现状投影:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs` 的 `direct_codex_item_intermediate_text()`(约 791 行)把 item **压成一行中文文本**(`正在执行命令:xxx` / `正在写入文件:xxx`),经 `DirectCodexTurnObservation::IntermediateText` 下发。
- 前端事件:`GameCreatorDirectTurnUpdateEvent`(`src/app/types.ts:1090`)只有 `projectPath / turnId / sequence / status / activity / accumulatedText / updatedAt`,**没有结构化工具调用**。
- 前端事件:`GameCreatorDirectTurnUpdateEvent`(该事件已随 issue #553 删除)只有 `projectPath / turnId / sequence / status / activity / accumulatedText / updatedAt`,**没有结构化工具调用**。
- 历史持久化:`.agent/conversations/project.jsonl` 现在只写 message 条目(实测 61 条全是 message),工具调用不留痕。
- 历史回读:`read_direct_project_chat_history_at()`(`direct_project_history.rs:575`)只把 `role ∈ {user, assistant}` 且有文本的条目投影成 `LocalConversationMessageRecord`,**形状上装不下工具调用**。
- 结论:要做成卡片必须同时改「采集 → 传输 → 持久化 → 回读 → 渲染」五段,纯前端做不出来。
## 契约(实现必须照此,不得自行改形状)
### 1. 工具调用条目(采集与持久化形状)
工具卡片的两处数据来源是 `.agent/conversations/project.jsonl` 的原始条目与 Thread Manager 的运行态事件;Rust 出口只做挑字段、脱敏、截断,卡片形状、可见性、合并与计时全部由前端投影完成。
新增独立历史文件:`<projectRoot>/.agent/conversations/tool-calls.jsonl`,一行一条,行信封与既有历史一致:
### 前端合并与渲染(回合唯一归属,连续工具成块)
```json
{ "type": "tool_call_item", "payload": { "schemaVersion": "agc-tool-call.v1", "id": "...", "turnId": "...", "kind": "command|file_change|mcp_tool|web_search|context_compaction|other", "title": "执行命令", "summary": "npm run build", "status": "running|completed|failed", "detail": { "command": "...", "output": "...", "changes": [{ "path": "game/src/x.ts", "kind": "add|update|delete" }] }, "startedAt": 0, "updatedAt": 0 } }
```
- `id`:Codex item 的 id;同一 item 的 `started` 与 `completed` 必须落成**同一条**(按 id 幂等 upsert,不允许写两行)。
- **状态单调**:同一 `id` 的每条快照按 `updatedAt` 合并落盘——`updatedAt` 更旧的快照不得覆盖更新的 `status` 与 `updatedAt`。逐条快照落盘与回合末整批落盘两条路径会并发竞争,后到的旧快照不能把已经 `completed` / `failed` 的卡片打回 `running`;`updatedAt` 相同时终态优先;`startedAt` 取最早的非零值(`item/completed` 不一定带 `startedAtMs`)。
- `title` 是折叠态的一行标题,按 kind 固定:`command` → `执行命令`、`file_change` → `编辑 N 个文件`(N = changes 去重后数量)、其余见 kind 枚举。
- `summary` 是折叠态标题后面的短摘要:命令取命令首行(截断 120 字符),`file_change` 取首个变更路径。`summary` 的每个来源(命令、变更路径、`tool`)都必须先脱敏再落盘。
- `detail.command` 读取原生命令或 MCP `arguments`,`detail.output` 读取 `aggregatedOutput` / `output` / `result` / `error`;对象格式化为 JSON,先脱敏再各截断到 4000 字符。展开工具行分别显示“输入”“输出”。MCP 摘要保留工具名,不把 JSON 开头的 `{` 当成摘要。状态相同但详情变化也必须更新;完成快照缺少输入字段时保留开始快照的输入。
- **路径形状**:`detail.changes[].path` 用**项目相对路径**(如 `game/src/x.ts`,分隔符统一成 `/`);项目外的绝对路径落成 `<absolute-path>` 占位。任何情况下都不得写出项目根目录本身、用户家目录或绝对路径的原始值。
- **必须脱敏**(落盘前统一走 `agent/direct_tool_calls.rs` 的 `sanitize_detail_text`,顺序:项目路径归一化 → `redact_absolute_path_tokens` → `redact_secret_tokens` → `sanitize_error_context`):
- 前缀型密钥沿用 `redact_secret_tokens`(`sk-…`、`tnr_sk_…`、`ghp_…`、`AKIA…`、`eyJ…` 等);
- 键值型凭据沿用 `sanitize_error_context`(= `redact_secret_tokens` + `redact_error_sensitive_assignments` + `redact_error_bearer_values` + `redact_error_config_names` 的既有组合),覆盖 `Authorization: Bearer …`、`Cookie: session=…`、`api_key=…`、`client_secret=…`、`token=…` 等形状;
- 含 `--password` / `--token` / `--secret` / `--api-key` 这类敏感 CLI 标志的行按既有 fail-closed 约定**整行**替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致;即使标志后面只是 `$VAR` 占位符也整行替换,占位符本身不会保留);
- 脱敏必须幂等(同一段文本连跑两次结果一致),且不得把未脱敏文本写进 `detail` / `summary`。
### 2. 实时事件(新增字段,不改既有字段语义)
`GameCreatorDirectTurnUpdateEvent` 增加**可选**字段:
```ts
toolCalls?: DirectTurnToolCall[] | null;
```
- 只有在本回合工具调用集合发生变化时才带(不要每个 heartbeat 都重发全量)。
- 字段**可选**:老版本事件解析路径必须保持兼容(前端拿到 `undefined` 时行为与现在一致)。
- `DirectTurnToolCall` 与上面 payload 同形(去掉 `turnId`)。
### 3. 落盘契约(写侧)
DirectRuntime 写 `<projectRoot>/.agent/conversations/tool-calls.jsonl`;回读命令与 Rust 侧回读函数已随聊天读路径退役删除,下面的语义约束的是**写进文件的行**。
- **上限语义**:200 条是「按时间保留最新 200 条」。超出时更早回合的卡片会被**静默丢弃**(老回合卡片会消失),不做分页、不做历史回填;同一 `id` 的多条记录先按 `updatedAt` 合并,再按时间正序裁剪。
- 历史文件缺失 → 返回空数组,不报错。
- 单行损坏 → 逐行读字节并逐行解码,跳过该行继续,不整体失败;只有损坏字节与下一行黏成一行(例如写入被截断、缺失换行)时,被丢掉的也只是那**一行**,其后的合法记录必须继续读回(与 Codex item 流一样是"尽力而为"的展示数据,不是业务真相)。
### 4. 前端合并与渲染(回合唯一归属,连续工具成块)——已作废,见文首修订
- 加载对话时按 `turnId` 归并为唯一回合容器,用户消息保留在该回合前部。有 `turn-stream.jsonl` 时,文本与工具按 item `seq` 交替,连续工具合为一块,遇到文本另起一块;没有流的历史回合才采用“工具块 + 历史正文”。
- 加载对话时按历史条目进入 `project.jsonl` 的原始顺序投影:用户消息、文本与工具块保持原序,连续工具合为一块、遇到文本另起一块,不按 `turnId` 重新归并、也不再有 `seq` 交替。
- 回合完成后,中间文本及所有工具块统一收进默认关闭的“执行过程”;最终回复及失败提示留在外面。展开后仍按原顺序查看中间输出和工具详情;运行中不使用外层折叠区。用户消息的发送时间从消息自身的历史时间读取,不能拿工具起点补造。
- 实时与回读共用同一投影,正文、工具和耗时不另建实时/未归属渲染出口。先在完整历史按消息身份关联,再分页;禁止按第 N 个工具回合匹配第 N 条用户消息。详情通过当前回合 `callId` 关联;同项目回读与实时增量幂等合并,切项目清空旧状态。完整合同见 [AGC 实施计划](./【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) 的“DirectProject 回合展示唯一归属”。
- 块 DOM 与交互(对齐 Codex):
@@ -146,32 +110,28 @@ DirectRuntime 写 `<projectRoot>/.agent/conversations/tool-calls.jsonl`;回读
## 验收判据(每条都要有可复现证据)
1. 一轮真实回合里,`tool-calls.jsonl` 里同 id 只有一行,`completed` 后 `status` 变 `completed`。
1. 一轮真实回合里,按项目对话历史投影出的工具卡片同 id 只有一张,`completed` 后状态变 `completed`。
2. 刷新页面 / 重开项目后,工具调用卡仍在原位(不重复、不丢)。
3. 实时回合中卡片状态从 `running` 走到 `completed`。
4. 折叠/展开键盘可达(Tab 到头部按钮、Enter/Space 切换、`aria-expanded` 同步)。
5. `Read` 到的既有对话(无工具调用)行为不变;老事件(无 `toolCalls` 字段)行为不变。
6. 脱敏检查:`tool-calls.jsonl` 里不出现 API Key / Token / 绝对用户目录。
5. `Read` 到的既有对话(无工具调用)行为不变。
6. 脱敏检查:卡片摘要、输入 / 输出与展开详情的落盘内容中不出现 API Key / Token / 绝对用户目录。
## 不做项(本次明确不做)
- 不做工具调用的重试 / 取消 / 编辑按钮。
- 不做 diff 级展开(只在展开态列文件路径与变更类型)。
- 不做工具输出的完整回放、不做卡片内搜索。
- 不改 `.agent/conversations/project.jsonl` 的既有格式与注入 Codex 上下文的路径(新数据走独立文件,避免污染模型上下文)。
- 不改 `.agent/conversations/project.jsonl` 的既有格式与注入 Codex 上下文的路径。
- 不改工具调用之外的聊天渲染(markdown、气泡、滚动)。
- 不做历史工具调用的迁移回填(存量项目没有卡片,属预期)。
## 涉及文件(实现边界)
- Rust(`apps/ai-game-creator-shell/src-tauri/src/`):`agent/codex_app_server.rs`(item → 结构化采集)、`agent/direct_runtime.rs`(随事件下发 + 回合结束持久化)、`agent/direct_project_history.rs` 或新增 `agent/direct_tool_calls.rs`(upsert 与回读)、`commands.rs`(新增命令注册)。
- 前端(`apps/ai-game-creator-shell/src/`):`app/types.ts`(新增字段与类型)、`App.tsx`(订阅、累加、加载时合并)、`features/agent-runtime/` 或新增 `features/project-workspace/ToolCallCard.tsx`(卡片组件)、`styles.css`(卡片样式,新样式集中在文件末尾中文注释区块)。
- 测试:Rust 侧单元测试(upsert 幂等、状态按 `updatedAt` 单调合并、截断、脱敏覆盖与幂等性、项目相对路径形状、非法 UTF-8 损坏行跳过、200 条上限保留最新)、前端 `tests/`(卡片渲染、折叠交互、重开项目后合并、老事件兼容)。
- Rust(`apps/ai-game-creator-shell/src-tauri/src/`):`agent/codex_app_server/mod.rs`(item → 结构化采集)、`agent/thread_manager/wire.rs`(读取期脱敏、截断与 ts-rs 导出模型)、`agent/direct_runtime/mod.rs`(运行态事件)。
- 前端(`apps/ai-game-creator-shell/src/`):`app/types.ts`(卡片类型)、`view/project-development/chat/conversation/directThreadItemProjection.ts`(历史条目 → 卡片投影)、`view/project-development/chat/components/ToolCallGroup/`(卡片组件与文案)、`packages/shared`(共享过程样式)。
- 测试:Rust 侧单元测试(历史条目投影、脱敏覆盖与幂等性、相对路径形状、截断)、前端 `tests/`(卡片渲染、折叠交互、重开项目后合并)。
## 实施顺序(每步都要能独立验证)
## 实施状态
1. Rust 采集 + 独立文件 upsert:先只落盘,单元测试证明幂等与截断。
2. 事件字段下发 + 前端类型:老路径不受影响(回归现有 appSurface 用例)。
3. Tauri 回读命令 + 前端加载合并:刷新后卡片存在。
4. 卡片组件 + 样式 + 无障碍:折叠/展开 + 键盘。
5. 真实回合联调(本地 Tauri 起一轮),截图留证。
卡片表现层已按本方案落地;数据来源层在 2026-09-16 改为「运行态事件 + 项目对话历史」前端投影(见文首修订),原先的直连工具账本链路已随 issue #553 清理移除。