refactor(client): localize nested Tool call trees

This commit is contained in:
imccyu
2026-08-08 17:08:22 +08:00
parent 5e0b7571e6
commit 9ed33dfe4f
48 changed files with 586 additions and 413 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md
2026-08-08-client-tool-presentation-ownership.md: e61c2030457cc9f0fda214e896b76afb37d0d2bc 2026-08-08-client-tool-presentation-ownership.md: 9ea7d7dfc5e18e541550569795c5e1db84d6ad91
2026-08-08-client-tool-presentation-ownership.zh.md: 5c56b8c17ef5ca6695f3b28f6b93218dade356c7 2026-08-08-client-tool-presentation-ownership.zh.md: 11a42ad69e4374afa6dccd3af74b8bd1b5e5c6f1

View File

@@ -12,19 +12,19 @@ That ownership made `ui-conversation` interpret business Tool names and made sub
## Decision ## Decision
Tool is a first-class Client UI concept with one presentation owner, `@deepseek-ai/dsh-client-ui-tool`. Session Event, projection, fold, `ConversationSnapshot` construction and caching, historical paging, and Code Dispatch indexing remain unchanged. Tool is a first-class Client UI concept with one presentation owner, `@deepseek-ai/dsh-client-ui-tool`. Runtime normalizes Code Dispatch into recursive `ToolCallBlock` values: every root or child owns its next level through `subCalls`, and `ConversationSnapshot` exposes no separate parent-to-children map.
“First-class concept” describes UI ownership only; it adds no Runtime data kind. `ConversationNode` remains the transcript projection, `ChatFlowItem` remains the render unit produced when conversation sorts and groups nodes, `ToolCallBlock` remains the standard data for one call, and `ToolCallTree` only composes root/subcall presentation within Tool. Command continues to render through the separate `'conversation.chat.commandview'` seat and does not become Tool. “First-class concept” describes UI ownership only; it adds no Runtime data kind. `ConversationNode` remains the transcript projection, `ChatFlowItem` remains the render unit produced when conversation sorts and groups nodes, `ToolCallBlock` remains the standard data for one call, and `ToolCallTree` only composes root/subcall presentation within Tool. Command continues to render through the separate `'conversation.chat.commandview'` seat and does not become Tool.
`ui-conversation` owns ordered placement. `deriveChatFlow()` still decides where a settled Tool group appears, and `ChatView` still appends running calls, maintains scroll anchors and selection, and supplies host actions. For each root call it renders the single/session `'conversation.chat.tool'` seat with the root block, selected call id, session cwd, and open-file/inspect callbacks. It does not read Code Dispatch children, branch on Tool names, or import Tool-specific views and card models. `ui-conversation` owns ordered placement. `deriveChatFlow()` still decides where a settled Tool group appears, and `ChatView` still appends running calls, maintains scroll anchors and selection, and supplies host actions. For each root call it renders the single/session `'conversation.chat.tool'` seat with the root block, selected call id, session cwd, and open-file/inspect callbacks. It does not read Code Dispatch children, branch on Tool names, or import Tool-specific views and card models.
`ui-tool` occupies that whole-Tool seat. Through its standard session slot props, `ToolCallTree` selects the Runtime-projected `codeDispatches[rootCallId]` array, renders the root followed by that one currently supported child level, and routes both forms through one keyed/session `'tool.call.toolview'` child slot using `entryKey: toolName`. An absent business registration renders `GenericToolCard`. This is deliberately one-level composition, not a claim that the Runtime supports an arbitrary recursive call graph. `ui-tool` occupies that whole-Tool seat. `ToolCallTree` recursively walks the root block's `subCalls` and routes every level through one keyed/session `'tool.call.toolview'` child slot using `entryKey: toolName`. An absent business registration renders `GenericToolCard`. It neither reads Session nor maintains a second call topology.
Business plugins register only atomic views against `'tool.call.toolview'`. Their owner payload is the standard Tool call block plus identity, cwd, and host actions; it carries no Session projector or conversation service. Skill remains an ordinary Tool and `ui-skill` registers the `skill` key through this seam. Existing first-party views live in `ui-tool` until a business package has a reason to own one independently. Business plugins register only atomic views against `'tool.call.toolview'`. Their owner payload is the standard Tool call block plus identity, cwd, and host actions; it carries no Session projector or conversation service. Skill remains an ordinary Tool and `ui-skill` registers the `skill` key through this seam. Existing first-party views live in `ui-tool` until a business package has a reason to own one independently.
The details panel is a second Tool presentation site but not a call-tree owner. `ui-conversation` delegates its selected output body through the single/session `'conversation.details.tool'` seat; `ui-tool` renders the card-aware output and the seat fallback preserves raw result text when the plugin is absent. Card models therefore have one production owner without introducing a reverse implementation import. The details panel is a second Tool presentation site but not a call-tree owner. `ui-conversation` delegates its selected output body through the single/session `'conversation.details.tool'` seat; `ui-tool` renders the card-aware output and the seat fallback preserves raw result text when the plugin is absent. Card models therefore have one production owner without introducing a reverse implementation import.
The Runtime remains the authority for Tool lifecycle and call topology. Code Dispatch stays a top-level official concept because it changes `codeDispatches` and parent/child identity; ordinary Tool business differences stay at the keyed presentation seam. This package boundary does not add a Tool projector/fold registry. The Runtime remains the authority for Tool lifecycle and call topology. Code Dispatch is an official top-level concept because it changes parent/child identity; a private `ToolCallTree` shares one fold between live and history paths and projects its index into standard recursive call blocks. Ordinary Tool business differences stay at the keyed presentation seam, and this package boundary adds no Tool projector/fold registry.
## Runtime and render path ## Runtime and render path
@@ -39,22 +39,22 @@ ConversationSnapshot.runningCalls |
-> ChatView flow tail ---------------+-> ToolSeat -> ChatView flow tail ---------------+-> ToolSeat
-> conversation.chat.tool -> conversation.chat.tool
-> ToolCallTree -> ToolCallTree
ConversationSnapshot.codeDispatches[rootCallId] -+ -> root ToolCallBlock
+-> root ToolCall + one-level child ToolCall `- subCalls[] (recursive)
-> tool.call.toolview(entryKey = toolName) -> tool.call.toolview(entryKey = toolName)
|- registered atomic view |- registered atomic view
`- GenericToolCard fallback `- GenericToolCard fallback
``` ```
The live Session's [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) caches arrays or maps such as `nodes`, `runningCalls`, and `codeDispatches` against independent revisions. Their references stay stable when the corresponding business state has not changed, allowing React selectors and memoization to skip unrelated updates. The historical projection's [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) reconstructs the same running-call and Code Dispatch shapes from entries in its window. Tool UI consumes the snapshot shapes already unified by those paths; presentation packages do not repeat call/result pairing, historical replay, or cache indexing. Runtime's [`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts) privately indexes child lifecycles by parent callId and is shared by the live [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) and historical [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) paths. It recursively projects children onto root `ToolCallBlock` values and copies only the owning ancestor path when a child changes. Unchanged siblings, other roots, and snapshot references with no Tool-topology change stay stable so React selectors and memoization can skip unrelated updates. Tool UI consumes this unified tree without repeating call/result pairing, historical replay, or cache indexing.
[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) reruns [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts) only when the `nodes` reference changes. It groups consecutive settled Tool results into a `tool-group`, while running root calls append at the flow tail. Both paths ultimately enter the same `ToolSeat`, so settled and running forms share the whole-Tool seat. `ToolCallTree` selects only the current root's `codeDispatches[rootCallId]`; it does not introduce a business projector for presentation of other roots. [`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) reruns [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts) only when the `nodes` reference changes. It groups consecutive settled Tool results into a `tool-group`, while running root calls append at the flow tail. Both paths ultimately enter the same `ToolSeat`, so settled and running forms share the whole-Tool seat. Selection is passed only to the root containing that call, and `ToolCallTree` then renders recursively within that local tree.
## Code and responsibility boundaries ## Code and responsibility boundaries
| Owner | Primary code | Owns | Explicitly does not own | | Owner | Primary code | Owns | Explicitly does not own |
|---|---|---|---| |---|---|---|---|
| Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts), [`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result pairing, running/settled lifecycle, Code Dispatch parent/child index, snapshot reference stability | Business views selected by Tool name | | Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts), [`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts), [`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result pairing, running/settled lifecycle, recursive parent/child tree, snapshot structural sharing | Business views selected by Tool name |
| `ui-conversation` | [`chat-flow.ts`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts), [`ChatView.tsx`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx), [`slots.ts`](../../../../packages/client/ui-conversation/src/client/contract/slots.ts) | ChatFlow order, settled groups, running tail, scroll anchors, selection and host actions, whole-Tool seat declaration | subcall composition, `toolName` dispatch, Generic fallback, Tool card models | | `ui-conversation` | [`chat-flow.ts`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts), [`ChatView.tsx`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx), [`slots.ts`](../../../../packages/client/ui-conversation/src/client/contract/slots.ts) | ChatFlow order, settled groups, running tail, scroll anchors, selection and host actions, whole-Tool seat declaration | subcall composition, `toolName` dispatch, Generic fallback, Tool card models |
| `ui-tool` | [`apply.ts`](../../../../packages/client/ui-tool/src/client/apply.ts), [`ToolCallTree.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx), [`slots.ts`](../../../../packages/client/ui-tool/src/client/contract/slots.ts) | root/subcall composition, atomic keyed dispatch, Generic fallback, Tool card models and built-in Tool views | ChatFlow ordering, Session Event fold | | `ui-tool` | [`apply.ts`](../../../../packages/client/ui-tool/src/client/apply.ts), [`ToolCallTree.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx), [`slots.ts`](../../../../packages/client/ui-tool/src/client/contract/slots.ts) | root/subcall composition, atomic keyed dispatch, Generic fallback, Tool card models and built-in Tool views | ChatFlow ordering, Session Event fold |
| Business Tool plugins | [`ui-skill` registration example](../../../../packages/client/ui-skill/src/client/index.ts) | Atomic views for one or more wire Tool names | root/subcall placement and lifecycle pairing | | Business Tool plugins | [`ui-skill` registration example](../../../../packages/client/ui-skill/src/client/index.ts) | Atomic views for one or more wire Tool names | root/subcall placement and lifecycle pairing |
@@ -82,7 +82,7 @@ ctx.slots.inject('tool.call.toolview', () =>
## Details path ## Details path
[`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) still locates the selected call in `nodes`, `runningCalls`, and `codeDispatches`, and it owns input arguments, empty states, and panel lifecycle. It passes only `{ block, cwd }` to `'conversation.details.tool'`; [`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) reuses Tool card models to render the output. When `ui-tool` is absent, a settled call falls back to raw result text and a running call shows conversation's running fallback, so details never imports the Tool implementation in reverse. [`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) locates the selected call recursively in `nodes` and `runningCalls` through their `subCalls`, and it owns input arguments, empty states, and panel lifecycle. It passes only `{ block, cwd }` to `'conversation.details.tool'`; [`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) reuses Tool card models to render the output. When `ui-tool` is absent, a settled call falls back to raw result text and a running call shows conversation's running fallback, so details never imports the Tool implementation in reverse.
## Verification ## Verification
@@ -96,7 +96,7 @@ Test ownership follows production ownership. `ui-conversation` tests install a l
**Add business-specific Session projectors or folds.** Rejected: ordinary Tool views consume the standard call block already reconstructed by Runtime. A second registry would create two authorities for call identity and historical replay. Only a feature that changes logged topology or lifecycle earns a Runtime-level extension. **Add business-specific Session projectors or folds.** Rejected: ordinary Tool views consume the standard call block already reconstructed by Runtime. A second registry would create two authorities for call identity and historical replay. Only a feature that changes logged topology or lifecycle earns a Runtime-level extension.
**Make each atomic Tool view render its own subcalls recursively.** Rejected: the atomic registrant receives one Tool call and should not know whether it is a root or child. Root/child composition belongs to `ui-tool`, and the current wire/runtime shape only supports one Code Dispatch child level. **Make each atomic Tool view render its own subcalls recursively.** Rejected: the atomic registrant receives one Tool call and should not know whether it is a root or child. Recursive root/child composition belongs centrally to `ui-tool`'s `ToolCallTree`.
**Import `ui-tool` components directly from `ui-conversation`.** Rejected: it would reverse the intended feature direction and make Tool presentation mandatory. Declared slots retain lifecycle ownership, fallback behavior, and independent plugin loading. **Import `ui-tool` components directly from `ui-conversation`.** Rejected: it would reverse the intended feature direction and make Tool presentation mandatory. Declared slots retain lifecycle ownership, fallback behavior, and independent plugin loading.

View File

@@ -12,19 +12,19 @@ Client Runtime 已经把 Tool 调用投影成稳定的生命周期:它按 `cal
## Decision ## Decision
Tool 成为 Client UI 的一级概念,并由 `@deepseek-ai/dsh-client-ui-tool` 统一拥有展示。Session Event、projection、fold、`ConversationSnapshot` 构建与缓存、历史分页及 Code Dispatch 索引保持不变。 Tool 成为 Client UI 的一级概念,并由 `@deepseek-ai/dsh-client-ui-tool` 统一拥有展示。Runtime 将 Code Dispatch 规范化为递归 `ToolCallBlock`:每个 root 或 child 通过自己的 `subCalls` 拥有下一层调用,`ConversationSnapshot` 不再公开单独的 parent-to-children map。
这里的“一级概念”只描述 UI 所有权,不增加 Runtime 数据种类。`ConversationNode` 仍是 transcript projection,`ChatFlowItem` 仍是 conversation 对节点进行排序与分组后得到的渲染单元,`ToolCallBlock` 仍是单次调用的标准数据,而 `ToolCallTree` 只负责 Tool 内部的 root/subcall 展示编排。Command 继续通过独立的 `'conversation.chat.commandview'` 席位渲染,不并入 Tool。 这里的“一级概念”只描述 UI 所有权,不增加 Runtime 数据种类。`ConversationNode` 仍是 transcript projection,`ChatFlowItem` 仍是 conversation 对节点进行排序与分组后得到的渲染单元,`ToolCallBlock` 仍是单次调用的标准数据,而 `ToolCallTree` 只负责 Tool 内部的 root/subcall 展示编排。Command 继续通过独立的 `'conversation.chat.commandview'` 席位渲染,不并入 Tool。
`ui-conversation` 拥有有序放置。`deriveChatFlow()` 仍决定 settled Tool group 在哪里出现,`ChatView` 仍追加 running call、维护滚动 anchor 与 selection,并提供宿主动作。对于每个 root call,它使用 root block、selected call id、session cwd 以及 open-file/inspect 回调渲染 single/session 的 `'conversation.chat.tool'` 席位。它不读取 Code Dispatch child、不按 Tool 名称分支,也不导入 Tool 专属 view 或 card model。 `ui-conversation` 拥有有序放置。`deriveChatFlow()` 仍决定 settled Tool group 在哪里出现,`ChatView` 仍追加 running call、维护滚动 anchor 与 selection,并提供宿主动作。对于每个 root call,它使用 root block、selected call id、session cwd 以及 open-file/inspect 回调渲染 single/session 的 `'conversation.chat.tool'` 席位。它不读取 Code Dispatch child、不按 Tool 名称分支,也不导入 Tool 专属 view 或 card model。
`ui-tool` 占据这个整体 Tool 席位。`ToolCallTree` 通过标准 session slot props 选择 Runtime 投影的 `codeDispatches[rootCallId]` 数组,先渲染 root,再渲染当前支持的一层 child;两种调用都通过同一个 keyed/session 的 `'tool.call.toolview'` 子 slot,以 `entryKey: toolName` 分发。业务未注册时渲染 `GenericToolCard`。这里刻意只编排一层,并不声称 Runtime 已支持任意递归调用图。 `ui-tool` 占据这个整体 Tool 席位。`ToolCallTree` 直接递归遍历 root block 的 `subCalls`,并让每一层调用都通过同一个 keyed/session 的 `'tool.call.toolview'` 子 slot,以 `entryKey: toolName` 分发。业务未注册时渲染 `GenericToolCard`。它不读取 Session,也不维护第二份调用拓扑。
业务插件只对 `'tool.call.toolview'` 注册原子 view。其 owner payload 是标准 Tool call block 加 identity、cwd 与宿主动作,不携带 Session projector 或 conversation service。Skill 仍是普通 Tool,`ui-skill` 通过该 seam 注册 `skill` key。现有第一方 view 暂留在 `ui-tool`,直到某个业务包确有理由独立拥有它。 业务插件只对 `'tool.call.toolview'` 注册原子 view。其 owner payload 是标准 Tool call block 加 identity、cwd 与宿主动作,不携带 Session projector 或 conversation service。Skill 仍是普通 Tool,`ui-skill` 通过该 seam 注册 `skill` key。现有第一方 view 暂留在 `ui-tool`,直到某个业务包确有理由独立拥有它。
details panel 是第二个 Tool 展示点,但不是调用树所有者。`ui-conversation` 通过 single/session 的 `'conversation.details.tool'` 席位委托 selected output body;`ui-tool` 渲染能够识别 card 的输出,插件缺席时由席位 fallback 保留 raw result text。因此 card model 只有一个生产代码所有者,也不需要引入反向实现依赖。 details panel 是第二个 Tool 展示点,但不是调用树所有者。`ui-conversation` 通过 single/session 的 `'conversation.details.tool'` 席位委托 selected output body;`ui-tool` 渲染能够识别 card 的输出,插件缺席时由席位 fallback 保留 raw result text。因此 card model 只有一个生产代码所有者,也不需要引入反向实现依赖。
Runtime 仍是 Tool 生命周期与调用拓扑的权威。Code Dispatch 会改变 `codeDispatches` 与 parent/child identity,因此继续作为官方顶级概念;普通 Tool 业务差异停留在 keyed 展示 seam。这个包边界不会增加 Tool projector/fold registry。 Runtime 仍是 Tool 生命周期与调用拓扑的权威。Code Dispatch 作为官方顶级概念改变 parent/child identity;私有 `ToolCallTree` 对 live 与 history 共用同一套 fold,并把索引投影成标准递归 call block。普通 Tool 业务差异停留在 keyed 展示 seam,这个包边界不会增加 Tool projector/fold registry。
## Runtime 与渲染链路 ## Runtime 与渲染链路
@@ -39,22 +39,22 @@ ConversationSnapshot.runningCalls |
-> ChatView flow tail ---------------+-> ToolSeat -> ChatView flow tail ---------------+-> ToolSeat
-> conversation.chat.tool -> conversation.chat.tool
-> ToolCallTree -> ToolCallTree
ConversationSnapshot.codeDispatches[rootCallId] -+ -> root ToolCallBlock
+-> root ToolCall + one-level child ToolCall `- subCalls[] (recursive)
-> tool.call.toolview(entryKey = toolName) -> tool.call.toolview(entryKey = toolName)
|- registered atomic view |- registered atomic view
`- GenericToolCard fallback `- GenericToolCard fallback
``` ```
Live Session 的 [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) 按独立 revision 缓存 `nodes`、`runningCalls`、`codeDispatches` 等数组或 map;没有对应业务变化时,它们保持引用稳定,供 React selector 与 memo 跳过无关更新。历史 projection 的 [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) 从窗口内 entry 重建相同的 running call 与 Code Dispatch 形态。Tool UI 直接消费这两个路径已经统一的 snapshot,不在展示包中重复 call/result 配对、历史 replay 或缓存索引。 Runtime 的 [`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts) 私下按 parent callId 索引 child lifecycle,并供 Live [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) 与历史 [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) 共用。它把 children 递归投影到 root `ToolCallBlock`,child 变化时只复制所属祖先路径;未变化的 sibling、其他 root,以及没有 Tool 拓扑变化的 snapshot 引用保持稳定,供 React selector 与 memo 跳过无关更新。Tool UI 直接消费这两个路径统一后的树,不重复 call/result 配对、历史 replay 或缓存索引。
[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只在 `nodes` 引用变化时重新执行 [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts),把连续 settled Tool result 合为 `tool-group`;running root call 则追加在 flow tail。两条路径最终都进入同一个 `ToolSeat`,因此 settled/running 形态共享整体 Tool 席位。`ToolCallTree` 只选择当前 root 的 `codeDispatches[rootCallId]`,不会因其他 root 的展示逻辑引入业务 projector。 [`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只在 `nodes` 引用变化时重新执行 [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts),把连续 settled Tool result 合为 `tool-group`;running root call 则追加在 flow tail。两条路径最终都进入同一个 `ToolSeat`,因此 settled/running 形态共享整体 Tool 席位。selection 只传给包含该 call 的 root,`ToolCallTree` 再沿该 root 的局部树递归渲染。
## 代码与职责边界 ## 代码与职责边界
| 所有者 | 主要代码 | 拥有的责任 | 明确不拥有 | | 所有者 | 主要代码 | 拥有的责任 | 明确不拥有 |
|---|---|---|---| |---|---|---|---|
| Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts)、[`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result 配对、running/settled 生命周期、Code Dispatch parent/child 索引、snapshot 引用稳定性 | Tool 名称对应的业务视图 | | Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts)、[`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts)、[`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result 配对、running/settled 生命周期、递归 parent/child 树、snapshot 结构共享 | Tool 名称对应的业务视图 |
| `ui-conversation` | [`chat-flow.ts`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts)、[`ChatView.tsx`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx)、[`slots.ts`](../../../../packages/client/ui-conversation/src/client/contract/slots.ts) | ChatFlow 顺序、settled group、running tail、scroll anchor、selection 与宿主动作、整体 Tool 席位声明 | subcall 组合、按 `toolName` 分发、Generic fallback、Tool card model | | `ui-conversation` | [`chat-flow.ts`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts)、[`ChatView.tsx`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx)、[`slots.ts`](../../../../packages/client/ui-conversation/src/client/contract/slots.ts) | ChatFlow 顺序、settled group、running tail、scroll anchor、selection 与宿主动作、整体 Tool 席位声明 | subcall 组合、按 `toolName` 分发、Generic fallback、Tool card model |
| `ui-tool` | [`apply.ts`](../../../../packages/client/ui-tool/src/client/apply.ts)、[`ToolCallTree.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx)、[`slots.ts`](../../../../packages/client/ui-tool/src/client/contract/slots.ts) | root/subcall 组合、原子 keyed dispatch、Generic fallback、Tool card model 与内置 Tool view | ChatFlow 排序、Session Event fold | | `ui-tool` | [`apply.ts`](../../../../packages/client/ui-tool/src/client/apply.ts)、[`ToolCallTree.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx)、[`slots.ts`](../../../../packages/client/ui-tool/src/client/contract/slots.ts) | root/subcall 组合、原子 keyed dispatch、Generic fallback、Tool card model 与内置 Tool view | ChatFlow 排序、Session Event fold |
| 业务 Tool 插件 | [`ui-skill` 注册例](../../../../packages/client/ui-skill/src/client/index.ts) | 一个或多个 wire Tool name 的原子 view | root/subcall 位置与生命周期配对 | | 业务 Tool 插件 | [`ui-skill` 注册例](../../../../packages/client/ui-skill/src/client/index.ts) | 一个或多个 wire Tool name 的原子 view | root/subcall 位置与生命周期配对 |
@@ -82,7 +82,7 @@ ctx.slots.inject('tool.call.toolview', () =>
## Details 路径 ## Details 路径
[`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) 仍从 `nodes`、`runningCalls` 与 `codeDispatches` 中定位选中的 call,并拥有 input 参数、空态和面板生命周期。它只把 `{ block, cwd }` 交给 `'conversation.details.tool'`;[`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) 复用 Tool card model 渲染 output。`ui-tool` 缺席时,settled call 回退为 raw result text,running call 显示 conversation 的 running fallback,因此 details 不反向导入 Tool 实现。 [`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) 在 `nodes` 与 `runningCalls` 的递归 `subCalls` 中定位 selected call,并拥有 input 参数、空态和面板生命周期。它只把 `{ block, cwd }` 交给 `'conversation.details.tool'`;[`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) 复用 Tool card model 渲染 output。`ui-tool` 缺席时,settled call 回退为 raw result text,running call 显示 conversation 的 running fallback,因此 details 不反向导入 Tool 实现。
## Verification ## Verification
@@ -96,7 +96,7 @@ ctx.slots.inject('tool.call.toolview', () =>
**增加业务专属 Session projector 或 fold。** 拒绝:普通 Tool view 消费 Runtime 已重建的标准 call block。第二套 registry 会为 call identity 与历史 replay 建立两个权威。只有会改变日志拓扑或生命周期的能力才应获得 Runtime 级扩展。 **增加业务专属 Session projector 或 fold。** 拒绝:普通 Tool view 消费 Runtime 已重建的标准 call block。第二套 registry 会为 call identity 与历史 replay 建立两个权威。只有会改变日志拓扑或生命周期的能力才应获得 Runtime 级扩展。
**让每个原子 Tool view 递归渲染自己的 subcall。** 拒绝:原子注册方只接收一个 Tool call,不应知道自己是 root 还是 child。root/child 编排归 `ui-tool`,且当前 wire/runtime 形态只支持一层 Code Dispatch child。 **让每个原子 Tool view 递归渲染自己的 subcall。** 拒绝:原子注册方只接收一个 Tool call,不应知道自己是 root 还是 child。递归 root/child 编排统一归 `ui-tool` 的 `ToolCallTree`。
**让 `ui-conversation` 直接导入 `ui-tool` 组件。** 拒绝:这会反转预期的 feature 依赖方向,并把 Tool 展示变成必选能力。声明式 slot 能保留生命周期所有权、fallback 行为与独立插件装载。 **让 `ui-conversation` 直接导入 `ui-tool` 组件。** 拒绝:这会反转预期的 feature 依赖方向,并把 Tool 展示变成必选能力。声明式 slot 能保留生命周期所有权、fallback 行为与独立插件装载。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md # pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: bb85d3c7b45eb0d82d789a9133bba787f1d6c5e9 README.md: fad0f6e4f948ebf412b2bd837a4f68e6dc805ec8
README.zh.md: 18aedbb487a490c85ce68ccc460fd82218157843 README.zh.md: 4ede12a320082a5023ec121306650fce72a12663

View File

@@ -44,9 +44,9 @@ Because the projection is log-ordered, the node array is seq-monotonic by constr
`SessionHistoryInspection.requests` is one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan. `SessionHistoryInspection.requests` is one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan.
## Code Mode sub-dispatch index ## Code Mode child-call tree
`ConversationSnapshot.codeDispatches` groups a `run_code` call's sub-dispatches under their parent callId, in start order, using the native call-block shapes: a `tool/code-dispatch-start` event lands the `RunningToolCall` form (rows derive the running ring from the shape) and its `tool/code-dispatch` settlement replaces it in place with the `ToolResultNode` form, `callTime` carrying the paired start's time. A settle whose start fell outside the replay window appends directly with `callTime: null` (duration unknown — never a fabricated zero). Live mux frames and history replay build the identical index; sub-calls never join the transcript `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps. Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Runtime's `ToolCallTree` privately maintains the parent-callId-to-children index: a `tool/code-dispatch-start` event lands as a `RunningToolCall`, and the matching `tool/code-dispatch` settlement replaces it in place with a `ToolResultNode` whose `callTime` comes from the paired start. When the start fell outside the replay window, the settlement appends directly with `callTime: null`; Runtime never fabricates a zero duration. Live mux frames and history replay share this fold and tree projection, and child calls never become independent roots in transcript `nodes`. A child update copies only its ancestor path to the owning root; unchanged siblings and other roots retain object identity.
## Session title projection ## Session title projection

View File

@@ -44,9 +44,9 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`SessionHistoryInspection.requests` 是一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。 `SessionHistoryInspection.requests` 是一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。
## Code Mode 子调用索引 ## Code Mode 子调用树
`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`(耗时未知——绝不伪造零耗时)。live mux 帧与历史回放构建相同的索引;子调用永不进入 transcript 的 `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。 每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Runtime 的 `ToolCallTree` 私下维护 parent callId 到 child 的索引:`tool/code-dispatch-start` 事件落成 `RunningToolCall`,对应的 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode`,其 `callTime` 来自成对 start 事件;start 落在回放窗口之外时,完结事件会以 `callTime: null` 直接追加,绝不伪造零耗时。live mux 帧与历史回放共用这套 fold 和树投影;子调用不会成为 transcript `nodes` 中的独立 root。一次 child 变化只会复制从该 child 到所属 root 的祖先链,未变化的 sibling 和其他 root 保持对象引用稳定。
## Session 标题投影 ## Session 标题投影

View File

@@ -8,7 +8,7 @@ import { SessionsService } from './sessions/service.ts'
import type { SessionListState } from './sessions/service.ts' import type { SessionListState } from './sessions/service.ts'
import { SessionHistoryService } from './session-history/service.ts' import { SessionHistoryService } from './session-history/service.ts'
import { WorkspacesService } from './workspaces/service.ts' import { WorkspacesService } from './workspaces/service.ts'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts' import type { ConversationSnapshot } from './sessions/conversation.ts'
import type { UseProjection } from './sessions/projection-store.ts' import type { UseProjection } from './sessions/projection-store.ts'
export { SlotsService } from './slots.ts' export { SlotsService } from './slots.ts'
@@ -49,10 +49,10 @@ export type {
} from './contract/store.ts' } from './contract/store.ts'
export type { export type {
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase, AssistantTiming, CommandNode, CompactionSummaryNode, ComposerPhase,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage, ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
RunningToolCall, RunningToolCall,
SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts' } from './sessions/conversation.ts'
export type { export type {
ConversationContext, ConversationContextOriginKind, ConversationContext, ConversationContextOriginKind,
@@ -89,13 +89,6 @@ declare module '@deepseek-ai/dsh-type-meta' {
/** The conversation-snapshot selector hook supplied to session-scoped UI entries. */ /** The conversation-snapshot selector hook supplied to session-scoped UI entries. */
export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot> export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
/**
* One tool call as the chat flow renders it: still-running (spinner card) or
* settled (result node). The fold produces both shapes; toolview components
* narrow on the discriminant fields.
*/
export type ToolCallBlock = RunningToolCall | ToolResultNode
declare module '@deepseek-ai/dsh-client-ui-slots' { declare module '@deepseek-ai/dsh-client-ui-slots' {
/** /**
* Session standard kit, real members (ui-slots declares the empty seat; * Session standard kit, real members (ui-slots declares the empty seat;

View File

@@ -1,4 +1,3 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { import {
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent, SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
@@ -7,7 +6,7 @@ import type {
HistoryEntry, ToolCallView, ToolResultView, HistoryEntry, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client' } from '@deepseek-ai/dsh-client-connection/client'
import type { import type {
AssistantRequestConfig, AssistantTiming, CodeSubCall, ConversationNode, AssistantRequestConfig, AssistantTiming, ConversationNode,
PartialAssistant, RunningToolCall, PartialAssistant, RunningToolCall,
} from '../sessions/conversation.ts' } from '../sessions/conversation.ts'
import { toAssistantBlocks } from '../sessions/conversation.ts' import { toAssistantBlocks } from '../sessions/conversation.ts'
@@ -20,6 +19,7 @@ import type { ConversationPromptSnapshot } from '../sessions/request-inspection.
import { PartialAccumulator } from '../sessions/partial.ts' import { PartialAccumulator } from '../sessions/partial.ts'
import type { AssistantStepMetadata } from '../sessions/assistant-timing.ts' import type { AssistantStepMetadata } from '../sessions/assistant-timing.ts'
import { indexAssistantStepTiming, settledAssistantTiming } from '../sessions/assistant-timing.ts' import { indexAssistantStepTiming, settledAssistantTiming } from '../sessions/assistant-timing.ts'
import { ToolCallTree } from '../sessions/tool-call-tree.ts'
interface CallIndexEntry { interface CallIndexEntry {
name: string name: string
@@ -41,7 +41,6 @@ export interface ConversationHistoryProjection {
interruptedNodes: readonly ConversationNode[] interruptedNodes: readonly ConversationNode[]
partial: PartialAssistant | null partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[] runningCalls: readonly RunningToolCall[]
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
} }
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean { function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
@@ -177,6 +176,7 @@ function materializeNode(
meta: event.data.meta, meta: event.data.meta,
callView: call?.callView ?? null, callView: call?.callView ?? null,
resultView, resultView,
subCalls: [],
} }
} }
default: default:
@@ -188,74 +188,22 @@ function materializeNode(
} }
/* jscpd:ignore-end */ /* jscpd:ignore-end */
function projectTransient(entries: readonly HistoryEntry[]): Pick< interface TransientProjection extends Pick<
ConversationHistoryProjection, ConversationHistoryProjection,
'interruptedNodes' | 'partial' | 'runningCalls' | 'codeDispatches' 'interruptedNodes' | 'partial' | 'runningCalls'
> { > {
toolCallTree: ToolCallTree
}
function projectTransient(entries: readonly HistoryEntry[]): TransientProjection {
let partial: PartialAccumulator | null = null let partial: PartialAccumulator | null = null
const openCalls = new Map<string, RunningToolCall>() const openCalls = new Map<string, RunningToolCall>()
const interruptedNodes: ConversationNode[] = [] const interruptedNodes: ConversationNode[] = []
const codeDispatches = new Map<string, readonly CodeSubCall[]>() const toolCallTree = new ToolCallTree()
for (const entry of entries) { for (const entry of entries) {
const { event } = entry const { event } = entry
if ((event.type as string) === 'tool/code-dispatch-start') { if (toolCallTree.apply(event)) continue
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
}
const siblings = codeDispatches.get(data.parentCallId) ?? []
// The independent replay emits the same public running-call shape as
// Chat without reading or mutating Session's live index.
/* jscpd:ignore-start */
codeDispatches.set(data.parentCallId, [...siblings, {
callId: data.subCallId,
name: data.name,
argsRaw: JSON.stringify(data.arguments),
turn: 0,
step: 0,
time: event.time,
callView: null,
}])
/* jscpd:ignore-end */
continue
}
if ((event.type as string) === 'tool/code-dispatch') {
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
isError: boolean
content: ContentBlock[]
}
const siblings = codeDispatches.get(data.parentCallId) ?? []
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
const started = at === -1 ? undefined : siblings[at]
// History independently reproduces the public settled-call shape instead
// of consuming Session's live code-dispatch projection.
/* jscpd:ignore-start */
const settled: CodeSubCall = {
kind: 'tool-result', seq: event.seq, time: event.time,
callId: data.subCallId,
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
callTime: started?.time ?? null,
content: data.content,
isError: data.isError,
callView: null,
resultView: null,
}
codeDispatches.set(
data.parentCallId,
at === -1
? [...siblings, settled]
: siblings.map((sub, index) => index === at ? settled : sub),
)
/* jscpd:ignore-end */
continue
}
switch (event.type) { switch (event.type) {
case 'assistant/chunk': { case 'assistant/chunk': {
const { turn, step, chunk } = event.data const { turn, step, chunk } = event.data
@@ -280,6 +228,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
step: event.data.step, step: event.data.step,
time: event.time, time: event.time,
callView: entry.view?.for === 'call' ? entry.view.view : null, callView: entry.view?.for === 'call' ? entry.view.view : null,
subCalls: [],
}) })
/* jscpd:ignore-end */ /* jscpd:ignore-end */
break break
@@ -317,6 +266,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
error: { name: 'Interrupted', code: 'interrupted' }, error: { name: 'Interrupted', code: 'interrupted' },
callView: call.callView, callView: call.callView,
resultView: null, resultView: null,
subCalls: [],
}) })
/* jscpd:ignore-end */ /* jscpd:ignore-end */
} }
@@ -331,7 +281,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
interruptedNodes, interruptedNodes,
partial: partial?.toPartial() ?? null, partial: partial?.toPartial() ?? null,
runningCalls: [...openCalls.values()], runningCalls: [...openCalls.values()],
codeDispatches, toolCallTree,
} }
} }
@@ -462,9 +412,17 @@ export function projectConversationHistory(
} }
} }
const transient = projectTransient(entries)
const projectedEventNodes = transient.toolCallTree.projectNodes(eventNodes)
const projectedContexts = contexts.map((context): ConversationContext => {
const nodes = transient.toolCallTree.projectNodes(context.nodes)
return nodes === context.nodes ? context : { ...context, nodes }
})
return { return {
eventNodes, eventNodes: projectedEventNodes,
contexts, contexts: projectedContexts,
...projectTransient(entries), interruptedNodes: transient.toolCallTree.projectNodes(transient.interruptedNodes),
partial: transient.partial,
runningCalls: transient.toolCallTree.projectRunningCalls(transient.runningCalls),
} }
} }

View File

@@ -174,6 +174,8 @@ export interface ToolResultNode {
callView: ToolCallView | null callView: ToolCallView | null
/** Host-computed render intent from this tool/result's wire view; null = same default. */ /** Host-computed render intent from this tool/result's wire view; null = same default. */
resultView: ToolResultView | null resultView: ToolResultView | null
/** Child calls owned by this call, in dispatch order. */
subCalls: readonly ToolCallBlock[]
} }
/** /**
@@ -263,21 +265,6 @@ export type ConversationNode =
| CompactionSummaryNode | CompactionSummaryNode
| UnknownSurfaceNode | UnknownSurfaceNode
/**
* One `run_code` sub-dispatch materialized in the native call-block shapes so
* every consumer (tool rows, details panel) renders it through the exact
* components that render a native call: a started-but-unsettled sub-call is a
* {@link RunningToolCall} (rows derive the running state from the shape,
* exactly as for native calls) and its `tool/code-dispatch` settlement
* replaces it in place with the {@link ToolResultNode} form. Never part of
* the transcript `nodes` flow — sub-calls live under their parent via
* {@link ConversationSnapshot.codeDispatches}. `callId` is the deterministic
* sub-call id (`<parent>:code:<n>`); the call side carries the sub-tool name
* and its JSON-stringified logged arguments; `content`/`isError` are the
* settled sub-call's complete logged outcome.
*/
export type CodeSubCall = RunningToolCall | ToolResultNode
/** In-flight tool card material: tool/call seen, tool/result not yet. */ /** In-flight tool card material: tool/call seen, tool/result not yet. */
export interface RunningToolCall { export interface RunningToolCall {
callId: string callId: string
@@ -289,8 +276,12 @@ export interface RunningToolCall {
time: number time: number
/** Host-computed render intent riding the tool/call frame; null = generic JSON card. */ /** Host-computed render intent riding the tool/call frame; null = generic JSON card. */
callView: ToolCallView | null callView: ToolCallView | null
/** Child calls owned by this call, in dispatch order. */
subCalls: readonly ToolCallBlock[]
} }
/** One running or settled call, recursively owning its child calls. */
export type ToolCallBlock = RunningToolCall | ToolResultNode
/** One transient inbox occurrence from the authoritative `session/queue` snapshot. */ /** One transient inbox occurrence from the authoritative `session/queue` snapshot. */
export interface QueuedMessage { export interface QueuedMessage {
@@ -355,13 +346,6 @@ export interface ConversationSnapshot {
turnEnds: ReadonlyMap<number, number> turnEnds: ReadonlyMap<number, number>
partial: PartialAssistant | null partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[] runningCalls: readonly RunningToolCall[]
/**
* `run_code` sub-dispatches grouped under their parent callId, in dispatch
* order. Populated from in-window `tool/code-dispatch` events (live and
* replay identically); the per-parent array reference is stable across
* unrelated snapshot swaps (memo premise, same regime as `nodes`).
*/
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
pending: readonly PendingInteraction[] pending: readonly PendingInteraction[]
/** Authoritative transient inbox snapshot, including queued and steering placements. */ /** Authoritative transient inbox snapshot, including queued and steering placements. */
queue: readonly QueuedMessage[] queue: readonly QueuedMessage[]

View File

@@ -1,7 +1,7 @@
import type { ToolSchema } from '@deepseek-ai/dsh-llm/types' import type { ToolSchema } from '@deepseek-ai/dsh-llm/types'
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client' import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
import type { import type {
CodeSubCall, ConversationNode, PartialAssistant, RunningToolCall, ConversationNode, PartialAssistant, RunningToolCall,
} from './conversation.ts' } from './conversation.ts'
import type { ConversationContext } from './conversation-context.ts' import type { ConversationContext } from './conversation-context.ts'
import { projectConversationHistory } from '../session-history/history-fold.ts' import { projectConversationHistory } from '../session-history/history-fold.ts'
@@ -34,7 +34,6 @@ export interface SessionHistoryInspection {
interruptedNodes: readonly ConversationNode[] interruptedNodes: readonly ConversationNode[]
partial: PartialAssistant | null partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[] runningCalls: readonly RunningToolCall[]
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
} }
/** /**
@@ -112,9 +111,6 @@ export function createHistoryInspection(
get runningCalls() { get runningCalls() {
return conversationProjection().runningCalls return conversationProjection().runningCalls
}, },
get codeDispatches() {
return conversationProjection().codeDispatches
},
get requests() { get requests() {
return requestProjection().requests return requestProjection().requests
}, },

View File

@@ -13,7 +13,7 @@ import type {
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { SessionFace } from '../contract/session.ts' import type { SessionFace } from '../contract/session.ts'
import type { import type {
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode, ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode,
OpenState, PromptError, QueuedMessage, RunningToolCall, OpenState, PromptError, QueuedMessage, RunningToolCall,
} from './conversation.ts' } from './conversation.ts'
import type { PendingInteraction } from './pending.ts' import type { PendingInteraction } from './pending.ts'
@@ -24,6 +24,7 @@ import { Notifier } from './notifier.ts'
import { isVisibleAssistantChunk, PartialAccumulator } from './partial.ts' import { isVisibleAssistantChunk, PartialAccumulator } from './partial.ts'
import { ProjectionValueStore } from './projection-store.ts' import { ProjectionValueStore } from './projection-store.ts'
import type { ProjectionsBaseline } from './projection-store.ts' import type { ProjectionsBaseline } from './projection-store.ts'
import { ToolCallTree } from './tool-call-tree.ts'
/** Messages requested per history page. */ /** Messages requested per history page. */
export const PAGE_MESSAGES = 50 export const PAGE_MESSAGES = 50
@@ -129,11 +130,8 @@ export class Session implements SessionFace {
private queued: QueuedMessage[] = [] private queued: QueuedMessage[] = []
private queueRev = 0 private queueRev = 0
private queueCache: { rev: number; value: QueuedMessage[] } | null = null private queueCache: { rev: number; value: QueuedMessage[] } | null = null
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends /** Window-derived child-call lifecycle and immutable tree projection. */
* copy-on-write the per-parent array so published snapshot references never mutate. */ private readonly toolCallTree = new ToolCallTree()
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
private dispatchesRev = 0
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
private running = false private running = false
private address: SubagentAddress | undefined private address: SubagentAddress | undefined
private parentAvailable = false private parentAvailable = false
@@ -746,65 +744,10 @@ export class Session implements SessionFace {
this.derivedRev++ this.derivedRev++
return return
} }
// The `tool/code-dispatch-start`/`tool/code-dispatch` pair is declared by // These lifecycle events are declared by a host-only plugin whose Context
// the host-side dsh-tools plugin whose types cannot enter the client // types cannot enter the client program. ToolCallTree owns their structural
// program (its host Context merges collide with the client's), so this // wire narrowing, pairing, and nested snapshot projection.
// wire consumer narrows them structurally — the same posture as every if (this.toolCallTree.apply(event)) return
// other cross-wire event payload.
if ((event.type as string) === 'tool/code-dispatch-start') {
// A started sub-dispatch enters the index as a RunningToolCall — the
// exact shape a native in-flight call renders from — under its parent
// run_code callId; it never joins the surface flow.
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
}
const running: CodeSubCall = {
callId: data.subCallId, name: data.name,
argsRaw: JSON.stringify(data.arguments),
turn: 0, step: 0, time: event.time, callView: null,
}
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
this.codeDispatches.set(data.parentCallId, [...siblings, running])
this.dispatchesRev++
return
}
if ((event.type as string) === 'tool/code-dispatch') {
// Settlement replaces the running entry in place (same array position,
// so parallel sub-calls keep their start order) with the
// ToolResultNode form; a settle with no observed start (history window
// cut mid-pair, or a pre-start-event log) appends directly.
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
isError: boolean
content: ContentBlock[]
}
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
const started = at === -1 ? undefined : siblings[at]
const settled: CodeSubCall = {
kind: 'tool-result', seq: event.seq, time: event.time,
callId: data.subCallId,
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
// Duration source: the paired start's time when observed; null =
// unknown (settle-only window), matching the native tool-result
// contract so views never present a fabricated zero duration.
callTime: started === undefined ? null : started.time,
content: data.content, isError: data.isError,
callView: null, resultView: null,
}
this.codeDispatches.set(
data.parentCallId,
at === -1 ? [...siblings, settled] : siblings.map((sub, index) => (index === at ? settled : sub)),
)
this.dispatchesRev++
return
}
switch (event.type) { switch (event.type) {
case 'turn/start': case 'turn/start':
this.lastStepByTurn.set(event.data.turn, 0) this.lastStepByTurn.set(event.data.turn, 0)
@@ -834,6 +777,7 @@ export class Session implements SessionFace {
callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments, callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments,
turn: event.data.turn, step: event.data.step, time: event.time, turn: event.data.turn, step: event.data.step, time: event.time,
callView: view?.for === 'call' ? view.view : null, callView: view?.for === 'call' ? view.view : null,
subCalls: [],
}) })
this.callsRev++ this.callsRev++
return return
@@ -901,7 +845,7 @@ export class Session implements SessionFace {
call: { name: call.name, argsRaw: call.argsRaw }, call: { name: call.name, argsRaw: call.argsRaw },
callTime: call.time, callTime: call.time,
content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' }, content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
callView: call.callView, resultView: null, callView: call.callView, resultView: null, subCalls: [],
}) })
this.derivedRev++ this.derivedRev++
} }
@@ -948,8 +892,7 @@ export class Session implements SessionFace {
this.turnTimingsRev++ this.turnTimingsRev++
this.turnEnds = new Map() this.turnEnds = new Map()
this.turnEndsRev++ this.turnEndsRev++
this.codeDispatches = new Map() this.toolCallTree.reset()
this.dispatchesRev++
for (let i = 0; i < this.events.length; i++) { for (let i = 0; i < this.events.length; i++) {
const event = this.events[i] const event = this.events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
@@ -988,22 +931,18 @@ export class Session implements SessionFace {
if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) { if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) {
this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] } this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] }
} }
if (this.dispatchesCache === null || this.dispatchesCache.rev !== this.dispatchesRev) {
this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) }
}
if (this.queueCache === null || this.queueCache.rev !== this.queueRev) { if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
this.queueCache = { rev: this.queueRev, value: this.queued } this.queueCache = { rev: this.queueRev, value: this.queued }
} }
const partial = this.partial?.toPartial() ?? null const partial = this.partial?.toPartial() ?? null
return { return {
sessionId: this.sessionId, sessionId: this.sessionId,
nodes, nodes: this.toolCallTree.projectNodes(nodes),
turnTimings: this.turnTimingsCache.value, turnTimings: this.turnTimingsCache.value,
turnEnds: this.turnEndsCache.value, turnEnds: this.turnEndsCache.value,
partial, partial,
runningCalls: this.callsCache.value, runningCalls: this.toolCallTree.projectRunningCalls(this.callsCache.value),
pending: this.pendingCache.value, pending: this.pendingCache.value,
codeDispatches: this.dispatchesCache.value,
queue: this.queueCache.value, queue: this.queueCache.value,
running: this.running, running: this.running,
subagent: this.address === undefined subagent: this.address === undefined

View File

@@ -0,0 +1,171 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode,
} from './conversation.ts'
interface ProjectedBlock {
source: ToolCallBlock
children: readonly ToolCallBlock[]
value: ToolCallBlock
}
function sameBlocks(
left: readonly ToolCallBlock[],
right: readonly ToolCallBlock[],
): boolean {
return left.length === right.length
&& left.every((block, index) => block === right[index])
}
/**
* Owns Code Dispatch pairing and projects its private parent index into the
* recursive Tool call contract exposed by conversation snapshots.
*/
export class ToolCallTree {
private readonly childrenByParent = new Map<string, readonly ToolCallBlock[]>()
private readonly projectedByCall = new Map<string, ProjectedBlock>()
private revision = 0
private nodesCache: {
source: readonly ConversationNode[]
revision: number
value: readonly ConversationNode[]
} | null = null
private runningCache: {
source: readonly RunningToolCall[]
revision: number
value: readonly RunningToolCall[]
} | null = null
/** Forget all event-derived child calls before replaying a new window. */
reset(): void {
this.childrenByParent.clear()
this.projectedByCall.clear()
this.revision++
}
/**
* Fold one event when it belongs to the Code Dispatch lifecycle.
* @param event - Session event from the current live or history window.
* @returns Whether the event was consumed as a child-call lifecycle event.
*/
apply(event: SessionEvent): boolean {
if ((event.type as string) === 'tool/code-dispatch-start') {
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
}
const running: RunningToolCall = {
callId: data.subCallId,
name: data.name,
argsRaw: JSON.stringify(data.arguments),
turn: 0,
step: 0,
time: event.time,
callView: null,
subCalls: [],
}
const siblings = this.childrenByParent.get(data.parentCallId) ?? []
this.childrenByParent.set(data.parentCallId, [...siblings, running])
this.revision++
return true
}
if ((event.type as string) !== 'tool/code-dispatch') return false
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
isError: boolean
content: ContentBlock[]
}
const siblings = this.childrenByParent.get(data.parentCallId) ?? []
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
const started = at === -1 ? undefined : siblings[at]
const settled: ToolResultNode = {
kind: 'tool-result',
seq: event.seq,
time: event.time,
callId: data.subCallId,
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
callTime: started?.time ?? null,
content: data.content,
isError: data.isError,
callView: null,
resultView: null,
subCalls: [],
}
this.childrenByParent.set(
data.parentCallId,
at === -1
? [...siblings, settled]
: siblings.map((sub, index) => index === at ? settled : sub),
)
this.revision++
return true
}
/**
* Attach recursively projected children to all settled roots in a node list.
* @param nodes - Cache-stable base conversation nodes.
* @returns The original list when no root changed, otherwise a structurally shared list.
*/
projectNodes(nodes: readonly ConversationNode[]): readonly ConversationNode[] {
if (this.nodesCache?.source === nodes && this.nodesCache.revision === this.revision) {
return this.nodesCache.value
}
let changed = false
const projected = nodes.map((node): ConversationNode => {
if (node.kind !== 'tool-result') return node
const value = this.projectBlock(node) as ToolResultNode
changed ||= value !== node
return value
})
const value = changed ? projected : nodes
this.nodesCache = { source: nodes, revision: this.revision, value }
return value
}
/**
* Attach recursively projected children to all running root calls.
* @param calls - Cache-stable base running calls.
* @returns The original list when no root changed, otherwise a structurally shared list.
*/
projectRunningCalls(calls: readonly RunningToolCall[]): readonly RunningToolCall[] {
if (this.runningCache?.source === calls && this.runningCache.revision === this.revision) {
return this.runningCache.value
}
let changed = false
const projected = calls.map((call): RunningToolCall => {
const value = this.projectBlock(call) as RunningToolCall
changed ||= value !== call
return value
})
const value = changed ? projected : calls
this.runningCache = { source: calls, revision: this.revision, value }
return value
}
private projectBlock(block: ToolCallBlock): ToolCallBlock {
const children = this.childrenByParent.get(block.callId) ?? block.subCalls
const projectedChildren = children.map(child => this.projectBlock(child))
const childValue = sameBlocks(children, projectedChildren)
? children
: projectedChildren
const cached = this.projectedByCall.get(block.callId)
if (cached?.source === block && sameBlocks(cached.children, childValue)) {
return cached.value
}
const value: ToolCallBlock = block.subCalls === childValue
? block
: { ...block, subCalls: childValue }
this.projectedByCall.set(block.callId, {
source: block,
children: childValue,
value,
})
return value
}
}

View File

@@ -103,6 +103,7 @@ function materializeNode(
meta: event.data.meta, meta: event.data.meta,
callView: call?.callView ?? null, callView: call?.callView ?? null,
resultView, resultView,
subCalls: [],
} }
} }
/* v8 ignore next 2 -- defensive arm: only the four surface-eligible types /* v8 ignore next 2 -- defensive arm: only the four surface-eligible types

View File

@@ -1151,7 +1151,17 @@ describe('resync', () => {
}) })
describe('run_code sub-dispatch indexing', () => { describe('nested run_code sub-dispatches', () => {
const subCallsOf = (session: Session, callId: string) => {
const snapshot = session.getSnapshot()
const running = snapshot.runningCalls.find(call => call.callId === callId)
if (running !== undefined) return running.subCalls
for (const node of snapshot.nodes) {
if (node.kind === 'tool-result' && node.callId === callId) return node.subCalls
}
return undefined
}
it('a start event lands as a running-shaped sub-call and its settle replaces it in place', async () => { it('a start event lands as a running-shaped sub-call and its settle replaces it in place', async () => {
const { api, session } = makeSession() const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答')) api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
@@ -1161,19 +1171,19 @@ describe('run_code sub-dispatch indexing', () => {
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}')) feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
feed(ev.codeDispatchStart(8, 'p1', 1, 'bash', { command: 'sleep' })) feed(ev.codeDispatchStart(8, 'p1', 1, 'bash', { command: 'sleep' }))
feed(ev.codeDispatchStart(9, 'p1', 2, 'read', { path: 'a.txt' })) feed(ev.codeDispatchStart(9, 'p1', 2, 'read', { path: 'a.txt' }))
const live = session.getSnapshot().codeDispatches.get('p1') const live = subCallsOf(session, 'p1')
expect(live).toHaveLength(2) expect(live).toHaveLength(2)
// Running shape (no 'kind'): the exact RunningToolCall form native rows use. // Running shape (no 'kind'): the exact RunningToolCall form native rows use.
expect(live?.[0]).toMatchObject({ callId: 'p1:code:1', name: 'bash', argsRaw: '{"command":"sleep"}' }) expect(live?.[0]).toMatchObject({ callId: 'p1:code:1', name: 'bash', argsRaw: '{"command":"sleep"}' })
expect(live?.[0] !== undefined && 'kind' in live[0]).toBe(false) expect(live?.[0] !== undefined && 'kind' in live[0]).toBe(false)
// Settle out of order (parallel run): #2 first — replaces in place, keeping start order. // Settle out of order (parallel run): #2 first — replaces in place, keeping start order.
feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'a.txt' }, 'alpha')) feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'a.txt' }, 'alpha'))
const mixed = session.getSnapshot().codeDispatches.get('p1') const mixed = subCallsOf(session, 'p1')
expect(mixed?.map(sub => 'kind' in sub)).toEqual([false, true]) expect(mixed?.map(sub => 'kind' in sub)).toEqual([false, true])
expect(mixed?.[1]).toMatchObject({ callId: 'p1:code:2', content: [{ type: 'text', text: 'alpha' }] }) expect(mixed?.[1]).toMatchObject({ callId: 'p1:code:2', content: [{ type: 'text', text: 'alpha' }] })
// The settle carries the paired start's time as callTime (duration source). // The settle carries the paired start's time as callTime (duration source).
feed(ev.codeDispatch(11, 'p1', 1, 'bash', { command: 'sleep' }, 'done')) feed(ev.codeDispatch(11, 'p1', 1, 'bash', { command: 'sleep' }, 'done'))
const settled = session.getSnapshot().codeDispatches.get('p1') const settled = subCallsOf(session, 'p1')
expect(settled?.map(sub => 'kind' in sub)).toEqual([true, true]) expect(settled?.map(sub => 'kind' in sub)).toEqual([true, true])
expect(settled?.[0]).toMatchObject({ callId: 'p1:code:1', callTime: 1_700_000_000_008 }) expect(settled?.[0]).toMatchObject({ callId: 'p1:code:1', callTime: 1_700_000_000_008 })
}) })
@@ -1187,7 +1197,7 @@ describe('run_code sub-dispatch indexing', () => {
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}')) feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'))
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls', description: '列目录' }, 'demo.txt')) feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls', description: '列目录' }, 'demo.txt'))
feed(ev.codeDispatch(9, 'p1', 2, 'read', { path: 'a.txt' }, 'Error: ENOENT', true)) feed(ev.codeDispatch(9, 'p1', 2, 'read', { path: 'a.txt' }, 'Error: ENOENT', true))
const subs = session.getSnapshot().codeDispatches.get('p1') const subs = subCallsOf(session, 'p1')
expect(subs).toHaveLength(2) expect(subs).toHaveLength(2)
expect(subs?.[0]).toMatchObject({ expect(subs?.[0]).toMatchObject({
kind: 'tool-result', callId: 'p1:code:1', kind: 'tool-result', callId: 'p1:code:1',
@@ -1205,23 +1215,29 @@ describe('run_code sub-dispatch indexing', () => {
expect(session.getSnapshot().nodes.some(n => n.kind === 'tool-result' && n.callId.includes(':code:'))).toBe(false) expect(session.getSnapshot().nodes.some(n => n.kind === 'tool-result' && n.callId.includes(':code:'))).toBe(false)
}) })
it('rebuilds the same index from a history window (replay parity)', async () => { it('rebuilds the same nested tree from a history window (replay parity)', async () => {
const { api, session } = makeSession() const { api, session } = makeSession()
api.onHistory = () => histResponse([ api.onHistory = () => histResponse([
...plainTurn(0, 0, '问', '答'), ...plainTurn(0, 0, '问', '答'),
ev.turnStart(6, 1), ev.turnStart(6, 1),
ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'), ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'),
ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'demo.txt'), ev.codeDispatchStart(8, 'p1', 1, 'run_code', { code: 'return tools.read({ path: "a.txt" })' }),
ev.toolResult(9, 1, 'p1', '{"done":true}'), ev.codeDispatch(9, 'p1:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'),
ev.turnEnd(10, 1), ev.codeDispatch(10, 'p1', 1, 'run_code', { code: 'return tools.read({ path: "a.txt" })' }, 'alpha'),
ev.toolResult(11, 1, 'p1', '{"done":true}'),
ev.turnEnd(12, 1),
]) ])
await session.open() await session.open()
const subs = session.getSnapshot().codeDispatches.get('p1') const subs = subCallsOf(session, 'p1')
expect(subs).toHaveLength(1) expect(subs).toHaveLength(1)
expect(subs?.[0]).toMatchObject({ callId: 'p1:code:1', call: { name: 'bash' } }) expect(subs?.[0]).toMatchObject({
callId: 'p1:code:1',
call: { name: 'run_code' },
subCalls: [{ callId: 'p1:code:1:code:1', call: { name: 'read' } }],
})
}) })
it('keeps the dispatch map reference across unrelated changes and swaps it on a new dispatch', async () => { it('keeps an unaffected root reference and path-copies it on a new child', async () => {
const { api, session } = makeSession() const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定')) api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定'))
await session.open() await session.open()
@@ -1230,13 +1246,48 @@ describe('run_code sub-dispatch indexing', () => {
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}')) feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'x')) feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'x'))
const before = session.getSnapshot() const before = session.getSnapshot()
const beforeRoot = before.runningCalls.find(call => call.callId === 'p1')!
feed(ev.chunkStart(9, 1)) feed(ev.chunkStart(9, 1))
feed(ev.chunkText(10, 1, '流式')) feed(ev.chunkText(10, 1, '流式'))
const after = session.getSnapshot() const after = session.getSnapshot()
expect(after.codeDispatches).toBe(before.codeDispatches) const afterRoot = after.runningCalls.find(call => call.callId === 'p1')!
expect(afterRoot).toBe(beforeRoot)
feed(ev.codeDispatch(11, 'p1', 2, 'read', { path: 'a' }, 'y')) feed(ev.codeDispatch(11, 'p1', 2, 'read', { path: 'a' }, 'y'))
expect(session.getSnapshot().codeDispatches).not.toBe(after.codeDispatches) const changedRoot = session.getSnapshot().runningCalls.find(call => call.callId === 'p1')!
expect(session.getSnapshot().codeDispatches.get('p1')).toHaveLength(2) expect(changedRoot).not.toBe(afterRoot)
expect(changedRoot.subCalls[0]).toBe(afterRoot.subCalls[0])
expect(changedRoot.subCalls).toHaveLength(2)
})
it('path-copies only the owning branch when a nested child changes', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '树', '结构'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"first"}'))
feed(ev.toolCall(8, 1, 'p2', 'run_code', '{"code":"2","description":"second"}'))
feed(ev.codeDispatch(9, 'p1', 1, 'run_code', { code: 'nested' }, 'child'))
feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'sibling' }, 'sibling'))
feed(ev.codeDispatch(11, 'p2', 1, 'bash', { command: 'pwd' }, 'root two'))
const before = session.getSnapshot()
const beforeFirst = before.runningCalls.find(call => call.callId === 'p1')!
const beforeSecond = before.runningCalls.find(call => call.callId === 'p2')!
const beforeChild = beforeFirst.subCalls[0]!
const beforeSibling = beforeFirst.subCalls[1]!
feed(ev.codeDispatch(12, 'p1:code:1', 1, 'read', { path: 'nested' }, 'leaf'))
const after = session.getSnapshot()
const afterFirst = after.runningCalls.find(call => call.callId === 'p1')!
const afterSecond = after.runningCalls.find(call => call.callId === 'p2')!
expect(afterFirst).not.toBe(beforeFirst)
expect(afterSecond).toBe(beforeSecond)
expect(afterFirst.subCalls[0]).not.toBe(beforeChild)
expect(afterFirst.subCalls[1]).toBe(beforeSibling)
expect(afterFirst.subCalls[0]?.subCalls).toMatchObject([
{ callId: 'p1:code:1:code:1', call: { name: 'read' } },
])
}) })
}) })

View File

@@ -50,7 +50,6 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
turnEnds: new Map(), turnEnds: new Map(),
partial: null, partial: null,
runningCalls: [], runningCalls: [],
codeDispatches: new Map(),
pending: [], pending: [],
queue: [], queue: [],
running: false, running: false,

View File

@@ -24,7 +24,7 @@ import {
memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
} from 'react' } from 'react'
import type { import type {
CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client' } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -111,6 +111,11 @@ type ChatScrollPosition = NonNullable<ReturnType<ChatViewSlotProps['chatScroll']
* chat view narrows once to the runtime snapshot the binding actually feeds. */ * chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook<ConversationSnapshot> type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
function treeContainsCall(block: ToolCallBlock, callId: string | undefined): boolean {
return callId !== undefined
&& (block.callId === callId || block.subCalls.some(child => treeContainsCall(child, callId)))
}
function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): number | null { function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): number | null {
if (!running) return null if (!running) return null
for (let index = nodes.length - 1; index >= 0; index -= 1) { for (let index = nodes.length - 1; index >= 0; index -= 1) {
@@ -174,7 +179,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
toolName={node.call?.name ?? ''} toolName={node.call?.name ?? ''}
block={node} block={node}
openFile={openFile} openFile={openFile}
selectedCallId={selectedCallId} selectedCallId={treeContainsCall(node, selectedCallId) ? selectedCallId : undefined}
cwd={cwd} cwd={cwd}
inspectCall={inspectCall} inspectCall={inspectCall}
/> />
@@ -595,7 +600,7 @@ export function ChatView({
toolName={call.name} toolName={call.name}
block={call} block={call}
openFile={openFile} openFile={openFile}
selectedCallId={selectedCallId} selectedCallId={treeContainsCall(call, selectedCallId) ? selectedCallId : undefined}
cwd={cwd} cwd={cwd}
inspectCall={inspectCall} inspectCall={inspectCall}
/> />

View File

@@ -40,19 +40,27 @@ function runningMaterial(call: RunningToolCall): CallMaterial {
return { name: call.name, argsRaw: call.argsRaw, block: call } return { name: call.name, argsRaw: call.argsRaw, block: call }
} }
function findCall(block: ToolCallBlock, callId: string): ToolCallBlock | undefined {
if (block.callId === callId) return block
for (const child of block.subCalls) {
const found = findCall(child, callId)
if (found !== undefined) return found
}
return undefined
}
function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null { function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null {
for (const node of s.nodes) { for (const node of s.nodes) {
if (node.kind === 'tool-result' && node.callId === callId) return settledMaterial(node, callId) if (node.kind !== 'tool-result') continue
const found = findCall(node, callId)
if (found !== undefined) {
return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found)
}
} }
const open = s.runningCalls.find(c => c.callId === callId) for (const root of s.runningCalls) {
if (open !== undefined) return runningMaterial(open) const found = findCall(root, callId)
// run_code sub-dispatches: the native call-block shapes, so a selected if (found !== undefined) {
// sub-row resolves through the same material as a native call — the return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found)
// settled ToolResultNode form, or the RunningToolCall form mid-flight.
for (const subs of s.codeDispatches.values()) {
for (const sub of subs) {
if (sub.callId !== callId) continue
return 'kind' in sub ? settledMaterial(sub, callId) : runningMaterial(sub)
} }
} }
return null return null

View File

@@ -42,7 +42,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
function snapshotBase(): ConversationSnapshot { function snapshotBase(): ConversationSnapshot {
return { return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
} }
@@ -86,7 +86,7 @@ describe('deriveStats', () => {
it('ignores tool results with no call time', () => { it('ignores tool results with no call time', () => {
const tool: ToolResultNode = { const tool: ToolResultNode = {
kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [], kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [],
isError: false, callView: null, resultView: null, isError: false, callView: null, resultView: null, subCalls: [],
} }
const stats = deriveStats([tool, assistant(1, 1)]) const stats = deriveStats([tool, assistant(1, 1)])
expect(stats.steps).toBe(1) expect(stats.steps).toBe(1)
@@ -104,7 +104,7 @@ describe('deriveStats', () => {
} }
const tool: ToolResultNode = { const tool: ToolResultNode = {
kind: 'tool-result', seq: 5, time: 7_000, callId: 'c', call: null, callTime: 4_000, content: [], kind: 'tool-result', seq: 5, time: 7_000, callId: 'c', call: null, callTime: 4_000, content: [],
isError: false, callView: null, resultView: null, isError: false, callView: null, resultView: null, subCalls: [],
} }
const stats = deriveStats([timed, untimed, tool]) const stats = deriveStats([timed, untimed, tool])
expect(stats.llmMs).toBe(2_500) expect(stats.llmMs).toBe(2_500)

View File

@@ -37,7 +37,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot { function snapshotBase(): ConversationSnapshot {
return { return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
} }
@@ -88,10 +88,10 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode
kind: 'tool-result', seq, time: seq * 1_000, callId, kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` }, call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
callTime: seq * 1_000 - 500, callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null, content: [], isError: false, callView: null, resultView: null, subCalls: [],
}) })
const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({ const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, subCalls: [],
}) })
const command = (over: Partial<CommandNode> = {}): CommandNode => ({ const command = (over: Partial<CommandNode> = {}): CommandNode => ({
kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'], kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'],

View File

@@ -47,7 +47,7 @@ function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotPr
function snapshotBase(): ConversationSnapshot { function snapshotBase(): ConversationSnapshot {
return { return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
} }
@@ -126,18 +126,28 @@ describe('render branch tails', () => {
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy() expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
}) })
it('DetailsPanel resolves a run_code sub-callId to its full logged args and output', () => { it('DetailsPanel resolves a nested run_code leaf to its full logged args and output', () => {
localStorage.clear() localStorage.clear()
const snap = snapshotBase() const snap = snapshotBase()
const longText = 'x'.repeat(1_000) const longText = 'x'.repeat(1_000)
snap.codeDispatches = new Map([['p1', [{ snap.runningCalls = [{
callId: 'p1', name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
time: 7_000, callView: null, subCalls: [{
kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1', kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1',
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' }, call: { name: 'run_code', argsRaw: '{"code":"return 1"}' },
callTime: 8_000, callTime: 8_000,
content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null, content: [], isError: false, callView: null, resultView: null,
}]]]) subCalls: [{
kind: 'tool-result', seq: 9, time: 9_000, callId: 'p1:code:1:code:1',
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
callTime: 8_500,
content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null,
subCalls: [],
}],
}],
}]
const chat = createChatStore().create() const chat = createChatStore().create()
chat.actions.select({ turnSeq: 8, callId: 'p1:code:1', toolName: 'read' } satisfies SelectionTarget) chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget)
const emptyList = createSnapshotStore<SessionListState>( const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({ const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
@@ -168,7 +178,7 @@ describe('render branch tails', () => {
expect(view.getByTestId('tool-details-seat')).toBeTruthy() expect(view.getByTestId('tool-details-seat')).toBeTruthy()
expect(owners).toHaveLength(1) expect(owners).toHaveLength(1)
expect(owners[0]?.block).toMatchObject({ expect(owners[0]?.block).toMatchObject({
callId: 'p1:code:1', callId: 'p1:code:1:code:1',
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' }, call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
content: [{ type: 'text', text: longText }], content: [{ type: 'text', text: longText }],
}) })

View File

@@ -35,7 +35,7 @@ const SID = 's1' as SessionId
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot { function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return { return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -26,7 +26,7 @@ const SID = 's1' as SessionId
/** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */ /** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) { function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
const session = createSnapshotStore<ConversationSnapshot>({ const session = createSnapshotStore<ConversationSnapshot>({
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active', pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false, removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -112,7 +112,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined) actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
const wiring = shell const wiring = shell
const sessionStore = createSnapshotStore<ConversationSnapshot>({ const sessionStore = createSnapshotStore<ConversationSnapshot>({
sessionId, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), sessionId, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -32,7 +32,7 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return { return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null, pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
} }

View File

@@ -70,7 +70,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot { function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return { return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -37,7 +37,7 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode
kind: 'tool-result', seq, time: seq * 1_000, callId, kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` }, call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
callTime: seq * 1_000 - 500, callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null, content: [], isError: false, callView: null, resultView: null, subCalls: [],
}) })
const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({
...toolResult(seq, callId, 'write'), ...toolResult(seq, callId, 'write'),

View File

@@ -28,13 +28,14 @@ function settled(over: Partial<ToolResultNode> = {}): ToolResultNode {
isError: false, isError: false,
callView: null, callView: null,
resultView: null, resultView: null,
subCalls: [],
...over, ...over,
} }
} }
function running(argsRaw = '{"name":"dsh-manage-issues"}'): RunningToolCall { function running(argsRaw = '{"name":"dsh-manage-issues"}'): RunningToolCall {
return { return {
callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, callView: null, callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, callView: null, subCalls: [],
} }
} }

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md # pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md
README.md: bf6213ebfacd8f7963463b2c443524c631c28bcd README.md: bab3f92e4f6780b041cee2e682cb8ece3386ef15
README.zh.md: 06a46a525eead005375bcf67794a1ceecde678bc README.zh.md: 9187510fa354ba072835271d4b1153c8ee1105c0

View File

@@ -4,11 +4,11 @@ English | [中文](README.zh.md)
Client Tool presentation plugin. `ui-conversation` supplies one ordered root call through `conversation.chat.tool`; this package renders that root and its Code Dispatch children, then dispatches every atomic call through the keyed `tool.call.toolview` slot. Unregistered Tool names use the generic card. Client Tool presentation plugin. `ui-conversation` supplies one ordered root call through `conversation.chat.tool`; this package renders that root and its Code Dispatch children, then dispatches every atomic call through the keyed `tool.call.toolview` slot. Unregistered Tool names use the generic card.
Business UI packages register only their wire Tool names and atomic views. They do not pair Session events, rebuild the transcript, or own root/subcall topology. The Runtime remains authoritative for call/result pairing, lifecycle, and `codeDispatches`; the conversation view remains authoritative for ChatFlow placement. Business UI packages register only their wire Tool names and atomic views. They do not pair Session events, rebuild the transcript, or own root/subcall topology. The Runtime remains authoritative for call/result pairing, lifecycle, and recursive `subCalls` projection; the conversation view remains authoritative for ChatFlow placement.
## Rendering contract ## Rendering contract
`ToolCallTree` receives one root `ToolCallBlock`, selection state, the session `cwd`, and Host callbacks for opening files and inspecting calls. Through its standard session slot props it selects the Runtime-projected `codeDispatches[rootCallId]` array, then sends the root and every child through the same atomic dispatch path. The Runtime currently exposes only one Code Dispatch child level, so the renderer preserves that shape instead of inventing recursive data. `ToolCallTree` receives one root `ToolCallBlock` that already contains recursive `subCalls`, selection state, the session `cwd`, and Host callbacks for opening files and inspecting calls. It recursively walks the standard call blocks and sends the root and children at every depth through the same atomic dispatch path, without subscribing to a separate parent-to-children map.
Each root and child wrapper preserves the `conversation.chat.tool` call-anchor DOM contract used for paging and selection. Each root and child wrapper preserves the `conversation.chat.tool` call-anchor DOM contract used for paging and selection.
@@ -44,6 +44,5 @@ None. The package is client-only presentation.
## Known Limitations and Deferred Work ## Known Limitations and Deferred Work
- The Runtime currently exposes one level of Code Dispatch children. The renderer sends roots and children through the same atomic path, but it does not claim an arbitrary recursive wire topology.
- Existing first-party Tool views are initially colocated here and can move to their owning business packages independently through the keyed slot. - Existing first-party Tool views are initially colocated here and can move to their owning business packages independently through the keyed slot.
- Tool copy temporarily reuses the `ui-conversation` locale namespace. - Tool copy temporarily reuses the `ui-conversation` locale namespace.

View File

@@ -4,11 +4,11 @@
Client Tool 展示插件。`ui-conversation` 通过 `conversation.chat.tool` 交付一个已经排好位置的 root call;本包渲染该 root 及其 Code Dispatch 子调用,并把每个原子调用通过 keyed slot `tool.call.toolview` 分发。没有注册的 Tool 名称使用通用卡片。 Client Tool 展示插件。`ui-conversation` 通过 `conversation.chat.tool` 交付一个已经排好位置的 root call;本包渲染该 root 及其 Code Dispatch 子调用,并把每个原子调用通过 keyed slot `tool.call.toolview` 分发。没有注册的 Tool 名称使用通用卡片。
业务 UI 包只注册 wire Tool 名称和原子视图,不配对 Session Event、不重建 transcript,也不拥有 root/subcall 拓扑。Runtime 继续负责 call/result 配对、生命周期和 `codeDispatches`;conversation view 继续负责 ChatFlow 位置。 业务 UI 包只注册 wire Tool 名称和原子视图,不配对 Session Event、不重建 transcript,也不拥有 root/subcall 拓扑。Runtime 继续负责 call/result 配对、生命周期和递归 `subCalls` 投影;conversation view 继续负责 ChatFlow 位置。
## 渲染契约 ## 渲染契约
`ToolCallTree` 接收一个 root `ToolCallBlock`、selection 状态、会话 `cwd`,以及用于打开文件和检查调用的 Host 回调。它通过标准 session slot props 选择 Runtime 投影的 `codeDispatches[rootCallId]` 数组,再让 root 与每个 child 经过同一条原子分发路径。Runtime 当前只暴露一层 Code Dispatch child,因此 renderer 保留该形状,不自行发明递归数据。 `ToolCallTree` 接收一个已经包含递归 `subCalls` 的 root `ToolCallBlock`、selection 状态、会话 `cwd`,以及用于打开文件和检查调用的 Host 回调。它递归遍历标准 call block,让 root 与任意深度的 child 经过同一条原子分发路径,不再订阅独立的 parent-to-children map。
每个 root 和 child wrapper 都保留 `conversation.chat.tool` 的 call-anchor DOM 契约,供分页和 selection 使用。 每个 root 和 child wrapper 都保留 `conversation.chat.tool` 的 call-anchor DOM 契约,供分页和 selection 使用。
@@ -44,6 +44,5 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`
## 已知限制与后续工作 ## 已知限制与后续工作
- Runtime 当前只暴露一层 Code Dispatch 子调用。renderer 会让 root 和 child 经过同一个原子分发路径,但不宣称 wire 拓扑已经支持任意递归。
- 现有第一方 Tool 视图初期仍集中在本包,之后可以通过 keyed slot 独立迁回各自业务包。 - 现有第一方 Tool 视图初期仍集中在本包,之后可以通过 keyed slot 独立迁回各自业务包。
- Tool 文案暂时复用 `ui-conversation` locale namespace。 - Tool 文案暂时复用 `ui-conversation` locale namespace。

View File

@@ -1,12 +1,12 @@
/** Root/subcall Tool composition with one keyed atomic dispatch path. */ /** Root/subcall Tool composition with one keyed atomic dispatch path. */
import { memo, useMemo, type ReactNode } from 'react' import { memo, useMemo, type ReactNode } from 'react'
import type { CodeSubCall, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolCallOwnerProps, ToolTreeProps } from '../contract/slots.ts' import type { ToolCallOwnerProps, ToolTreeProps } from '../contract/slots.ts'
import { GenericToolCard } from './toolviews/GenericToolCard.tsx' import { GenericToolCard } from './toolviews/GenericToolCard.tsx'
import css from './ToolCallTree.module.css' import css from './ToolCallTree.module.css'
/** Resolve a Code Dispatch child's wire Tool name from either lifecycle form. */ /** Resolve a Tool call's wire name from either lifecycle form. */
function subCallName(node: CodeSubCall): string { function callName(node: ToolCallBlock): string {
return 'kind' in node ? node.call?.name ?? '' : node.name return 'kind' in node ? node.call?.name ?? '' : node.name
} }
@@ -44,40 +44,33 @@ const ToolCall = memo(function ToolCall({
) )
}) })
/** const ToolCallBranch = memo(function ToolCallBranch({
* Render one root Tool call and its currently supported one-level Code renderSlot, block, selectedCallId, cwd, openFile, inspectCall, t,
* Dispatch children. Root and children use the same atomic keyed dispatch. }: Pick<ToolTreeProps, 'renderSlot' | 'selectedCallId' | 'cwd' | 'openFile' | 'inspectCall' | 't'> & {
* @param props - whole-Tool owner data and the Tool-owned child-slot share. block: ToolCallBlock
* @returns the Tool call tree. }) {
*/
export function ToolCallTree({
useSession, renderSlot, callId, toolName, block, selectedCallId, cwd, openFile, inspectCall, t,
}: ToolTreeProps) {
const subCalls = useSession(snapshot => snapshot.codeDispatches.get(callId))
return ( return (
<ToolCall <ToolCall
renderSlot={renderSlot} renderSlot={renderSlot}
callId={callId} callId={block.callId}
toolName={toolName} toolName={callName(block)}
block={block} block={block}
openFile={openFile} openFile={openFile}
selected={callId === selectedCallId} selected={block.callId === selectedCallId}
cwd={cwd} cwd={cwd}
inspectCall={inspectCall} inspectCall={inspectCall}
t={t} t={t}
> >
{subCalls !== undefined && subCalls.length > 0 ? ( {block.subCalls.length > 0 ? (
<div className={css.subCalls} data-subcalls> <div className={css.subCalls} data-subcalls>
{subCalls.map(node => ( {block.subCalls.map(child => (
<ToolCall <ToolCallBranch
key={node.callId} key={child.callId}
renderSlot={renderSlot} renderSlot={renderSlot}
callId={node.callId} block={child}
toolName={subCallName(node)} selectedCallId={selectedCallId}
block={node}
openFile={openFile}
selected={node.callId === selectedCallId}
cwd={cwd} cwd={cwd}
openFile={openFile}
inspectCall={inspectCall} inspectCall={inspectCall}
t={t} t={t}
/> />
@@ -86,4 +79,26 @@ export function ToolCallTree({
) : null} ) : null}
</ToolCall> </ToolCall>
) )
})
/**
* Render one root Tool call and its recursive children through the same
* atomic keyed dispatch.
* @param props - whole-Tool owner data and the Tool-owned child-slot share.
* @returns the Tool call tree.
*/
export function ToolCallTree({
renderSlot, block, selectedCallId, cwd, openFile, inspectCall, t,
}: ToolTreeProps) {
return (
<ToolCallBranch
renderSlot={renderSlot}
block={block}
selectedCallId={selectedCallId}
cwd={cwd}
openFile={openFile}
inspectCall={inspectCall}
t={t}
/>
)
} }

View File

@@ -24,11 +24,11 @@ const resultNode = (argsRaw: string, resultText: string | null, over?: Partial<T
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1', kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
call: { name: 'ask_user_question', argsRaw }, call: { name: 'ask_user_question', argsRaw },
content: resultText === null ? [] : [{ type: 'text', text: resultText }], content: resultText === null ? [] : [{ type: 'text', text: resultText }],
isError: false, callView: null, resultView: null, ...over, isError: false, callView: null, resultView: null, subCalls: [], ...over,
}) })
const runningCall = (argsRaw: string) => const runningCall = (argsRaw: string) =>
({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, callView: null }) ({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, callView: null, subCalls: [] })
// Standard locale seat stub mirroring the real ns → common → key chain. // Standard locale seat stub mirroring the real ns → common → key chain.
const t = makeTranslate(zh, commonZh) const t = makeTranslate(zh, commonZh)

View File

@@ -40,7 +40,7 @@ const todoResult = (seq: number): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`, kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`,
call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) }, call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) },
callTime: seq * 1_000 - 500, callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null, content: [], isError: false, callView: null, resultView: null, subCalls: [],
}) })
const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>): ToolResultNode => ({ const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -50,6 +50,7 @@ const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>)
content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false, content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false,
callView: { card: 'terminal', title: 'ls -la', description: 'List files' }, callView: { card: 'terminal', title: 'ls -la', description: 'List files' },
resultView: { card: 'terminal', output: 'total 2\ndemo.txt\n', exitCode: 0 }, resultView: { card: 'terminal', output: 'total 2\ndemo.txt\n', exitCode: 0 },
subCalls: [],
...over, ...over,
}) })

View File

@@ -13,8 +13,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react' import { cleanup, fireEvent, render } from '@testing-library/react'
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { import type {
CodeSubCall, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ConversationSnapshot, RunningToolCall, SessionId, SessionListState,
ToolResultNode, WorkspaceListState, ToolCallBlock, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client' } from '@deepseek-ai/dsh-client-runtime/client'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react' import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
@@ -48,27 +48,33 @@ const codeResult = (seq: number, callId: string): ToolResultNode => ({
call: { name: 'run_code', argsRaw: RUN_CODE_ARGS }, call: { name: 'run_code', argsRaw: RUN_CODE_ARGS },
callTime: seq * 1_000 - 500, callTime: seq * 1_000 - 500,
content: [{ type: 'text', text: 'demo.txt' }], isError: false, callView: null, resultView: null, content: [{ type: 'text', text: 'demo.txt' }], isError: false, callView: null, resultView: null,
subCalls: [],
}) })
const runningCode = (callId: string): RunningToolCall => ({ const runningCode = (callId: string): RunningToolCall => ({
callId, name: 'run_code', argsRaw: RUN_CODE_ARGS, turn: 9, step: 0, time: 9_000, callView: null, callId, name: 'run_code', argsRaw: RUN_CODE_ARGS, turn: 9, step: 0, time: 9_000, callView: null,
subCalls: [],
}) })
const subCall = (seq: number, parent: string, n: number, name: string, args: object, resultText: string, isError = false): CodeSubCall => ({ const subCall = (seq: number, parent: string, n: number, name: string, args: object, resultText: string, isError = false): ToolCallBlock => ({
kind: 'tool-result', seq, time: seq * 1_000, kind: 'tool-result', seq, time: seq * 1_000,
callId: `${parent}:code:${n}`, callId: `${parent}:code:${n}`,
call: { name, argsRaw: JSON.stringify(args) }, call: { name, argsRaw: JSON.stringify(args) },
callTime: seq * 1_000, callTime: seq * 1_000,
content: [{ type: 'text', text: resultText }], isError, callView: null, resultView: null, content: [{ type: 'text', text: resultText }], isError, callView: null, resultView: null,
subCalls: [],
}) })
function snapshotWith( function snapshotWith(
nodes: ToolResultNode[], nodes: ToolResultNode[],
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>, subCalls: readonly ToolCallBlock[],
runningCalls: RunningToolCall[] = [], runningCalls: RunningToolCall[] = [],
): ConversationSnapshot { ): ConversationSnapshot {
const nestedNodes = nodes.map(node => ({ ...node, subCalls }))
const nestedRunningCalls = runningCalls.map(call => ({ ...call, subCalls }))
return { return {
sessionId: SID, nodes, turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls, codeDispatches, sessionId: SID, nodes: nestedNodes, turnTimings: new Map(), turnEnds: new Map(), partial: null,
runningCalls: nestedRunningCalls,
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false, pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
openState: 'open', openError: null, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
@@ -165,11 +171,11 @@ function mountApp(slots: SlotsService) {
describe('run_code sub-calls through the real chat machinery', () => { describe('run_code sub-calls through the real chat machinery', () => {
it('renders the code-variant parent row with the description summary and nested sub-rows', async () => { it('renders the code-variant parent row with the description summary and nested sub-rows', async () => {
const parent = 'call-64' const parent = 'call-64'
const dispatches = new Map([[parent, [ const subCalls = [
subCall(11, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'), subCall(11, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
subCall(12, parent, 2, 'mystery', { n: 1 }, 'ok'), subCall(12, parent, 2, 'mystery', { n: 1 }, 'ok'),
]]]) ]
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches)) const b = await bench(snapshotWith([codeResult(10, parent)], subCalls))
const view = mountApp(b.slots) const view = mountApp(b.slots)
// Parent row: the code variant with the model-authored description. // Parent row: the code variant with the model-authored description.
@@ -193,12 +199,12 @@ describe('run_code sub-calls through the real chat machinery', () => {
it('renders Cordis sub-calls with lifecycle titles over the generic variants', async () => { it('renders Cordis sub-calls with lifecycle titles over the generic variants', async () => {
const parent = 'call-cordis' const parent = 'call-cordis'
const code = 'return { name: "audit", apply(ctx) {} }' const code = 'return { name: "audit", apply(ctx) {} }'
const dispatches = new Map([[parent, [ const subCalls = [
subCall(11, parent, 1, 'cordis_inspect', { what: 'temporary' }, '## Temporary Plugins'), subCall(11, parent, 1, 'cordis_inspect', { what: 'temporary' }, '## Temporary Plugins'),
subCall(12, parent, 2, 'cordis_mount', { code }, 'Temporary Plugin dyn-2 is running'), subCall(12, parent, 2, 'cordis_mount', { code }, 'Temporary Plugin dyn-2 is running'),
subCall(13, parent, 3, 'cordis_unmount', { id: 'dyn-2' }, 'Temporary Plugin dyn-2 was unmounted and removed.'), subCall(13, parent, 3, 'cordis_unmount', { id: 'dyn-2' }, 'Temporary Plugin dyn-2 was unmounted and removed.'),
]]]) ]
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches)) const b = await bench(snapshotWith([codeResult(10, parent)], subCalls))
const view = mountApp(b.slots) const view = mountApp(b.slots)
const nest = view.container.querySelector('[data-subcalls]')! const nest = view.container.querySelector('[data-subcalls]')!
@@ -214,7 +220,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => { it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => {
const parent = 'call-64' const parent = 'call-64'
const b = await bench(snapshotWith([codeResult(10, parent)], new Map())) const b = await bench(snapshotWith([codeResult(10, parent)], []))
const view = mountApp(b.slots) const view = mountApp(b.slots)
// The code row is expandable via the whole summary row (body = the program). // The code row is expandable via the whole summary row (body = the program).
const toggle = view.container.querySelector('[data-variant="code"] [data-expandable]') const toggle = view.container.querySelector('[data-variant="code"] [data-expandable]')
@@ -230,10 +236,10 @@ describe('run_code sub-calls through the real chat machinery', () => {
it('an isError sub-call renders the error state dot exactly like a failed native row', async () => { it('an isError sub-call renders the error state dot exactly like a failed native row', async () => {
const parent = 'call-64' const parent = 'call-64'
const dispatches = new Map([[parent, [ const subCalls = [
subCall(11, parent, 1, 'mystery', { n: 1 }, 'Error: boom', true), subCall(11, parent, 1, 'mystery', { n: 1 }, 'Error: boom', true),
]]]) ]
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches)) const b = await bench(snapshotWith([codeResult(10, parent)], subCalls))
const view = mountApp(b.slots) const view = mountApp(b.slots)
const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="error"]') const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="error"]')
expect(nested).not.toBeNull() expect(nested).not.toBeNull()
@@ -241,11 +247,11 @@ describe('run_code sub-calls through the real chat machinery', () => {
it('a file sub-row click opens the host path; bash sub-rows do not open details', async () => { it('a file sub-row click opens the host path; bash sub-rows do not open details', async () => {
const parent = 'call-64' const parent = 'call-64'
const dispatches = new Map([[parent, [ const subCalls = [
subCall(11, parent, 1, 'read', { path: 'notes/demo.txt' }, 'ok'), subCall(11, parent, 1, 'read', { path: 'notes/demo.txt' }, 'ok'),
subCall(12, parent, 2, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'), subCall(12, parent, 2, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
]]]) ]
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches)) const b = await bench(snapshotWith([codeResult(10, parent)], subCalls))
const view = mountApp(b.slots) const view = mountApp(b.slots)
view.getByText('notes/demo.txt').click() view.getByText('notes/demo.txt').click()
expect(b.layout.openDetails).not.toHaveBeenCalled() expect(b.layout.openDetails).not.toHaveBeenCalled()
@@ -258,10 +264,10 @@ describe('run_code sub-calls through the real chat machinery', () => {
it('a RUNNING run_code call nests its so-far dispatches under the spinner row', async () => { it('a RUNNING run_code call nests its so-far dispatches under the spinner row', async () => {
const parent = 'call-live' const parent = 'call-live'
const dispatches = new Map([[parent, [ const subCalls = [
subCall(21, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'), subCall(21, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
]]]) ]
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)])) const b = await bench(snapshotWith([], subCalls, [runningCode(parent)]))
const view = mountApp(b.slots) const view = mountApp(b.slots)
const running = view.container.querySelector('[data-variant="code"][data-state="running"]') const running = view.container.querySelector('[data-variant="code"][data-state="running"]')
expect(running).not.toBeNull() expect(running).not.toBeNull()
@@ -272,12 +278,11 @@ describe('run_code sub-calls through the real chat machinery', () => {
it('a started-but-unsettled sub-call renders the running state exactly like a native in-flight row', async () => { it('a started-but-unsettled sub-call renders the running state exactly like a native in-flight row', async () => {
const parent = 'call-live' const parent = 'call-live'
const runningSub: CodeSubCall = { const runningSub: ToolCallBlock = {
callId: `${parent}:code:1`, name: 'grep', argsRaw: '{"pattern":"todo"}', callId: `${parent}:code:1`, name: 'grep', argsRaw: '{"pattern":"todo"}',
turn: 0, step: 0, time: 21_000, callView: null, turn: 0, step: 0, time: 21_000, callView: null, subCalls: [],
} }
const dispatches = new Map([[parent, [runningSub]]]) const b = await bench(snapshotWith([], [runningSub], [runningCode(parent)]))
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)]))
const view = mountApp(b.slots) const view = mountApp(b.slots)
// The nested row derives 'running' from the RunningToolCall shape — the // The nested row derives 'running' from the RunningToolCall shape — the
// same data-state chrome (row sweep) a native in-flight row wears. // same data-state chrome (row sweep) a native in-flight row wears.
@@ -291,9 +296,9 @@ describe('run_code sub-calls through the real chat machinery', () => {
kind: 'tool-result', seq: 10, time: 10_000, callId: parent, kind: 'tool-result', seq: 10, time: 10_000, callId: parent,
call: { name: 'mystery', argsRaw: '{"n":1}' }, call: { name: 'mystery', argsRaw: '{"n":1}' },
callTime: 9_500, callTime: 9_500,
content: [], isError: false, callView: null, resultView: null, content: [], isError: false, callView: null, resultView: null, subCalls: [],
} }
const b = await bench(snapshotWith([plain], new Map())) const b = await bench(snapshotWith([plain], []))
const view = mountApp(b.slots) const view = mountApp(b.slots)
expect(view.container.querySelector('[data-subcalls]')).toBeNull() expect(view.container.querySelector('[data-subcalls]')).toBeNull()
}) })

View File

@@ -57,7 +57,7 @@ describe('Tool presentation tails', () => {
kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5', kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5',
call: { name: 'todo_write', argsRaw: '{"note":"x"}' }, call: { name: 'todo_write', argsRaw: '{"note":"x"}' },
callTime: 1_000, callTime: 1_000,
content: [], isError: false, callView: null, resultView: null, content: [], isError: false, callView: null, resultView: null, subCalls: [],
} }
const props: GenericToolCardProps = { const props: GenericToolCardProps = {
callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(), t, callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(), t,
@@ -72,7 +72,7 @@ describe('Tool presentation tails', () => {
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' }, call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' },
callTime: 2_000, callTime: 2_000,
content: [], isError: false, callView: null, resultView: null, content: [], isError: false, callView: null, resultView: null, subCalls: [],
} }
const view = render(<BashRow {...bashProps(settled)} />) const view = render(<BashRow {...bashProps(settled)} />)
const row = view.container.querySelector('[data-sample="bash"]')! const row = view.container.querySelector('[data-sample="bash"]')!
@@ -84,13 +84,13 @@ describe('Tool presentation tails', () => {
it('BashRow carries data-state for running and StateDots for error/stopped', () => { it('BashRow carries data-state for running and StateDots for error/stopped', () => {
const running: RunningToolCall = { const running: RunningToolCall = {
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}', callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',
turn: 1, step: 1, time: 1_000, callView: null, turn: 1, step: 1, time: 1_000, callView: null, subCalls: [],
} }
const errorResult: ToolResultNode = { const errorResult: ToolResultNode = {
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1', kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"boom"}' }, call: { name: 'bash', argsRaw: '{"command":"boom"}' },
callTime: 500, callTime: 500,
content: [], isError: true, callView: null, resultView: null, content: [], isError: true, callView: null, resultView: null, subCalls: [],
} }
const stoppedResult: ToolResultNode = { const stoppedResult: ToolResultNode = {
...errorResult, ...errorResult,

View File

@@ -48,7 +48,7 @@ const resultDiff = (over?: Partial<Extract<ToolResultView, { card: 'diff' }>>):
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({ const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'edit', argsRaw: ARGS, callId: 'c1', name: 'edit', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, callView: callDiff(), ...over, turn: 1, step: 1, time: 1_000, callView: callDiff(), subCalls: [], ...over,
}) })
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({ const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -56,7 +56,7 @@ const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
call: { name: 'edit', argsRaw: ARGS }, call: { name: 'edit', argsRaw: ARGS },
callTime: 1_000, callTime: 1_000,
content: [{ type: 'text', text: 'The file notes/demo.txt has been updated successfully.' }], isError: false, content: [{ type: 'text', text: 'The file notes/demo.txt has been updated successfully.' }], isError: false,
callView: callDiff(), resultView: resultDiff(), ...over, callView: callDiff(), resultView: resultDiff(), subCalls: [], ...over,
}) })
describe('diffCardModel', () => { describe('diffCardModel', () => {
@@ -343,7 +343,7 @@ describe('DetailsPanel diff Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot { function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return { return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

View File

@@ -59,7 +59,7 @@ const resultRead = (over?: Partial<Extract<ToolResultView, { card: 'read' }>>):
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({ const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'read', argsRaw: ARGS, callId: 'c1', name: 'read', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, ...over, turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, subCalls: [], ...over,
}) })
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({ const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -67,7 +67,7 @@ const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
call: { name: 'read', argsRaw: ARGS }, call: { name: 'read', argsRaw: ARGS },
callTime: 1_000, callTime: 1_000,
content: [{ type: 'text', text: '41: export const a = 1' }], isError: false, content: [{ type: 'text', text: '41: export const a = 1' }], isError: false,
callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, resultView: resultRead(), ...over, callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, resultView: resultRead(), subCalls: [], ...over,
}) })
describe('readCardModel', () => { describe('readCardModel', () => {
@@ -289,7 +289,7 @@ describe('DetailsPanel Output section (read)', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot { function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return { return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

View File

@@ -65,7 +65,7 @@ const resultPaths = (over?: Partial<Extract<ToolResultView, { card: 'search'; sh
const runningGrep = (over?: Partial<RunningToolCall>): RunningToolCall => ({ const runningGrep = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'grep', argsRaw: GREP_ARGS, callId: 'c1', name: 'grep', argsRaw: GREP_ARGS,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, ...over, turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, subCalls: [], ...over,
}) })
const settledGrep = (over?: Partial<ToolResultNode>): ToolResultNode => ({ const settledGrep = (over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -73,7 +73,7 @@ const settledGrep = (over?: Partial<ToolResultNode>): ToolResultNode => ({
call: { name: 'grep', argsRaw: GREP_ARGS }, call: { name: 'grep', argsRaw: GREP_ARGS },
callTime: 1_000, callTime: 1_000,
content: [{ type: 'text', text: 'a.ts\n Line 12: const foo = 1' }], isError: false, content: [{ type: 'text', text: 'a.ts\n Line 12: const foo = 1' }], isError: false,
callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, resultView: resultMatches(), ...over, callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, resultView: resultMatches(), subCalls: [], ...over,
}) })
const settledGlob = (over?: Partial<ToolResultNode>): ToolResultNode => ({ const settledGlob = (over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -81,7 +81,7 @@ const settledGlob = (over?: Partial<ToolResultNode>): ToolResultNode => ({
call: { name: 'glob', argsRaw: GLOB_ARGS }, call: { name: 'glob', argsRaw: GLOB_ARGS },
callTime: 1_000, callTime: 1_000,
content: [{ type: 'text', text: 'src/a.ts\nsrc/b.ts' }], isError: false, content: [{ type: 'text', text: 'src/a.ts\nsrc/b.ts' }], isError: false,
callView: { card: 'generic', title: 'Glob **/*.ts', kind: 'search' }, resultView: resultPaths(), ...over, callView: { card: 'generic', title: 'Glob **/*.ts', kind: 'search' }, resultView: resultPaths(), subCalls: [], ...over,
}) })
describe('searchCardModel', () => { describe('searchCardModel', () => {
@@ -405,7 +405,7 @@ describe('DetailsPanel Output section (search)', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot { function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return { return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

View File

@@ -58,7 +58,7 @@ const resultTerminal = (over?: Partial<Extract<ToolResultView, { card: 'terminal
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({ const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'bash', argsRaw: ARGS, callId: 'c1', name: 'bash', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, callView: callTerminal(), ...over, turn: 1, step: 1, time: 1_000, callView: callTerminal(), subCalls: [], ...over,
}) })
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({ const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -66,7 +66,7 @@ const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
call: { name: 'bash', argsRaw: ARGS }, call: { name: 'bash', argsRaw: ARGS },
callTime: 1_000, callTime: 1_000,
content: [{ type: 'text', text: 'a.ts b.ts\nc.ts d.ts\n' }], isError: false, content: [{ type: 'text', text: 'a.ts b.ts\nc.ts d.ts\n' }], isError: false,
callView: callTerminal(), resultView: resultTerminal(), ...over, callView: callTerminal(), resultView: resultTerminal(), subCalls: [], ...over,
}) })
describe('terminalCardModel', () => { describe('terminalCardModel', () => {
@@ -480,7 +480,7 @@ describe('DetailsPanel Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot { function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return { return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
@@ -568,15 +568,17 @@ describe('DetailsPanel Output section', () => {
// pins the resolution path with views injected directly, and the arm below // pins the resolution path with views injected directly, and the arm below
// pins what the shipped path actually shows today. // pins what the shipped path actually shows today.
it('a run_code sub-dispatch resolves to its own terminal card once views reach it', () => { it('a run_code sub-dispatch resolves to its own terminal card once views reach it', () => {
const child = settled({ callId: 'c1' })
const view = mount(snapshot({ const view = mount(snapshot({
codeDispatches: new Map([['p1', [settled({ callId: 'c1' })]]]), runningCalls: [running({ callId: 'p1', subCalls: [child] })],
}), target) }), target)
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy() expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
}) })
it('a sub-dispatch as the wire actually delivers it (no views) keeps the flattened form', () => { it('a sub-dispatch as the wire actually delivers it (no views) keeps the flattened form', () => {
const child = settled({ callId: 'c1', callView: null, resultView: null })
const view = mount(snapshot({ const view = mount(snapshot({
codeDispatches: new Map([['p1', [settled({ callId: 'c1', callView: null, resultView: null })]]]), runningCalls: [running({ callId: 'p1', subCalls: [child] })],
}), target) }), target)
// No terminal card: the generic path renders the result text in the Output // No terminal card: the generic path renders the result text in the Output
// section's <pre> (the Input section has its own, hence the scoping). // section's <pre> (the Input section has its own, hence the scoping).
@@ -588,7 +590,10 @@ describe('DetailsPanel Output section', () => {
it('a running run_code sub-dispatch resolves through the running material', () => { it('a running run_code sub-dispatch resolves through the running material', () => {
const view = mount(snapshot({ const view = mount(snapshot({
// The leading non-matching sub-call exercises the scan's skip. // The leading non-matching sub-call exercises the scan's skip.
codeDispatches: new Map([['p1', [running({ callId: 'other' }), running()]]]), runningCalls: [running({
callId: 'p1',
subCalls: [running({ callId: 'other' }), running()],
})],
}), target) }), target)
expect(view.getByText('ls -la')).toBeTruthy() expect(view.getByText('ls -la')).toBeTruthy()
}) })

View File

@@ -61,7 +61,7 @@ describe('planSummary', () => {
const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResultNode => ({ const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1', kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
call: { name: 'todo_write', argsRaw }, call: { name: 'todo_write', argsRaw },
content: [], isError: false, callView: null, resultView: null, ...over, content: [], isError: false, callView: null, resultView: null, subCalls: [], ...over,
}) })
function rowProps(block: unknown): TodoRowProps { function rowProps(block: unknown): TodoRowProps {
@@ -106,7 +106,7 @@ describe('TodoRow', () => {
it('keeps non-ok execution states visible through the shared row states', () => { it('keeps non-ok execution states visible through the shared row states', () => {
const args = JSON.stringify({ todos: LIST }) const args = JSON.stringify({ todos: LIST })
const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />) const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null, subCalls: [] })} />)
expect(running.container.querySelector('[data-state="running"]')).not.toBeNull() expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull() expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull()
running.unmount() running.unmount()

View File

@@ -2,7 +2,7 @@
/** ToolCallTree-owned root/subcall markers and selection projection. */ /** ToolCallTree-owned root/subcall markers and selection projection. */
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react' import { cleanup, render } from '@testing-library/react'
import type { CodeSubCall, ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { ToolTreeProps } from '../src/client/contract/slots.ts' import type { ToolTreeProps } from '../src/client/contract/slots.ts'
@@ -15,15 +15,14 @@ const t: ToolTreeProps['t'] = makeTranslate(zh, commonZh)
const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => ({ const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => ({
kind: 'tool-result', seq: 3, time: 3_000, callId, call, callTime: 2_000, kind: 'tool-result', seq: 3, time: 3_000, callId, call, callTime: 2_000,
content: [], isError: false, callView: null, resultView: null, content: [], isError: false, callView: null, resultView: null, subCalls: [],
}) })
function props( function props(
block: ToolResultNode, block: ToolResultNode,
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]> = new Map(),
selectedCallId?: string, selectedCallId?: string,
): ToolTreeProps { ): ToolTreeProps {
const snapshot = { codeDispatches } as ConversationSnapshot const snapshot = {} as ConversationSnapshot
const useSession = ((selector: (value: ConversationSnapshot) => unknown) => selector(snapshot)) as ToolTreeProps['useSession'] const useSession = ((selector: (value: ConversationSnapshot) => unknown) => selector(snapshot)) as ToolTreeProps['useSession']
const renderSlot = ((_key: string, _owner: object, options?: { fallback?: React.ReactNode }) => const renderSlot = ((_key: string, _owner: object, options?: { fallback?: React.ReactNode }) =>
options?.fallback ?? null) as unknown as ToolTreeProps['renderSlot'] options?.fallback ?? null) as unknown as ToolTreeProps['renderSlot']
@@ -43,7 +42,7 @@ function props(
describe('ToolCallTree', () => { describe('ToolCallTree', () => {
it('owns the root marker, generic fallback, and selected state for a window-truncated call', () => { it('owns the root marker, generic fallback, and selected state for a window-truncated call', () => {
const block = root('w1', null) const block = root('w1', null)
const view = render(<ToolCallTree {...props(block, new Map(), 'w1')} />) const view = render(<ToolCallTree {...props(block, 'w1')} />)
const row = view.container.querySelector('[data-chat-call-id="w1"]') const row = view.container.querySelector('[data-chat-call-id="w1"]')
expect(row?.getAttribute('data-chat-anchor-key')).toBe('call:w1') expect(row?.getAttribute('data-chat-anchor-key')).toBe('call:w1')
expect(row?.getAttribute('data-selected')).toBe('true') expect(row?.getAttribute('data-selected')).toBe('true')
@@ -51,15 +50,23 @@ describe('ToolCallTree', () => {
expect(view.getByText('w1')).toBeTruthy() expect(view.getByText('w1')).toBeTruthy()
}) })
it('marks a selected subcall without selecting its root', () => { it('recursively renders a selected leaf without selecting its ancestors', () => {
const block = root('parent', { name: 'run_code', argsRaw: '{"code":"return 1"}' }) const leaf = root('parent:code:1:code:1', { name: 'read', argsRaw: '{"path":"a.ts"}' })
const child: CodeSubCall = root('parent:code:1', { name: 'read', argsRaw: '{"path":"a.ts"}' }) const child = {
const view = render( ...root('parent:code:1', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
<ToolCallTree {...props(block, new Map([['parent', [child]]]), child.callId)} />, subCalls: [leaf],
) }
expect(view.container.querySelector('[data-subcalls]')?.parentElement) const block = {
.toBe(view.container.querySelector('[data-chat-call-id="parent"]')) ...root('parent', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
subCalls: [child],
}
const view = render(<ToolCallTree {...props(block, leaf.callId)} />)
const nests = view.container.querySelectorAll('[data-subcalls]')
expect(nests[0]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent"]'))
expect(nests[1]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent:code:1"]'))
expect(view.container.querySelector('[data-chat-call-id="parent"]')?.hasAttribute('data-selected')).toBe(false) expect(view.container.querySelector('[data-chat-call-id="parent"]')?.hasAttribute('data-selected')).toBe(false)
expect(view.container.querySelector('[data-chat-call-id="parent:code:1"]')?.getAttribute('data-selected')).toBe('true') expect(view.container.querySelector('[data-chat-call-id="parent:code:1"]')?.hasAttribute('data-selected')).toBe(false)
expect(view.container.querySelector('[data-chat-call-id="parent:code:1:code:1"]')?.getAttribute('data-selected')).toBe('true')
expect(nests).toHaveLength(2)
}) })
}) })

View File

@@ -20,14 +20,14 @@ const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({ const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}', callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
turn: 1, step: 1, time: 1_000, callView: null, ...over, turn: 1, step: 1, time: 1_000, callView: null, subCalls: [], ...over,
}) })
const result = (over?: Partial<ToolResultNode>): ToolResultNode => ({ const result = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1', kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' }, call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
callTime: 1_000, callTime: 1_000,
content: [], isError: false, callView: null, resultView: null, ...over, content: [], isError: false, callView: null, resultView: null, subCalls: [], ...over,
}) })
describe('tool-call-model', () => { describe('tool-call-model', () => {

View File

@@ -42,7 +42,7 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
kind: 'tool-result', seq, time: seq * 1_000, callId, kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: args }, call: { name, argsRaw: args },
callTime: seq * 1_000 - 500, callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null, content: [], isError: false, callView: null, resultView: null, subCalls: [],
}) })
/** Test-owned AppFrame role: declares and renders the resident conversation area. */ /** Test-owned AppFrame role: declares and renders the resident conversation area. */

View File

@@ -56,7 +56,7 @@ const resultFetch = (over?: Partial<Extract<ToolResultView, { card: 'web'; kind:
const runningSearch = (over?: Partial<RunningToolCall>): RunningToolCall => ({ const runningSearch = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'web_search', argsRaw: SEARCH_ARGS, callId: 'c1', name: 'web_search', argsRaw: SEARCH_ARGS,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Search', kind: 'search' }, ...over, turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Search', kind: 'search' }, subCalls: [], ...over,
}) })
const settledSearch = (over?: Partial<ToolResultNode>): ToolResultNode => ({ const settledSearch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -64,7 +64,7 @@ const settledSearch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
call: { name: 'web_search', argsRaw: SEARCH_ARGS }, call: { name: 'web_search', argsRaw: SEARCH_ARGS },
callTime: 1_000, callTime: 1_000,
content: [{ type: 'text', text: 'search text' }], isError: false, content: [{ type: 'text', text: 'search text' }], isError: false,
callView: { card: 'generic', title: 'Search', kind: 'search' }, resultView: resultSearch(), ...over, callView: { card: 'generic', title: 'Search', kind: 'search' }, resultView: resultSearch(), subCalls: [], ...over,
}) })
const settledFetch = (over?: Partial<ToolResultNode>): ToolResultNode => ({ const settledFetch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -72,7 +72,7 @@ const settledFetch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
call: { name: 'web_fetch', argsRaw: FETCH_ARGS }, call: { name: 'web_fetch', argsRaw: FETCH_ARGS },
callTime: 1_000, callTime: 1_000,
content: [{ type: 'text', text: 'fetch body' }], isError: false, content: [{ type: 'text', text: 'fetch body' }], isError: false,
callView: { card: 'generic', title: 'Fetch', kind: 'fetch' }, resultView: resultFetch(), ...over, callView: { card: 'generic', title: 'Fetch', kind: 'fetch' }, resultView: resultFetch(), subCalls: [], ...over,
}) })
describe('webCardModel', () => { describe('webCardModel', () => {
@@ -235,7 +235,7 @@ describe('DetailsPanel web Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot { function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return { return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

View File

@@ -211,7 +211,6 @@ export function TrajectoryView({
const nodes = inspection.eventNodes const nodes = inspection.eventNodes
const partial = inspection.partial const partial = inspection.partial
const runningCalls = inspection.runningCalls const runningCalls = inspection.runningCalls
const codeDispatches = inspection.codeDispatches
const loadHistoryTailRef = useRef(loadHistoryTail) const loadHistoryTailRef = useRef(loadHistoryTail)
loadHistoryTailRef.current = loadHistoryTail loadHistoryTailRef.current = loadHistoryTail
const historyControllerRef = useRef<AbortController | null>(null) const historyControllerRef = useRef<AbortController | null>(null)
@@ -366,12 +365,11 @@ export function TrajectoryView({
runningCalls, runningCalls,
requests: selectedRequests, requests: selectedRequests,
callSchemas, callSchemas,
codeDispatches,
}) })
return { turns, lastIndex: lastCellIndex(turns) } return { turns, lastIndex: lastCellIndex(turns) }
}, [ }, [
selectedNodes, partialTurn, partialStep, selectedNodes, partialTurn, partialStep,
runningCalls, selectedRequests, callSchemas, codeDispatches, runningCalls, selectedRequests, callSchemas,
]) ])
const timelinePartialSignature = partialStructureSignature(partial) const timelinePartialSignature = partialStructureSignature(partial)
const timelinePartial = useMemo<ConversationSnapshot['partial']>(() => partial === null const timelinePartial = useMemo<ConversationSnapshot['partial']>(() => partial === null

View File

@@ -5,11 +5,11 @@
import type { import type {
AssistantBlock, AssistantBlock,
AssistantMessageNode, AssistantMessageNode,
CodeSubCall,
ConversationSnapshot, ConversationSnapshot,
RequestInspectionSnapshot, RequestInspectionSnapshot,
RequestPromptChange, RequestPromptChange,
RequestView, RequestView,
ToolCallBlock,
ToolResultNode, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client' } from '@deepseek-ai/dsh-client-runtime/client'
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives' import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -39,8 +39,6 @@ export interface TrajectoryLayoutInput {
runningCalls: ConversationSnapshot['runningCalls'] runningCalls: ConversationSnapshot['runningCalls']
requests?: readonly RequestView[] requests?: readonly RequestView[]
callSchemas?: RequestInspectionSnapshot['callSchemas'] callSchemas?: RequestInspectionSnapshot['callSchemas']
/** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
codeDispatches: ConversationSnapshot['codeDispatches']
} }
interface UsageLike { interface UsageLike {
@@ -57,6 +55,7 @@ interface LaidCell {
absTime: number | null absTime: number | null
toolName?: string toolName?: string
callId?: string callId?: string
subCalls?: readonly ToolCallBlock[]
} }
interface LaidGroup { interface LaidGroup {
@@ -131,9 +130,11 @@ function inputCellDetail(node: InputNode): Pick<
*/ */
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] { export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
const { const {
nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches, nodes, partial, runningCalls, requests = [], callSchemas,
} = input } = input
const resultByCall = indexResults(nodes) const resultByCall = indexResults(nodes)
const callById = new Map<string, ToolCallBlock>(resultByCall)
for (const call of runningCalls) callById.set(call.callId, call)
const emittedCallIds = indexAssistantCallIds(nodes) const emittedCallIds = indexAssistantCallIds(nodes)
const callStartById = new Map<string, number>() const callStartById = new Map<string, number>()
for (const result of resultByCall.values()) { for (const result of resultByCall.values()) {
@@ -344,8 +345,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
} }
if (node.kind === 'assistant') { if (node.kind === 'assistant') {
const laidList = withSubCalls( const laidList = withSubCalls(
expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById), expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById, callById),
codeDispatches,
) )
if (node.step > 0) pushStep(node.turn, node.step, laidList) if (node.step > 0) pushStep(node.turn, node.step, laidList)
else for (const laid of laidList) pushMessage(node.turn, laid) else for (const laid of laidList) pushMessage(node.turn, laid)
@@ -381,6 +381,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
absTime: finiteTime(node.callTime ?? node.time), absTime: finiteTime(node.callTime ?? node.time),
...(toolName !== undefined ? { toolName } : {}), ...(toolName !== undefined ? { toolName } : {}),
callId: node.callId, callId: node.callId,
subCalls: node.subCalls,
cell: { cell: {
index: ++index, index: ++index,
kind: 'tool', kind: 'tool',
@@ -398,7 +399,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
startedAt: finiteTime(node.callTime), startedAt: finiteTime(node.callTime),
}, },
}] }]
for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) { for (const laid of expandSubCalls(node.subCalls, index)) {
laidList.push(laid) laidList.push(laid)
index = laid.cell.index index = laid.cell.index
} }
@@ -413,14 +414,15 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
kind: 'assistant', seq: Number.MAX_SAFE_INTEGER, time: 0, kind: 'assistant', seq: Number.MAX_SAFE_INTEGER, time: 0,
turn: partial.turn, step: partial.step, blocks: partial.blocks, turn: partial.turn, step: partial.step, blocks: partial.blocks,
} }
const laidList = expandAssistant( const laidList = withSubCalls(expandAssistant(
fake, fake,
index + 1, index + 1,
prevAbsTime, prevAbsTime,
resultByCall, resultByCall,
callStartById, callStartById,
callById,
{ streaming: true }, { streaming: true },
) ))
if (partial.step > 0) pushStep(partial.turn, partial.step, laidList) if (partial.step > 0) pushStep(partial.turn, partial.step, laidList)
else for (const laid of laidList) pushMessage(partial.turn, laid) else for (const laid of laidList) pushMessage(partial.turn, laid)
const last = laidList[laidList.length - 1] const last = laidList[laidList.length - 1]
@@ -434,6 +436,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
absTime: null, absTime: null,
toolName: call.name, toolName: call.name,
callId: call.callId, callId: call.callId,
subCalls: call.subCalls,
cell: { cell: {
index: ++index, index: ++index,
kind: 'tool', kind: 'tool',
@@ -444,7 +447,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
startedAt: finiteTime(call.time), startedAt: finiteTime(call.time),
}, },
}] }]
for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) { for (const laid of expandSubCalls(call.subCalls, index)) {
laidList.push(laid) laidList.push(laid)
index = laid.cell.index index = laid.cell.index
} }
@@ -491,7 +494,6 @@ export function appendTrajectoryPartialLayout(
nodes: [], nodes: [],
partial, partial,
runningCalls: [], runningCalls: [],
codeDispatches: new Map(),
}).at(0) }).at(0)
if (partialTurn === undefined) return turns if (partialTurn === undefined) return turns
const streamed: TrajectoryTurnModel = { const streamed: TrajectoryTurnModel = {
@@ -622,6 +624,7 @@ function expandAssistant(
prevAbsTime: number | null, prevAbsTime: number | null,
results: Map<string, ToolResultNode>, results: Map<string, ToolResultNode>,
callStarts: ReadonlyMap<string, number>, callStarts: ReadonlyMap<string, number>,
calls: ReadonlyMap<string, ToolCallBlock>,
opts?: { streaming?: boolean }, opts?: { streaming?: boolean },
): LaidCell[] { ): LaidCell[] {
if (opts?.streaming === true && node.blocks.length === 0) return [] if (opts?.streaming === true && node.blocks.length === 0) return []
@@ -677,10 +680,12 @@ function expandAssistant(
? null ? null
: durationSeconds(result.time, result.callTime) : durationSeconds(result.time, result.callTime)
const callAbs = finiteTime(callStarts.get(block.callId)) const callAbs = finiteTime(callStarts.get(block.callId))
const call = calls.get(block.callId)
out.push({ out.push({
absTime: callAbs, absTime: callAbs,
toolName: block.name, toolName: block.name,
callId: block.callId, callId: block.callId,
...(call === undefined ? {} : { subCalls: call.subCalls }),
cell: { cell: {
index: ++index, kind: 'tool', index: ++index, kind: 'tool',
text: summarizeCall(block.name, block.argsRaw), text: summarizeCall(block.name, block.argsRaw),
@@ -883,15 +888,14 @@ function collectCallIds(
/** Interleave each tool cell's run_code sub-dispatch cells right after it, reindexing followers. */ /** Interleave each tool cell's nested child calls right after it, reindexing followers. */
function withSubCalls(laidList: LaidCell[], codeDispatches: ConversationSnapshot['codeDispatches']): LaidCell[] { function withSubCalls(laidList: LaidCell[]): LaidCell[] {
if (codeDispatches.size === 0) return laidList if (!laidList.some(laid => laid.subCalls !== undefined && laid.subCalls.length > 0)) return laidList
const out: LaidCell[] = [] const out: LaidCell[] = []
let index = laidList[0] !== undefined ? laidList[0].cell.index - 1 : 0 let index = laidList[0] !== undefined ? laidList[0].cell.index - 1 : 0
for (const laid of laidList) { for (const laid of laidList) {
out.push({ ...laid, cell: { ...laid.cell, index: ++index } }) out.push({ ...laid, cell: { ...laid.cell, index: ++index } })
if (laid.callId === undefined) continue for (const sub of expandSubCalls(laid.subCalls, index)) {
for (const sub of expandSubCalls(codeDispatches.get(laid.callId), index)) {
out.push(sub) out.push(sub)
index = sub.cell.index index = sub.cell.index
} }
@@ -901,7 +905,7 @@ function withSubCalls(laidList: LaidCell[], codeDispatches: ConversationSnapshot
/** Sub-dispatch cells for one run_code parent, in start order (running = null duration). */ /** Sub-dispatch cells for one run_code parent, in start order (running = null duration). */
function expandSubCalls( function expandSubCalls(
subs: readonly CodeSubCall[] | undefined, subs: readonly ToolCallBlock[] | undefined,
startIndex: number, startIndex: number,
): LaidCell[] { ): LaidCell[] {
if (subs === undefined || subs.length === 0) return [] if (subs === undefined || subs.length === 0) return []
@@ -909,10 +913,11 @@ function expandSubCalls(
let index = startIndex let index = startIndex
for (const sub of subs) { for (const sub of subs) {
const settled = 'kind' in sub const settled = 'kind' in sub
out.push({ const laid: LaidCell = {
absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time), absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time),
toolName: settled ? sub.call?.name ?? sub.callId : sub.name, toolName: settled ? sub.call?.name ?? sub.callId : sub.name,
callId: sub.callId, callId: sub.callId,
subCalls: sub.subCalls,
cell: { cell: {
index: ++index, index: ++index,
kind: 'subtool', kind: 'subtool',
@@ -938,7 +943,12 @@ function expandSubCalls(
? finiteTime(sub.callTime) ? finiteTime(sub.callTime)
: finiteTime(sub.time), : finiteTime(sub.time),
}, },
}) }
out.push(laid)
for (const child of expandSubCalls(sub.subCalls, index)) {
out.push(child)
index = child.cell.index
}
} }
return out return out
} }

View File

@@ -74,7 +74,7 @@ describe('deriveTrajectoryLayout', () => {
content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null, content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null,
}, },
] as unknown as ConversationSnapshot['nodes'] ] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] }) const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
expect(turns).toHaveLength(1) expect(turns).toHaveLength(1)
expect(turns[0]?.turn).toBe(1) expect(turns[0]?.turn).toBe(1)
const kinds = turns[0]?.groups.flatMap(g => g.cells.map(c => c.kind)) const kinds = turns[0]?.groups.flatMap(g => g.cells.map(c => c.kind))
@@ -90,12 +90,11 @@ describe('deriveTrajectoryLayout', () => {
it('adds runningCalls not already present and leaves their time blank', () => { it('adds runningCalls not already present and leaves their time blank', () => {
const turns = deriveTrajectoryLayout({ const turns = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes: [], nodes: [],
partial: null, partial: null,
runningCalls: [{ runningCalls: [{
callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}', callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
turn: 1, step: 2, time: 9_000, callView: null, turn: 1, step: 2, time: 9_000, callView: null, subCalls: [],
}], }],
}) })
expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2']) expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2'])
@@ -119,7 +118,6 @@ describe('deriveTrajectoryLayout', () => {
startedAt: 3_000, completedAt: null, status: 'running', startedAt: 3_000, completedAt: null, status: 'running',
} as unknown as RequestView } as unknown as RequestView
const base = deriveTrajectoryLayout({ const base = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes, nodes,
partial: { ...partial, blocks: [] }, partial: { ...partial, blocks: [] },
requests: [request], requests: [request],
@@ -152,12 +150,11 @@ describe('deriveTrajectoryLayout', () => {
}], }],
} }
const base = deriveTrajectoryLayout({ const base = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes: [], nodes: [],
partial: { ...partial, blocks: [] }, partial: { ...partial, blocks: [] },
runningCalls: [{ runningCalls: [{
callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}', callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}',
turn: 1, step: 1, time: 9_000, callView: null, turn: 1, step: 1, time: 9_000, callView: null, subCalls: [],
}], }],
}) })
@@ -180,7 +177,7 @@ describe('deriveTrajectoryLayout', () => {
usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 }, usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 },
}, },
] as unknown as ConversationSnapshot['nodes'] ] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] }) const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const cells = turns[0]?.groups.flatMap(g => g.cells) ?? [] const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
expect(cells.find(c => c.kind === 'message')?.timeSeconds).toBeNull() expect(cells.find(c => c.kind === 'message')?.timeSeconds).toBeNull()
expect(turns[0]?.groups.find(g => g.title === 'Step 1')?.description).toBeUndefined() expect(turns[0]?.groups.find(g => g.title === 'Step 1')?.description).toBeUndefined()
@@ -206,7 +203,7 @@ describe('deriveTrajectoryLayout', () => {
content: [], isError: false, callView: null, resultView: null, content: [], isError: false, callView: null, resultView: null,
}, },
] as unknown as ConversationSnapshot['nodes'] ] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] }) const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
expect(turns[0]?.groups[0]?.description).toBe('3,000 ms bash×2') expect(turns[0]?.groups[0]?.description).toBe('3,000 ms bash×2')
}) })
@@ -223,7 +220,7 @@ describe('deriveTrajectoryLayout', () => {
blocks: [{ kind: 'text', text: 'ok2' }], blocks: [{ kind: 'text', text: 'ok2' }],
}, },
] as unknown as ConversationSnapshot['nodes'] ] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] }) const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
expect(turns.map(t => t.turn)).toEqual([1, 2]) expect(turns.map(t => t.turn)).toEqual([1, 2])
expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['first', 'ok1']) expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['first', 'ok1'])
expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2']) expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2'])
@@ -254,7 +251,6 @@ describe('deriveTrajectoryLayout', () => {
} }
const turns = deriveTrajectoryLayout({ const turns = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes, nodes,
partial: null, partial: null,
runningCalls: [], runningCalls: [],
@@ -280,7 +276,7 @@ describe('deriveTrajectoryLayout', () => {
usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 }, usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 },
}, },
] as unknown as ConversationSnapshot['nodes'] ] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] }) const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message') const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
expect(message).toMatchObject({ expect(message).toMatchObject({
text: '…', input: 11, output: 22, think: 3, text: '…', input: 11, output: 22, think: 3,
@@ -295,7 +291,7 @@ describe('deriveTrajectoryLayout', () => {
}] as unknown as ConversationSnapshot['nodes'] }] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ const turns = deriveTrajectoryLayout({
codeDispatches: new Map(), nodes, partial: null, runningCalls: [], nodes, partial: null, runningCalls: [],
}) })
const message = turns[0]?.groups.flatMap(group => group.cells) const message = turns[0]?.groups.flatMap(group => group.cells)
.find(cell => cell.kind === 'message') .find(cell => cell.kind === 'message')
@@ -333,7 +329,7 @@ describe('deriveTrajectoryLayout', () => {
blocks: [{ kind: 'text', text: 'done' }], blocks: [{ kind: 'text', text: 'done' }],
}, },
] as unknown as ConversationSnapshot['nodes'] ] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] }) const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const cells = turns[0]?.groups.flatMap(g => g.cells) ?? [] const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
const message = cells.find(c => c.kind === 'message' && c.text === 'done') const message = cells.find(c => c.kind === 'message' && c.text === 'done')
// From the compaction marker at 9.5s, not from context at 9s or the earlier surfaces. // From the compaction marker at 9.5s, not from context at 9s or the earlier surfaces.
@@ -352,7 +348,7 @@ describe('deriveTrajectoryLayout', () => {
}, },
] as unknown as ConversationSnapshot['nodes'] ] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ const turns = deriveTrajectoryLayout({
codeDispatches: new Map(), nodes, partial: null, runningCalls: [], nodes, partial: null, runningCalls: [],
}) })
const message = turns[0]?.groups.flatMap(group => group.cells) const message = turns[0]?.groups.flatMap(group => group.cells)
.find(cell => cell.kind === 'message') .find(cell => cell.kind === 'message')
@@ -372,6 +368,7 @@ describe('run_code sub-dispatch cells', () => {
kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1', kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
call: { name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' }, callTime: 6_200, call: { name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' }, callTime: 6_200,
content: [{ type: 'text', text: 'done' }], isError: false, callView: null, resultView: null, content: [{ type: 'text', text: 'done' }], isError: false, callView: null, resultView: null,
subCalls: [],
}, },
] as unknown as ConversationSnapshot['nodes'] ] as unknown as ConversationSnapshot['nodes']
@@ -380,14 +377,18 @@ describe('run_code sub-dispatch cells', () => {
callId: `p1:code:${n}`, callId: `p1:code:${n}`,
call: { name, argsRaw: '{"x":1}' }, callTime: start, call: { name, argsRaw: '{"x":1}' }, callTime: start,
content: [{ type: 'text' as const, text: 'ok' }], isError: false, callView: null, resultView: null, content: [{ type: 'text' as const, text: 'ok' }], isError: false, callView: null, resultView: null,
subCalls: [],
}) })
const withSubCalls = (subCalls: readonly ReturnType<typeof settledSub>[] | readonly object[]) =>
runCodeNodes.map(node => node.kind === 'tool-result' ? { ...node, subCalls } : node) as ConversationSnapshot['nodes']
it('nests settled sub-cells after their parent Tool cell with real durations', () => { it('nests settled sub-cells after their parent Tool cell with real durations', () => {
const codeDispatches = new Map([['p1', [ const subCalls = [
settledSub(1, 'bash', 6_300, 7_300), settledSub(1, 'bash', 6_300, 7_300),
settledSub(2, 'read', 7_300, 7_800), settledSub(2, 'read', 7_300, 7_800),
]]]) as unknown as ConversationSnapshot['codeDispatches'] ]
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] }) const turns = deriveTrajectoryLayout({ nodes: withSubCalls(subCalls), partial: null, runningCalls: [] })
const cells = turns[0]!.groups.flatMap(g => g.cells) const cells = turns[0]!.groups.flatMap(g => g.cells)
expect(cells.map(c => c.kind)).toEqual(['message', 'tool', 'subtool', 'subtool']) expect(cells.map(c => c.kind)).toEqual(['message', 'tool', 'subtool', 'subtool'])
expect(cells[0]?.text).toBe('Tool call only') expect(cells[0]?.text).toBe('Tool call only')
@@ -400,11 +401,29 @@ describe('run_code sub-dispatch cells', () => {
it('a running (unsettled) sub-call renders a subtool cell with blank time', () => { it('a running (unsettled) sub-call renders a subtool cell with blank time', () => {
const running = { const running = {
callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}', callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}',
turn: 0, step: 0, time: 6_400, callView: null, turn: 0, step: 0, time: 6_400, callView: null, subCalls: [],
} }
const codeDispatches = new Map([['p1', [running]]]) as unknown as ConversationSnapshot['codeDispatches'] const turns = deriveTrajectoryLayout({ nodes: withSubCalls([running]), partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool') const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool')
expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null }) expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null })
}) })
it('recursively flattens nested child calls immediately after their parent', () => {
const leaf = {
...settledSub(2, 'read', 7_300, 7_800),
callId: 'p1:code:1:code:1',
}
const child = {
...settledSub(1, 'run_code', 6_300, 8_000),
subCalls: [leaf],
}
const turns = deriveTrajectoryLayout({ nodes: withSubCalls([child]), partial: null, runningCalls: [] })
const cells = turns[0]!.groups.flatMap(group => group.cells)
expect(cells.map(cell => cell.kind)).toEqual(['message', 'tool', 'subtool', 'subtool'])
expect(cells.slice(2).map(cell => cell.callId)).toEqual([
'p1:code:1',
'p1:code:1:code:1',
])
expect(cells.map(cell => cell.index)).toEqual([1, 2, 3, 4])
})
}) })

View File

@@ -82,7 +82,6 @@ function historySnapshot(
interruptedNodes: [], interruptedNodes: [],
partial: null, partial: null,
runningCalls: [], runningCalls: [],
codeDispatches: new Map(),
...inspection, ...inspection,
}, },
} }
@@ -115,7 +114,7 @@ function standaloneDuration(): Pick<
function fakeSession(nodes: ConversationSnapshot['nodes']) { function fakeSession(nodes: ConversationSnapshot['nodes']) {
const store = createSnapshotStore({ const store = createSnapshotStore({
nodes, pending: [], partial: null, nodes, pending: [], partial: null,
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(), runningCalls: [] as ConversationSnapshot['runningCalls'],
}) })
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> } return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
} }
@@ -186,7 +185,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
running: false, removed: false, promptError: null, nodes, running: false, removed: false, promptError: null, nodes,
pending: [], pending: [],
openState: 'open' as const, hasMore: true, loadingOlder: false, openState: 'open' as const, hasMore: true, loadingOlder: false,
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(), partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
}) })
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot> const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
const chat = createChatStore().create() const chat = createChatStore().create()