fix(client): harden nested Tool call projection

This commit is contained in:
imccyu
2026-08-08 17:46:50 +08:00
parent 9ed33dfe4f
commit 091b993198
18 changed files with 160 additions and 52 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md
2026-07-26-code-mode-chat-subcall-rows.md: dc09e8fda9dbfb156b6218dec67584ee7bfead75
2026-07-26-code-mode-chat-subcall-rows.zh.md: d445ddac4704fb940550d27ef7400e79d859393b
2026-07-26-code-mode-chat-subcall-rows.md: 40b4b8fd8c5cea8e9aea01731d7eceff692db188
2026-07-26-code-mode-chat-subcall-rows.zh.md: 9981fded054f663dd539660ed2a2eca68a9d3138

View File

@@ -12,12 +12,12 @@ With Code Mode enabled, the chat view showed one opaque `run_code` row: raw prog
## Decision
**Sub-calls are `ToolResultNode`s indexed off the surface flow, rendered through the same keyed slot as native rows, nested always-visible under their parent.**
**Sub-calls are standard Tool call blocks attached recursively to their parent outside the surface flow, rendered through the same keyed slot as native rows, and always visible under their parent.**
- **Data layer**: `Session.applyEventSideEffects` folds each in-window `tool/code-dispatch` into `ConversationSnapshot.codeDispatches: ReadonlyMap<parentCallId, readonly CodeSubCall[]>`, where `CodeSubCall` IS `ToolResultNode` (the sub-call id as `callId`, the logged args JSON-stringified into `call.argsRaw`, the full logged `content`/`isError`). Live mux frames and history replay build the identical index (`rebuildDerivedFromWindow` clears and re-derives; copy-on-write per-parent arrays keep snapshot references memo-stable). Sub-calls never join `nodes` — the surface flow remains exactly the model-visible turn structure. The event is narrowed structurally at the wire-consumer boundary (dsh-tools' host types cannot enter the client program — the host/client `Context` merges collide), the same posture as every cross-wire payload.
- **Render layer**: `ChatView` passes each parent and its indexed children through the whole-Tool `'conversation.chat.tool'` seat. ui-tool's `ToolCallTree` renders the parent followed by a `[data-subcalls]` nest, and every atomic call dispatches through the same `'tool.call.toolview'` keyed slot with `entryKey = Tool name` and the same `GenericToolCard` fallback. A keyed registration therefore takes over child and top-level calls without registration changes. Running parents (`runningCalls`) receive their accumulated dispatches through the same owner payload, so child rows stream in during the run.
- **Data layer**: Runtime's `ToolCallTree` folds in-window `tool/code-dispatch-start` and `tool/code-dispatch` events into a private per-parent index, then projects running and settled children onto recursive `ToolCallBlock.subCalls`. Live Session projection and `projectConversationHistory` share that fold; copy-on-write parent arrays and path-copy projection keep unrelated roots and siblings reference-stable. Sub-calls never join `nodes` — the surface flow remains exactly the model-visible turn structure. The events are narrowed structurally at the wire-consumer boundary, which also rejects cyclic parent relationships (dsh-tools' host types cannot enter the client program because the host/client `Context` merges collide).
- **Render layer**: `ChatView` passes each parent with its recursive children through the whole-Tool `'conversation.chat.tool'` seat. ui-tool's `ToolCallTree` renders the parent followed by `[data-subcalls]` nests, and every atomic call dispatches through the same `'tool.call.toolview'` keyed slot with `entryKey = Tool name` and the same `GenericToolCard` fallback. A keyed registration therefore takes over descendant and top-level calls without registration changes. Running parents (`runningCalls`) receive accumulated dispatches in the same recursive block, so child rows stream in during the run.
- **`run_code` presentation**: a new `code` row variant (classifier `run_code → code`, `Code` title, `IconCodeOutline16`) summarizes with the model-authored `description` and expands to the program itself (monospace on the markdown code-block fill) rather than the args JSON envelope.
- **Details panel**: `materialFor` falls through nodes → runningCalls → the dispatch index, so a selected sub-callId resolves to full args and complete output through the identical rendering path as a native settled call.
- **Details panel**: `materialFor` recursively searches `nodes` and `runningCalls`, so a selected descendant callId resolves to full args and complete output through the identical rendering path as a native settled call.
## Alternatives considered
@@ -29,4 +29,4 @@ With Code Mode enabled, the chat view showed one opaque `run_code` row: raw prog
## Consequences
Custom toolview registrations apply to sub-calls for free — and deliberately: there is no per-registration opt-out short of the component reading its own context, which no current consumer needs. Selection highlighting reaches nested rows through the same `selectedCallId` channel (group membership tests both levels). Trajectory/waterfall still render `run_code` as a single row — their sub-call spans are deferred to the PR that adds dispatch timing (start/end events), without which a waterfall span would be a lie. Fixture turn 64 (`?fixture`) plus the `code-mode-round` browser e2e (recorded real round, keyless replay) pin the full surface; the jsdom suites pin the slot dispatch, error states, details resolution, and index reference stability.
Custom toolview registrations apply to sub-calls for free — and deliberately: there is no per-registration opt-out short of the component reading its own context, which no current consumer needs. Selection highlighting reaches nested rows through the same `selectedCallId` channel (group membership searches the whole tree). Trajectory/waterfall still render `run_code` as a single row — their sub-call spans are deferred to the PR that adds dispatch timing (start/end events), without which a waterfall span would be a lie. Fixture turn 64 (`?fixture`) plus the `code-mode-round` browser e2e (recorded real round, keyless replay) pin the full surface; the jsdom and Runtime suites pin slot dispatch, error states, recursive details resolution, history projection, and reference-stable path copying.

View File

@@ -12,12 +12,12 @@ Status: implemented
## 决策
**子调用在界面流之外单独索引为 `ToolResultNode`,经由与原生行相同的 keyed slot 渲染,以始终可见的方式嵌套在父行之下。**
**子调用是在 surface 流之外递归附着到父级的标准 Tool 调用块,经由与原生行相同的 keyed slot 渲染,并始终显示在父级之下。**
- **数据层**:`Session.applyEventSideEffects` 把窗口内的每条 `tool/code-dispatch` 折入 `ConversationSnapshot.codeDispatches: ReadonlyMap<parentCallId, readonly CodeSubCall[]>`,其中 `CodeSubCall` 本身就是 `ToolResultNode`(子调用 id 充当 `callId`,已记录的参数经 JSON 字符串化写入 `call.argsRaw`,完整记录的 `content`/`isError` 原样携带)。实时多路复用帧与历史回放构建出同一份索引(`rebuildDerivedFromWindow` 先清空再重新推导;逐父级的写时复制(copy-on-write)数组保持快照引用稳定,便于 memo 化)。子调用永不进入 `nodes`——surface 流始终精确等于模型可见的轮次结构。该事件在 wire 消费方边界作结构性收窄(dsh-tools 的宿主类型无法进入客户端程序——宿主端/客户端两侧的 `Context` 声明合并会冲突),姿态与所有跨 wire 载荷一致。
- **渲染层**:`ChatView` 通过整体 Tool seat `'conversation.chat.tool'` 传递每个 parent 及其已索引的 child。ui-tool 的 `ToolCallTree` 先渲染 parent,再渲染一组 `[data-subcalls]` 嵌套;每个原子调用都通过同一个 `'tool.call.toolview'` keyed slot,以 Tool 名称作为 `entryKey`,并共用 `GenericToolCard` fallback。一个 keyed 注册因此无需变化即可同时接管 child 与顶层调用。运行中的 parent(`runningCalls`)通过同一 owner 载荷接收已累积的 dispatch,使 child 行在运行期间实时流入。
- **数据层**:运行时的 `ToolCallTree` 把窗口内的 `tool/code-dispatch-start` 与 `tool/code-dispatch` 事件折入私有的逐父级索引,再把运行中和已结算的子级投影到递归的 `ToolCallBlock.subCalls` 上。实时 Session 投影与 `projectConversationHistory` 共享这一折叠过程;逐父级的写时复制数组和路径复制投影让无关 root 与 sibling 保持引用稳定。子调用永不进入 `nodes`——surface 流始终精确等于模型可见的轮次结构。这些事件在 wire 消费方边界作结构性收窄,该边界也会拒绝成环的父子关系(dsh-tools 的宿主类型无法进入客户端程序,因为宿主端与客户端两侧的 `Context` 声明合并会冲突)。
- **渲染层**:`ChatView` 通过整体 Tool seat `'conversation.chat.tool'` 传递每个 parent 及其递归 child。ui-tool 的 `ToolCallTree` 先渲染 parent,再渲染 `[data-subcalls]` 嵌套;每个原子调用都通过同一个 `'tool.call.toolview'` keyed slot,以 Tool 名称作为 `entryKey`,并共用 `GenericToolCard` fallback。一个 keyed 注册因此无需变化即可同时接管任意后代与顶层调用。运行中的 parent(`runningCalls`)在同一个递归块中接收已累积的 dispatch,使 child 行在运行期间实时流入。
- **`run_code` 的呈现**:新增一种 `code` 行变体(分类器映射 `run_code → code`、标题 `Code`、图标 `IconCodeOutline16`),以模型撰写的 `description` 作摘要,展开后显示程序本身(在 markdown 代码块的填充底色上以等宽字体呈现),而非参数的 JSON 封装。
- **详情面板**:`materialFor` 按 nodes → runningCalls → 分发索引的顺序逐级回落,因此被选中的子调用 callId 会经由与已完结的原生调用完全相同的渲染路径,解析出完整参数与完整输出。
- **详情面板**:`materialFor` 递归搜索 `nodes` 与 `runningCalls`,因此被选中的后代 callId 会经由与已完结的原生调用完全相同的渲染路径,解析出完整参数与完整输出。
## 曾考虑的替代方案
@@ -29,4 +29,4 @@ Status: implemented
## 后果
自定义 toolview 注册免费适用于子调用——而且是刻意为之:不存在按注册粒度的退出机制,唯一的出路是组件自行读取自身上下文,而当前没有任何消费方需要这么做。选中高亮经由同一条 `selectedCallId` 通道到达嵌套行(分组归属判断会同时检验两个层级)。trajectory/waterfall 仍把 `run_code` 渲染为单独一行——它们的子调用 span 推迟到增加分发计时(start/end 事件)的那个 PR;缺少计时,waterfall 上的 span 就是在撒谎。fixture(测试前置数据)的轮次 64(`?fixture`),加上 `code-mode-round` 浏览器 e2e(录制的真实轮次、无密钥回放),共同锁定整个界面;jsdom 测试套件则锁定 slot 分发、错误状态、详情解析与索引引用稳定性。
自定义 toolview 注册免费适用于子调用——而且是刻意为之:不存在按注册粒度的退出机制,唯一的出路是组件自行读取自身上下文,而当前没有任何消费方需要这么做。选中高亮经由同一条 `selectedCallId` 通道到达嵌套行(分组归属会搜索整棵树)。trajectory/waterfall 仍把 `run_code` 渲染为单独一行——它们的子调用 span 推迟到增加分发计时(start/end 事件)的那个 PR;缺少计时,waterfall 上的 span 就是在撒谎。fixture(测试前置数据)的轮次 64(`?fixture`),加上 `code-mode-round` 浏览器 e2e(录制的真实轮次、无密钥回放),共同锁定整个界面;jsdom 与运行时测试套件则锁定 slot 分发、错误状态、递归详情解析、历史投影与引用稳定的路径复制。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.md
2026-07-26-code-mode-live-parallel-dispatch.md: b4afc21be902d8ed3e5bee2ad1a540413a864f25
2026-07-26-code-mode-live-parallel-dispatch.zh.md: b6169e6d243163f8444ffc7b87b741ade7631198
2026-07-26-code-mode-live-parallel-dispatch.md: e85d1f99f58f1aae28091cd8875ee87a6df5a1e7
2026-07-26-code-mode-live-parallel-dispatch.zh.md: 39da98d9c3630d549f13df0ea555687614f3065a

View File

@@ -16,7 +16,7 @@ Two gaps remained after the first two PRs. Sub-call rows appeared only when each
- **Event pair**: `tool/code-dispatch-start` (parent/sub ids, name, normalized args) is appended when the scheduler actually starts a call — not at submission, so a queued call abandoned by run settlement logs nothing. The existing `tool/code-dispatch` settles the pair (same `subCallId`); every started call settles exactly once (aborts settle as `isError` outcomes through the pipeline). Timing = the two events' `time` fields. Both stay log-only; model context is untouched; format stays v0.
- **Bridge scheduler**: submitted calls are classified at start time via `registry.executionMode` (the SAME fail-closed `isConcurrencySafe` contract the loop uses) and start strictly in submission order. One single-lane driver owns every ORDERED stage — the start append, `prepare` (pre-execute/guards), the head-of-line `finalize`/`finish` commit (post-execute + context deferral + settle append) — so ordered policy stages never overlap each other and only the around-dispatch/body stage runs concurrently, exactly the native loop's sequencing (`fillPool` awaits `startCall` then `commitReady`). Consecutive parallel-classified calls overlap up to `maxParallelSubCalls` (a `Config` field validated by the Loader schema AND re-validated at direct construction, default 10 — the loop scheduler's own default; `1` restores serial dispatch); an exclusive call drains the pool, runs alone, and holds its barrier until its COMMIT completes (post-execute included), like a native exclusive group. Run settlement aborts in-flight dispatches and abandons queued-unstarted ones (binding rejection, no events), then drains to quiescence — including a commit already mid-flight when the program returned — before the outer result closes the turn.
- **Client**: `CodeSubCall` widens to `RunningToolCall | ToolResultNode` — a start event lands the running shape in the dispatch index (rows derive the running ring from the shape, exactly as for native in-flight calls), and its settle replaces the entry in place, preserving start order under parallel completion and carrying the start's `time` as `callTime` (duration source). A settle with no observed start (window cut mid-pair, or a pre-start-event log) appends directly, so old logs keep rendering.
- **Client**: Runtime's `ToolCallTree` stores a start event as a `RunningToolCall` child and projects it through the parent's recursive `subCalls` (rows derive the running ring from that shape, exactly as for native in-flight calls). Its settle replaces the private-index entry in place, preserving start order under parallel completion and carrying the start's `time` as `callTime` (duration source). A settle with no observed start (window cut mid-pair, or a pre-start-event log) appends directly, so old logs keep rendering.
- **SDK prompt**: the model-facing "calls execute sequentially" sentence is replaced with the true contract (independent safe calls may overlap under `Promise.all`; dependent work sequences with `await`) — a model-visible change, re-recorded across every code-mode snapshot.
## Alternatives considered

View File

@@ -16,7 +16,7 @@ Status: implemented
- **事件对**:`tool/code-dispatch-start`(父/子 id、名称、规范化参数)在调度器真正启动某个调用时才追加,而非在提交时,因此因 run 结算而被放弃的排队调用不会留下任何日志。既有的 `tool/code-dispatch` 结算该事件对(`subCallId` 相同);每个已启动的调用恰好结算一次(中止也会作为 `isError` 结果经由流水线结算)。计时即这两个事件的 `time` 字段。两个事件都保持仅日志;模型上下文不受影响;格式保持 v0。
- **桥接层调度器**:已提交的调用在启动那一刻经 `registry.executionMode` 分类(与 loop 所用完全相同、故障时默认判为不安全的 `isConcurrencySafe` 契约),并严格按提交顺序启动。所有有序阶段——start 事件追加、`prepare`(pre-execute/守卫)、队首 `finalize`/`finish` 提交(post-execute + 上下文延迟提交 + settle 事件追加)——由单通道驱动器独占执行,因此有序策略阶段彼此绝不重叠,只有 around-dispatch/工具体阶段并发运行,与原生 loop 的时序完全一致(`fillPool` 先 await `startCall` 再 `commitReady`)。连续被分类为可并行的调用可以重叠执行,上限为 `maxParallelSubCalls`(`Config` 字段,Loader schema 校验之外直接构造时也重新校验,默认值 10,即 loop 调度器自身的默认值;设为 `1` 即恢复串行分发);独占调用则先排空池、独自运行,且其屏障保持到自身提交(含 post-execute)完成为止,与原生独占分组一致。run 结算时会中止仍在运行的分发,并放弃已排队未启动的分发(绑定调用被拒绝,不产生事件),随后排空到完全停稳——包括程序返回时已在途的提交——之后外层结果才结束该轮次。
- **客户端侧**:`CodeSubCall` 拓宽为 `RunningToolCall | ToolResultNode`:start 事件把运行中形状写入分发索引(行组件从该形状推导出运行指示环,与原生运行中的调用处理完全一致),其结算事件则原位替换该条目,即使并行完成也保持启动顺序不变,并把 start 事件的 `time` 作为 `callTime`(时长来源)带入。未观察到对应 start 的结算事件(窗口切在事件对中间,或日志录制于 start 事件引入之前)会直接追加,因此旧日志仍能照常渲染。
- **客户端侧**:运行时的 `ToolCallTree` 把 start 事件存为 `RunningToolCall` 子级,并通过父级递归的 `subCalls` 投影出来(行组件从该形状推导出运行指示环,与原生运行中的调用处理完全一致)。其结算事件会原位替换私有索引中的条目,即使并行完成也保持启动顺序不变,并把 start 事件的 `time` 作为 `callTime`(时长来源)带入。未观察到对应 start 的结算事件(窗口切在事件对中间,或日志录制于 start 事件引入之前)会直接追加,因此旧日志仍能照常渲染。
- **SDK 提示词**:面向模型的「调用按顺序执行」一句替换为真实契约(相互独立的安全调用可以在 `Promise.all` 下重叠执行;相互依赖的工作以 `await` 顺序衔接);这是模型可见的变更,每一份 Code Mode 快照都已重新录制。
## 曾考虑的替代方案