diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml
index 51a624d98a..31c76de3e1 100644
--- a/packages/api/session-controller/README.i18n.yaml
+++ b/packages/api/session-controller/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: cda9349e432472a0ed9fd623afef0b689ff72f73
-README.zh.md: 2aaee8f968cf7373110e291c197adbeca21f490e
+README.md: 7631e1623f90f9349eca78bc76d46505d13d2e0e
+README.zh.md: 7a733b45b1cdbb17096d1e76bb25b54d3bdc0e06
diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md
index cda9349e43..7631e1623f 100644
--- a/packages/api/session-controller/README.md
+++ b/packages/api/session-controller/README.md
@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `ctx.remote.session` namespace. It serves Session list, search, creation, model selection, rename, fork, prompt, attachment, queue, cancellation, message-aligned history, live log following, and Host-wide control state.
+History pages and follow event frames carry only raw `SessionWireEvent` values. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data.
+
Each endpoint states its activation policy. List, search, attachment, history pages, and log following can inspect persistence without activating an Agent; queue mutation and cancellation require the corresponding live state; model, rename, and prompt commands may explicitly resume an ordinary Session. Create and fork are the only operations that create a new Agent. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces.
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md
index 2aaee8f968..7a733b45b1 100644
--- a/packages/api/session-controller/README.zh.md
+++ b/packages/api/session-controller/README.zh.md
@@ -4,6 +4,8 @@
`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务和生成的 Client `ctx.remote.session` namespace。它提供 Session 列表、搜索、创建、模型选择、重命名、fork、prompt、附件、queue、取消、按消息对齐的历史、live 日志跟随和 Host 范围 control 状态。
+历史页与 follow event frame 只携带原始 `SessionWireEvent`。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。
+
每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页和日志跟随可以在不激活 Agent 的情况下检查 persistence;queue 变更和取消要求对应 live 状态仍然存在;模型、重命名和 prompt 命令可以显式恢复普通 Session。只有 create 和 fork 会创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md
index 2165e19c2a..b45dc60a48 100644
--- a/packages/client/AGENTS.md
+++ b/packages/client/AGENTS.md
@@ -52,7 +52,7 @@ Non-negotiables across the layers:
- **Business data lives in the object layer, never a store.** Entry-declared stores carry shared viewing/interaction state (selection, drafts, panel widths); sessions, frames, and connections stay in the object layer.
- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)).
- **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `../api/session-controller/src/client/sessions/notifier.ts`.
-- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule).
+- **The web layer is pure presentation.** Nothing that is only "how to draw" enters the session log. Tool cards derive in the Client from raw call/result events and persisted result metadata; process-local control state uses its own snapshots and frames. Unknown or malformed tool data falls back to the generic form. A new *model-visible* input still requires a session event (repo-wide rule).
## Dependency declaration
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index ffb66bc995..a6102ddac0 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: 6f47cd87af46cc270d3160482ad047b249aa5053
-README.zh.md: 38e3c626073e9e90a16eebdb91af1e89d4da7c22
+README.md: 4c9665b680fe1922770403d88a04dffc755481ad
+README.zh.md: 8bf3db2cb1401427f29016f8dbddcd9d27ec9635
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index 6f47cd87af..4c9665b680 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -8,7 +8,7 @@ English | [中文](README.zh.md)
`UiConversation.events` is the single registry for event Definitions, and `UiConversation.views` is the single registry for target snapshot builders. Both registries reject duplicate keys, preserve registration order, return idempotent disposers, and rebuild existing bindings when their contribution roster changes. `UiConversation.binding(bindingOrSessionId)` returns one identity-stable Conversation binding for the current Session Controller binding. It does not open another event source.
-The adapter converts each `SessionEventEntry` to `ConversationEventInput` as `{ event, view? }`: the raw Session event is preserved and the envelope-level tool view is included only when present. Contiguous append and prepend revisions use incremental assembly; replacement windows and revision gaps rebuild from the complete loaded window. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`.
+The adapter converts each `SessionEventEntry` to a `{ event }` `ConversationEventInput` and preserves the raw Session event, including tool-result metadata. Contiguous append and prepend revisions use incremental assembly; replacement windows and revision gaps rebuild from the complete loaded window. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`.
Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry.
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index 38e3c62607..8bf3db2cb1 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -8,7 +8,7 @@
`UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开 event source。
-adapter 将每个 `SessionEventEntry` 转换成 `{ event, view? }` 形式的 `ConversationEventInput`:原始 Session event 保持不变,仅在 envelope-level tool view 存在时携带 `view`。连续 revision 的 append 和 prepend 使用增量组装;replace window 或 revision 断档从完整已加载窗口重建。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。
+adapter 将每个 `SessionEventEntry` 转换成 `{ event }` 形式的 `ConversationEventInput`,并保留原始 Session event,包括工具结果 metadata。连续 revision 的 append 和 prepend 使用增量组装;replace window 或 revision 断档从完整已加载窗口重建。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。
target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。
diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml
index c131cc33d9..77799f1afb 100644
--- a/packages/client/ui-deliverables/README.i18n.yaml
+++ b/packages/client/ui-deliverables/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md
-README.md: e7118eac75f31b3ffc3f2434371dba06f029d241
-README.zh.md: 62aa568bb7a14b5f703e60a0c263e851d01734ed
+README.md: ace08fae3a001080918973c23aa362080cd69066
+README.zh.md: 9df1664bc42c39012c2eebf397cb368b3b5c6260
diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md
index e7118eac75..ace08fae3a 100644
--- a/packages/client/ui-deliverables/README.md
+++ b/packages/client/ui-deliverables/README.md
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Produced-files and clickable-reference feature owner. The Node half registers final-response guidance with the system-prompt registry; the browser half registers the deliverables row a finished turn ends with into the chat view's `conversation.chat.turnTail` hole and links matching inline-code references in the closing prose. The shipped Web patch is the only composition that loads this package. Removing its one cordis.yml entry removes the guidance, row, and prose links together.
-`deliverablesDefinition` folds each Turn's successful mutation calls into engine-published `DeliverablesTurnData`; `producedForClosing` reads that data with the closing Assistant seq. The vocabulary is the mutation tools' own follow-along `locations`, never the closing prose: a produced file is listed whether or not the model remembered to name it. A mutation is recognized by render intent, not tool name — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a new mutation tool joins by declaring what it does. Reads, deletes, and failed calls contribute nothing; a path appears once per Turn in first-seen order. The Conversation Location index owns Turn membership, so a Turn that mutates and then ends without content text cannot spill into the next Turn's row.
+`deliverablesDefinition` folds each Turn's successful first-party mutation calls into engine-published `DeliverablesTurnData`; `producedForClosing` reads that data with the closing Assistant seq. The source is the validated raw arguments of `write`, `edit`, and the mutating `str_replace_editor` commands (`create`, `str_replace`, and `insert`), never presentation data or closing prose: a produced file is listed whether or not the model remembered to name it. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing; a path appears once per Turn in first-seen order. A new mutation tool needs an explicit Client contribution before it joins this list. The Conversation Location index owns Turn membership, so a Turn that mutates and then ends without content text cannot spill into the next Turn's row.
`ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label and one measured file lane. It shows the largest leading prefix that fits (up to six chips; basename text, full path as the `title`) while reserving the exact localized `+ N files` width, so the remainder stays visible without wrapping or horizontal scrolling. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. When files are hidden, a second-line **Show in folder** action opens the session workspace through that same owner path only while the page is loopback and the current Host handshake reports `canOpenPath`; direct remote Web and headless/container Linux Hosts omit the action by default. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md).
diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md
index 62aa568bb7..9df1664bc4 100644
--- a/packages/client/ui-deliverables/README.zh.md
+++ b/packages/client/ui-deliverables/README.zh.md
@@ -4,7 +4,7 @@
产出文件与可点击文件引用功能的属主。Node 侧向系统提示词 registry 注册最终回复指引;浏览器侧把已完成轮次末尾的产出文件行注册到 chat 视图的 `conversation.chat.turnTail` slot,并将收尾正文中匹配的行内代码引用转换为链接。正式提供的组合中只有 Web patch 加载本包;从 cordis.yml 中删去这一项会同时移除提示词、文件行与正文链接。
-`deliverablesDefinition` 把每个轮次中成功的修改调用折叠进引擎发布的 `DeliverablesTurnData`;`producedForClosing` 结合收尾 Assistant 的 seq 读取这份数据。依据的是修改工具自身附带的 `locations`,而不是收尾正文:无论模型是否记得点名,产出文件都会被列出。修改操作按渲染意图而非工具名识别:diff 卡片,或 `kind` 为 `edit` 的通用卡片(即 `str_replace_editor` 的 insert 操作所呈现的形态);因此新的修改工具只需声明自身行为即可加入。读取、删除和失败的调用不贡献任何条目;同一路径在一个轮次内按首见顺序只出现一次。Conversation Location 索引负责维护轮次归属关系,因此一个轮次即使先修改文件、随后没有正文内容就结束,也不会溢进下一个轮次的行里。
+`deliverablesDefinition` 把每个轮次中成功的第一方修改调用折叠进引擎发布的 `DeliverablesTurnData`;`producedForClosing` 结合收尾 Assistant 的 seq 读取这份数据。依据的是 `write`、`edit` 和 `str_replace_editor` 修改命令(`create`、`str_replace`、`insert`)经过校验的原始参数,而不是展示数据或收尾正文:无论模型是否记得点名,产出文件都会被列出。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目;同一路径在一个轮次内按首见顺序只出现一次。新的修改工具必须先增加显式 Client contribution,才能加入该列表。Conversation Location 索引负责维护轮次归属关系,因此一个轮次即使先修改文件、随后没有正文内容就结束,也不会溢进下一个轮次的行里。
`ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个低调的标签和一条经过测量的单行文件 lane。它展示能够放下的最大前缀(至多六个标签项;文本为文件名,完整路径作为 `title`),并为本地化后的精确 `+ N 个文件` 宽度预留空间,因此剩余计数始终可见,既不换行也不横向滚动。每个标签项经由属主提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。存在隐藏文件时,第二行的**在文件夹中显示**也经由同一属主路径打开会话 workspace;它只在页面使用 loopback 且当前 Host 握手报告 `canOpenPath` 时出现,直接远程 Web 与 headless/容器 Linux Host 默认均省略该操作。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md)。
diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml
index f31167fbde..a9bd41853a 100644
--- a/packages/client/ui-tool/README.i18n.yaml
+++ b/packages/client/ui-tool/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md
-README.md: 79b1bf27d848f015f132e38a635dd98c05a5dd87
-README.zh.md: 89469445b346dcb58a192a51cacdf9e66af50647
+README.md: 16fd06332d24b265ac7d6b7b000870686262b9c5
+README.zh.md: a21acab8ed6e7b52770a9ee6a63219c11b949c3d
diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md
index 79b1bf27d8..16fd06332d 100644
--- a/packages/client/ui-tool/README.md
+++ b/packages/client/ui-tool/README.md
@@ -12,7 +12,7 @@ Business UI packages register only their wire Tool names and atomic views. They
Each root and child wrapper preserves the `data-chat-anchor-key="call:"` and `data-chat-call-id` DOM contract used for paging and selection.
-The package also fills `conversation.details.tool` with `ToolDetails`. The row and details renderers share the same pure card models for `terminal`, `read`, `diff`, `search`, and `web` render intents. Unknown intent tags and malformed wire card data fall back to flattened Tool result text.
+The package also fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card. These models validate raw call arguments, result content, failure state, persisted metadata, the existing Code Dispatch `parentCallId`, and Session path facts; unsupported or malformed inputs fall back to flattened Tool result text.
Generic rows classify known Tool names into search, read, shell, write, edit, code, or generic variants. Running, successful, failed, and interrupted lifecycle states come only from the frozen call/result slice. File paths resolve against the session `cwd` only when the user invokes the Host open-file callback; presentation code does not read Session services.
@@ -28,9 +28,9 @@ ctx.slots.inject('tool.call.toolview', () =>
}, BusinessToolRow))
```
-The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, and plain `openFile`/`inspect` callbacks. Path summaries relativize to the session cwd first, then replace a leftover POSIX host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal session slot runtime share. It does not receive React nodes, Runtime services, or root/subcall knowledge.
+The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, and plain `openFile`/`inspect` callbacks. A Code Dispatch block retains its event's `parentCallId`; the field is absent on a root Session call, so row and Details card models preserve the generic flattened form for descendants without another placement flag. Path summaries relativize to the Session cwd first, then replace a leftover POSIX Host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal Session slot runtime share but no React node or Runtime service.
-This package currently owns the generic fallback and the built-in shell/pwsh, read, write/edit, grep/glob, web, todo, question, and Code Dispatch presentations. `ui-skill` demonstrates a business-owned registration for `skill`.
+This package currently owns the generic fallback and the built-in shell/pwsh, read, write/edit, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall`/`presentResult` values never enter the Client. `ui-skill` demonstrates a business-owned registration for `skill`.
Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), and [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md) notes.
diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md
index 89469445b3..a21acab8ed 100644
--- a/packages/client/ui-tool/README.zh.md
+++ b/packages/client/ui-tool/README.zh.md
@@ -12,7 +12,7 @@ Client 工具展示插件。`ui-conversation` 通过 `conversation.chat.node`
每个 root 和 child 包装层都保留 `data-chat-anchor-key="call:"` 与 `data-chat-call-id` DOM 约定,供分页和 selection 使用。
-本包还通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与详情 renderer 共用同一组面向 `terminal`、`read`、`diff`、`search` 和 `web` render intent 的纯 card model。未知的 intent 标签和格式错误的 wire card 数据都会回退为压平的工具结果文本。
+本包还通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、现有 Code Dispatch `parentCallId` 与 Session 路径事实;不受支持或格式错误的输入回退为压平的工具结果文本。
通用行把已知工具名称归类为 search、read、shell、write、edit、code 或 generic 变体。运行中、成功、失败和中断状态只来自冻结的 call/result slice。只有用户调用 Host 打开文件回调时,文件路径才相对会话 `cwd` 解析;展示代码不读取会话服务。
@@ -28,9 +28,9 @@ ctx.slots.inject('tool.call.toolview', () =>
}, BusinessToolRow))
```
-owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`,以及普通的 `openFile`、`inspect` 回调。路径摘要先相对会话 cwd 缩短,再把剩余的 POSIX 宿主家目录写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规的会话 slot 运行时共享数据,但不会收到 React node、运行时服务或 root/subcall 知识。
+owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`,以及普通的 `openFile`、`inspect` 回调。Code Dispatch block 保留其事件已有的 `parentCallId`;root Session call 没有该字段,因此 row 与 Details card model 无需另一项 placement 标志即可让 descendant 保持 generic 压平形态。路径摘要先相对 Session cwd 缩短,再把剩余的 POSIX Host home 写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规 Session slot runtime share,但不会收到 React node 或 runtime service。
-本包当前拥有 generic fallback,以及 shell/pwsh、read、write/edit、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
+本包当前拥有 generic fallback,以及 shell/pwsh、read、write/edit、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall`/`presentResult` 值不会进入 Client。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md) 和 [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) Agent Note 负责。
diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml
index dfeb0e2eb3..571012276f 100644
--- a/packages/core/tools/README.i18n.yaml
+++ b/packages/core/tools/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/tools/README.md
-README.md: 4f67dee611e6f28547ab276bd0884e787aeae0d9
-README.zh.md: 0137f295f2810930212dcb3f45e90af73bcf449c
+README.md: a140255a04187d4f2206df0f90c950d7608ee8ac
+README.zh.md: f519ba0a364f05ab63967c07ffd6681a5f62a70a
diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md
index 4f67dee611..a140255a04 100644
--- a/packages/core/tools/README.md
+++ b/packages/core/tools/README.md
@@ -20,7 +20,7 @@ tools:
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing or unsupported output declarations and a non-positive or non-finite `timeoutMs` fail at registration. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber.
- `ctx.tools.presentAs(mode: ToolPresentationMode): () => void` selects this agent's model-facing presentation, shadowing the `mode` config for that agent alone; it throws from a plain context (a process-wide presentation is the config field) and from a second declaration in the same scope. A code mode also registers that agent's own `tools:sdk` section. The catalog is unchanged — `schemas(agent)` still reports the agent's capabilities; only the assembly's tools collapse. Disposed with the calling fiber.
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
-- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
+- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent). A Host-local presenter consumer passes the calling agent when it must match the definition that executed.
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)).
- `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
- `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body. Around wrappers may replace only `signal`; the registry re-fuses the original caller signal immediately before the body.
@@ -49,7 +49,7 @@ The live registry pipeline has three transformable waterfalls, then the definiti
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../interaction/user-approval/README.md) when mounted and otherwise degrades to deny.
- `PostToolDecision` — accept may replace `content` or `value`, never both, and may attach `additionalContexts`; block turns feedback into a valueless failure. Content replacement preserves the canonical value and metadata. Value replacement is revalidated and rerenders content/metadata. Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision.
- `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.
-- `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation").
+- `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged values returned by the retained Host-side `presentCall` / `presentResult` extension (see "Host presentation descriptors"). The built-in Web Client does not consume these values.
### Extension points
@@ -104,14 +104,14 @@ Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. E
`JsonSchemaNode` is the raw counterpart shared by tool outputs, Code Mode generation, subagents, and workflows. It permits any JSON root, an annotation-only unconstrained JSON node, and exact-one `oneOf`; annotations must remain lossless JSON. `assertSupportedJsonSchema()` rejects unsupported constructs, while `validateJsonSchemaValue()` returns path-qualified violations. Subagents and workflows retain their caller-defined object-root requirement through `assertObjectJsonSchema()` and `ObjectJsonSchema`, not through a limitation in the shared vocabulary.
-### Tool-owned UI presentation
+### Host presentation descriptors
-Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names:
+Tools may retain pure `presentCall()` and `presentResult()` render intents for Host-local consumers:
- Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`.
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, `{ card: 'search', shape, title?, truncated, total, … }` (a completed discovery search — grouped-by-file matches for `shape: 'matches'` (grep) or a flat path list for `shape: 'paths'` (glob), with `truncated`/`total` so a UI never presents a capped result as complete; the view carries no result text and a search has no `card: 'search'` call-time analogue), `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lines` is `{ number, text }[]` keeping each file line number, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content).
-Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct top-level calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary.
+Returning `undefined` selects generic fallback for a consumer that invokes the presenter. Presenters depend only on their arguments and the durable result so a consumer can use them during live streaming or log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct top-level calls; that metadata persists with `tool/result` and is available both to `presentResult` and to clients that derive their own presentation, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and returns `undefined` on mismatch. Session Remote does not invoke or transport these presenters: the built-in Web Client selects its renderer through `tool.call.toolview` and derives card props from raw call arguments, result content, failure state, and persisted metadata. `dsh-tool-bash` and `dsh-tool-fs` remain reference presenter implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split, the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns the retained card vocabulary, and the [Client-derived presentation decision](../../../.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md) owns the Web transport split.
### Code Mode
diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md
index 0137f295f2..f519ba0a36 100644
--- a/packages/core/tools/README.zh.md
+++ b/packages/core/tools/README.zh.md
@@ -20,7 +20,7 @@ tools:
- `ctx.tools.register(definition: ToolDefinition): () => void`:注册一个受信任、带类型的同进程定义,其中必须包含规范的 `output` 声明。所在层由调用上下文的作用域决定:普通插件上下文会全局注册;agent 的 `agent.ctx` 只为该 agent 注册,并在此处遮蔽同名全局工具。同一层内名称重复会抛出;非原生模式还会拒绝保留的 `run_code` 传输名称。缺失或不受支持的输出声明,以及非正数或非有限的 `timeoutMs`,都会使注册失败。可选的同步 `finalizeContent` 回调会在调用开始时纳入快照;在所有流水线结果(包括实体化其他结果字段时发现的错误)规范化之后,它只能替换最终面向模型的内容。该注册会随调用方 fiber 一同 dispose(资源释放)。
- `ctx.tools.presentAs(mode: ToolPresentationMode): () => void`:为本 agent 选择面向模型的呈现方式,仅对该 agent 遮蔽 `mode` 配置;从普通上下文调用会抛出(进程级呈现方式是那个配置字段),同一 scope 内第二次声明也会抛出。code 类模式还会为该 agent 注册它自己的 `tools:sdk` 段。工具目录保持不变:`schemas(agent)` 仍会报告该 agent 的能力;只有组装结果中的工具列表会按所选呈现方式收束。随调用方 fiber dispose。
- `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md#security-and-authority-are-non-goals)。
-- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:返回指定作用域可见的解析结果,其中已应用名称遮蔽;被作用域限制排除的全局工具会被视为不存在。呈现器会传入发起调用的 agent,使卡片与实际执行内容一致。
+- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:返回指定作用域可见的解析结果,其中已应用名称遮蔽;被作用域限制排除的全局工具会被视为不存在。需要匹配实际执行 definition 的 Host 本地 presenter 消费方会传入发起调用的 agent。
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]`:返回该作用域可见的所有 schema(不含 `execute` 函数)。已交付工具的 schema 收录在 [docs/tool-catalog.md](../../../docs/tool-catalog.zh.md) 中;该目录通过启动每个工具插件并采集此方法的结果生成(参见[工具 schema 目录 Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md))。
- `ctx.tools.guard(guard: ToolGuard): () => void`:在 `tools/pre-execute` 之后注册单调同步执行守卫:返回理由会拒绝调用,返回 `undefined` 则保持原决定。普通上下文守卫全局生效;`agent.ctx` 守卫只对该 agent 生效。后续 waterfall(瀑布式事件)监听器无法将守卫的拒绝重新变为允许。随调用 fiber dispose。
- `ctx.tools.execute(exec)`:以无损方式快照并冻结参数,分配不透明 token,运行完整的策略/分发/结果流水线,然后在最终观测前独立快照权威结果。无效参数会进入同一结果路径,但不会到达策略或工具主体。环绕包装层只能替换 `signal`;注册表会在进入工具主体之前,立即将调用方的原始信号重新合并到当前信号中。
@@ -49,7 +49,7 @@ tools:
- `PreToolDecision`:`{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`。该类型有意不提供输入改写;`ask` 在挂载 [`ctx.approval`](../../interaction/user-approval/README.zh.md) 时由它处理,否则退化为拒绝。
- `PostToolDecision`:接受决定可以替换 `content` 或 `value`(不能同时替换),并可附加 `additionalContexts`;阻止决定会把反馈变成无值失败。替换内容会保留规范值和元数据。替换值会重新验证,并重新呈现内容/元数据。接受决定会先保留工具延迟的上下文,再附加决定上下文;阻止决定会丢弃工具延迟的上下文,只公开阻止决定显式提供的上下文。
- `ToolGuard`:`(execution) => string | undefined`;返回的字符串是最终单调拒绝理由,在可重排的前置执行 waterfall 之后、分发之前求值。
-- `ToolCallView` / `ToolResultView`:提供方无关、带 `card` 标签的呈现意图;工具通过 `presentCall` / `presentResult` 返回该意图,从而拥有 UI 呈现其自身调用的方式(参见「工具拥有的 UI 呈现」)。
+- `ToolCallView` / `ToolResultView`:保留的 Host 侧 `presentCall` / `presentResult` 扩展所返回的、提供方无关且带 `card` 标签的值(参见「Host 展示描述」)。内置 Web Client 不消费这些值。
@@ -106,14 +106,14 @@ ctx.tools.register(defineTool({
`JsonSchemaNode` 是工具输出、Code Mode 生成、subagent 和工作流共享的原始 JSON Schema 对应类型。它允许任意 JSON 根、仅含注解且不施加约束的 JSON 节点,以及恰好匹配一个分支的 `oneOf`;注解必须保持为无损 JSON。`assertSupportedJsonSchema()` 拒绝不受支持的构造,而 `validateJsonSchemaValue()` 返回带路径的违规信息。subagent 和工作流通过 `assertObjectJsonSchema()` 与 `ObjectJsonSchema` 保留调用方定义的对象根要求,而不是依赖共享词汇的限制。
-### 由工具定义的 UI 呈现
+### Host 展示描述
-工具可以选择通过纯函数 `presentCall()` 和 `presentResult()` 定义呈现意图,使 UI 无需针对工具名称编写特殊逻辑:
+工具可以为 Host 本地消费方保留纯函数 `presentCall()` 和 `presentResult()` 呈现意图:
- 调用视图为 `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`、`{ card: 'terminal', title, description?, cwd? }` 或 `{ card: 'diff', title, diffs, locations? }`。
- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }`、`{ card: 'search', shape, title?, truncated, total, … }`(已完成的发现型搜索——`shape: 'matches'`(grep)为按文件分组的匹配,`shape: 'paths'`(glob)为扁平路径列表,配 `truncated`/`total` 使 UI 永不把被截断的结果当作完整结果呈现;该视图不携带结果文本,且搜索没有 `card: 'search'` 的调用时对应视图)、`{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lines` 是 `{ number, text }[]`,保留每一行的文件行号,`content` 是去除读取结果外层封装后的正文,供不支持读取视图的 UI 回退显示)或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。
-返回 `undefined` 会选择通用回退。呈现器只依赖其参数和持久结果,因为 UI 会在实时流式输出和日志回放期间调用它们。`output.presentationMeta(args, value)` 为直接的顶层调用派生 JSON 元数据;该元数据随 `tool/result` 持久化并传回 `presentResult`,而规范值本身仍只存在于执行局部,绝不会回放。嵌套 Code 分发不会计算元数据。`defineTool` 会软验证较旧的日志参数并回退,而不会使回放崩溃。`dsh-tool-bash` 与 `dsh-tool-fs` 是参考实现;[规范输出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md) 规定值/呈现拆分,[呈现意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md) 规定卡片词汇。
+返回 `undefined` 会让调用 presenter 的消费方选择通用回退。presenter 只依赖其参数和持久结果,因此消费方可以在实时流式输出或日志回放期间使用。`output.presentationMeta(args, value)` 为直接顶层调用派生 JSON metadata;该 metadata 随 `tool/result` 持久化,既可供 `presentResult` 使用,也可供自行派生展示的 Client 使用,而规范值本身仍只存在于执行局部,绝不会回放。嵌套 Code 分发不会计算 metadata。`defineTool` 会软验证旧日志参数,并在不匹配时返回 `undefined`。Session Remote 不调用也不运输这些 presenter:内置 Web Client 通过 `tool.call.toolview` 选择 renderer,并从原始调用参数、结果内容、失败状态和持久 metadata 派生 card props。`dsh-tool-bash` 与 `dsh-tool-fs` 仍是 presenter 参考实现;[规范输出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md) 规定值/呈现拆分,[呈现意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md) 规定保留的卡片词汇,[Client 派生展示决定](../../../.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md) 规定 Web transport 的拆分。
### Code Mode
diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts
index e8558ee64d..3901480cb0 100644
--- a/packages/core/tools/src/index.ts
+++ b/packages/core/tools/src/index.ts
@@ -294,9 +294,10 @@ export interface ToolResult {
/** Whether the call failed. */
isError: boolean
/**
- * The tool-private presentation payload projected by its output declaration
- * and threaded verbatim from the `tool/result` event. Absent when the tool
- * declared no projector or the call was nested under a composite transport.
+ * The tool-private presentation payload projected by its output declaration.
+ * It is persisted verbatim on `tool/result` for Host presenters and Client
+ * renderers to narrow independently. Absent when the tool declared no
+ * projector or the call was nested under a composite transport.
*/
meta?: JsonValue
}
diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts
index fce084cf6a..0458ac1704 100644
--- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts
+++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts
@@ -647,7 +647,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionEventEntry',
- declaration: 'export interface SessionEventEntry {\n readonly event: SessionWireEvent;\n readonly view?: SessionToolView;\n}',
+ declaration: 'export interface SessionEventEntry {\n readonly event: SessionWireEvent;\n}',
},
{
name: 'SessionEventSource',
@@ -685,14 +685,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionStandardProps',
declaration: 'export interface SessionStandardProps {\n}',
},
- {
- name: 'SessionToolCallView',
- declaration: 'export type SessionToolCallView = (Omit & {\n readonly rawInput?: JsonValue;\n}) | TerminalCallView | DiffCallView;',
- },
- {
- name: 'SessionToolView',
- declaration: 'export type SessionToolView = {\n readonly for: \'call\';\n readonly view: SessionToolCallView;\n} | {\n readonly for: \'result\';\n readonly view: ToolResultView;\n};',
- },
{
name: 'SessionWireEvent',
declaration: 'export interface SessionWireEvent {\n readonly type: string;\n readonly seq: number;\n readonly time: number;\n readonly data: JsonValue;\n readonly ignorable?: true;\n readonly sourceEventSeqs?: number[];\n readonly surfaceOp?: SurfaceOp;\n}',
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index 9d0fc17eb8..6f7d362aa8 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -1275,7 +1275,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
signature: '@Remote(\'page\') page(request: SessionPageRequest, signal: AbortSignal): Promise',
description: 'Read one cold-safe, message-aligned Session history page.',
- parameters: [{ name: 'request', description: 'durable address, backward cursor, and page budget.' }, { name: 'signal', description: 'cancellation for persistence and presentation reads.' }],
+ parameters: [{ name: 'request', description: 'durable address, backward cursor, and page budget.' }, { name: 'signal', description: 'cancellation for persistence reads.' }],
returns: 'one chronological page and optional latest projections.',
},
{
@@ -4415,7 +4415,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionEventEntry',
- declaration: 'export interface SessionEventEntry {\n readonly event: SessionWireEvent;\n readonly view?: SessionToolView;\n}',
+ declaration: 'export interface SessionEventEntry {\n readonly event: SessionWireEvent;\n}',
},
{
name: 'SessionEventMap',
@@ -4753,14 +4753,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionTitleUserMessage',
declaration: 'export interface SessionTitleUserMessage {\n readonly seq: number;\n readonly text: string;\n}',
},
- {
- name: 'SessionToolCallView',
- declaration: 'export type SessionToolCallView = (Omit & {\n readonly rawInput?: JsonValue;\n}) | TerminalCallView | DiffCallView;',
- },
- {
- name: 'SessionToolView',
- declaration: 'export type SessionToolView = {\n readonly for: \'call\';\n readonly view: SessionToolCallView;\n} | {\n readonly for: \'result\';\n readonly view: ToolResultView;\n};',
- },
{
name: 'SessionUpdateQueueRequest',
declaration: 'export interface SessionUpdateQueueRequest {\n readonly sessionId: SessionId;\n readonly itemId: MessageId;\n readonly action: QueueAction;\n}',
From a42c0b523ae558ebe51131717d184363a9c82bc6 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Sun, 23 Aug 2026 21:11:39 +0800
Subject: [PATCH 2/8] refactor(session): stream raw tool events
---
apps/web/tests/seeded-history.e2e.ts | 12 +-
packages/api/remotes/src/client/index.ts | 2 +-
packages/api/session-controller/package.json | 5 +-
.../src/client/sessions/session.ts | 8 -
.../api/session-controller/src/history.ts | 149 +------
packages/api/session-controller/src/index.ts | 3 +-
packages/api/session-controller/src/types.ts | 20 +-
.../tests/controller.host.spec.ts | 5 +
.../tests/event-script.client.ts | 2 +-
...s => session-history-journal.host.spec.ts} | 232 +++-------
.../tests/session.client.spec.ts | 42 +-
.../tests/transport.host.spec.ts | 127 ------
.../session-controller/tsconfig.client.json | 1 -
.../api/session-controller/tsconfig.host.json | 1 -
packages/client/connection/package.json | 6 +-
packages/client/connection/src/client/api.ts | 1 -
.../client/connection/src/client/fixture.ts | 400 +++++++-----------
.../client/connection/src/client/index.ts | 1 -
.../connection/tests/fixture.client.spec.ts | 44 +-
pnpm-lock.yaml | 6 -
20 files changed, 300 insertions(+), 767 deletions(-)
rename packages/api/session-controller/tests/{session-history-view.host.spec.ts => session-history-journal.host.spec.ts} (51%)
diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts
index 2b2d194657..de689175d9 100644
--- a/apps/web/tests/seeded-history.e2e.ts
+++ b/apps/web/tests/seeded-history.e2e.ts
@@ -258,13 +258,11 @@ describe('web e2e: seeded history renders through cold resume', () => {
// The seed carries a session/title event: the title unit is host-plane, so
// it folds the detached log and serves the value with nothing composed.
expect(typeof projections?.values.title).toBe('string')
- // `todos` IS here, as its empty fold (null). Its unit is registered by
- // `tool-todo` inside the default preset's STANDING mount, which the read
- // itself ensures — deterministically, not because some unrelated session
- // happens to be composed. A present-but-null key is what keeps the
- // client's "omitted key = capability absent → clear the row" rule from
- // wiping preset-owned projections on cold reads.
- expect(projections?.values).toHaveProperty('todos', null)
+ // `todos` is absent because its unit belongs to the agent preset and this
+ // directly seeded session never composed that preset. History computes
+ // the baseline through the standard projection registry without mounting
+ // an Agent composition as a read side effect.
+ expect(projections?.values).not.toHaveProperty('todos')
// The session-stats unit is a shipped web-app bundle row: whole-log
// turn/step counts ride the same tail block (the stats strip's source).
const sessionStats = projections?.values.sessionStats as { turns: number; steps: number } | undefined
diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts
index db78433d82..528d78dc89 100644
--- a/packages/api/remotes/src/client/index.ts
+++ b/packages/api/remotes/src/client/index.ts
@@ -52,7 +52,7 @@ export type {
MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection,
RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId,
SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk,
- SubagentAddress, SubagentCatalog, ToolCallView, ToolResultView,
+ SubagentAddress, SubagentCatalog,
} from '@deepseek-ai/dsh-client-connection/client'
export type {} from '@deepseek-ai/dsh-api-gateway/client'
export type {} from '@deepseek-ai/dsh-cordis-host-runner/remote'
diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json
index 4079dd712a..4fb6a87747 100644
--- a/packages/api/session-controller/package.json
+++ b/packages/api/session-controller/package.json
@@ -96,7 +96,6 @@
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
- "@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/dsh-workspace": "workspace:^",
@@ -106,8 +105,7 @@
"@deepseek-ai/dsh-jobs": { "optional": true },
"@deepseek-ai/dsh-session-persistence": { "optional": true },
"@deepseek-ai/dsh-session-projection": { "optional": true },
- "@deepseek-ai/dsh-session-projection-cache": { "optional": true },
- "@deepseek-ai/dsh-tools": { "optional": true }
+ "@deepseek-ai/dsh-session-projection-cache": { "optional": true }
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
@@ -131,7 +129,6 @@
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
- "@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/dsh-util-crypto": "workspace:^",
diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts
index e894517e59..d51d475474 100644
--- a/packages/api/session-controller/src/client/sessions/session.ts
+++ b/packages/api/session-controller/src/client/sessions/session.ts
@@ -22,7 +22,6 @@ import type {
SessionQueuedItem,
SessionRequestId,
SessionError,
- SessionToolView,
} from '../../types.ts'
import type { ClientFailure, ClientResult } from '../contract/result.ts'
import { transportResult } from '../contract/result.ts'
@@ -74,9 +73,6 @@ export interface SessionOptions {
export class Session implements SessionFace {
// ---- Window and derived state (all private; the snapshot is the only read API) ----
private eventWindow: SessionEvent[] = []
- /** Wire views aligned with `eventWindow` by index (envelope annotations; undefined = no view).
- * Kept parallel so `eventWindow` remains the raw log slice (model-visible ⟺ logged). */
- private views: (SessionToolView | undefined)[] = []
private baseSeq = 0
private hasMore = false
private openState: OpenState = 'cold'
@@ -405,7 +401,6 @@ export class Session implements SessionFace {
this.openState = 'cold'
this.openError = null
this.eventWindow = []
- this.views = []
this.baseSeq = 0
this.notifier.markDirty()
await this.open()
@@ -582,7 +577,6 @@ export class Session implements SessionFace {
/** Replace the complete contiguous window and apply page-owned projection metadata. */
private installWindow(entries: readonly SessionEventEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
this.eventWindow = entries.map(entry => entry.event as SessionEvent)
- this.views = entries.map(entry => entry.view)
this.baseSeq = this.eventWindow[0]?.seq ?? 0
this.hasMore = hasMore
if (this.eventWindow.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false
@@ -594,7 +588,6 @@ export class Session implements SessionFace {
/** Prepend one stream-validated history page. */
private prependWindow(entries: readonly SessionEventEntry[], hasMore: boolean): void {
this.eventWindow = [...entries.map(entry => entry.event as SessionEvent), ...this.eventWindow]
- this.views = [...entries.map(entry => entry.view), ...this.views]
this.baseSeq = this.eventWindow[0]?.seq ?? 0
this.hasMore = hasMore
this.eventSource.prepend(entries, hasMore)
@@ -604,7 +597,6 @@ export class Session implements SessionFace {
private appendLive(entry: SessionEventEntry): boolean {
const event = entry.event as SessionEvent
this.eventWindow.push(event)
- this.views.push(entry.view)
const awaitingFirstTurn = this.firstPromptPendingTurn
if (event.type === 'turn/start') this.firstPromptPendingTurn = false
const queueChanged = this.queueMirror.acceptDurable(event)
diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts
index ab7bce2525..e30b4f1139 100644
--- a/packages/api/session-controller/src/history.ts
+++ b/packages/api/session-controller/src/history.ts
@@ -1,14 +1,10 @@
/** Cold Session history pagination and live-event source. */
import type { Context } from '@deepseek-ai/cordis'
-import type { Agent } from '@deepseek-ai/dsh-agent'
-import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
-import type { ScopeKey } from '@deepseek-ai/dsh-scope'
import { foldSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
-import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import type {
SessionAddress,
@@ -19,34 +15,21 @@ import type {
SessionPageRequest,
SessionProjectionsBlock,
SessionProjectionValues,
- SessionToolCallView,
- SessionToolView,
SessionWireEvent,
} from './types.ts'
const DEFAULT_MAX_MESSAGES = 50
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
-interface ToolCallData {
- readonly callId: string
- readonly name: string
- readonly arguments: string
-}
-
type SessionSource =
| { readonly kind: 'attached'; readonly session: Session }
| { readonly kind: 'detached'; readonly header: SessionHeader; readonly events: readonly SessionEvent[] }
-interface BufferedEvent {
- readonly session: Session
- readonly event: SessionEvent
-}
-
/** Implements cold-safe history operations delegated by the Session Controller. */
export class SessionHistoryController {
private readonly closeFollowers = new Set<() => void>()
- /** @param ctx - Host context carrying Session, persistence, presenter, and projection services. */
+ /** @param ctx - Host context carrying Session, persistence, and projection services. */
constructor(private readonly ctx: Context) {
ctx.effect(() => () => {
for (const close of this.closeFollowers) close()
@@ -57,7 +40,7 @@ export class SessionHistoryController {
/**
* Read one message-aligned history page without activating an Agent.
* @param request - durable address and backwards-page cursor.
- * @param signal - caller cancellation for persistence and preset reads.
+ * @param signal - caller cancellation for persistence reads.
* @returns a contiguous event page and a projection baseline on tail reads.
*/
async page(request: SessionPageRequest, signal: AbortSignal): Promise {
@@ -77,11 +60,8 @@ export class SessionHistoryController {
if ((events.at(-1)?.seq ?? -1) !== request.throughSeq) {
reject('internal', `session log does not contain through seq ${String(request.throughSeq)}`, {})
}
- const scope = await this.presenterScopeFor(addressId(request.address), source, events)
- signal.throwIfAborted()
const page = paginate(events, request.beforeSeq, request.maxMessages ?? DEFAULT_MAX_MESSAGES)
- const argsFor = (callId: string) => backscanArgs(page.events, callId)
- const entries = page.events.map(event => entryFor(this.ctx, event, argsFor, scope))
+ const entries = page.events.map(entryFor)
const projections = request.beforeSeq === undefined
? this.projectionsFor(request.address, source, events)
: undefined
@@ -102,12 +82,7 @@ export class SessionHistoryController {
validateFollowRequest(request)
const { address, afterSeq } = request
const target = addressId(address)
- const buffered: BufferedEvent[] = []
- const openCalls = new Map()
- let fallbackEvents: readonly SessionEvent[] = []
- const argsFor = (callId: string): { readonly name: string; readonly args: unknown } | undefined => (
- openCalls.get(callId) ?? backscanArgs(fallbackEvents, callId)
- )
+ const buffered: SessionEvent[] = []
let wake: (() => void) | undefined
const notify = (): void => {
const resume = wake
@@ -122,7 +97,7 @@ export class SessionHistoryController {
this.closeFollowers.add(close)
const disposeEvent = this.ctx.on('session/event', (session, event) => {
if (session.id !== target) return
- buffered.push({ session, event })
+ buffered.push(event)
notify()
}, { global: true })
const disposeCreated = this.ctx.on('session/created', (session) => {
@@ -130,7 +105,7 @@ export class SessionHistoryController {
// Session construction appends session/end-seed before attachment, so the
// marker has no session/event notification. Earlier session/created listeners
// may publish later setup events first; this suffix must precede those notifications.
- const suffix = session.events.slice(session.firstLiveSeq).map(event => ({ session, event }))
+ const suffix = session.events.slice(session.firstLiveSeq)
buffered.unshift(...suffix)
notify()
}, { global: true })
@@ -139,8 +114,6 @@ export class SessionHistoryController {
try {
const source = await this.sourceFor(address, signal)
const events = [...sourceEvents(source)]
- fallbackEvents = events
- const scope = await this.presenterScopeFor(target, source, events)
signal.throwIfAborted()
const cursor = events.at(-1)?.seq ?? -1
if (afterSeq !== undefined && afterSeq > cursor) {
@@ -155,7 +128,7 @@ export class SessionHistoryController {
reject('internal', `session event replay skipped seq ${String(nextSeq)}`, {})
}
nextSeq++
- yield { type: 'event', ...entryFor(this.ctx, event, argsFor, scope) }
+ yield { type: 'event', ...entryFor(event) }
}
}
while (!follower.closed && !signal.aborted) {
@@ -164,26 +137,12 @@ export class SessionHistoryController {
await new Promise((resolve) => { wake = resolve })
continue
}
- if (item.event.seq < nextSeq) continue
- if (item.event.seq !== nextSeq) {
+ if (item.seq < nextSeq) continue
+ if (item.seq !== nextSeq) {
reject('internal', `session event stream skipped seq ${String(nextSeq)}`, {})
}
nextSeq++
- if (item.event.type === 'tool/call') {
- const data = item.event.data as ToolCallData
- const call = parseToolCall(data)
- /* v8 ignore next -- malformed durable tool arguments intentionally skip the live presentation cache. */
- if (call !== undefined) openCalls.set(data.callId, call)
- } else if (item.event.type === 'turn/end') {
- openCalls.clear()
- }
- if (item.event.type === 'tool/result'
- && !openCalls.has(item.event.data.message.source.callId)) {
- fallbackEvents = item.session.events
- }
- const liveScope: Agent | undefined = this.ctx.get('agents')?.get(target)
- const entry = entryFor(this.ctx, item.event, argsFor, liveScope ?? scope)
- yield { type: 'event', ...entry }
+ yield { type: 'event', ...entryFor(item) }
}
} finally {
this.closeFollowers.delete(close)
@@ -214,25 +173,6 @@ export class SessionHistoryController {
return { kind: 'detached', header: inspected.meta, events: inspected.events }
}
- private async presenterScopeFor(
- sessionId: SessionId,
- source: SessionSource,
- events: readonly SessionEvent[],
- ): Promise {
- const live = this.ctx.get('agents')?.get(sessionId)
- if (live !== undefined) return live
- const presets = this.ctx.get('agentPresets')
- if (presets === undefined) return undefined
- const session = source.kind === 'attached'
- ? { header: source.session.header, events }
- : { header: source.header, events }
- try {
- return await presets.standingKeyFor(resolveSessionPreset(session))
- } catch {
- return undefined
- }
- }
-
private projectionsFor(
address: SessionAddress,
source: SessionSource,
@@ -368,76 +308,9 @@ function paginate(
return { events: window.filter(event => event.seq >= cut), hasMore: cut > 0 }
}
-function entryFor(
- ctx: Context,
- event: SessionEvent,
- argsFor: (callId: string) => { readonly name: string; readonly args: unknown } | undefined,
- scope?: ScopeKey,
-): SessionEventEntry {
- const view = viewFor(ctx, event, argsFor, scope)
+function entryFor(event: SessionEvent): SessionEventEntry {
return {
// Session.append validates and freezes event data as JSON before publication.
event: event as unknown as SessionWireEvent,
- ...(view === undefined ? {} : { view }),
}
}
-
-function viewFor(
- ctx: Context,
- event: SessionEvent,
- argsFor: (callId: string) => { readonly name: string; readonly args: unknown } | undefined,
- scope?: ScopeKey,
-): SessionToolView | undefined {
- if (event.type !== 'tool/call' && event.type !== 'tool/result') return undefined
- const tools = ctx.get('tools')
- /* v8 ignore next -- deployments without the optional Tools service omit presentation metadata. */
- if (tools === undefined) return undefined
- try {
- if (event.type === 'tool/call') {
- const data = event.data as ToolCallData
- const view: ToolCallView | undefined = tools.get(data.name, scope)?.presentCall?.(JSON.parse(data.arguments))
- return view === undefined ? undefined : { for: 'call', view: jsonView(view) }
- }
- const [result] = event.data.message.content
- const call = argsFor(event.data.message.source.callId)
- if (call === undefined) return undefined
- const view: ToolResultView | undefined = tools.get(call.name, scope)?.presentResult?.(call.args, {
- content: result.content,
- isError: result.isError === true,
- ...(event.data.meta === undefined ? {} : { meta: event.data.meta }),
- })
- return view === undefined ? undefined : { for: 'result', view: jsonView(view) }
- } catch (error) {
- ctx.logger.warn(`session: presenter failed for ${event.type}: ${String(error)}`)
- }
- return undefined
-}
-
-function backscanArgs(
- events: readonly SessionEvent[],
- callId: string,
-): { readonly name: string; readonly args: unknown } | undefined {
- for (let index = events.length - 1; index >= 0; index--) {
- const event = events[index] as SessionEvent
- if (event.type !== 'tool/call') continue
- const data = event.data as ToolCallData
- if (data.callId !== callId) continue
- return parseToolCall(data)
- }
- return undefined
-}
-
-function parseToolCall(data: ToolCallData): { readonly name: string; readonly args: unknown } | undefined {
- try {
- return { name: data.name, args: JSON.parse(data.arguments) }
- } catch {
- return undefined
- }
-}
-
-function jsonView(view: ToolCallView): SessionToolCallView
-function jsonView(view: ToolResultView): ToolResultView
-function jsonView(view: ToolCallView | ToolResultView): SessionToolCallView | ToolResultView {
- const encoded = JSON.stringify(view)
- return JSON.parse(encoded) as SessionToolCallView | ToolResultView
-}
diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts
index b4b6b4c53a..d4c9cbd8e1 100644
--- a/packages/api/session-controller/src/index.ts
+++ b/packages/api/session-controller/src/index.ts
@@ -69,7 +69,6 @@ export class SessionController extends TypertRemoteService {
'llm',
'sessions',
'sessionQuery',
- 'tools',
'typert',
'workspaceRegistry',
]
@@ -260,7 +259,7 @@ export class SessionController extends TypertRemoteService {
/**
* Read one cold-safe, message-aligned Session history page.
* @param request - durable address, backward cursor, and page budget.
- * @param signal - cancellation for persistence and presentation reads.
+ * @param signal - cancellation for persistence reads.
* @returns one chronological page and optional latest projections.
*/
@Remote('page')
diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts
index 56e0d7d91a..adcf8f6c06 100644
--- a/packages/api/session-controller/src/types.ts
+++ b/packages/api/session-controller/src/types.ts
@@ -10,12 +10,6 @@ import type { JsonValue, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/t
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
-import type {
- DiffCallView,
- GenericCallView,
- TerminalCallView,
- ToolResultView,
-} from '@deepseek-ai/dsh-tools/presentation'
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
@@ -333,21 +327,9 @@ export type SessionAddress =
readonly mode: 'one-shot' | 'continuable'
}
-/** JSON-safe call render intent crossing the Session Remote boundary. */
-export type SessionToolCallView =
- | (Omit & { readonly rawInput?: JsonValue })
- | TerminalCallView
- | DiffCallView
-
-/** Host-computed render intent accompanying one tool event. */
-export type SessionToolView =
- | { readonly for: 'call'; readonly view: SessionToolCallView }
- | { readonly for: 'result'; readonly view: ToolResultView }
-
-/** One raw Session event plus its optional transient render intent. */
+/** One raw Session event in the Remote journal. */
export interface SessionEventEntry {
readonly event: SessionWireEvent
- readonly view?: SessionToolView
}
/** Session event wire form; durable readers own recognition of merge-extensible event names. */
diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts
index 61b926d93a..f34745e326 100644
--- a/packages/api/session-controller/tests/controller.host.spec.ts
+++ b/packages/api/session-controller/tests/controller.host.spec.ts
@@ -5,6 +5,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import { describe, expect, it, vi } from 'vitest'
+import SessionController from '../src/index.ts'
import { createSessionTestController } from './test-remote.ts'
const defaults = {
@@ -13,6 +14,10 @@ const defaults = {
}
describe('SessionController facade', () => {
+ it('does not require the Tools service', () => {
+ expect(SessionController.inject).not.toContain('tools')
+ })
+
it('owns Host service methods and publishes Agent lifecycle projections', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
diff --git a/packages/api/session-controller/tests/event-script.client.ts b/packages/api/session-controller/tests/event-script.client.ts
index 0781621296..6aba942774 100644
--- a/packages/api/session-controller/tests/event-script.client.ts
+++ b/packages/api/session-controller/tests/event-script.client.ts
@@ -142,7 +142,7 @@ export function plainTurn(startSeq: number, turn: number, ask: string, answer: s
]
}
-/** Wrap raw events as view-less history entries (the wire shape history returns). */
+/** Wrap raw events in the journal envelope returned by history. */
export function entries(events: readonly SessionEvent[]): { event: SessionEvent }[] {
return events.map(event => ({ event }))
}
diff --git a/packages/api/session-controller/tests/session-history-view.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts
similarity index 51%
rename from packages/api/session-controller/tests/session-history-view.host.spec.ts
rename to packages/api/session-controller/tests/session-history-journal.host.spec.ts
index 9de6a00985..b4c442d9f2 100644
--- a/packages/api/session-controller/tests/session-history-view.host.spec.ts
+++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts
@@ -1,38 +1,15 @@
-/**
- * Tool-card view computation over Session Controller history and follow: three standard card types
- * arrive on the frame, a presenterless tool ships no view field, a call-only
- * presenter keeps raw result content out of the view payload, and a throwing
- * presenter soft-falls to no view (the event still ships). Result pairing
- * works for both paged and live entries.
- */
+/** Raw Session journal transport and message-aligned pagination coverage. */
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
-import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
-import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
-import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
-import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
-import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
import { createSessionTestRemote } from './test-remote.ts'
-const reply = (text: string): Promise => Promise.resolve([{ type: 'text', text }])
-
-function tool(name: string, presenters: Pick): ToolDefinition {
- return defineContentToolFixture({
- name,
- description: `tool ${name}`,
- parameters: {},
- execute: () => reply(`ran:${name}`),
- ...presenters,
- })
-}
-
/** Append a production-shaped human prompt to the session surface. */
function appendUserText(session: Session, text: string): SessionEvent {
return session.append('user/message', createUserMessage({
@@ -65,27 +42,7 @@ function appendExtension(session: Session, type: string, data: unknown): Session
async function harness(): Promise<{ ctx: Context }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
- await ctx.plugin(SystemPrompt, { persona: '' })
- await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
- ctx.tools.register(tool('gen', {
- presentCall: () => ({ card: 'generic', title: 'gen call' }),
- presentResult: (_args, result) => ({ card: 'generic', title: result.isError ? 'gen failed' : 'gen done' }),
- }))
- ctx.tools.register(tool('term', {
- presentCall: args => ({ card: 'terminal', title: (args as { cmd?: string }).cmd ?? '' }),
- presentResult: () => ({ card: 'terminal', output: 'done' }),
- }))
- ctx.tools.register(tool('diffy', {
- presentCall: () => ({ card: 'diff', title: 'Write f.txt', diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] }),
- }))
- ctx.tools.register(tool('call-only', {
- presentCall: () => ({ card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' }),
- }))
- ctx.tools.register(tool('plain', {}))
- ctx.tools.register(tool('boom', {
- presentCall: () => { throw new Error('presenter exploded') },
- }))
return { ctx }
}
@@ -119,73 +76,37 @@ async function openFollow(
return { [Symbol.asyncIterator]: () => iterator }
}
-describe('Session history view computation', () => {
- it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
+describe('Session history raw journal', () => {
+ it('follows raw tool events and preserves result metadata without a Tools service', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create()
const history = new SessionHistoryController(ctx)
const abort = new AbortController()
const stream = await openFollow(history, session.id, abort.signal)
- const collected = collect(stream, 9, abort)
- const rawResult = `RAW_RESULT:${'x'.repeat(64 * 1024)}`
-
- session.append('turn/start', { turn: 1 })
- session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' })
- session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' })
- session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' })
- session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-call-only'), name: 'call-only', arguments: '{}' })
- session.append('tool/result', {
+ const collected = collect(stream, 2, abort)
+ const call = session.append('tool/call', {
+ turn: 1, step: 1, callId: CallId('raw-call'), name: 'custom', arguments: '{malformed',
+ })
+ const result = session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
- callId: CallId('c-call-only'),
- content: [{ type: 'text', text: rawResult }],
- isError: false,
- }),
- }, { surfaceOp: 'append' })
- session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-plain'), name: 'plain', arguments: '{}' })
- session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-boom'), name: 'boom', arguments: '{}' })
- session.append('tool/result', {
- turn: 1, step: 1,
- message: createToolResultMessage({
- callId: CallId('c-gen'),
- content: [{ type: 'text', text: 'ok' }],
+ callId: CallId('raw-call'),
+ content: [{ type: 'text', text: 'raw output' }],
isError: false,
}),
+ meta: { nested: { count: 2 }, paths: ['a.ts', 'b.ts'] },
}, { surfaceOp: 'append' })
const frames = await collected
- const events = frames.filter(f => f.type === 'event')
- const byCall = new Map(events
- .filter(f => f.event.type === 'tool/call' || f.event.type === 'tool/result')
- .map(f => [
- `${f.event.type}:${f.event.type === 'tool/call'
- ? (f.event.data as unknown as SessionEvent<'tool/call'>['data']).callId
- : (f.event.data as unknown as SessionEvent<'tool/result'>['data']).message.source.callId}`,
- f,
- ]))
-
- expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } })
- expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } })
- expect(byCall.get('tool/call:c-diff')?.view?.view.card).toBe('diff')
- expect(byCall.get('tool/call:c-call-only')?.view).toEqual({
- for: 'call',
- view: { card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' },
- })
- const callOnlyResult = byCall.get('tool/result:c-call-only')
- expect('view' in (callOnlyResult ?? {})).toBe(false)
- const serializedResult = JSON.stringify(callOnlyResult)
- expect(serializedResult.indexOf(rawResult)).toBeGreaterThanOrEqual(0)
- expect(serializedResult.indexOf(rawResult)).toBe(serializedResult.lastIndexOf(rawResult))
- // No presenter → the frame carries no view property at all.
- expect('view' in (byCall.get('tool/call:c-plain') ?? {})).toBe(false)
- // Throwing presenter → soft-fall: event ships, no view.
- expect(byCall.get('tool/call:c-boom')).toBeDefined()
- expect('view' in (byCall.get('tool/call:c-boom') ?? {})).toBe(false)
- // Result pairing through the live table: presentResult saw the call's args.
- expect(byCall.get('tool/result:c-gen')?.view).toEqual({ for: 'result', view: { card: 'generic', title: 'gen done' } })
+ expect(frames).toEqual([
+ { type: 'event', event: call },
+ { type: 'event', event: result },
+ ])
+ expect((frames[1] as Extract).event.data)
+ .toMatchObject({ meta: { nested: { count: 2 }, paths: ['a.ts', 'b.ts'] } })
})
- it('pairs live results from the open-call table without rescanning Session history', async () => {
+ it('follows live results without rescanning Session history', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create()
const history = new SessionHistoryController(ctx)
@@ -197,7 +118,7 @@ describe('Session history view computation', () => {
turn: 1, step: 1, callId: CallId('live-fast'), name: 'term', arguments: '{"cmd":"pwd"}',
})
await expect(iterator.next()).resolves.toMatchObject({
- value: { type: 'event', view: { for: 'call', view: { card: 'terminal', title: 'pwd' } } },
+ value: { type: 'event', event: { type: 'tool/call', data: { callId: 'live-fast' } } },
})
const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => {
@@ -213,7 +134,7 @@ describe('Session history view computation', () => {
}),
}, { surfaceOp: 'append' })
await expect(iterator.next()).resolves.toMatchObject({
- value: { type: 'event', view: { for: 'result', view: { card: 'terminal', output: 'done' } } },
+ value: { type: 'event', event: { type: 'tool/result', data: { message: { source: { callId: 'live-fast' } } } } },
})
} finally {
events.mockRestore()
@@ -223,53 +144,22 @@ describe('Session history view computation', () => {
}
})
- it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
+ it('serves raw call and result entries without parsing tool arguments', async () => {
const { ctx } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create()
- // history resolves the agent first; a live structural stub is enough (only
- // .session is read on this path).
- ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
- session.append('turn/start', { turn: 1 })
- session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-term'), name: 'term', arguments: '{"cmd":"ls"}' })
- // meta rides through to presentResult's ToolResult (the spread arm).
- session.append('tool/result', {
+ const start = session.append('turn/start', { turn: 1 })
+ const call = session.append('tool/call', {
+ turn: 1, step: 1, callId: CallId('history-call'), name: 'custom', arguments: '{broken',
+ })
+ const result = session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
- callId: CallId('h-term'),
- content: [{ type: 'text', text: 'ok' }],
- isError: false,
- }),
- meta: { n: 1 },
- }, { surfaceOp: 'append' })
- // Unpaired result: no tool/call with this id anywhere in the page.
- session.append('tool/result', {
- turn: 1, step: 1,
- message: createToolResultMessage({
- callId: CallId('h-orphan'),
- content: [{ type: 'text', text: 'x' }],
- isError: false,
- }),
- }, { surfaceOp: 'append' })
- // Paired, but the call's stored arguments do not parse: backscan soft-falls.
- session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-bad'), name: 'term', arguments: '{broken' })
- session.append('tool/result', {
- turn: 1, step: 1,
- message: createToolResultMessage({
- callId: CallId('h-bad'),
- content: [{ type: 'text', text: 'y' }],
- isError: false,
- }),
- }, { surfaceOp: 'append' })
- // Presenterless tool: pairing succeeds but presentResult is absent.
- session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-plain'), name: 'plain', arguments: '{}' })
- session.append('tool/result', {
- turn: 1, step: 1,
- message: createToolResultMessage({
- callId: CallId('h-plain'),
- content: [{ type: 'text', text: 'z' }],
- isError: false,
+ callId: CallId('history-call'),
+ content: [{ type: 'text', text: 'failed raw output' }],
+ isError: true,
}),
+ meta: { persisted: true, count: 3 },
}, { surfaceOp: 'append' })
const response = await remote.page({
@@ -278,27 +168,17 @@ describe('Session history view computation', () => {
})
expect(response.ok).toBe(true)
if (!response.ok) throw new Error('unreachable')
- const entries = response.value.events
- const byKey = new Map(entries
- .filter(entry => entry.event.type === 'tool/call' || entry.event.type === 'tool/result')
- .map(entry => [
- `${entry.event.type}:${entry.event.type === 'tool/call'
- ? (entry.event.data as unknown as SessionEvent<'tool/call'>['data']).callId
- : (entry.event.data as unknown as SessionEvent<'tool/result'>['data']).message.source.callId}`,
- entry,
- ]))
- expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } })
- expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
- expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false)
- expect('view' in (byKey.get('tool/result:h-bad') ?? {})).toBe(false)
- expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
+ expect(response.value.events).toEqual([
+ { event: start },
+ { event: call },
+ { event: result },
+ ])
})
it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => {
const { ctx } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create()
- ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1 })
const first = appendUserText(session, 'first prompt')
appendAssistantText(session, 'first reply', 1)
@@ -348,7 +228,6 @@ describe('Session history view computation', () => {
const { ctx } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create()
- ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1 })
const sources = Array.from({ length: 128 }, (_unused, index) => session.append('assistant/chunk', {
turn: 1,
@@ -384,28 +263,41 @@ describe('Session history view computation', () => {
}
})
- it('pairs a followed result after turn/end from the addressed Session log', async () => {
+ it('follows a result after turn/end without reading the addressed Session log', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create()
const history = new SessionHistoryController(ctx)
const abort = new AbortController()
const stream = await openFollow(history, session.id, abort.signal)
- const collected = collect(stream, 4, abort)
+ const iterator = stream[Symbol.asyncIterator]()
session.append('turn/start', { turn: 1 })
+ await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'turn/start' } } })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
+ await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'tool/call' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- session.append('tool/result', {
- turn: 1, step: 1,
- message: createToolResultMessage({
- callId: CallId('c-late'),
- content: [{ type: 'text', text: 'ok' }],
- isError: false,
- }),
- }, { surfaceOp: 'append' })
-
- const frames = await collected
- const result = frames.find(f => f.type === 'event' && f.event.type === 'tool/result')
- expect(result?.type === 'event' && result.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
+ await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'turn/end' } } })
+ const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => {
+ throw new Error('live result rescanned Session history')
+ })
+ try {
+ const result = session.append('tool/result', {
+ turn: 1, step: 1,
+ message: createToolResultMessage({
+ callId: CallId('c-late'),
+ content: [{ type: 'text', text: 'ok' }],
+ isError: false,
+ }),
+ }, { surfaceOp: 'append' })
+ await expect(iterator.next()).resolves.toEqual({
+ done: false,
+ value: { type: 'event', event: result },
+ })
+ } finally {
+ events.mockRestore()
+ abort.abort()
+ await iterator.next()
+ await ctx.fiber.dispose()
+ }
})
})
diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts
index 17533bb585..aa8f26de24 100644
--- a/packages/api/session-controller/tests/session.client.spec.ts
+++ b/packages/api/session-controller/tests/session.client.spec.ts
@@ -4,7 +4,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { RemoteStreamError } from '@deepseek-ai/dsh-api-gateway/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
-import type { SessionToolView } from '@deepseek-ai/dsh-api-session-controller/types'
import { Session, type SessionOptions } from '../src/client/sessions/session.ts'
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
import { entries, ev, plainTurn } from './event-script.client.ts'
@@ -26,12 +25,10 @@ function makeSession(
function follow(
api: FakeApiClient,
event: SessionEvent,
- view?: SessionToolView,
): Promise {
return api.pushFollow(SID, {
type: 'event',
event: event as never,
- ...(view === undefined ? {} : { view }),
})
}
@@ -44,7 +41,7 @@ function eventSeqs(session: Session): number[] {
}
function histResponse(events: SessionEvent[], hasMore = false) {
- // history returns HistoryEntry[] ({event, view?}); these tests are view-less.
+ // History returns raw journal envelopes around each event.
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
@@ -601,39 +598,30 @@ describe('remaining branches', () => {
await expect(session.dispose()).resolves.toBeUndefined()
})
- it('carries history-entry and follow-frame views through the event feed', async () => {
+ it('carries raw history and follow events through the event feed', async () => {
const { api, session } = makeSession()
- const callView = { for: 'call', view: { card: 'generic', title: '历史卡' } }
+ const historyCall = ev.toolCall(6, 1, 'h1', 'bash', '{"cmd":"pwd"}')
+ const historyResult = ev.toolResult(7, 1, 'h1', 'done')
api.onHistory = () => Promise.resolve(ok({
events: [
...entries(plainTurn(0, 0, 'a', 'b')),
- { event: ev.toolCall(6, 1, 'h1', 'bash', '{}'), view: callView },
- { event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } },
+ { event: historyCall },
+ { event: historyResult },
] as never[],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
await session.open()
- expect(windowEntries(session).slice(-2).map(item => item.view)).toEqual([
- callView,
- { for: 'result', view: { card: 'generic', title: '历史果' } },
+ expect(windowEntries(session).slice(-2)).toEqual([
+ { event: historyCall },
+ { event: historyResult },
])
- await follow(
- api,
- ev.toolCall(8, 2, 'l1', 'write', '{}'),
- { for: 'call', view: { card: 'generic', title: '直播卡' } },
- )
- expect(windowEntries(session).at(-1)?.view).toEqual({
- for: 'call', view: { card: 'generic', title: '直播卡' },
- })
- await follow(
- api,
- ev.toolResult(9, 2, 'l1', 'ok'),
- { for: 'result', view: { card: 'generic', title: '直播果' } },
- )
- expect(windowEntries(session).at(-1)?.view).toEqual({
- for: 'result', view: { card: 'generic', title: '直播果' },
- })
+ const liveCall = ev.toolCall(8, 2, 'l1', 'write', '{"file_path":"a.ts"}')
+ await follow(api, liveCall)
+ expect(windowEntries(session).at(-1)).toEqual({ event: liveCall })
+ const liveResult = ev.toolResult(9, 2, 'l1', 'ok')
+ await follow(api, liveResult)
+ expect(windowEntries(session).at(-1)).toEqual({ event: liveResult })
})
})
diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts
index 7bdb426e9d..c30d3f095f 100644
--- a/packages/api/session-controller/tests/transport.host.spec.ts
+++ b/packages/api/session-controller/tests/transport.host.spec.ts
@@ -482,60 +482,6 @@ describe('SessionHistoryController', () => {
expect(warn).toHaveBeenCalledWith(expect.stringContaining('child projection failed'))
})
- it('resolves presenter scope from a live Agent or the durable preset and tolerates lookup failure', async () => {
- const live = await setup()
- const liveSession = live.ctx.sessions.create(SessionId('live-scope'), { meta: { cwd: '/workspace' } })
- const liveAgent = { id: liveSession.id }
- const preset = vi.fn(() => Promise.resolve('preset-scope'))
- live.ctx.provide('agents', { get: () => liveAgent } as never)
- live.ctx.provide('agentPresets', { standingKeyFor: preset } as never)
- await live.transport.page({
- address: { kind: 'session', sessionId: liveSession.id }, throughSeq: -1,
- }, signal())
- expect(preset).not.toHaveBeenCalled()
-
- const attached = await setup()
- const attachedSession = attached.ctx.sessions.create(SessionId('preset-scope'), {
- meta: { cwd: '/workspace', agentPreset: 'minimal' },
- })
- const standingKeyFor = vi.fn(() => Promise.resolve('standing-scope'))
- attached.ctx.provide('agentPresets', { standingKeyFor } as never)
- await attached.transport.page({
- address: { kind: 'session', sessionId: attachedSession.id },
- throughSeq: -1,
- }, signal())
- expect(standingKeyFor).toHaveBeenCalledWith('minimal')
-
- const detached = await setup()
- const detachedId = SessionId('detached-scope')
- const header = {
- version: 0, id: detachedId, createdAt: 1, cwd: '/workspace', agentPreset: 'standard',
- }
- cold(detached.ctx, header, [])
- const rejected = vi.fn(() => Promise.reject(new Error('preset unavailable')))
- detached.ctx.provide('agentPresets', { standingKeyFor: rejected } as never)
- await expect(detached.transport.page({
- address: { kind: 'session', sessionId: detachedId },
- throughSeq: -1,
- }, signal())).resolves.toMatchObject({ events: [] })
- expect(rejected).toHaveBeenCalledWith('standard')
-
- const switched = await setup()
- const switchedId = SessionId('switched-scope')
- const switchedHeader = {
- version: 0, id: switchedId, createdAt: 1, cwd: '/workspace', agentPreset: 'standard',
- }
- cold(switched.ctx, switchedHeader, [
- event('agent-preset/selected', 0, { agentPreset: 'minimal' }),
- ])
- const switchedKey = vi.fn(() => Promise.resolve('switched-scope'))
- switched.ctx.provide('agentPresets', { standingKeyFor: switchedKey } as never)
- await switched.transport.page({
- address: { kind: 'session', sessionId: switchedId }, throughSeq: 0,
- }, signal())
- expect(switchedKey).toHaveBeenCalledWith('minimal')
- })
-
it('keeps message-aligned pagination contiguous across replacement provenance', async () => {
const { ctx, transport } = await setup()
const session = ctx.sessions.create(SessionId('pagination'), { meta: { cwd: '/workspace' } })
@@ -576,77 +522,4 @@ describe('SessionHistoryController', () => {
expect(page.hasMore).toBe(false)
})
- it('projects tool call and result views and contains malformed presenters', async () => {
- const { ctx, transport } = await setup()
- const sessionId = SessionId('presenters')
- const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
- const events = [
- event('fixture/start', 0),
- event('tool/call', 1, { callId: 'c1', name: 'present', arguments: '{"path":"a.ts"}' }),
- event('tool/result', 2, {
- message: {
- source: { callId: 'c1' },
- content: [{ content: [{ type: 'text', text: 'ok' }], isError: true }],
- },
- meta: { persisted: true },
- }),
- event('tool/result', 3, {
- message: {
- source: { callId: 'missing' },
- content: [{ content: [{ type: 'text', text: 'missing' }] }],
- },
- }),
- event('tool/call', 4, { callId: 'c2', name: 'present', arguments: '{' }),
- event('tool/result', 5, {
- message: {
- source: { callId: 'c2' },
- content: [{ content: [{ type: 'text', text: 'bad args' }] }],
- },
- }),
- event('tool/call', 6, { callId: 'c3', name: 'empty', arguments: '{}' }),
- event('tool/result', 7, {
- message: {
- source: { callId: 'c3' },
- content: [{ content: [{ type: 'text', text: 'no presenter' }], isError: false }],
- },
- }),
- event('tool/call', 8, { callId: 'c4', name: 'throw-call', arguments: '{}' }),
- event('tool/call', 9, { callId: 'c5', name: 'throw-result', arguments: '{}' }),
- event('tool/result', 10, {
- message: {
- source: { callId: 'c5' },
- content: [{ content: [{ type: 'text', text: 'throw' }], isError: false }],
- },
- }),
- ]
- cold(ctx, header, events)
- ctx.provide('tools', {
- get: (name: string) => {
- if (name === 'present') {
- return {
- presentCall: (args: unknown) => ({ card: 'generic', title: 'Call', rawInput: args }),
- presentResult: (_args: unknown, result: unknown) => ({ card: 'generic', title: 'Result', result }),
- }
- }
- if (name === 'empty') return {}
- if (name === 'throw-call') return { presentCall: () => { throw new Error('call presenter failed') } }
- if (name === 'throw-result') return { presentResult: () => { throw new Error('result presenter failed') } }
- return undefined
- },
- } as never)
- const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
-
- const page = await transport.page({
- address: { kind: 'session', sessionId }, throughSeq: 10,
- }, signal())
- expect(page.events[1]?.view).toEqual({
- for: 'call', view: { card: 'generic', title: 'Call', rawInput: { path: 'a.ts' } },
- })
- expect(page.events[2]?.view).toMatchObject({ for: 'result', view: { card: 'generic', title: 'Result' } })
- for (const index of [0, 3, 4, 5, 6, 7, 8, 9, 10]) {
- expect(page.events[index]).not.toHaveProperty('view')
- }
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('call presenter failed'))
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('result presenter failed'))
- })
})
diff --git a/packages/api/session-controller/tsconfig.client.json b/packages/api/session-controller/tsconfig.client.json
index 838fa6a03c..648055bcee 100644
--- a/packages/api/session-controller/tsconfig.client.json
+++ b/packages/api/session-controller/tsconfig.client.json
@@ -21,7 +21,6 @@
{ "path": "../../llm/llm" },
{ "path": "../../session/session-projection" },
{ "path": "../../session/session-title" },
- { "path": "../../core/tools" },
{ "path": "../../util/brand" },
{ "path": "../../util/crypto" },
{ "path": "../../util/workspace-path" },
diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json
index efceb803df..a778326d62 100644
--- a/packages/api/session-controller/tsconfig.host.json
+++ b/packages/api/session-controller/tsconfig.host.json
@@ -24,7 +24,6 @@
{ "path": "../../core/agent-default-model" },
{ "path": "../../core/scope" },
{ "path": "../../core/session" },
- { "path": "../../core/tools" },
{ "path": "../../attachment/attachment" },
{ "path": "../../interaction/permission-presets" },
{ "path": "../../jobs/jobs" },
diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json
index 478468f637..95ce0d1981 100644
--- a/packages/client/connection/package.json
+++ b/packages/client/connection/package.json
@@ -55,8 +55,7 @@
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
- "@deepseek-ai/dsh-tool-todo": "workspace:^",
- "@deepseek-ai/dsh-tools": "workspace:^"
+ "@deepseek-ai/dsh-tool-todo": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-host-webserver": "workspace:^",
@@ -67,7 +66,6 @@
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
- "@deepseek-ai/dsh-tool-todo": "workspace:^",
- "@deepseek-ai/dsh-tools": "workspace:^"
+ "@deepseek-ai/dsh-tool-todo": "workspace:^"
}
}
diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts
index e712ca5c3c..2c899ef0e7 100644
--- a/packages/client/connection/src/client/api.ts
+++ b/packages/client/connection/src/client/api.ts
@@ -17,7 +17,6 @@ export type {
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
} from '@deepseek-ai/dsh-host-apiproxy/api'
-export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, RpcMessage,
diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
index 8d6c16cfce..22a0f64e55 100644
--- a/packages/client/connection/src/client/fixture.ts
+++ b/packages/client/connection/src/client/fixture.ts
@@ -17,6 +17,7 @@ import type {
} from '@deepseek-ai/dsh-llm'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {
+ JsonValue,
SessionEvent,
SessionId,
} from '@deepseek-ai/dsh-session/types'
@@ -29,7 +30,6 @@ import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surfac
import type {
ApiProxy, ClientRequest,
ModelProviderGroup, ModelSelection, RpcRequest, RpcResponse, RpcResult, ServerResponse,
- ToolCallView, ToolResultView,
} from './api.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
import { AbstractApiClient, RpcId } from './api.ts'
@@ -71,13 +71,8 @@ interface FixtureProjectionsBlock {
readonly values: Readonly>
}
-type FixtureToolView =
- | { readonly for: 'call'; readonly view: ToolCallView }
- | { readonly for: 'result'; readonly view: ToolResultView }
-
interface FixtureHistoryEntry {
readonly event: SessionEvent
- readonly view?: FixtureToolView
}
type FixtureSessionAddress =
@@ -362,11 +357,9 @@ function sgr(code: number, body: string): string {
* basic-16 SGR foreground runs (green, red, bright-black) that must resolve to
* `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll
* rather than fold, more than DEFAULT_TERMINAL_MAX_LINES (16) lines so the
- * height cap collapses the middle. The exit status is authored separately in
- * TERMINAL_EXIT_STATUS and deliberately absent from this text: the real bash
- * presenter CONSUMES its `[exit code: N]` marker out of the body, because a
- * terminal card shows the exit as its own pill and leaving the marker in would
- * render it twice (packages/shell/tool-bash/src/render.ts).
+ * height cap collapses the middle. This constant is the visible body; the call
+ * site appends the shell result's `[exit code: N]` marker so Client derivation
+ * can consume it into the terminal status pill.
*/
const TERMINAL_OUTPUT_FIXTURE = [
sgr(1, 'Running 4 checks'),
@@ -393,20 +386,10 @@ const TERMINAL_OUTPUT_FIXTURE = [
].join('\n')
/**
- * Exit status for each terminal sample, keyed by its output text. Authored
- * alongside the sample rather than parsed back out of its trailing marker,
- * which is the bash tool's own job and not something to reimplement here.
- */
-const TERMINAL_EXIT_STATUS: Record = {
- [TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
-}
-
-/**
- * Structured grep result for the search sample (turn 67): matches grouped by
- * file, authored inline because the client-side fixture cannot import the tool
- * that produces the canonical value. `truncated` with a larger `total` than the
- * retained match count exercises the search card's capped indicator; the file
- * with more than CHAT_SEARCH_MAX_LINES rows exercises its head/tail height cap.
+ * Structured grep metadata for the search sample (turn 67). `truncated` with a
+ * larger `total` than the retained match count exercises the search card's
+ * capped indicator; the file with more than CHAT_SEARCH_MAX_LINES rows
+ * exercises its head/tail height cap.
*/
const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; line: string }[] }[] = [
{
@@ -475,18 +458,23 @@ const READ_SAMPLE_SOURCE = [
const READ_SAMPLE_LINES = READ_SAMPLE_SOURCE.map((text, index) => ({ number: READ_SAMPLE_FIRST_LINE + index, text }))
const READ_SAMPLE_PATH = 'packages/client/ui-primitives/src/ReadBlock.tsx'
const READ_SAMPLE_TOTAL = 180
-const READ_SAMPLE_TEXT = READ_SAMPLE_SOURCE.map((text, index) => `${READ_SAMPLE_FIRST_LINE + index}: ${text}`).join('\n')
+const READ_SAMPLE_LAST_LINE = READ_SAMPLE_FIRST_LINE + READ_SAMPLE_SOURCE.length - 1
+const READ_SAMPLE_TEXT = [
+ `${READ_SAMPLE_PATH}`,
+ 'file',
+ '',
+ ...READ_SAMPLE_SOURCE.map((text, index) => `${READ_SAMPLE_FIRST_LINE + index}: ${text}`),
+ '',
+ `(Showing lines ${READ_SAMPLE_FIRST_LINE}-${READ_SAMPLE_LAST_LINE} of ${READ_SAMPLE_TOTAL}. Use offset=${READ_SAMPLE_LAST_LINE + 1} to continue.)`,
+ '',
+].join('\n')
/**
- * The structured `web_search` result view for the web-search turn, authored inline
- * because this client-side fixture cannot import the web tool that projects it.
- * The sources exercise the citation list's features: a titled source with a
- * snippet and a date, a source with no title (its hostname labels the link) and
- * a snippet but no date, and a source with a title and a date but no snippet.
- * `truncated` marks the capped indicator. The shape is the contract's own
- * search view minus its wire discriminants.
+ * The `web_search` result metadata for the web-search turn. The sources cover a
+ * titled source with a snippet and date, a hostname-label fallback, and a
+ * titled source without a snippet; `truncated` exercises the capped indicator.
*/
-const WEB_SEARCH_RESULT: Omit, 'card' | 'kind'> = {
+const WEB_SEARCH_META = {
answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.',
sources: [
{
@@ -506,14 +494,14 @@ const WEB_SEARCH_RESULT: Omit, 'card' | 'kind'> = {
+/** The `web_fetch` result metadata for the web-fetch turn. */
+const WEB_FETCH_META = {
url: 'https://www.deepseek.com/blog/harness-architecture',
statusCode: 200,
truncated: false,
-}
+} satisfies JsonValue
const DEEPSEEK_REASONING = {
efforts: [
@@ -649,10 +637,16 @@ function buildAlphaLog(): SessionEvent[] {
}
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
- // Three view-sample turns (60-62) cover the built-in card types. The real filesystem names in
- // turns 62-63 also exercise their dedicated generic-row icon/title/path summaries. `echo` above
- // stays presenter-less as the unknown fallback.
- const toolTurn = (turn: number, name: string, args: string, resultText: string): void => {
+ // The structured samples use real first-party names and result metadata so
+ // the fixture follows the same event-to-card path as a persisted Session.
+ // `echo` above remains the unknown-tool fallback.
+ const toolTurn = (
+ turn: number,
+ name: string,
+ args: string,
+ resultText: string,
+ resultMeta?: JsonValue,
+ ): void => {
const callId = `fx-call-${turn}`
push({ type: 'turn/start', data: { turn } })
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:${name} 样本。`)) })
@@ -662,23 +656,66 @@ function buildAlphaLog(): SessionEvent[] {
data: { turn, step: 0, message: assistantMessage([{ type: 'tool-call', id: callId, name, arguments: args } as ContentBlock]) },
})
push({ type: 'tool/call', data: { turn, step: 0, callId, name, arguments: args } })
- push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, message: toolResultMessage(callId, text(resultText), false) } })
+ push({
+ type: 'tool/result',
+ surfaceOp: 'append',
+ data: {
+ turn,
+ step: 0,
+ message: toolResultMessage(callId, text(resultText), false),
+ ...resultMeta === undefined ? {} : { meta: resultMeta },
+ },
+ })
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
// A two-line command, so the fixture covers the terminal card's one-row-per-
// command-line prompt (and that the card still marks the call exactly once).
- toolTurn(60, 'fx-bash', '{"command":"ls -la\\necho done","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
- toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
- toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
- toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
+ toolTurn(
+ 60,
+ 'bash',
+ '{"command":"ls -la\\necho done","description":"fixture 终端样本","workdir":"/tmp/fixture"}',
+ 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt',
+ )
+ toolTurn(
+ 61,
+ 'write',
+ '{"file_path":"notes/demo.txt","content":"hello fixture\\n"}',
+ 'wrote notes/demo.txt',
+ { diffs: [{ path: 'notes/demo.txt', oldText: null, newText: 'hello fixture\n' }] },
+ )
+ toolTurn(
+ 62,
+ 'edit',
+ '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}',
+ '已编辑',
+ { diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }] },
+ )
+ toolTurn(
+ 63,
+ 'write',
+ '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}',
+ '已写入',
+ { diffs: [{ path: 'notes/new-demo.txt', oldText: null, newText: 'hello fixture\n' }] },
+ )
// Turn 64: a multi-hunk edit — two scattered replacements in one file. Named
// `edit` so it lands on the keyed FileMutationRow (the resident diff card the
- // single-hunk turn 62 also uses), and file_path `src/config.ts` is the marker
- // the presenter reads to emit the two-hunk sample: the card draws one path
- // header, the first hunk, a `⋯` gap, then the second (the same-file
+ // single-hunk turn 62 also uses). Its result metadata carries two scattered
+ // hunks under one path header, so the card draws the first hunk, a `⋯` gap,
+ // then the second (the same-file
// second-hunk arm turns 62/63 cannot reach).
- toolTurn(64, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
+ toolTurn(
+ 64,
+ 'edit',
+ '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}',
+ '已编辑',
+ {
+ diffs: [
+ { path: 'src/config.ts', oldText: 'const timeout = 30', newText: 'const timeout = 60' },
+ { path: 'src/config.ts', oldText: 'retries: 1', newText: 'retries: 3' },
+ ],
+ },
+ )
// Turn 65: one run_code turn with three logged sub-dispatches — the Code
// Mode acceptance surface (parent code row + nested native-identical rows,
// including an isError sub-call and a bash sub-call that must hit the same
@@ -734,52 +771,75 @@ function buildAlphaLog(): SessionEvent[] {
]
// Turn 66: the terminal sample turn 60's two clean prompt rows cannot cover —
// ANSI SGR coloring, output past the terminal card's height cap, a nested cwd
- // whose prompt label is its last segment, and a non-zero exit authored beside
- // the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no
- // `[exit code: N]` marker, since the real presenter consumes that one out of
- // the body. Named `bash`, so it also covers
- // the keyed toolview row (turn 60's `fx-bash` covers the render-site fallback
- // row) — the two chat-row shapes the terminal card renders in.
+ // whose prompt label is its last segment, and a non-zero exit. The raw result
+ // includes an `[exit code: N]` marker below; Client
+ // derivation consumes it into the status pill before rendering the body.
//
// Ordered BEFORE the todo turn deliberately: the standing plan retires at the
// next `turn/start`, so a turn appended after it would leave the dock's plan
// strip empty and take the todo surfaces' own coverage with it.
- toolTurn(66, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
+ toolTurn(
+ 66,
+ 'bash',
+ '{"command":"pnpm run check","description":"fixture 终端样本","workdir":"/tmp/fixture/deep/nested"}',
+ `${TERMINAL_OUTPUT_FIXTURE}\n[exit code: 1]`,
+ )
- // Turns 67-68: the search card's two shapes. `grep` emits a `card: 'search'`
- // `shape: 'matches'` result view (grouped-by-file matches, truncated with a
- // larger `total`), `glob` emits `shape: 'paths'` (a flat path list, likewise
- // truncated). Both ride the keyed SearchRow registration under their own
- // names; the render-site fallback row is covered by the model derivation
- // tests, since every fixture search tool has a keyed row. Ordered before the
- // todo turn for the same standing-plan reason the bash turn is.
- toolTurn(67, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT)
- toolTurn(68, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT)
+ // Turns 67-68 carry the search card's two metadata variants: grouped matches
+ // and a flat path list, both truncated with a larger pre-cap total. Both use
+ // the keyed SearchRow registration. They stay before the todo turn for the
+ // same standing-plan reason as the bash turn.
+ toolTurn(
+ 67,
+ 'grep',
+ '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}',
+ SEARCH_MATCHES_TEXT,
+ { shape: 'matches', files: SEARCH_MATCHES_FIXTURE, truncated: true, total: 42 },
+ )
+ toolTurn(
+ 68,
+ 'glob',
+ '{"pattern":"**/SearchBlock*","path":"packages/client"}',
+ SEARCH_PATHS_TEXT,
+ { shape: 'paths', paths: SEARCH_PATHS_FIXTURE, truncated: true, total: 23 },
+ )
// Turn 69: the read sample — a WINDOW past an offset so the card draws file
// line numbers starting above 1 and a "showing N of M" note (the window is
// shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path
// highlights. Named `read`, so it exercises the keyed ReadRow registration.
- // The render-site fallback ROW SHAPE (a read call on the generic flattened
- // path) is covered by the turn 65 run_code read sub-dispatches, which
- // session.ts folds with resultView: null; the fallback-row + read-CARD
- // combination is pinned by the web_fetch case in read-card.spec.tsx, not by
- // this fixture. The read render intent is result-side only, so its pending
- // call stays a generic `kind: 'read'` card; presentResult carries the
- // structured window.
- toolTurn(69, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
+ // The run_code sub-dispatches above cover nested read calls without result
+ // metadata; this top-level result carries the structured window.
+ toolTurn(
+ 69,
+ 'read',
+ `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`,
+ READ_SAMPLE_TEXT,
+ {
+ path: READ_SAMPLE_PATH,
+ offset: READ_SAMPLE_FIRST_LINE,
+ lines: READ_SAMPLE_LINES,
+ totalLines: READ_SAMPLE_TOTAL,
+ lang: 'ts',
+ },
+ )
- // Turns 70-71: the web render intent — a web_search whose result view carries
- // structured sources plus an answer (the citation list, one source lacking a
- // title so its hostname labels the link, the capped indicator on), and a
- // web_fetch whose result view carries the fetched URL and its HTTP status.
- // Both keep a generic pending call view and add the `web` card only at
- // result time, which is the contract's result-only web shape. Named after
- // the real tools so they hit the keyed WebRow registration. Ordered BEFORE
- // the todo turn for the same reason turn 66 is: the standing plan retires at
- // the next turn/start, so a turn after it would empty the dock's plan strip.
- toolTurn(70, 'web_search', '{"queries":["deepseek harness architecture"]}', 'Search results for deepseek harness architecture.')
- toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
+ // Turns 70-71 carry the web tools' result metadata. They stay before the todo
+ // turn because a later turn/start retires the standing plan projection.
+ toolTurn(
+ 70,
+ 'web_search',
+ '{"queries":["deepseek harness architecture"]}',
+ 'Search results for deepseek harness architecture.',
+ WEB_SEARCH_META,
+ )
+ toolTurn(
+ 71,
+ 'web_fetch',
+ '{"url":"https://www.deepseek.com/blog/harness-architecture"}',
+ '# Harness architecture\n\nEverything is a plugin.',
+ WEB_FETCH_META,
+ )
// Turn 72: max-tokens sample — the provider ends the turn at its output cap
// mid-sentence, so the chat flow must render the turn-max-tokens notice
@@ -832,151 +892,6 @@ function buildAlphaLog(): SessionEvent[] {
return events as unknown as SessionEvent[]
}
-/** Narrows a parsed-JSON field to string; fixture args are authored in-file, so non-strings only mean a typo here. */
-/* v8 ignore next -- the fallback arm is the same in-file-typo guard as the JSON.parse catch above. */
-const str = (value: unknown, fallback = ''): string => typeof value === 'string' ? value : fallback
-
-/** Fixture presenter registry (mirrors host viewFor): pure derivation, undefined = no view. */
-function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
- let args: Record
- try {
- args = JSON.parse(argsRaw) as Record
- } catch {
- /* v8 ignore next 2 -- defensive: fixture args are authored in-file as valid JSON; only an in-file typo could reach the catch. */
- return undefined
- }
- switch (name) {
- // Both names present the same terminal card: `fx-bash` lands on the
- // render-site fallback row, `bash` on the keyed BashRow registration.
- case 'fx-bash':
- case 'bash':
- return { card: 'terminal', title: str(args.command), cwd: str(args.cwd, '/tmp/fixture'), description: 'fixture 终端样本' }
- case 'fx-write':
- return {
- card: 'diff', title: `Write ${str(args.path)}`,
- diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
- }
- // A read pending call is a GENERIC card (kind: 'read', a follow-along
- // location): the read render intent is result-side only, because a call
- // carries no file content until execute returns. The rich read card arrives
- // in presentResult.
- case 'read':
- return { card: 'generic', title: `Read ${str(args.file_path)}`, kind: 'read', locations: [{ path: str(args.file_path) }] }
- case 'edit':
- // The multi-hunk sample (turn 64) is keyed on its file_path, so the two
- // scattered hunks share one path header and the card draws the `⋯` gap.
- if (str(args.file_path) === 'src/config.ts') {
- return {
- card: 'diff', title: `Edit ${str(args.file_path)}`,
- diffs: [
- { path: str(args.file_path), oldText: 'const timeout = 30', newText: 'const timeout = 60' },
- { path: str(args.file_path), oldText: 'retries: 1', newText: 'retries: 3' },
- ],
- }
- }
- return {
- card: 'diff', title: `Edit ${str(args.file_path)}`,
- diffs: [{ path: str(args.file_path), oldText: str(args.old_string), newText: str(args.new_string) }],
- }
- case 'write':
- return {
- card: 'diff', title: `Write ${str(args.file_path)}`,
- diffs: [{ path: str(args.file_path), oldText: null, newText: str(args.content) }],
- }
- // A search call stays a generic card (kind: 'search'): the structured
- // matches/paths exist only after execute, so the search card is result-time
- // only (presentResult builds it). This mirrors the real grep/glob presenters.
- case 'grep':
- return { card: 'generic', title: `Grep ${str(args.pattern)}`, kind: 'search', rawInput: args }
- case 'glob':
- return { card: 'generic', title: `Glob ${str(args.pattern)}`, kind: 'search', rawInput: args }
- // The web tools keep a GENERIC pending card and add the `web` result card
- // only at result time (the contract's result-only web shape); their pending
- // kind matches the result kind so a call and its result read as one category.
- case 'web_search': {
- const queries = Array.isArray(args.queries) ? args.queries.filter((query): query is string => typeof query === 'string' && query !== '') : []
- const title = queries.join(', ')
- return { card: 'generic', title: `Search ${title}`, kind: 'search', rawInput: args }
- }
- case 'web_fetch':
- return { card: 'generic', title: `Fetch ${str(args.url)}`, kind: 'fetch', rawInput: args }
- default:
- return undefined // echo et al: the documented no-view fallback path
- }
-}
-
-function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined {
- const call = presentCall(name, argsRaw)
- if (call === undefined) return undefined
- // Search is result-time only: the call stays a generic search card, and the
- // result view carries the structured shape the card renders. The view holds no
- // result text — a UI without a search card falls back to the raw tool/result
- // content — so the truncation recovery footer rides that raw content (the
- // `toolTurn` message text), not the view. `total` exceeds the retained count so
- // the card shows its capped indicator.
- if (name === 'grep') {
- return { card: 'search', shape: 'matches', files: SEARCH_MATCHES_FIXTURE, truncated: true, total: 42 }
- }
- if (name === 'glob') {
- return { card: 'search', shape: 'paths', paths: SEARCH_PATHS_FIXTURE, truncated: true, total: 23 }
- }
- // The read result is the structured window the tool projects through
- // `presentationMeta`; the fixture authors it inline (it cannot import the
- // tool). Keyed on the name because the read pending call is a generic card,
- // so `call.card` alone does not distinguish it from edit/write.
- if (name === 'read') {
- return {
- card: 'read', path: READ_SAMPLE_PATH, offset: READ_SAMPLE_FIRST_LINE, lines: READ_SAMPLE_LINES,
- totalLines: READ_SAMPLE_TOTAL, lang: 'ts', content: text(resultText),
- }
- }
- // The web tools keep a generic pending card, so their result card is chosen
- // by tool name rather than by the pending card tag: the structured `web` card
- // the frontend consumes. The view carries no `content` copy (per the contract
- // and the web-result-card note); a capability-less UI falls back to the raw
- // `tool/result` content, which this fixture emits from `resultText`.
- if (name === 'web_search') {
- return { card: 'web', kind: 'search', ...WEB_SEARCH_RESULT }
- }
- if (name === 'web_fetch') {
- return { card: 'web', kind: 'fetch', ...WEB_FETCH_RESULT }
- }
- switch (call.card) {
- case 'terminal':
- // The sample's own exit status, authored beside it: re-parsing the
- // trailing marker here would duplicate the bash tool's `parseExitStatus`,
- // which this client-side fixture cannot import.
- return { card: 'terminal', output: resultText, ...(TERMINAL_EXIT_STATUS[resultText] ?? { exitCode: 0 }) }
- case 'diff':
- return { card: 'diff', diffs: call.diffs }
- case 'generic':
- return { card: 'generic', content: text(resultText) }
- }
-}
-
-/** Host-side viewFor mirror: tool/call presents from its own args; tool/result back-scans the log for the paired call. */
-function viewFor(event: SessionEvent, log: readonly SessionEvent[]): FixtureToolView | undefined {
- if (event.type === 'tool/call') {
- const view = presentCall(event.data.name, event.data.arguments)
- return view === undefined ? undefined : { for: 'call', view }
- }
- if (event.type === 'tool/result') {
- const callId = String(event.data.message.source.callId)
- for (let i = log.length - 1; i >= 0; i--) {
- const candidate = log[i]
- /* v8 ignore next -- dense-array guard: i stays within [0, log.length),
- so the undefined arm needs a sparse log no code path builds. */
- if (candidate !== undefined && candidate.type === 'tool/call' && String(candidate.data.callId) === callId) {
- const resultText = event.data.message.content[0].content.map(b => (b.type === 'text' ? b.text : '')).join('')
- const view = presentResult(candidate.data.name, candidate.data.arguments, resultText)
- return view === undefined ? undefined : { for: 'result', view }
- }
- }
- return undefined // cross-page unpaired: documented default
- }
- return undefined
-}
-
/**
* Fixture parallel of the plan unit's lifecycle fold. The paired
* `command/done` retains successful plan selections and drops failures;
@@ -1422,11 +1337,9 @@ function projectionFramesOf(
}
/**
- * Message-boundary paging (mirrors the host's paging contract): count
- * maxMessages messages
- * backwards from end, cut at a turn/start boundary.
- Entries carry pagination-time views
- * (the host analogue computes viewFor per entry at page time). */
+ * Message-boundary paging mirrors the Host contract: count `maxMessages`
+ * backwards from the end and cut at a turn/start boundary.
+ */
function pageOf(
log: readonly SessionEvent[],
beforeSeq: number | undefined,
@@ -1445,10 +1358,7 @@ function pageOf(
break
}
}
- const events = log.slice(start, end).map((event): FixtureHistoryEntry => {
- const view = viewFor(event, log)
- return view === undefined ? { event } : { event, view }
- })
+ const events = log.slice(start, end).map((event): FixtureHistoryEntry => ({ event }))
return { events, hasMore: start > 0 }
}
@@ -1974,12 +1884,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
const log = logOf(id)
const event = { seq: log.length, time: Date.now(), ...e } as unknown as SessionEvent
log.push(event)
- // Emission-time view derivation (mirrors the host's live path).
- const view = viewFor(event, log)
- /* v8 ignore next 2 -- the view-present arm needs a live tool/call emission,
- but the fixture replay produces text-only turns; view vocabulary is
- exercised through the history samples (turns 60-62). */
- emitFollow(id, view === undefined ? { event } : { event, view })
+ emitFollow(id, { event })
// Host eager-drive parallel: a unit-advancing event pushes its finished value.
for (const frame of projectionFramesOf(id, log, event)) emitControl(frame)
if (event.type === 'user/message' && event.data.source.kind === 'user') {
@@ -3007,8 +2912,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
throw new Error(`fixture: session event replay skipped seq ${String(nextSeq)}`)
}
nextSeq++
- const view = viewFor(event, snapshot)
- yield view === undefined ? { type: 'event', event } : { type: 'event', event, view }
+ yield { type: 'event', event }
}
}
for await (const frame of conn.drain(signal)) {
diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts
index c56bf17c84..438303580f 100644
--- a/packages/client/connection/src/client/index.ts
+++ b/packages/client/connection/src/client/index.ts
@@ -32,7 +32,6 @@ declare module '@deepseek-ai/cordis' {
export type {
ApiProxy, HostApi,
DirectoryEntry, DirectoryListing,
- ToolCallView, ToolResultView,
SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
MessageId, ModelReasoningEffort, ModelSelection,
diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts
index 58c347fec3..814fe53aa8 100644
--- a/packages/client/connection/tests/fixture.client.spec.ts
+++ b/packages/client/connection/tests/fixture.client.spec.ts
@@ -37,7 +37,6 @@ interface FixtureSessionSummary {
interface FixtureHistoryEntry {
readonly event: SessionEvent
- readonly view?: unknown
}
interface FixturePage {
@@ -648,6 +647,49 @@ describe('createFixtureApi', () => {
})
})
+ it('serves raw history entries with replayable tool-result metadata', async () => {
+ const api = createFixtureApi()
+ const response = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 200 }))
+ if (!response.result.ok) throw new Error('history failed')
+
+ const entries = response.result.value.events
+ expect(entries.every(entry => !Object.hasOwn(entry, 'view'))).toBe(true)
+ const results = entries
+ .map(entry => entry.event)
+ .filter(event => event.type === 'tool/result')
+
+ expect(results.find(event => event.data.turn === 64)).toMatchObject({
+ data: {
+ meta: {
+ diffs: [
+ { path: 'src/config.ts', oldText: 'const timeout = 30', newText: 'const timeout = 60' },
+ { path: 'src/config.ts', oldText: 'retries: 1', newText: 'retries: 3' },
+ ],
+ },
+ },
+ })
+ expect(results.find(event => event.data.turn === 67)).toMatchObject({
+ data: { meta: { shape: 'matches', truncated: true, total: 42 } },
+ })
+ expect(results.find(event => event.data.turn === 69)).toMatchObject({
+ data: { meta: { path: 'packages/client/ui-primitives/src/ReadBlock.tsx', offset: 41, totalLines: 180 } },
+ })
+ const webSearch = results.find(event => event.data.turn === 70)
+ expect(webSearch).toHaveProperty('data.meta.truncated', true)
+ expect(webSearch).toHaveProperty('data.meta.sources', expect.arrayContaining([
+ expect.objectContaining({ url: 'https://github.com/deepseek-ai/deepseek-harness' }),
+ ]))
+ expect(results.find(event => event.data.turn === 71)).toMatchObject({
+ data: { meta: { url: 'https://www.deepseek.com/blog/harness-architecture', statusCode: 200 } },
+ })
+ const terminal = results.find(event => event.data.turn === 66)
+ expect(terminal).toHaveProperty('data.message.content.0.content.0.type', 'text')
+ expect(terminal).toHaveProperty(
+ 'data.message.content.0.content.0.text',
+ expect.stringContaining('\n[exit code: 1]'),
+ )
+ })
+
it('serves grouped models and keeps a selection for later history and fixture requests', async () => {
const api = createFixtureApi()
const sessionId = sid('fx-alpha')
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a30fa4f902..f5f3e827dd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1059,9 +1059,6 @@ importers:
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../../subagent/subagent
- '@deepseek-ai/dsh-tools':
- specifier: workspace:^
- version: link:../../core/tools
'@deepseek-ai/dsh-typert-protocol':
specifier: workspace:^
version: link:../../typert/protocol
@@ -1806,9 +1803,6 @@ importers:
'@deepseek-ai/dsh-tool-todo':
specifier: workspace:^
version: link:../../todo/tool-todo
- '@deepseek-ai/dsh-tools':
- specifier: workspace:^
- version: link:../../core/tools
packages/client/hmr:
dependencies:
From a4c296f9fe1be6692053276426c49ee5d1e9a279 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Sun, 23 Aug 2026 21:12:22 +0800
Subject: [PATCH 3/8] refactor(client): derive tool cards from raw events
---
.../src/client/conversation-nodes/tool.ts | 11 +-
.../src/client/details/DetailsPanel.tsx | 5 +-
.../src/client/model/tool-call-tree.ts | 5 +-
.../ui-chat/tests/chat-stats.client.spec.tsx | 4 +-
.../ui-chat/tests/chat-view.client.spec.tsx | 11 +-
...nversation-node-definitions.client.spec.ts | 23 +-
.../tests/gate-branch-tails.client.spec.tsx | 15 +-
.../tests/tool-call-tree.client.spec.ts | 6 +-
.../src/client/contract/conversation.ts | 4 +-
.../src/client/contract/records.ts | 13 +-
.../src/client/conversation/assembler.ts | 2 +-
.../src/client/conversation/assembly.ts | 5 +-
.../ui-skill/tests/skill-row.client.spec.tsx | 5 +-
.../ui-tool/src/client/tool/ToolDetails.tsx | 4 +-
.../src/client/tool/models/diff-card-model.ts | 92 ++---
.../src/client/tool/models/raw-tool-call.ts | 64 ++++
.../src/client/tool/models/read-card-model.ts | 111 +++---
.../client/tool/models/search-card-model.ts | 200 ++++-------
.../client/tool/models/terminal-card-model.ts | 182 ++++++----
.../src/client/tool/models/tool-call-model.ts | 5 +-
.../src/client/tool/models/web-card-model.ts | 142 ++++----
.../client/tool/toolviews/GenericToolCard.tsx | 2 +-
.../src/client/tool/toolviews/bash-sample.tsx | 6 +-
.../src/client/tool/toolviews/search-row.tsx | 2 +-
.../tests/ask-question-row.client.spec.tsx | 4 +-
.../tests/assembly-surfaces.client.spec.tsx | 12 +-
.../tests/chat-code-subcalls.client.spec.tsx | 12 +-
.../tests/coverage-tails.client.spec.tsx | 8 +-
.../ui-tool/tests/diff-card.client.spec.tsx | 137 ++++----
.../ui-tool/tests/read-card.client.spec.tsx | 103 +++---
.../ui-tool/tests/search-card.client.spec.tsx | 164 +++++----
.../tests/terminal-card.client.spec.tsx | 330 +++++++++---------
.../ui-tool/tests/todo-row.client.spec.tsx | 6 +-
.../tests/tool-call-tree.client.spec.tsx | 25 +-
.../tests/tool-details-render.client.tsx | 10 +-
.../ui-tool/tests/tool-row.client.spec.tsx | 4 +-
.../tests/toolview-slot.client.spec.tsx | 2 +-
.../ui-tool/tests/web-card.client.spec.tsx | 93 +++--
.../src/client/trajectory-tool-definition.ts | 11 +-
.../conversation-definitions.client.spec.ts | 40 ++-
.../tests/layout.client.spec.tsx | 18 +-
.../tests/snapshot-builder.client.spec.ts | 1 -
.../ui-trajectory/tests/views.client.spec.tsx | 2 +-
.../ui-cordis/tests/card-model.client.spec.ts | 4 +-
.../ui-cordis/tests/versioning.client.spec.ts | 4 -
45 files changed, 1004 insertions(+), 905 deletions(-)
create mode 100644 packages/client/ui-tool/src/client/tool/models/raw-tool-call.ts
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts
index 20ce57831f..b1f675c4e4 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts
+++ b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts
@@ -45,7 +45,6 @@ function rootCall(match: ConversationMatch): RunningToolCall {
turn: match.event.data.turn,
step: match.event.data.step,
time: match.event.time,
- callView: match.view?.for === 'call' ? match.view.view : null,
subCalls: [],
}
}
@@ -64,8 +63,6 @@ function rootResult(match: ConversationMatch, previous?: RunningToolCall): ToolR
isError: result.isError === true,
...match.event.data.error === undefined ? {} : { error: match.event.data.error },
meta: match.event.data.meta,
- callView: previous?.callView ?? null,
- resultView: match.view?.for === 'result' ? match.view.view : null,
subCalls: [],
}
}
@@ -82,12 +79,12 @@ interface DispatchData {
function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall {
return {
callId: data.subCallId,
+ parentCallId: data.parentCallId,
name: data.name,
argsRaw: jsonArguments(data.arguments),
turn: locationTurn(match),
step: locationStep(match),
time: match.event.time,
- callView: null,
subCalls: [],
}
}
@@ -98,12 +95,11 @@ function childResult(match: ConversationMatch, data: DispatchData, previous?: To
seq: match.event.seq,
time: match.event.time,
callId: data.subCallId,
+ parentCallId: data.parentCallId,
call: { name: data.name, argsRaw: jsonArguments(data.arguments) },
callTime: previous?.time ?? null,
content: data.content ?? [],
isError: data.isError === true,
- callView: null,
- resultView: null,
subCalls: [],
}
}
@@ -197,13 +193,12 @@ function projectBlock(
seq: interruptedAt.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedFollowup,
time: interruptedAt.time,
callId: block.callId,
+ ...block.parentCallId === undefined ? {} : { parentCallId: block.parentCallId },
call: { name: block.name, argsRaw: block.argsRaw },
callTime: block.time,
content: [],
isError: true,
error: { name: 'Interrupted', code: 'interrupted' },
- callView: block.callView,
- resultView: null,
subCalls: children,
}
projectedBlocks.set(block, { children, interruptionSeq, interruptionTime, value: projected })
diff --git a/packages/client/ui-chat/src/client/details/DetailsPanel.tsx b/packages/client/ui-chat/src/client/details/DetailsPanel.tsx
index d3109076f8..05c997e1fe 100644
--- a/packages/client/ui-chat/src/client/details/DetailsPanel.tsx
+++ b/packages/client/ui-chat/src/client/details/DetailsPanel.tsx
@@ -47,8 +47,8 @@ function rawResultText(block: ToolCallBlock): string {
export function DetailsPanel({ useChat, useSessions, sessionId, useStore, renderSlot, closeDetails, t }: DetailsPanelProps) {
const selection = useStore(s => s.selection)
- // Session workspace root: an omitted or relative terminal cwd resolves
- // against it, which the pure presenter cannot see.
+ // Session workspace root: a card model resolves omitted or relative
+ // tool paths against it without reading Session services.
const sessionCwd = useSessions(list => list.byId[sessionId]?.cwd)
const callId = selection?.callId
// materialFor builds a fresh wrapper; shallowEqual short-circuits on its
@@ -56,7 +56,6 @@ export function DetailsPanel({ useChat, useSessions, sessionId, useStore, render
const material = useChat(
s => (callId === undefined ? null : materialFor(s, callId)),
(a, b) => shallowEqual(a, b))
-
return (
diff --git a/packages/client/ui-chat/src/client/model/tool-call-tree.ts b/packages/client/ui-chat/src/client/model/tool-call-tree.ts
index 705bc481dc..ce311f5291 100644
--- a/packages/client/ui-chat/src/client/model/tool-call-tree.ts
+++ b/packages/client/ui-chat/src/client/model/tool-call-tree.ts
@@ -59,12 +59,12 @@ export class ToolCallTree {
const data = event.data
const running: RunningToolCall = {
callId: data.subCallId,
+ parentCallId: data.parentCallId,
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) ?? []
@@ -84,12 +84,11 @@ export class ToolCallTree {
seq: event.seq,
time: event.time,
callId: data.subCallId,
+ parentCallId: data.parentCallId,
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(
diff --git a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx
index 1600ccfdef..2332c35517 100644
--- a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx
@@ -78,7 +78,7 @@ describe('deriveStats', () => {
it('ignores tool results with no call time', () => {
const tool: ToolResultNode = {
kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [],
- isError: false, callView: null, resultView: null, subCalls: [],
+ isError: false, subCalls: [],
}
const stats = deriveStats([tool, assistant(1, 1)])
expect(stats.steps).toBe(1)
@@ -96,7 +96,7 @@ describe('deriveStats', () => {
}
const tool: ToolResultNode = {
kind: 'tool-result', seq: 5, time: 7_000, callId: 'c', call: null, callTime: 4_000, content: [],
- isError: false, callView: null, resultView: null, subCalls: [],
+ isError: false, subCalls: [],
}
const stats = deriveStats([timed, untimed, tool])
expect(stats.llmMs).toBe(2_500)
diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
index 30f25e2277..d6ccc631e6 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -137,10 +137,10 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
callTime: seq * 1_000 - 500,
- content: [], isError: false, callView: null, resultView: null, subCalls: [],
+ content: [], isError: false, subCalls: [],
})
const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
- callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, subCalls: [],
+ callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, subCalls: [],
})
const command = (over: Partial
= {}): CommandNode => ({
kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'],
@@ -361,17 +361,14 @@ function installScrollMetrics(element: HTMLElement, initialHeight: number, clien
describe('Chat node rendering', () => {
it('threads the injected file-mention vocabulary into the closing prose only', () => {
- const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({
+ const wrote = (seq: number, callId: string): ToolResultNode => ({
...toolResult(seq, callId, 'write'),
- callView: {
- card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }],
- },
})
const h = makeHarness({
nodes: [
user(1, 'build it'),
assistant(2, 'writing `report.html` now', 1),
- wrote(3, 'w', 'site/report.html'),
+ wrote(3, 'w'),
assistant(4, 'Wrote `report.html`; `notes.md` untouched.', 1),
],
turnEnds: new Map([[1, 4]]),
diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
index 8b3b0f5eea..88b103ae66 100644
--- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
+++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
@@ -106,7 +106,7 @@ function assistantMessage(id: string, text: string) {
}
}
-function toolResult(callId: string, text: string) {
+function toolResult(callId: string, text: string, isError = false) {
return {
id: `result-${callId}`,
role: 'user',
@@ -115,7 +115,7 @@ function toolResult(callId: string, text: string) {
type: 'tool-result',
toolCallId: callId,
content: [{ type: 'text', text }],
- isError: false,
+ isError,
}],
}
}
@@ -310,7 +310,9 @@ describe('built-in conversation node Definitions', () => {
value.append(at(4, 'tool/result', {
turn: 1,
step: 1,
- message: toolResult('root', 'done'),
+ message: toolResult('root', 'done', true),
+ error: { name: 'ToolError', code: 'failed' },
+ meta: { presentation: 'raw' },
}, { surfaceOp: 'append' }))
value.flush()
@@ -318,7 +320,15 @@ describe('built-in conversation node Definitions', () => {
const settled = node(settledSnapshot, 'tool-call')
expect(settled?.key).toBe(running?.key)
expect(settledSnapshot.order).toBe(order)
- expect((settled?.data as ToolChatData).root).toMatchObject({ kind: 'tool-result', callId: 'root' })
+ expect((settled?.data as ToolChatData).root).toMatchObject({
+ kind: 'tool-result',
+ callId: 'root',
+ call: { name: 'code', argsRaw: '{}' },
+ content: [{ type: 'text', text: 'done' }],
+ isError: true,
+ error: { name: 'ToolError', code: 'failed' },
+ meta: { presentation: 'raw' },
+ })
const history = assembler([
at(14, 'tool/code-dispatch-start', {
@@ -345,7 +355,7 @@ describe('built-in conversation node Definitions', () => {
], true)
const before = node(snapshot(history), 'tool-call')
expect((before?.data as ToolChatData).root.subCalls).toMatchObject([
- { kind: 'tool-result', callId: 'child', call: { name: 'read' } },
+ { kind: 'tool-result', callId: 'child', parentCallId: 'history-root', call: { name: 'read' } },
])
history.prepend([
@@ -364,7 +374,7 @@ describe('built-in conversation node Definitions', () => {
const after = node(snapshot(history), 'tool-call')
expect(after?.key).toBe(before?.key)
expect((after?.data as ToolChatData).root.subCalls).toMatchObject([
- { kind: 'tool-result', callId: 'child', call: { name: 'read' } },
+ { kind: 'tool-result', callId: 'child', parentCallId: 'history-root', call: { name: 'read' } },
])
const firstChild = (after?.data as ToolChatData).root.subCalls[0]
@@ -1011,7 +1021,6 @@ describe('built-in conversation node Definitions', () => {
// behavior of both required Definition members anyway.
const match = (seq: number, type: string, data: unknown) => ({
event: { seq, time: seq * 1_000, type, data },
- view: undefined,
role: 'start',
location: undefined,
}) as unknown as Parameters[1]
diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx
index f8f050d682..d4e4022e1c 100644
--- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx
+++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx
@@ -168,22 +168,24 @@ describe('render branch tails', () => {
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
})
- it('DetailsPanel resolves a nested run_code leaf to its full logged args and output', () => {
+ it('DetailsPanel passes the existing parentCallId through to the Tool details seat', () => {
localStorage.clear()
const session = sessionSnapshot()
const longText = 'x'.repeat(1_000)
const runningCalls: readonly RunningToolCall[] = [{
callId: 'p1', name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
- time: 7_000, callView: null, subCalls: [{
+ time: 7_000, subCalls: [{
kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1',
+ parentCallId: 'p1',
call: { name: 'run_code', argsRaw: '{"code":"return 1"}' },
callTime: 8_000,
- content: [], isError: false, callView: null, resultView: null,
+ content: [], isError: false,
subCalls: [{
kind: 'tool-result', seq: 9, time: 9_000, callId: 'p1:code:1:code:1',
+ parentCallId: 'p1:code:1',
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
callTime: 8_500,
- content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null,
+ content: [{ type: 'text', text: longText }], isError: false,
subCalls: [],
}],
}],
@@ -224,13 +226,14 @@ describe('render branch tails', () => {
t={t}
/>,
)
- // Chat resolves the selected sub-call and hands its complete
- // frozen block to the Tool-owned details seat.
+ // Chat resolves the selected sub-call and keeps its Code Dispatch parent
+ // identity on the block handed to the Tool-owned details seat.
expect(view.getByText('read')).toBeTruthy()
expect(view.getByTestId('tool-details-seat')).toBeTruthy()
expect(owners).toHaveLength(1)
expect(owners[0]?.block).toMatchObject({
callId: 'p1:code:1:code:1',
+ parentCallId: 'p1:code:1',
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
content: [{ type: 'text', text: longText }],
})
diff --git a/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts b/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts
index 28cfda51be..d53c34e69c 100644
--- a/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts
+++ b/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts
@@ -21,7 +21,7 @@ const settle = (seq: number, parentCallId: string, subCallId: string): SessionEv
const root = (callId: string): RunningToolCall => ({
callId, name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
- time: 1_700_000_000_000, callView: null, subCalls: [],
+ time: 1_700_000_000_000, subCalls: [],
})
describe('ToolCallTree', () => {
@@ -42,8 +42,8 @@ describe('ToolCallTree', () => {
expect(tree.projectRunningCalls([root('a')])).toMatchObject([{
callId: 'a',
subCalls: [{
- callId: 'b',
- subCalls: [{ callId: 'c', subCalls: [] }],
+ callId: 'b', parentCallId: 'a',
+ subCalls: [{ callId: 'c', parentCallId: 'b', subCalls: [] }],
}],
}])
})
diff --git a/packages/client/ui-conversation/src/client/contract/conversation.ts b/packages/client/ui-conversation/src/client/contract/conversation.ts
index 01a019802d..d8954bb8cf 100644
--- a/packages/client/ui-conversation/src/client/contract/conversation.ts
+++ b/packages/client/ui-conversation/src/client/contract/conversation.ts
@@ -1,14 +1,12 @@
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
-import type { SessionToolView } from '@deepseek-ai/dsh-api-session-controller/types'
/* oxlint-disable typescript/no-duplicate-type-constituents, typescript/no-redundant-type-constituents --
* The unaugmented declaration-merge maps intentionally resolve to never in the Runtime program;
* installed business packages supply their concrete keys in consuming Client programs. */
-/** One raw log event plus its optional envelope-level presentation view. */
+/** One raw Session log event consumed by Conversation assembly. */
export interface ConversationEventInput {
readonly event: SessionEvent
- readonly view?: SessionToolView
}
/** Definition-local identity and lifecycle role extracted from one event. */
diff --git a/packages/client/ui-conversation/src/client/contract/records.ts b/packages/client/ui-conversation/src/client/contract/records.ts
index a03ac77ce7..0b43e1fa55 100644
--- a/packages/client/ui-conversation/src/client/contract/records.ts
+++ b/packages/client/ui-conversation/src/client/contract/records.ts
@@ -8,9 +8,6 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
-import type {
- ToolCallView, ToolResultView,
-} from '@deepseek-ai/dsh-api-remotes/client'
import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts'
export type { TodoItem }
@@ -161,6 +158,8 @@ export interface ToolResultNode {
/** Unix epoch ms from the tool/result session event. */
time: number
callId: string
+ /** Parent Tool call for a Code Dispatch result; absent on a root Session result. */
+ parentCallId?: string
/** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */
call: { name: string; argsRaw: string } | null
/** Unix epoch ms of the paired tool/call when the call is still in-window; used for call-row duration. */
@@ -169,10 +168,6 @@ export interface ToolResultNode {
isError: boolean
error?: { name: string; code: string }
meta?: unknown
- /** Host-computed render intent from the paired tool/call's wire view; null = generic JSON card (documented default). */
- callView: ToolCallView | null
- /** Host-computed render intent from this tool/result's wire view; null = same default. */
- resultView: ToolResultView | null
/** Child calls owned by this call, in dispatch order. */
subCalls: readonly ToolCallBlock[]
}
@@ -268,14 +263,14 @@ export type ConversationNode =
/** In-flight tool card material: tool/call seen, tool/result not yet. */
export interface RunningToolCall {
callId: string
+ /** Parent Tool call for a Code Dispatch start; absent on a root Session call. */
+ parentCallId?: string
name: string
argsRaw: string
turn: number
step: number
/** Unix epoch ms when the tool/call event was logged. */
time: number
- /** Host-computed render intent riding the tool/call frame; null = generic JSON card. */
- callView: ToolCallView | null
/** Child calls owned by this call, in dispatch order. */
subCalls: readonly ToolCallBlock[]
}
diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts
index 4fedf47572..a51b531bfe 100644
--- a/packages/client/ui-conversation/src/client/conversation/assembler.ts
+++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts
@@ -189,7 +189,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
/**
* Add one contiguous live tail event without scanning existing Contexts.
- * @param input - appended Event and optional wire view.
+ * @param input - appended Session event.
* @returns highest requested publication cadence.
*/
append(input: ConversationEventInput): ConversationPublication {
diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts
index cf0c4380b7..58ea1bf79d 100644
--- a/packages/client/ui-conversation/src/client/conversation/assembly.ts
+++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts
@@ -128,10 +128,7 @@ class BoundConversation implements ConversationBinding {
}
function conversationInput(entry: SessionEventEntry): ConversationEventInput {
- return {
- event: entry.event as unknown as SessionEvent,
- ...(entry.view === undefined ? {} : { view: entry.view }),
- }
+ return { event: entry.event as unknown as SessionEvent }
}
interface BindingRecord {
diff --git a/packages/client/ui-skill/tests/skill-row.client.spec.tsx b/packages/client/ui-skill/tests/skill-row.client.spec.tsx
index 432a45e9aa..daac0ed0de 100644
--- a/packages/client/ui-skill/tests/skill-row.client.spec.tsx
+++ b/packages/client/ui-skill/tests/skill-row.client.spec.tsx
@@ -24,8 +24,6 @@ function settled(over: Partial = {}): ToolResultNode {
callTime: 2_000,
content: [{ type: 'text', text: 'Follow the issue workflow.\nKeep project fields in sync.' }],
isError: false,
- callView: null,
- resultView: null,
subCalls: [],
...over,
}
@@ -33,7 +31,7 @@ function settled(over: Partial = {}): ToolResultNode {
function running(argsRaw = '{"name":"dsh-manage-issues"}'): RunningToolCall {
return {
- callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, callView: null, subCalls: [],
+ callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, subCalls: [],
}
}
@@ -42,6 +40,7 @@ function props(block: SkillRowProps['block'], inspect?: () => void): SkillRowPro
callId: block.callId,
toolName: 'skill',
block,
+
openFile: vi.fn(),
inspect,
t,
diff --git a/packages/client/ui-tool/src/client/tool/ToolDetails.tsx b/packages/client/ui-tool/src/client/tool/ToolDetails.tsx
index ec908af84a..42ddd41824 100644
--- a/packages/client/ui-tool/src/client/tool/ToolDetails.tsx
+++ b/packages/client/ui-tool/src/client/tool/ToolDetails.tsx
@@ -13,8 +13,8 @@ import { webCardModel } from './models/web-card-model.ts'
import css from './ToolDetails.module.css'
/**
- * Render the selected Tool call's structured output when its presentation
- * intent is known, otherwise preserve the flattened result text.
+ * Render the selected Tool call's structured output when its raw fields form a
+ * supported root card, otherwise preserve the flattened result text.
* @param props - selected call slice, workspace root, host home, and locale seat.
* @returns the details output body.
*/
diff --git a/packages/client/ui-tool/src/client/tool/models/diff-card-model.ts b/packages/client/ui-tool/src/client/tool/models/diff-card-model.ts
index ec19f1cce0..eed7826594 100644
--- a/packages/client/ui-tool/src/client/tool/models/diff-card-model.ts
+++ b/packages/client/ui-tool/src/client/tool/models/diff-card-model.ts
@@ -1,14 +1,7 @@
-/**
- * Pure derivation of the diff-card props from a frozen call slice: the
- * `card:'diff'` render intent the write/edit tools declare arrives on the
- * snapshot as `callView`/`resultView`, and this is the one place that turns
- * that pair into what {@link DiffBlock} draws. Both conversation render sites
- * (the chat tool row's expanded body and the details panel's Output section)
- * call this, so the hunks they show are derived once.
- * @module
- */
+/** Pure diff-card derivation from raw write/edit calls and result metadata. @module */
import type { DiffBlockProps, DiffHunk } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolCallBlock } from './tool-call-model.ts'
+import { parsedToolCall, validEscalationFields } from './raw-tool-call.ts'
/**
* Diff-body lines the chat row shows before collapsing the middle — half the
@@ -36,13 +29,8 @@ export interface DiffCardModel {
}
/**
- * Narrow a wire `card:'diff'` view's `diffs` to well-formed hunks. The event
- * view crosses the wire and `toolEventViewSchema` validates only the `card`
- * string, so a version mismatch or an anomalous plugin can deliver a `diff` card
- * whose `diffs` is absent, not an array, or carries malformed hunks. Returning
- * null for any of those routes the block to the generic path instead of letting
- * DiffBlock's `for...of`/`split` throw and crash the row or the details panel.
- * @param diffs - the view's `diffs` field, unverified.
+ * Narrow opaque result metadata's `diffs` to well-formed hunks.
+ * @param diffs - the metadata field to validate.
* @returns the validated hunks, or null when the payload is not usable.
*/
function narrowDiffs(diffs: unknown): DiffHunk[] | null {
@@ -59,39 +47,51 @@ function narrowDiffs(diffs: unknown): DiffHunk[] | null {
return out
}
+type IntendedDiff = { tool: 'write' | 'edit'; diff: DiffHunk }
+
+function intendedDiff(block: ToolCallBlock): IntendedDiff | null {
+ const parsed = parsedToolCall(block)
+ if (parsed === null) return null
+ const { file_path: path } = parsed.args
+ if (typeof path !== 'string' || path.trim() === '') return null
+ if (!validEscalationFields(parsed.args)) return null
+ if (parsed.name === 'write') {
+ const { content } = parsed.args
+ return typeof content === 'string'
+ ? { tool: 'write', diff: { path, oldText: null, newText: content } }
+ : null
+ }
+ if (parsed.name !== 'edit') return null
+ const { old_string: oldText, new_string: newText, replace_all: replaceAll } = parsed.args
+ if (typeof oldText !== 'string' || typeof newText !== 'string') return null
+ if (replaceAll !== undefined && typeof replaceAll !== 'boolean') return null
+ return { tool: 'edit', diff: { path, oldText: oldText || null, newText } }
+}
+
+function appliedDiffs(meta: unknown): DiffHunk[] | 'empty' | null {
+ if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return null
+ const diffs = (meta as Record).diffs
+ if (!Array.isArray(diffs)) return null
+ if (diffs.length === 0) return 'empty'
+ return narrowDiffs(diffs)
+}
+
/**
- * Derive the diff-card props for a tool call, or null when this call is not a
- * diff card and belongs on the generic path.
- *
- * The result side is authoritative once the call settles: the write/edit tools
- * return the applied contextual hunks there (an edit's real before/after, a
- * create's whole-file diff), which replace the call-time diff derived from the
- * arguments alone. While the call is still running only the call side exists,
- * so a running write/edit shows its intended change. Null is the documented
- * generic-card default and covers every non-diff card — including a `card`
- * value this UI version does not know, which arrives over the wire and cannot
- * be trusted to be one of the compiled variants — and a settled call whose
- * result view is generic (how write/edit keep their execution errors on the
- * generic path).
- *
- * This derivation consumes only `diffs`; the render intent's `title` field is
- * deliberately dropped. The row supplies its own title (`Edit`/`Write · path`
- * from the args), which outranks the view's `title`. A tool that names its own
- * diff header therefore does not surface that text on the Web row.
- * @param block - RunningToolCall or ToolResultNode off the snapshot caches.
+ * Derive intended running or applied settled diffs for a root write/edit call.
+ * A successful write with valid empty metadata uses its argument-derived
+ * whole-file diff, matching create and identical-overwrite presentation.
+ * @param block - running or settled Tool block.
* @returns the diff-card props, or null for the generic path.
*/
export function diffCardModel(block: ToolCallBlock): DiffCardModel | null {
- if (!('kind' in block)) {
- // Running: the call view may carry the intended diff; the result is absent.
- const call = block.callView?.card === 'diff' ? block.callView : null
- const diffs = call === null ? null : narrowDiffs(call.diffs)
- return diffs === null ? null : { card: { diffs } }
+ if (block.parentCallId !== undefined) return null
+ const intended = intendedDiff(block)
+ if (intended === null) return null
+ if (!('kind' in block)) return { card: { diffs: [intended.diff] } }
+ if (block.isError) return null
+ const applied = appliedDiffs(block.meta)
+ if (applied === null || applied === 'empty') {
+ return intended.tool === 'write' ? { card: { diffs: [intended.diff] } } : null
}
- // Settled: the result view's applied hunks replace the call-time diff. A
- // window that dropped the call head leaves only the result, which still
- // renders — the result view carries the whole change.
- const result = block.resultView?.card === 'diff' ? block.resultView : null
- const diffs = result === null ? null : narrowDiffs(result.diffs)
- return diffs === null ? null : { card: { diffs } }
+ return { card: { diffs: applied } }
}
diff --git a/packages/client/ui-tool/src/client/tool/models/raw-tool-call.ts b/packages/client/ui-tool/src/client/tool/models/raw-tool-call.ts
new file mode 100644
index 0000000000..8d3dad773a
--- /dev/null
+++ b/packages/client/ui-tool/src/client/tool/models/raw-tool-call.ts
@@ -0,0 +1,64 @@
+/** Shared narrowing for raw Tool call and result fields consumed by card models. */
+import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
+
+/** A parsed, in-window Tool call whose arguments are a JSON object. */
+export interface ParsedToolCall {
+ name: string
+ args: Record
+}
+
+const parsedCalls = new WeakMap()
+
+/**
+ * Parse the call head paired with one immutable Tool block.
+ * @param block - running or settled Tool block.
+ * @returns the Tool name and object arguments, or null when the call head or valid JSON object is unavailable.
+ */
+export function parsedToolCall(block: ToolCallBlock): ParsedToolCall | null {
+ const cached = parsedCalls.get(block)
+ if (cached !== undefined || parsedCalls.has(block)) return cached ?? null
+ const call = 'kind' in block ? block.call : block
+ if (call === null) {
+ parsedCalls.set(block, null)
+ return null
+ }
+ let value: unknown
+ try {
+ value = JSON.parse(call.argsRaw)
+ } catch {
+ parsedCalls.set(block, null)
+ return null
+ }
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+ parsedCalls.set(block, null)
+ return null
+ }
+ const parsed = { name: call.name, args: value as Record }
+ parsedCalls.set(block, parsed)
+ return parsed
+}
+
+/**
+ * Read the exact single text block consumed by first-party card derivations.
+ * @param block - settled Tool result.
+ * @returns its text, or undefined for any other content layout.
+ */
+export function singleResultText(block: ToolResultNode): string | undefined {
+ if (block.content.length !== 1) return undefined
+ const only = block.content[0]
+ return only?.type === 'text' ? only.text : undefined
+}
+
+/**
+ * Validate the optional escalation pair shared by first-party shell and file
+ * mutation tools.
+ * @param args - parsed open-root Tool arguments.
+ * @returns whether the declared escalation fields form a valid pair.
+ */
+export function validEscalationFields(args: Record): boolean {
+ const permission = args.sandbox_permissions
+ const justification = args.justification
+ if (permission === undefined && justification === undefined) return true
+ if (permission !== 'workspace-write' && permission !== 'danger-full-access') return false
+ return typeof justification === 'string' && justification.trim() !== ''
+}
diff --git a/packages/client/ui-tool/src/client/tool/models/read-card-model.ts b/packages/client/ui-tool/src/client/tool/models/read-card-model.ts
index b00915a433..a5a8fffdfe 100644
--- a/packages/client/ui-tool/src/client/tool/models/read-card-model.ts
+++ b/packages/client/ui-tool/src/client/tool/models/read-card-model.ts
@@ -1,21 +1,8 @@
-/**
- * Pure derivation of the read-card props from a frozen call slice: the
- * `card:'read'` render intent the read tool declares arrives on the snapshot as
- * the settled result node's `resultView`, and this is the one place that turns
- * it into what {@link ReadBlock} draws. Both conversation render sites (the chat
- * tool row's resident body and the details panel's Output section) call this, so
- * the path, lines, total, and language they show are derived once.
- *
- * The read card is result-side only ([read card note](../../../../../../.agents/notes/implemented/feature/2026-07-30-web-read-card.md)):
- * a call carries no file content until `execute` returns, so the pending call
- * stays a generic card (`kind: 'read'`). A running read therefore has no read
- * card, and this returns null for it — the row keeps its args-derived summary
- * until the result arrives.
- * @module
- */
+/** Pure read-card derivation from raw result content and metadata. @module */
import type { ReadBlockLine, ReadBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path'
import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts'
+import { parsedToolCall, singleResultText } from './raw-tool-call.ts'
/**
* Content lines the chat row's resident read body shows before collapsing the
@@ -36,43 +23,75 @@ export const CHAT_READ_MAX_LINES = 8
*/
export type ReadCardModel = Pick
+interface ReadMeta {
+ path: string
+ offset: number
+ lines: ReadBlockLine[]
+ totalLines: number
+ lang?: string
+}
+
+function validReadCall(block: ToolCallBlock): boolean {
+ const call = parsedToolCall(block)
+ if (call?.name !== 'read') return false
+ const { file_path: path, offset, limit } = call.args
+ if (typeof path !== 'string' || path.trim() === '') return false
+ if (offset !== undefined && (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 1)) return false
+ if (limit !== undefined && (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1)) return false
+ return true
+}
+
+function readMeta(meta: unknown): ReadMeta | null {
+ if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return null
+ const { path, offset, lines, totalLines, lang } = meta as Record
+ if (typeof path !== 'string' || typeof offset !== 'number' || !Number.isInteger(offset) || offset < 1) return null
+ if (typeof totalLines !== 'number' || !Number.isInteger(totalLines) || totalLines < 0 || !Array.isArray(lines)) return null
+ if (lang !== undefined && typeof lang !== 'string') return null
+ const narrowed: ReadBlockLine[] = []
+ let previous = offset - 1
+ for (const line of lines) {
+ if (typeof line !== 'object' || line === null || Array.isArray(line)) return null
+ const { number, text } = line as Record
+ if (typeof number !== 'number' || !Number.isInteger(number) || number < 1 || number <= previous) return null
+ if (number > totalLines || typeof text !== 'string') return null
+ previous = number
+ narrowed.push({ number, text })
+ }
+ return {
+ path,
+ offset,
+ lines: narrowed,
+ totalLines,
+ ...lang === undefined ? {} : { lang },
+ }
+}
+
/**
- * Derive the read-card props for a tool call, or null when this call is not a
- * read card and belongs on the generic path.
- *
- * The read card is result-side only, so only a settled call whose result view
- * declares `card:'read'` produces one. Every other case is null — the
- * documented generic-card default:
- *
- * - A running call: it has no result view yet, and a read carries no content at
- * call time.
- * - A settled call whose result view is not a read card — including a `card`
- * value this UI version does not know, which arrives over the wire and cannot
- * be trusted to be one of the compiled variants, and the read tool's own
- * generic fallback for an error result or a non-envelope body.
- *
- * The label is the read view's `title` when the tool supplied one (the
- * presentation contract's replacement-title rule), otherwise the file path
- * shortened the same way the row summary is: workspace-relative first, then
- * POSIX `~` for a leftover host-home path.
- * @param block - RunningToolCall or ToolResultNode off the snapshot caches.
+ * Derive a settled root read card after validating its persisted metadata and
+ * model-facing read envelope.
+ * @param block - running or settled Tool block.
* @param sessionCwd - the session workspace root; a workspace-rooted absolute
* path label displays relative to it. Absent leaves the path as authored.
* @param home - host account home; a leftover POSIX home path displays as `~`.
* @returns the read-card props, or null for the generic path.
*/
-export function readCardModel(block: ToolCallBlock, sessionCwd?: string, home?: string): ReadCardModel | null {
- // Running has no result view; a read carries no content until execute returns.
- if (!('kind' in block)) return null
- const result = block.resultView?.card === 'read' ? block.resultView : null
- if (result === null) return null
- // Lines arrive frozen off the snapshot; copy into the primitive's own line
- // shape so the card never holds a reference into the runtime's cache.
- const lines: ReadBlockLine[] = result.lines.map(line => ({ number: line.number, text: line.text }))
+export function readCardModel(
+ block: ToolCallBlock,
+ sessionCwd?: string,
+ home?: string,
+): ReadCardModel | null {
+ if (block.parentCallId !== undefined || !('kind' in block) || block.isError) return null
+ if (!validReadCall(block)) return null
+ const meta = readMeta(block.meta)
+ if (meta === null) return null
+ const text = singleResultText(block)
+ if (text === undefined) return null
+ const body = /^[^\n]*<\/path>\nfile<\/type>\n\n([\s\S]*)\n<\/content>$/u.exec(text)?.[1]
+ if (body === undefined) return null
return {
- label: result.title ?? abbreviateHomePath(relativizeToCwd(result.path, sessionCwd), home),
- lines,
- totalLines: result.totalLines,
- lang: result.lang,
+ label: abbreviateHomePath(relativizeToCwd(meta.path, sessionCwd), home),
+ lines: meta.lines,
+ totalLines: meta.totalLines,
+ lang: meta.lang,
}
}
diff --git a/packages/client/ui-tool/src/client/tool/models/search-card-model.ts b/packages/client/ui-tool/src/client/tool/models/search-card-model.ts
index 4536833215..cc4d25b6b5 100644
--- a/packages/client/ui-tool/src/client/tool/models/search-card-model.ts
+++ b/packages/client/ui-tool/src/client/tool/models/search-card-model.ts
@@ -1,109 +1,71 @@
-/**
- * Pure derivation of the search-card props from a frozen call slice: the
- * `card:'search'` render intent the `grep` and `glob` tools declare arrives on
- * the snapshot as `resultView`, and this is the one place that turns it into
- * what {@link SearchBlock} draws. Both conversation render sites (the chat tool
- * row's resident body and the details panel's Output section) call this, so the
- * grouped matches or the path list they show are derived once.
- *
- * The search card is result-time only: a search call has no matches or paths
- * before `execute`, so its pending state stays a `GenericCallView`
- * ({@link module:@deepseek-ai/dsh-tools/src/presentation}). This derivation
- * therefore reads only `resultView` and returns null for a still-running call,
- * unlike the terminal card whose call view carries the command before
- * execution.
- *
- * A capped result also carries a recovery locator (grep/glob's `Full … stored
- * at …` footer) in the raw `tool/result` content, not in the structured
- * matches/paths the view carries. Since both render sites replace that raw
- * result with the card, this derivation surfaces the block's own result text as
- * {@link SearchCardModel.recovery} so the one path to the dropped rows is not
- * lost.
- * @module
- */
+/** Pure search-card derivation from raw grep/glob result metadata. @module */
import type { SearchBlockProps, SearchFileGroup } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolCallBlock } from './tool-call-model.ts'
+import { parsedToolCall } from './raw-tool-call.ts'
-/**
- * Distributive `Omit`: a plain `Omit` keeps only the keys common to
- * both members, which would drop the `files`/`paths` discriminated fields.
- * Distributing over the naked type parameter `T` preserves each shape.
- */
type DistributiveOmit = T extends unknown ? Omit : never
/** The {@link SearchBlockProps} union minus each render site's own fields. */
type SearchBlockModelProps = DistributiveOmit
-/**
- * Result rows the chat row's resident search body shows before collapsing the
- * middle — half the primitive's own default, which the details panel keeps. A
- * chat row is a summary surface inside the message flow: the flow must stay
- * scannable across many calls, while the details panel is the single-call
- * reading surface. A design constant of this UI's row geometry, not a
- * deployment choice, so it is fixed here rather than a plugin Config field.
- */
+/** Result rows retained in a Chat card before its middle collapses. */
export const CHAT_SEARCH_MAX_LINES = 8
-/**
- * The {@link SearchBlock} props this derivation owns. Held as a nested object
- * (`card`) so a render site spreads exactly the primitive's own surface and can
- * never leak a neighbouring field into it. `maxLines`/`className` belong to each
- * render site.
- */
+/** Search-card props plus an optional locator for a capped full result. */
export interface SearchCardModel {
- /**
- * The props {@link SearchBlock} draws, minus each render site's own
- * `maxLines`/`className`.
- */
+ /** Props consumed by {@link SearchBlock}. */
card: SearchBlockModelProps
- /**
- * The result view's replacement title, which the presentation contract lets a
- * search tool set at settle time. Absent when the presenter supplied none; a
- * row then keeps its args-derived summary.
- */
- title: string | undefined
- /**
- * The raw `tool/result` text, flattened, surfaced only when the search was
- * capped. The card renders the retained matches or paths, but the recovery
- * locator a capped result carries — grep/glob's `Full … stored at: `
- * footer, the one way to reach the rows the cap dropped — lives only in the raw
- * result text, which the card replaces. A UI that shows the card would
- * otherwise lose it. Absent when the result was not capped (the card holds
- * every result) or the block carries no text.
- */
+ /** Raw result text containing the full-result locator for a capped search. */
recovery: string | undefined
}
-/**
- * Whether every file group in a matches view is structurally valid: the wire
- * frame carries `shape` and `card` as strings the host schema checks, but not the
- * grouped `files` fields, so a version mismatch or loose producer could deliver
- * `shape: 'matches'` with a missing or malformed `files`. Rendering that would
- * crash {@link SearchBlock} at `.reduce`/`.map`; invalid fields select the
- * generic path instead.
- * @param files - the candidate `files` field off the untrusted result view.
- * @returns whether `files` is a valid {@link SearchFileGroup} array.
- */
-function isValidFiles(files: unknown): files is SearchFileGroup[] {
- return Array.isArray(files) && files.every(file =>
- typeof file === 'object' && file !== null
- && typeof (file as { path?: unknown }).path === 'string'
- && Array.isArray((file as { matches?: unknown }).matches)
- && (file as { matches: unknown[] }).matches.every(match =>
- typeof match === 'object' && match !== null
- && typeof (match as { lineNumber?: unknown }).lineNumber === 'number'
- && typeof (match as { line?: unknown }).line === 'string'))
+function validSearchCall(block: ToolCallBlock): 'grep' | 'glob' | null {
+ const call = parsedToolCall(block)
+ if (call === null) return null
+ const { pattern, path } = call.args
+ if (typeof pattern !== 'string') return null
+ if (call.name === 'grep' && pattern === '') return null
+ if (call.name === 'glob' && pattern.trim() === '') return null
+ if (call.name !== 'grep' && call.name !== 'glob') return null
+ if (path !== undefined && (typeof path !== 'string' || path.trim() === '')) return null
+ if (call.name === 'grep') {
+ const { include } = call.args
+ if (include !== undefined && (typeof include !== 'string' || !validInclude(include))) return null
+ }
+ return call.name
+}
+
+function validInclude(include: string): boolean {
+ if (include.trim() === '' || include.startsWith('!')) return false
+ let braceDepth = 0
+ for (const character of include) {
+ if (character === '{') braceDepth += 1
+ else if (character === '}') braceDepth = Math.max(0, braceDepth - 1)
+ else if (character === ',' && braceDepth === 0) return false
+ }
+ return true
+}
+
+function searchFiles(value: unknown): SearchFileGroup[] | null {
+ if (!Array.isArray(value)) return null
+ const files: SearchFileGroup[] = []
+ for (const file of value) {
+ if (typeof file !== 'object' || file === null || Array.isArray(file)) return null
+ const { path, matches } = file as Record
+ if (typeof path !== 'string' || !Array.isArray(matches)) return null
+ const narrowed: { lineNumber: number; line: string }[] = []
+ for (const match of matches) {
+ if (typeof match !== 'object' || match === null || Array.isArray(match)) return null
+ const { lineNumber, line } = match as Record
+ if (typeof lineNumber !== 'number' || !Number.isInteger(lineNumber) || lineNumber < 1) return null
+ if (typeof line !== 'string') return null
+ narrowed.push({ lineNumber, line })
+ }
+ files.push({ path, matches: narrowed })
+ }
+ return files
}
-/**
- * Flatten a settled tool result's content blocks to their text, joined by
- * newlines. The search view carries no result text — a UI without a card falls
- * back to the raw `tool/result` content — so the truncation recovery footer is
- * read from the block's own content here. Non-text blocks (a search result
- * carries none) are skipped.
- * @param content - the result node's content blocks.
- * @returns the joined text, or undefined when empty.
- */
function flattenContent(content: readonly { type: string; text?: string }[]): string | undefined {
const text = content
.filter((block): block is { type: 'text'; text: string } => block.type === 'text' && typeof block.text === 'string')
@@ -113,48 +75,26 @@ function flattenContent(content: readonly { type: string; text?: string }[]): st
}
/**
- * Derive the search-card props for a tool call, or null when this call is not a
- * search card and belongs on the generic path.
- *
- * Only the result side matters: the search card carries no call-time state, so
- * a still-running call (no result view) is null, as is a settled call whose
- * result view is not a search card — including a `card` value this UI version
- * does not know, which arrives over the wire and cannot be trusted to be one of
- * the compiled variants, a `card: 'search'` view whose `shape` is neither
- * `matches` nor `paths` (equally untrusted wire data), and a generic result a
- * `grep`/`glob` failure or nested `run_code` dispatch produces (its text keeps
- * the generic path).
- * @param block - RunningToolCall or ToolResultNode off the snapshot caches.
- * @returns the search-card props, or null for the generic path.
+ * Derive a settled root grep/glob card from persisted metadata.
+ * @param block - running or settled Tool block.
+ * @returns search-card props, or null for the generic path.
*/
export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {
- // Running: no result view exists yet, and a search card is result-only.
- if (!('kind' in block)) return null
- const result = block.resultView?.card === 'search' ? block.resultView : null
- if (result === null) return null
- const common = { truncated: result.truncated, total: result.total }
- // The recovery footer only matters when the tool capped the result: an
- // uncapped card holds every match/path, so the raw text adds nothing the card
- // does not already show. When capped, the raw result's `Full … stored at …`
- // locator is the only way to retrieve the omitted rows, so include it.
- const recovery = result.truncated ? flattenContent(block.content) : undefined
- if (result.shape === 'matches') {
- // `files` rides the untrusted wire frame: the host schema checks `card`/`shape`
- // strings but not the grouped `files` fields, so validate them before
- // SearchBlock, which would crash on a missing or malformed `files`.
- // Invalid fields select the generic view.
- if (!isValidFiles(result.files)) return null
- return { title: result.title, recovery, card: { kind: 'matches', files: result.files, ...common } }
+ if (block.parentCallId !== undefined || !('kind' in block) || block.isError) return null
+ const tool = validSearchCall(block)
+ if (tool === null) return null
+ if (typeof block.meta !== 'object' || block.meta === null || Array.isArray(block.meta)) return null
+ const meta = block.meta as Record
+ if (typeof meta.truncated !== 'boolean') return null
+ if (typeof meta.total !== 'number' || !Number.isInteger(meta.total) || meta.total < 0) return null
+ const common = { truncated: meta.truncated, total: meta.total }
+ const recovery = meta.truncated ? flattenContent(block.content) : undefined
+ if (tool === 'grep') {
+ if (meta.shape !== 'matches') return null
+ const files = searchFiles(meta.files)
+ return files === null ? null : { recovery, card: { kind: 'matches', files, ...common } }
}
- // `shape` rides the same untrusted wire frame as `card`, so a version mismatch
- // or a loose protocol producer could deliver a `card: 'search'` subtype this
- // client does not compile. Guard the paths shape explicitly: an unknown shape
- // falls to the generic path rather than being rendered as a paths card, which
- // would leave SearchBlock calling `.length`/`.map` on an absent `paths`.
- // oxlint-disable-next-line typescript/no-unnecessary-condition -- shape is wire data; the compiled union cannot prove this exhaustive.
- if (result.shape !== 'paths') return null
- // `paths` is likewise unchecked by the wire schema; a known shape with a
- // missing/malformed array would crash the paths card at `.map`.
- if (!Array.isArray(result.paths) || !result.paths.every((path): path is string => typeof path === 'string')) return null
- return { title: result.title, recovery, card: { kind: 'paths', paths: result.paths, ...common } }
+ if (meta.shape !== 'paths' || !Array.isArray(meta.paths)) return null
+ if (!meta.paths.every((path): path is string => typeof path === 'string')) return null
+ return { recovery, card: { kind: 'paths', paths: [...meta.paths], ...common } }
}
diff --git a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts
index b8b25fa8bf..a34a56dfc7 100644
--- a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts
+++ b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts
@@ -1,17 +1,9 @@
-/**
- * Pure derivation of the terminal-card props from a frozen call slice: the
- * `card:'terminal'` render intent the shell tools declare arrives on the
- * snapshot as `callView`/`resultView`, and this is the one place that turns
- * that pair into what {@link TerminalBlock} draws. Both conversation render
- * sites (the chat tool row's expanded body and the details panel's Output
- * section) call this, so the command, cwd, output and exit status they show
- * are derived once.
- * @module
- */
+/** Pure terminal-card derivation from raw Tool call and result fields. @module */
import type { TerminalBlockLabels, TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
import type { ToolCallBlock } from './tool-call-model.ts'
+import { parsedToolCall, singleResultText, validEscalationFields } from './raw-tool-call.ts'
/**
* Build the TerminalBlock display copy from the conversation locale seat —
@@ -40,9 +32,8 @@ export function terminalBlockLabels(t: TranslateNS<'conversation'>): TerminalBlo
/**
* The {@link TerminalBlock} props this derivation owns. Picked off the
- * primitive's props so the two stay in step; `home` is absent because the web
- * client has no home path for the session host (a cwd renders as its last
- * path segment), and `maxLines`/`className` belong to each render site.
+ * primitive's props so the two stay in step; `maxLines`/`className` belong to
+ * each render site.
*/
export interface TerminalCardModel {
/**
@@ -52,10 +43,8 @@ export interface TerminalCardModel {
*/
card: Pick
/**
- * The call view's model-authored description, which the contract defines as
- * rendering ABOVE the card (the card itself has no description slot). Absent
- * when the presenter supplied none, or when the window dropped the call side;
- * a row then keeps its args-derived summary.
+ * The model-authored call description rendered above the card. Absent for
+ * persistent shells, whose parameter set has no description.
*/
description: string | undefined
}
@@ -75,21 +64,18 @@ export function terminalFailed(model: TerminalCardModel): boolean {
}
/**
- * Resolve a terminal view's working directory the way the render-intent
- * contract assigns to the UI bridge: an absolute path is used as-is, a relative
- * one joins under the session workspace, and an omitted one IS the session
- * workspace. A pure presenter cannot see the session cwd, which is why this
- * resolution belongs here rather than in the tool. Without a session cwd there
- * is nothing to resolve against, so a relative path stays as authored and an
- * omitted one stays absent (the prompt row then draws a bare `$`).
- * @param viewCwd - the cwd the terminal call view carries, if any.
+ * Resolve a shell call's workdir for display: an absolute path is used as-is,
+ * a relative one joins under the session workspace, and an omitted one is the
+ * session workspace. Without a session cwd, a relative path stays as authored
+ * and an omitted one stays absent.
+ * @param workdir - the raw call's workdir, if any.
* @param sessionCwd - the session workspace root, if the caller knows it.
* @returns the working directory for the prompt label, or undefined.
*/
-function resolveTerminalCwd(viewCwd: string | undefined, sessionCwd: string | undefined): string | undefined {
- if (viewCwd === undefined || viewCwd === '') return sessionCwd
- if (sessionCwd === undefined || sessionCwd === '') return normalizeSegments(viewCwd)
- return normalizeSegments(resolveWorkspacePath(sessionCwd, viewCwd))
+function resolveTerminalCwd(workdir: string | undefined, sessionCwd: string | undefined): string | undefined {
+ if (workdir === undefined || workdir === '') return sessionCwd
+ if (sessionCwd === undefined || sessionCwd === '') return normalizeSegments(workdir)
+ return normalizeSegments(resolveWorkspacePath(sessionCwd, workdir))
}
/**
@@ -153,40 +139,94 @@ function collapse(body: string, rooted: boolean, separator = '/'): string {
return kept.join(separator)
}
+interface ShellCall {
+ command: string
+ description: string | undefined
+ workdir: string | undefined
+ persistent: boolean
+ background: boolean
+}
+
+function shellCall(name: string, args: Record): ShellCall | null {
+ if (name !== 'bash' && name !== 'pwsh') return null
+ const { command, description, timeoutMs, workdir, run_in_background: background } = args
+ if (typeof command !== 'string' || command.trim() === '') return null
+ if (timeoutMs !== undefined && (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0)) return null
+ if (workdir !== undefined && typeof workdir !== 'string') return null
+ if (background !== undefined && typeof background !== 'boolean') return null
+ if (!validEscalationFields(args)) return null
+ if (description === undefined) {
+ // Persistent shell providers consume only `command`; parameter roots are
+ // open, so unrelated fields do not change their running-card behavior.
+ return { command, description: undefined, workdir: undefined, persistent: true, background: false }
+ }
+ if (typeof description !== 'string' || description.trim() === '') return null
+ return {
+ command,
+ description,
+ workdir,
+ persistent: false,
+ background: background === true,
+ }
+}
+
+interface TerminalSendCall {
+ command: string
+ description: string
+ background: boolean
+}
+
+function terminalSendCall(name: string, args: Record): TerminalSendCall | null {
+ if (name !== 'terminal_send') return null
+ const { sessionId, text, submit, run_in_background: background } = args
+ if (typeof sessionId !== 'string' || sessionId === '' || typeof text !== 'string') return null
+ if (submit !== undefined && typeof submit !== 'boolean') return null
+ if (background !== undefined && typeof background !== 'boolean') return null
+ return {
+ command: text || '(send input)',
+ description: `Terminal ${sessionId}`,
+ background: background === true,
+ }
+}
+
+function parseExitStatus(text: string): { output: string; exitCode?: number; signal?: string } {
+ const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
+ if (signal?.[1] !== undefined) return { output: text.slice(0, signal.index), signal: signal[1] }
+ const exit = /\n\[exit code: (\d+)\]$/.exec(text)
+ if (exit?.[1] !== undefined) return { output: text.slice(0, exit.index), exitCode: Number(exit[1]) }
+ return { output: text, exitCode: 0 }
+}
+
/**
- * Derive the terminal-card props for a tool call, or null when this call is
- * not a terminal card and belongs on the generic path.
- *
- * The call side supplies the command and its working directory; the result
- * side supplies the captured output and exit status. Three cases produce
- * null, all of them the documented generic-card default:
- *
- * - Neither side declares `card:'terminal'` — including a `card` value this
- * UI version does not know, which arrives over the wire and therefore
- * cannot be trusted to be one of the compiled variants.
- * - A settled call whose result view is not a terminal card: the result
- * presentation decides how the settled call renders, and the bash tool
- * returns a generic fenced card for an execution error or a background
- * start, whose text and error styling the generic path preserves.
- *
- * Window truncation can drop the call head from a settled `ToolResultNode`,
- * leaving a terminal result with no call side. That still renders: the command
- * falls back to the result view's replacement title, then to an empty command (the prompt line
- * draws bare), and the prompt shows no cwd.
- * @param block - RunningToolCall or ToolResultNode off the snapshot caches.
- * @param sessionCwd - the session workspace root, which resolves an omitted or
- * relative view cwd (see {@link resolveTerminalCwd}); absent leaves both unresolved.
+ * Derive terminal props for supported root shell and terminal-send calls.
+ * Standard shell results parse their final status marker; persistent shell
+ * results, background calls, errors, malformed input, or child dispatches use
+ * the generic path.
+ * @param block - running or settled Tool block.
+ * @param sessionCwd - session workspace root used to resolve workdir.
* @returns the terminal-card props, or null for the generic path.
*/
-export function terminalCardModel(block: ToolCallBlock, sessionCwd?: string): TerminalCardModel | null {
- const call = block.callView?.card === 'terminal' ? block.callView : null
+export function terminalCardModel(
+ block: ToolCallBlock,
+ sessionCwd?: string,
+): TerminalCardModel | null {
+ if (block.parentCallId !== undefined) return null
+ const parsed = parsedToolCall(block)
+ if (parsed === null) return null
+ const shell = shellCall(parsed.name, parsed.args)
+ const send = terminalSendCall(parsed.name, parsed.args)
+ if (shell === null && send === null) return null
+ if (shell?.background === true || send?.background === true) return null
+
+ const command = shell?.command ?? send?.command ?? ''
+ const description = shell?.description ?? send?.description
+ const cwd = resolveTerminalCwd(shell?.workdir, sessionCwd)
if (!('kind' in block)) {
- // Running: the call view exists, the result view does not yet.
- return call === null ? null : {
- description: call.description,
+ return {
+ description,
card: {
- command: call.title,
- cwd: resolveTerminalCwd(call.cwd, sessionCwd),
+ command,
+ cwd,
output: undefined,
exitCode: undefined,
signal: undefined,
@@ -194,24 +234,18 @@ export function terminalCardModel(block: ToolCallBlock, sessionCwd?: string): Te
},
}
}
- const result = block.resultView?.card === 'terminal' ? block.resultView : null
- if (result === null) return null
+ if (block.isError || shell?.persistent === true) return null
+ const output = singleResultText(block)
+ if (output === undefined) return null
+ const status = shell === null ? { output } : parseExitStatus(output)
return {
- description: call?.description,
+ description,
card: {
- // The result's title REPLACES the pending one when the tool supplies it
- // (the presentation contract's replacement-title rule); the call title is
- // what a result without one keeps.
- command: result.title ?? call?.title ?? '',
- // Only a PRESENT call view can mean "omitted the cwd, so use the
- // workspace". When the window dropped the call head there is no cwd
- // anywhere — the result view carries none — and the original call may
- // well have used an explicit workdir, so the prompt draws a bare `$`
- // rather than naming a directory this card cannot know.
- cwd: call === null ? undefined : resolveTerminalCwd(call.cwd, sessionCwd),
- output: result.output,
- exitCode: result.exitCode,
- signal: result.signal,
+ command,
+ cwd,
+ output: status.output,
+ exitCode: status.exitCode,
+ signal: status.signal,
running: false,
},
}
diff --git a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts
index 8af6fb5335..a95861ea01 100644
--- a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts
+++ b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts
@@ -2,9 +2,8 @@
* Pure row-model derivation for tool summary rows: variant classification,
* one-line summary, expanded-body text, and flattened result output from the
* frozen call slice. Input material comes from the call ARGUMENTS; output and
- * error material from the settled result node. A call whose render intent is
- * a terminal card gets its expanded body from the views instead, through
- * `terminalCardModel` in terminal-card-model.ts.
+ * error material from the settled result node. A supported terminal call gets
+ * its expanded body from `terminalCardModel` instead.
*/
// The block union's defining home is runtime (fold-product types); this
// contract only forwards it (type-definition authority stays with the layer
diff --git a/packages/client/ui-tool/src/client/tool/models/web-card-model.ts b/packages/client/ui-tool/src/client/tool/models/web-card-model.ts
index 8239861953..27e416f99d 100644
--- a/packages/client/ui-tool/src/client/tool/models/web-card-model.ts
+++ b/packages/client/ui-tool/src/client/tool/models/web-card-model.ts
@@ -1,84 +1,82 @@
-/**
- * Pure derivation of the web-card props from a frozen call slice: the
- * `card:'web'` render intent the `web_search`/`web_fetch` tools declare at
- * result time arrives on the snapshot as `resultView`, and this is the one
- * place that turns it into what {@link WebBlock} draws. Both conversation
- * render sites (the chat tool row's resident/expanded body and the details
- * panel's Output section) call this, so the sources and fetch summary they
- * show are derived once.
- *
- * The web card is result-only by contract: those tools keep a generic pending
- * call view, so there is nothing to derive while the call is still running and
- * a running call always takes the generic path.
- * @module
- */
+/** Pure web-card derivation from raw web result metadata. @module */
import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolCallBlock } from './tool-call-model.ts'
+import { parsedToolCall } from './raw-tool-call.ts'
-/**
- * Derive the web-card props for a tool call, or null when this call is not a
- * web card and belongs on the generic path.
- *
- * The result side supplies the whole card: the sources and answer for a
- * `search`, the URL and status for a `fetch`. Cases producing null, all of
- * them the documented generic-card default:
- *
- * - A running call (no `resultView` yet): the web tools keep a generic pending
- * card, so nothing web-shaped exists until the call settles.
- * - A settled call whose result view is not a web card — including a `card`
- * value this UI version does not know, which arrives over the wire and so
- * cannot be trusted to be one of the compiled variants, and a generic result
- * view (a web tool's error path returns the generic card, whose text the
- * generic path preserves).
- * - A web card whose `kind` this UI version does not know (a newer host's
- * value): the wire cannot be trusted to be `search` or `fetch`, so it takes
- * the generic path rather than rendering as a malformed fetch.
- * @param block - RunningToolCall or ToolResultNode off the snapshot caches.
- * @returns the web-card props, or null for the generic path.
- */
type DistributiveOmit = T extends unknown ? Omit : never
/** Web-card data owned by the presenter; render sites add localized labels and classes. */
export type WebCardModelProps = DistributiveOmit
-/**
- * Derive locale-independent web-card data from a frozen tool-call slice.
- * @param block - Running or settled tool call from the conversation snapshot.
- * @returns Web-card data, or null when the generic presenter owns the call.
- */
-export function webCardModel(block: ToolCallBlock): WebCardModelProps | null {
- // Running calls have no result view; the web card is result-only.
- if (!('kind' in block)) return null
- const result = block.resultView
- if (result?.card !== 'web') return null
- if (result.kind === 'search') {
- return {
- kind: 'search',
- answer: result.answer,
- sources: result.sources.map(source => ({
- url: source.url,
- title: source.title,
- snippet: source.snippet,
- publishedAt: source.publishedAt,
- })),
- truncated: result.truncated,
- }
+function validWebCall(block: ToolCallBlock): 'web_search' | 'web_fetch' | null {
+ const call = parsedToolCall(block)
+ if (call === null) return null
+ if (call.name === 'web_search') {
+ const { queries } = call.args
+ if (!Array.isArray(queries) || queries.length === 0) return null
+ return queries.every(query => typeof query === 'string' && query.trim() !== '') ? call.name : null
}
- // Discriminate `fetch` explicitly rather than treating it as the else of
- // `search`: a `kind` this UI version does not know arrives over the wire from
- // a newer host, and reading it as a fetch would draw an empty URL and
- // `HTTP undefined`. It takes the generic path, the same wire-boundary default
- // an unknown `card` tag takes above. The static union narrows `kind` to
- // `'fetch'` here, but the runtime value is off the wire, so the guard and its
- // null fallthrough are load-bearing despite the type.
- // oxlint-disable-next-line typescript/no-unnecessary-condition
- if (result.kind === 'fetch') {
- return {
- kind: 'fetch',
- url: result.url,
- statusCode: result.statusCode,
- truncated: result.truncated,
- }
+ if (call.name === 'web_fetch') {
+ const { url } = call.args
+ return typeof url === 'string' && url.trim() !== '' ? call.name : null
}
return null
}
+
+interface WebSource {
+ url: string
+ title?: string
+ snippet?: string
+ publishedAt?: string
+}
+
+function webSources(value: unknown): WebSource[] | null {
+ if (!Array.isArray(value)) return null
+ const sources: WebSource[] = []
+ for (const source of value) {
+ if (typeof source !== 'object' || source === null || Array.isArray(source)) return null
+ const { url, title, snippet, publishedAt } = source as Record
+ if (typeof url !== 'string') return null
+ if (title !== undefined && typeof title !== 'string') return null
+ if (snippet !== undefined && typeof snippet !== 'string') return null
+ if (publishedAt !== undefined && typeof publishedAt !== 'string') return null
+ sources.push({
+ url,
+ ...title === undefined ? {} : { title },
+ ...snippet === undefined ? {} : { snippet },
+ ...publishedAt === undefined ? {} : { publishedAt },
+ })
+ }
+ return sources
+}
+
+/**
+ * Derive a settled root web-search or web-fetch card from persisted metadata.
+ * @param block - running or settled Tool block.
+ * @returns web-card props, or null for the generic path.
+ */
+export function webCardModel(block: ToolCallBlock): WebCardModelProps | null {
+ if (block.parentCallId !== undefined || !('kind' in block) || block.isError) return null
+ const tool = validWebCall(block)
+ if (tool === null || typeof block.meta !== 'object' || block.meta === null || Array.isArray(block.meta)) return null
+ const meta = block.meta as Record
+ if (typeof meta.truncated !== 'boolean') return null
+ if (tool === 'web_search') {
+ const sources = webSources(meta.sources)
+ if (sources === null || (meta.answer !== undefined && typeof meta.answer !== 'string')) return null
+ return {
+ kind: 'search',
+ answer: meta.answer,
+ sources,
+ truncated: meta.truncated,
+ }
+ }
+ if (typeof meta.url !== 'string') return null
+ if (typeof meta.statusCode !== 'number' || !Number.isInteger(meta.statusCode)) return null
+ return {
+ kind: 'fetch',
+ url: meta.url,
+ statusCode: meta.statusCode,
+ truncated: meta.truncated,
+ }
+}
diff --git a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx
index 32d7bb0dae..0fe0883e0a 100644
--- a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx
+++ b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx
@@ -47,7 +47,7 @@ export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect,
toolName={toolName}
icon={VARIANT_ICONS[model.variant]}
title={t(model.titleKey)}
- summary={terminal?.description ?? search?.title ?? model.summary}
+ summary={terminal?.description ?? model.summary}
// Single-file tools never expose an args body — the path link is the only
// args interaction. A card is not an args body: a read/write/edit row is
// single-file AND carries a card, so the card expands under the path link.
diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx
index a408110581..eaba61f015 100644
--- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx
+++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx
@@ -35,8 +35,8 @@ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null {
/** Renders expandable Bash output with an accessible lifecycle label. */
export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) {
const model = toolRowModel(toolName, block)
- // Session workspace root: the terminal view's cwd resolves against it (an
- // omitted workdir IS the workspace), which the pure presenter cannot do.
+ // An omitted shell workdir is the session workspace; relative values resolve
+ // against it before reaching the terminal primitive.
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
const terminal = terminalCardModel(block, cwd)
// A failing exit status is the terminal card's own error signal (the call
@@ -47,7 +47,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
const status = stateStatus(state, t)
const [expanded, setExpanded] = useState(false)
// Execution failures (for example cancellation before the process reports a
- // terminal result) use the generic presenter. Keep their recorded args and
+ // terminal result) use the generic body. Keep their recorded args and
// full error reachable instead of collapsing the row to the first line.
const genericError = terminal === null
&& model.state === 'error'
diff --git a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx
index dfd33251de..4bb2617d57 100644
--- a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx
+++ b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx
@@ -27,7 +27,7 @@ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
title={t(toolName === 'grep'
? SEARCH_TITLE_KEYS.grep
: toolName === 'glob' ? SEARCH_TITLE_KEYS.glob : model.titleKey)}
- summary={search?.title ?? model.summary}
+ summary={model.summary}
body={null}
// ToolRow ignores output when a structured card is present; otherwise it
// preserves the generic fallback for errors and legacy results.
diff --git a/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx b/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx
index 976ba3df53..29632827d7 100644
--- a/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx
+++ b/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx
@@ -24,11 +24,11 @@ const resultNode = (argsRaw: string, resultText: string | null, over?: Partial
- ({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, callView: null, subCalls: [] })
+ ({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, subCalls: [] })
const t = makeTranslate(zh, commonZh)
diff --git a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx
index f30bb1a3bd..f6e0ef3098 100644
--- a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx
+++ b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx
@@ -46,7 +46,7 @@ const todoResult = (seq: number): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`,
call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) },
callTime: seq * 1_000 - 500,
- content: [], isError: false, callView: null, resultView: null, subCalls: [],
+ content: [], isError: false, subCalls: [],
})
const bashResult = (seq: number, callId: string, over?: Partial): ToolResultNode => ({
@@ -54,8 +54,6 @@ const bashResult = (seq: number, callId: string, over?: Partial)
call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
callTime: seq * 1_000 - 500,
content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false,
- callView: { card: 'terminal', title: 'ls -la', description: 'List files' },
- resultView: { card: 'terminal', output: 'total 2\ndemo.txt\n', exitCode: 0 },
subCalls: [],
...over,
})
@@ -142,8 +140,10 @@ describe('terminal card assembly', () => {
it('both the keyed bash row and the fallback row reach the terminal card through the whole-row expand', async () => {
const runtime = await bench([
bashResult(3, 'c-keyed'),
- // An unregistered tool with terminal views: GenericToolCard fallback.
- bashResult(4, 'c-fallback', { call: { name: 'fx-bash', argsRaw: '{"command":"ls -la"}' } }),
+ // pwsh has no package-local keyed row, so GenericToolCard owns its raw terminal card.
+ bashResult(4, 'c-fallback', {
+ call: { name: 'pwsh', argsRaw: '{"command":"ls -la","description":"List files"}' },
+ }),
])
const view = runtime.renderRoot()
@@ -157,7 +157,7 @@ describe('terminal card assembly', () => {
})
// Fallback row: same unified expand interaction.
- const fallback = view.container.querySelector('[data-tool="fx-bash"]')
+ const fallback = view.container.querySelector('[data-tool="pwsh"]')
expect(fallback).not.toBeNull()
expect(fallback!.querySelector('[data-terminal]')).toBeNull()
fireEvent.click(fallback!.querySelector('[data-expandable]')!)
diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx
index 79b4b84c12..ad7302d2c1 100644
--- a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx
+++ b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx
@@ -46,12 +46,12 @@ const codeResult = (seq: number, callId: string): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name: 'run_code', argsRaw: RUN_CODE_ARGS },
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,
subCalls: [],
})
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,
subCalls: [],
})
@@ -60,9 +60,10 @@ const subCall = (
): ToolCallBlock => ({
kind: 'tool-result', seq, time: seq * 1_000,
callId: `${parent}:code:${n}`,
+ parentCallId: parent,
call: { name, argsRaw: JSON.stringify(args) },
callTime: seq * 1_000,
- content: [{ type: 'text', text: resultText }], isError, callView: null, resultView: null,
+ content: [{ type: 'text', text: resultText }], isError,
subCalls: [],
})
@@ -244,7 +245,8 @@ describe('run_code sub-calls through the real chat machinery', () => {
const parent = 'call-live'
const runningSub: ToolCallBlock = {
callId: `${parent}:code:1`, name: 'grep', argsRaw: '{"pattern":"todo"}',
- turn: 0, step: 0, time: 21_000, callView: null, subCalls: [],
+ parentCallId: parent,
+ turn: 0, step: 0, time: 21_000, subCalls: [],
}
const b = await bench(snapshotWith([], [runningSub], [runningCode(parent)]))
const view = mountApp(b.runtime)
@@ -260,7 +262,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
kind: 'tool-result', seq: 10, time: 10_000, callId: parent,
call: { name: 'mystery', argsRaw: '{"n":1}' },
callTime: 9_500,
- content: [], isError: false, callView: null, resultView: null, subCalls: [],
+ content: [], isError: false, subCalls: [],
}
const b = await bench(snapshotWith([plain], []))
const view = mountApp(b.runtime)
diff --git a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx
index 7c20a7c91f..723b2d1bd1 100644
--- a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx
+++ b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx
@@ -57,7 +57,7 @@ describe('Tool presentation tails', () => {
kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5',
call: { name: 'todo_write', argsRaw: '{"note":"x"}' },
callTime: 1_000,
- content: [], isError: false, callView: null, resultView: null, subCalls: [],
+ content: [], isError: false, subCalls: [],
}
const props: GenericToolCardProps = {
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',
call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' },
callTime: 2_000,
- content: [], isError: false, callView: null, resultView: null, subCalls: [],
+ content: [], isError: false, subCalls: [],
}
const view = render()
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', () => {
const running: RunningToolCall = {
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',
- turn: 1, step: 1, time: 1_000, callView: null, subCalls: [],
+ turn: 1, step: 1, time: 1_000, subCalls: [],
}
const errorResult: ToolResultNode = {
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
callTime: 500,
- content: [], isError: true, callView: null, resultView: null, subCalls: [],
+ content: [], isError: true, subCalls: [],
}
const stoppedResult: ToolResultNode = {
...errorResult,
diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx
index 91e6e365a9..3c113b3d3e 100644
--- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx
+++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx
@@ -11,7 +11,6 @@ import type {
} from '@deepseek-ai/dsh-client-ui-chat/client'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-api-remotes/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/tool/models/diff-card-model.ts'
@@ -34,21 +33,11 @@ const chatT = makeTranslate(chatZh, commonZh)
const ARGS = '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}'
-/** The edit tool's own call view (a call-time diff derived from the arguments). */
-const callDiff = (over?: Partial>): ToolCallView => ({
- card: 'diff', title: 'Edit notes/demo.txt',
- diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }], ...over,
-})
-
-/** The edit tool's own result view (the applied hunk diff). */
-const resultDiff = (over?: Partial>): ToolResultView => ({
- card: 'diff', title: 'Edit notes/demo.txt',
- diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }], ...over,
-})
+const DIFFS = [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }]
const running = (over?: Partial): RunningToolCall => ({
callId: 'c1', name: 'edit', argsRaw: ARGS,
- turn: 1, step: 1, time: 1_000, callView: callDiff(), subCalls: [], ...over,
+ turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
})
const settled = (over?: Partial): ToolResultNode => ({
@@ -56,60 +45,83 @@ const settled = (over?: Partial): ToolResultNode => ({
call: { name: 'edit', argsRaw: ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'The file notes/demo.txt has been updated successfully.' }], isError: false,
- callView: callDiff(), resultView: resultDiff(), subCalls: [], ...over,
+ meta: { diffs: DIFFS }, subCalls: [], ...over,
})
describe('diffCardModel', () => {
- it('derives a running card from the call view alone', () => {
+ it('derives a running card from raw edit arguments', () => {
expect(diffCardModel(running())).toEqual({
card: { diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }] },
})
})
- it('derives a settled card from the result view, which replaces the call-time diff', () => {
- // The applied hunks (result) win over the args-derived call diff.
+ it('preserves the Host presenter\'s whole-file diff for an empty old_string', () => {
+ expect(diffCardModel(running({
+ argsRaw: '{"file_path":"notes/demo.txt","old_string":"","new_string":"replacement"}',
+ }))).toEqual({
+ card: { diffs: [{ path: 'notes/demo.txt', oldText: null, newText: 'replacement' }] },
+ })
+ })
+
+ it('derives a settled card from result metadata, which replaces the intended diff', () => {
expect(diffCardModel(settled({
- resultView: resultDiff({ diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] }),
+ meta: { diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] },
}))).toEqual({
card: { diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] },
})
})
- it('renders a settled diff even when the window dropped the call head', () => {
- // A truncated call carries only the result view, which holds the whole change.
- expect(diffCardModel(settled({ call: null, callView: null }))?.card.diffs).toHaveLength(1)
- })
-
- it('returns null for every non-diff call: no views, generic views, unknown cards', () => {
- expect(diffCardModel(running({ callView: null }))).toBeNull()
- expect(diffCardModel(settled({ callView: null, resultView: null }))).toBeNull()
- expect(diffCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull()
- // A generic result settles a diff call on the generic path (write/edit's
- // own execution-error arm).
- expect(diffCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull()
- // A card tag this UI version does not know arrives over the wire; the
- // documented generic-card default takes it, not a crash.
- const future = { card: 'chart', title: 'plot' } as unknown as ToolCallView
- expect(diffCardModel(running({ callView: future }))).toBeNull()
+ it('uses the intended write diff when successful metadata reports no applied hunk', () => {
+ const writeArgs = JSON.stringify({ file_path: 'notes/new.txt', content: 'hello fixture\n' })
expect(diffCardModel(settled({
- callView: future, resultView: { card: 'chart' } as unknown as ToolResultView,
- }))).toBeNull()
+ call: { name: 'write', argsRaw: writeArgs },
+ meta: { diffs: [] },
+ }))).toEqual({
+ card: { diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture\n' }] },
+ })
})
- it('falls back to null for a malformed diff payload off the wire', () => {
- // toolEventViewSchema validates only the `card` string, so a version
- // mismatch can deliver a diff card with an unusable diffs field. Each shape
- // routes to the generic path instead of throwing inside DiffBlock.
- const bad = (diffs: unknown): ToolResultView => ({ card: 'diff', diffs } as unknown as ToolResultView)
- expect(diffCardModel(settled({ resultView: bad(undefined) }))).toBeNull()
- expect(diffCardModel(settled({ resultView: bad([]) }))).toBeNull()
- expect(diffCardModel(settled({ resultView: bad('nope') }))).toBeNull()
- expect(diffCardModel(settled({ resultView: bad([null]) }))).toBeNull()
- expect(diffCardModel(settled({ resultView: bad([{ path: 1, oldText: null, newText: 'x' }]) }))).toBeNull()
- expect(diffCardModel(settled({ resultView: bad([{ path: 'a', oldText: 5, newText: 'x' }]) }))).toBeNull()
- expect(diffCardModel(settled({ resultView: bad([{ path: 'a', oldText: null, newText: 9 }]) }))).toBeNull()
- // The running side narrows identically.
- expect(diffCardModel(running({ callView: { card: 'diff', diffs: 'nope' } as unknown as ToolCallView }))).toBeNull()
+ it('returns null for missing calls, errors, malformed args, unrelated tools, and child dispatches', () => {
+ expect(diffCardModel(settled({ call: null }))).toBeNull()
+ expect(diffCardModel(settled({ isError: true }))).toBeNull()
+ expect(diffCardModel(running({ argsRaw: '{' }))).toBeNull()
+ expect(diffCardModel(running({ name: 'read' }))).toBeNull()
+ expect(diffCardModel(running({ parentCallId: 'parent' }))).toBeNull()
+ expect(diffCardModel(settled({ parentCallId: 'parent' }))).toBeNull()
+ })
+
+ it('keeps edit generic for missing or malformed applied metadata', () => {
+ expect(diffCardModel(settled({ meta: undefined }))).toBeNull()
+ expect(diffCardModel(settled({ meta: null }))).toBeNull()
+ expect(diffCardModel(settled({ meta: { diffs: 'nope' } }))).toBeNull()
+ expect(diffCardModel(settled({ meta: { diffs: [null] } }))).toBeNull()
+ expect(diffCardModel(settled({ meta: { diffs: [{ path: 1, oldText: null, newText: 'x' }] } }))).toBeNull()
+ expect(diffCardModel(settled({ meta: { diffs: [{ path: 'a', oldText: 5, newText: 'x' }] } }))).toBeNull()
+ expect(diffCardModel(settled({ meta: { diffs: [{ path: 'a', oldText: null, newText: 9 }] } }))).toBeNull()
+ })
+
+ it.each([
+ undefined,
+ null,
+ { diffs: 'nope' },
+ { diffs: [null] },
+ ])('uses the intended write diff when applied metadata is absent or malformed: %j', (meta) => {
+ const writeArgs = JSON.stringify({ file_path: 'notes/new.txt', content: 'hello fixture\n' })
+ expect(diffCardModel(settled({
+ call: { name: 'write', argsRaw: writeArgs },
+ meta,
+ }))).toEqual({
+ card: { diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture\n' }] },
+ })
+ })
+
+ it('validates mutation escalation fields but accepts unrelated open-root fields', () => {
+ const args = (fields: Record) => JSON.stringify({
+ file_path: 'notes/demo.txt', old_string: 'hello', new_string: 'hello fixture', ...fields,
+ })
+ expect(diffCardModel(running({ argsRaw: args({ sandbox_permissions: 7, justification: 'Need access' }) }))).toBeNull()
+ expect(diffCardModel(running({ argsRaw: args({ sandbox_permissions: 'workspace-write' }) }))).toBeNull()
+ expect(diffCardModel(running({ argsRaw: args({ extension: { version: 1 } }) }))).not.toBeNull()
})
})
@@ -142,7 +154,7 @@ describe('chat row diff body', () => {
callId: 'c1', toolName: 'some_tool', openFile: vi.fn(), t,
block: settled({
call: { name: 'some_tool', argsRaw: '{"foo":"bar"}' },
- callView: null, resultView: null,
+ meta: undefined,
}),
}} />)
fireEvent.click(view.container.querySelector('[data-expandable]')!)
@@ -197,8 +209,7 @@ describe('FileMutationRow diff card', () => {
const writeArgs = '{"file_path":"notes/new.txt","content":"hello fixture\\n"}'
const view = render()
// The footer counts live inside the collapsed diff card.
toggleRow(view)
@@ -209,12 +220,12 @@ describe('FileMutationRow diff card', () => {
const runningView = render()
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
cleanup()
- const errorView = render()
+ const errorView = render()
expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull()
})
- it('a mutation call with no diff view renders the summary row alone', () => {
- const view = render()
+ it('a mutation result with no metadata renders the summary row alone', () => {
+ const view = render()
// No diff material: expanding shows the args-JSON body, never a diff card.
expect(view.container.querySelector('[data-diff]')).toBeNull()
toggleRow(view)
@@ -222,11 +233,9 @@ describe('FileMutationRow diff card', () => {
})
it('surfaces the result text when an errored mutation has no diff card', () => {
- // write/edit return undefined from presentResult on isError, so the failure
- // has no diff — ToolRow shows the model-facing error text as the collapsed
- // summary's first line (errorSummary) instead of a bare red dot.
+ // Failed mutations have no diff; ToolRow keeps the model-facing error text.
const view = render()
expect(view.container.querySelector('[data-diff]')).toBeNull()
@@ -235,7 +244,7 @@ describe('FileMutationRow diff card', () => {
it('falls back to the error name/code when an errored result has no text block', () => {
const view = render()
expect(view.getByText('ToolError: sandbox_denied')).toBeTruthy()
@@ -252,7 +261,7 @@ describe('FileMutationRow diff card', () => {
it('shows the stopped state when the call was interrupted', () => {
const view = render()
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
@@ -264,7 +273,7 @@ describe('FileMutationRow diff card', () => {
it('renders a plain summary span when the call carries no file path', () => {
// Empty args leave deriveFilePath undefined, so the summary is not a link.
const view = render()
expect(view.container.querySelector('[class*="_fileLink_"]')).toBeNull()
expect(view.container.querySelector('[class*="_summary_"]')).not.toBeNull()
@@ -378,7 +387,7 @@ describe('DetailsPanel diff Output section', () => {
it('a non-diff result keeps the flattened pre', () => {
const view = mount(snapshot({
nodes: [settled({
- callView: null, resultView: null,
+ meta: undefined,
content: [{ type: 'text', text: 'permission denied' }],
})],
}), target)
diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx
index dbf8a213f4..40b7042f30 100644
--- a/packages/client/ui-tool/tests/read-card.client.spec.tsx
+++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx
@@ -13,7 +13,6 @@ import type {
} from '@deepseek-ai/dsh-client-ui-chat/client'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client'
import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/tool/models/read-card-model.ts'
import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
@@ -35,7 +34,6 @@ const chatT = makeTranslate(chatZh, commonZh)
// use it so the row exercises a production-shaped call. `web_fetch` (below) has
// its own schema whose key is not `file_path`, so it keeps a `url`-less `path`.
const ARGS = '{"file_path":"src/a.ts","offset":41}'
-const WEB_FETCH_ARGS = '{"path":"src/a.ts","offset":41}'
/** The read block's rendered content cells, one string per row (highlighting
* breaks a line across token spans, so match on the row's textContent). */
@@ -50,26 +48,35 @@ const sampleLines = [
{ number: 43, text: 'export const c = 3' },
]
-/** The read tool's own result view for a settled file read. */
-const resultRead = (over?: Partial>): ToolResultView => ({
- card: 'read', path: 'src/a.ts', offset: 41, lines: sampleLines, totalLines: 180, lang: 'ts', ...over,
+interface ReadMetaFixture {
+ path: string
+ offset: number
+ lines: { number: number; text: string }[]
+ totalLines: number
+ lang?: string
+}
+
+const readMeta = (over?: Partial): ReadMetaFixture => ({
+ path: 'src/a.ts', offset: 41, lines: sampleLines, totalLines: 180, lang: 'ts', ...over,
})
+const readContent = (body = 'export const a = 1'): string => `src/a.ts\nfile\n\n${body}\n`
+
const running = (over?: Partial): RunningToolCall => ({
callId: 'c1', name: 'read', argsRaw: ARGS,
- turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, subCalls: [], ...over,
+ turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
})
const settled = (over?: Partial): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'read', argsRaw: ARGS },
callTime: 1_000,
- content: [{ type: 'text', text: '41: export const a = 1' }], isError: false,
- callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, resultView: resultRead(), subCalls: [], ...over,
+ content: [{ type: 'text', text: readContent() }], isError: false,
+ meta: readMeta(), subCalls: [], ...over,
})
describe('readCardModel', () => {
- it('derives the card from a settled read result view', () => {
+ it('derives the card from settled read metadata and its raw envelope', () => {
expect(readCardModel(settled())).toEqual({
label: 'src/a.ts', lines: sampleLines, totalLines: 180, lang: 'ts',
})
@@ -82,38 +89,31 @@ describe('readCardModel', () => {
expect(model?.lines[0]).not.toBe(sampleLines[0])
})
- it('takes the result view\'s replacement title over the relativized path', () => {
- // The presentation contract defines a result title as REPLACING the pending
- // one, so a tool that supplies a label wins over the path here.
- expect(readCardModel(settled({ resultView: resultRead({ title: 'Read (head) src/a.ts' }) }))?.label)
- .toBe('Read (head) src/a.ts')
- })
-
it('relativizes a workspace-rooted path label, and leaves others as authored', () => {
// A workspace-rooted absolute path shows its short form.
- expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }), '/w/app')?.label)
+ expect(readCardModel(settled({ meta: readMeta({ path: '/w/app/src/a.ts' }) }), '/w/app')?.label)
.toBe('src/a.ts')
// A path outside the workspace stays as authored.
- expect(readCardModel(settled({ resultView: resultRead({ path: '/srv/other.ts' }) }), '/w/app')?.label)
+ expect(readCardModel(settled({ meta: readMeta({ path: '/srv/other.ts' }) }), '/w/app')?.label)
.toBe('/srv/other.ts')
// With no session cwd there is nothing to relativize against.
- expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }))?.label)
+ expect(readCardModel(settled({ meta: readMeta({ path: '/w/app/src/a.ts' }) }))?.label)
.toBe('/w/app/src/a.ts')
})
it('abbreviates a leftover POSIX home path label', () => {
- expect(readCardModel(settled({ resultView: resultRead({ path: '/Users/u/notes.md' }) }), '/tmp/ws', '/Users/u')?.label)
+ expect(readCardModel(settled({ meta: readMeta({ path: '/Users/u/notes.md' }) }), '/tmp/ws', '/Users/u')?.label)
.toBe('~/notes.md')
- expect(readCardModel(settled({ resultView: resultRead({ path: '/Users/u/app/src/a.ts' }) }), '/Users/u/app', '/Users/u')?.label)
+ expect(readCardModel(settled({ meta: readMeta({ path: '/Users/u/app/src/a.ts' }) }), '/Users/u/app', '/Users/u')?.label)
.toBe('src/a.ts')
- expect(readCardModel(settled({ resultView: resultRead({ path: 'C:\\Users\\u\\a.ts' }) }), '/tmp/ws', '/Users/u')?.label)
+ expect(readCardModel(settled({ meta: readMeta({ path: 'C:\\Users\\u\\a.ts' }) }), '/tmp/ws', '/Users/u')?.label)
.toBe('C:\\Users\\u\\a.ts')
})
it('carries an omitted language through as undefined', () => {
- const noLang = resultRead()
+ const noLang = readMeta()
delete (noLang as { lang?: string }).lang
- expect(readCardModel(settled({ resultView: noLang }))?.lang).toBeUndefined()
+ expect(readCardModel(settled({ meta: noLang }))?.lang).toBeUndefined()
})
it('returns null for a running read: the read intent is result-side only', () => {
@@ -122,19 +122,36 @@ describe('readCardModel', () => {
expect(readCardModel(running())).toBeNull()
})
- it('returns null for every non-read settled call: no view, generic view, unknown card', () => {
- expect(readCardModel(settled({ resultView: null }))).toBeNull()
- expect(readCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull()
- // A card tag this UI version does not know arrives over the wire; the
- // documented generic-card default takes it, not a crash.
- const future = { card: 'chart' } as unknown as ToolResultView
- expect(readCardModel(settled({ resultView: future }))).toBeNull()
+ it('returns null for missing calls, errors, malformed metadata/envelopes, unrelated tools, and children', () => {
+ expect(readCardModel(settled({ call: null }))).toBeNull()
+ expect(readCardModel(settled({ isError: true }))).toBeNull()
+ expect(readCardModel(settled({ meta: undefined }))).toBeNull()
+ expect(readCardModel(settled({ meta: { ...readMeta(), lines: [{ number: 0, text: 'bad' }] } }))).toBeNull()
+ expect(readCardModel(settled({ content: [{ type: 'text', text: 'plain result' }] }))).toBeNull()
+ expect(readCardModel(settled({ call: { name: 'echo', argsRaw: '{}' } }))).toBeNull()
+ expect(readCardModel(settled({ parentCallId: 'parent' }))).toBeNull()
+ })
+
+ it.each([
+ ['missing file_path', '{}'],
+ ['non-string file_path', '{"file_path":7}'],
+ ['blank file_path', '{"file_path":" "}'],
+ ['non-number offset', '{"file_path":"src/a.ts","offset":"41"}'],
+ ['non-positive offset', '{"file_path":"src/a.ts","offset":0}'],
+ ['fractional limit', '{"file_path":"src/a.ts","limit":1.5}'],
+ ])('keeps malformed recognized read args generic: %s', (_label, argsRaw) => {
+ expect(readCardModel(settled({ call: { name: 'read', argsRaw } }))).toBeNull()
+ })
+
+ it('accepts unknown fields because first-party parameter roots are open', () => {
+ const argsRaw = JSON.stringify({ file_path: 'src/a.ts', offset: 41, extension: { version: 1 } })
+ expect(readCardModel(settled({ call: { name: 'read', argsRaw } }))).not.toBeNull()
})
})
describe('GenericToolCard read body', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
- callId: 'c1', toolName: 'web_fetch', block, openFile: vi.fn(), t,
+ callId: 'c1', toolName: 'read', block, openFile: vi.fn(), t,
})
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
@@ -144,9 +161,7 @@ describe('GenericToolCard read body', () => {
it('expands to the read card, capped tighter than the panel', () => {
expect(CHAT_READ_MAX_LINES).toBeLessThan(16)
- // web_fetch lands on the read variant without its own keyed row, so the
- // fallback card owns the read block once expanded.
- const view = render()
+ const view = render()
// Collapsed: no read card in the DOM yet.
expect(view.container.querySelector('[data-read]')).toBeNull()
toggleRow(view)
@@ -159,15 +174,15 @@ describe('GenericToolCard read body', () => {
it('a non-read tool renders the bare row with no read card', () => {
const view = render()
toggleRow(view)
expect(view.container.querySelector('[data-read]')).toBeNull()
})
- it('a running read renders the summary row alone (no result view yet)', () => {
- const view = render()
+ it('a running read renders the summary row alone (no result metadata yet)', () => {
+ const view = render()
expect(view.container.querySelector('[data-read]')).toBeNull()
})
})
@@ -229,7 +244,7 @@ describe('ReadRow keyed toolview', () => {
it('an error read result shows the error state and no read card', () => {
const view = render()
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('error')
@@ -238,7 +253,7 @@ describe('ReadRow keyed toolview', () => {
it('an interrupted read shows the stopped state', () => {
const view = render()
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('stopped')
})
@@ -323,7 +338,7 @@ describe('DetailsPanel Output section (read)', () => {
it('renders the read card at full height, keeping the JSON Input section', () => {
const long = Array.from({ length: 20 }, (_, i) => ({ number: i + 1, text: `row-${i}` }))
const view = mount(snapshot({
- nodes: [settled({ resultView: resultRead({ lines: long, totalLines: 20 }) })],
+ nodes: [settled({ meta: readMeta({ offset: 1, lines: long, totalLines: 20 }) })],
}), target)
expect(view.getByText(/"file_path"/)).toBeTruthy()
expect(view.container.querySelector('[data-read]')).not.toBeNull()
@@ -335,7 +350,7 @@ describe('DetailsPanel Output section (read)', () => {
it('a non-read result keeps the flattened pre form', () => {
const view = mount(snapshot({
nodes: [settled({
- callView: null, resultView: null,
+ meta: undefined,
content: [{ type: 'text', text: 'plain result' }],
})],
}), target)
@@ -345,14 +360,14 @@ describe('DetailsPanel Output section (read)', () => {
it('abbreviates a leftover POSIX home path on the read card label', () => {
const view = mount(snapshot({
- nodes: [settled({ resultView: resultRead({ path: '/Users/u/notes.md' }) })],
+ nodes: [settled({ meta: readMeta({ path: '/Users/u/notes.md' }) })],
}), target, '/tmp/ws', {
version: '0', cwd: '/tmp', attachedSessions: 0, home: '/Users/u', canOpenPath: false,
})
expect(view.getByText('~/notes.md')).toBeTruthy()
})
- it('a running read keeps the 运行中… placeholder (no result view)', () => {
+ it('a running read keeps the 运行中… placeholder (no result metadata)', () => {
const view = mount(snapshot({ runningCalls: [running()] }), target)
expect(view.getByText('运行中…')).toBeTruthy()
expect(view.container.querySelector('[data-read]')).toBeNull()
diff --git a/packages/client/ui-tool/tests/search-card.client.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx
index d1b3d8a1e0..e42e8103b6 100644
--- a/packages/client/ui-tool/tests/search-card.client.spec.tsx
+++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx
@@ -11,7 +11,6 @@ import type {
} from '@deepseek-ai/dsh-client-ui-chat/client'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../src/client/tool/models/search-card-model.ts'
@@ -45,9 +44,23 @@ const SID = 's1' as SessionId
const GREP_ARGS = '{"pattern":"foo","path":"src"}'
const GLOB_ARGS = '{"pattern":"**/*.ts","path":"src"}'
-/** A grep result view: matches grouped by file. */
-const resultMatches = (over?: Partial>): ToolResultView => ({
- card: 'search', shape: 'matches',
+interface MatchesMeta {
+ shape: 'matches'
+ files: { path: string; matches: { lineNumber: number; line: string }[] }[]
+ truncated: boolean
+ total: number
+}
+
+interface PathsMeta {
+ shape: 'paths'
+ paths: string[]
+ truncated: boolean
+ total: number
+}
+
+/** Persisted grep metadata: matches grouped by file. */
+const matchesMeta = (over?: Partial): MatchesMeta => ({
+ shape: 'matches',
files: [
{ path: 'a.ts', matches: [{ lineNumber: 12, line: 'const foo = 1' }, { lineNumber: 40, line: 'return foo' }] },
{ path: 'b.ts', matches: [{ lineNumber: 7, line: 'foo()' }] },
@@ -55,14 +68,14 @@ const resultMatches = (over?: Partial>): ToolResultView => ({
- card: 'search', shape: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over,
+/** Persisted glob metadata: a flat path list. */
+const pathsMeta = (over?: Partial): PathsMeta => ({
+ shape: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over,
})
const runningGrep = (over?: Partial): RunningToolCall => ({
callId: 'c1', name: 'grep', argsRaw: GREP_ARGS,
- turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, subCalls: [], ...over,
+ turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
})
const settledGrep = (over?: Partial): ToolResultNode => ({
@@ -70,7 +83,7 @@ const settledGrep = (over?: Partial): ToolResultNode => ({
call: { name: 'grep', argsRaw: GREP_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'a.ts\n Line 12: const foo = 1' }], isError: false,
- callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, resultView: resultMatches(), subCalls: [], ...over,
+ meta: matchesMeta(), subCalls: [], ...over,
})
const settledGlob = (over?: Partial): ToolResultNode => ({
@@ -78,13 +91,12 @@ const settledGlob = (over?: Partial): ToolResultNode => ({
call: { name: 'glob', argsRaw: GLOB_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'src/a.ts\nsrc/b.ts' }], isError: false,
- callView: { card: 'generic', title: 'Glob **/*.ts', kind: 'search' }, resultView: resultPaths(), subCalls: [], ...over,
+ meta: pathsMeta(), subCalls: [], ...over,
})
describe('searchCardModel', () => {
- it('derives a matches card from the grep result view', () => {
+ it('derives a matches card from grep result metadata', () => {
expect(searchCardModel(settledGrep())).toEqual({
- title: undefined,
recovery: undefined,
card: {
kind: 'matches',
@@ -97,84 +109,78 @@ describe('searchCardModel', () => {
})
})
- it('derives a paths card from the glob result view, carrying the truncation signal', () => {
+ it('derives a paths card from glob result metadata, carrying the truncation signal', () => {
// Empty block content isolates the truncation signal from the recovery arm.
- expect(searchCardModel(settledGlob({ content: [], resultView: resultPaths({ truncated: true, total: 20 }) }))).toEqual({
- title: undefined,
+ expect(searchCardModel(settledGlob({ content: [], meta: pathsMeta({ truncated: true, total: 20 }) }))).toEqual({
recovery: undefined,
card: { kind: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: true, total: 20 },
})
})
- it('carries the result view\'s replacement title when the presenter sets one', () => {
- expect(searchCardModel(settledGrep({ resultView: resultMatches({ title: '3 matches' }) }))?.title).toBe('3 matches')
- // Without one it is absent, so the row keeps its args-derived summary.
- expect(searchCardModel(settledGrep())?.title).toBeUndefined()
- })
-
- it('returns null for every non-search call: running, no views, generic, terminal, unknown cards', () => {
- // A search card is result-time only: a running call has no result view yet.
+ it('returns null for running, missing calls, errors, malformed args, unrelated tools, and children', () => {
expect(searchCardModel(runningGrep())).toBeNull()
- expect(searchCardModel(settledGrep({ callView: null, resultView: null }))).toBeNull()
- // A generic result settles a search call as a generic card (grep/glob failure
- // or a nested run_code dispatch), which keeps the generic path.
- expect(searchCardModel(settledGrep({ resultView: { card: 'generic' } }))).toBeNull()
- // A terminal result view is a different card entirely.
- expect(searchCardModel(settledGrep({ resultView: { card: 'terminal', output: 'x' } }))).toBeNull()
- // A card tag this UI version does not know arrives over the wire; the
- // documented generic-card default takes it, not a crash.
- const future = { card: 'chart' } as unknown as ToolResultView
- expect(searchCardModel(settledGrep({ resultView: future }))).toBeNull()
+ expect(searchCardModel(settledGrep({ call: null }))).toBeNull()
+ expect(searchCardModel(settledGrep({ isError: true }))).toBeNull()
+ expect(searchCardModel(settledGrep({ call: { name: 'grep', argsRaw: '{' } }))).toBeNull()
+ expect(searchCardModel(settledGrep({ call: { name: 'echo', argsRaw: '{}' } }))).toBeNull()
+ expect(searchCardModel(settledGrep({ parentCallId: 'parent' }))).toBeNull()
})
- it('returns null for a card:search view whose shape this version does not compile', () => {
- // `shape` rides the same untrusted wire frame as `card`; a subtype this client
- // does not know must fall to the generic path, never render as a paths card
- // that would crash SearchBlock on an absent `paths`.
- const futureShape = {
- card: 'search', shape: 'future', truncated: false, total: 0,
- } as unknown as ToolResultView
- expect(searchCardModel(settledGrep({ resultView: futureShape }))).toBeNull()
+ it('returns null for metadata whose shape does not match the tool', () => {
+ expect(searchCardModel(settledGrep({ meta: { shape: 'future', truncated: false, total: 0 } }))).toBeNull()
+ expect(searchCardModel(settledGrep({ meta: pathsMeta() }))).toBeNull()
+ expect(searchCardModel(settledGlob({ meta: matchesMeta() }))).toBeNull()
+ })
+
+ it('validates declared search argument fields and accepts open-root extensions', () => {
+ expect(searchCardModel(settledGrep({
+ call: { name: 'grep', argsRaw: '{"pattern":"foo","include":7}' },
+ }))).toBeNull()
+ expect(searchCardModel(settledGrep({
+ call: { name: 'grep', argsRaw: '{"pattern":"foo","include":"!*.ts"}' },
+ }))).toBeNull()
+ expect(searchCardModel(settledGlob({
+ call: { name: 'glob', argsRaw: '{"pattern":"**/*.ts","path":7}' },
+ }))).toBeNull()
+ expect(searchCardModel(settledGrep({
+ call: { name: 'grep', argsRaw: '{"pattern":"foo","extension":1}' },
+ }))).not.toBeNull()
})
it('returns null for a known shape whose structured shape is missing or malformed', () => {
- // The host wire schema checks the `card`/`shape` strings but not the grouped
- // shape, so a version mismatch could deliver shape:'matches' with no `files`
- // (or shape:'paths' with no `paths`). Rendering that crashes SearchBlock at
- // `.reduce`/`.map`; the derivation drops to the generic path instead.
- const noFiles = { card: 'search', shape: 'matches', truncated: false, total: 0 } as unknown as ToolResultView
- expect(searchCardModel(settledGrep({ resultView: noFiles }))).toBeNull()
+ const noFiles = { shape: 'matches', truncated: false, total: 0 }
+ expect(searchCardModel(settledGrep({ meta: noFiles }))).toBeNull()
const badFile = {
- card: 'search', shape: 'matches', truncated: false, total: 1,
+ shape: 'matches', truncated: false, total: 1,
files: [{ path: 'a.ts', matches: [{ lineNumber: 'x', line: 1 }] }],
- } as unknown as ToolResultView
- expect(searchCardModel(settledGrep({ resultView: badFile }))).toBeNull()
- const noPaths = { card: 'search', shape: 'paths', truncated: false, total: 0 } as unknown as ToolResultView
- expect(searchCardModel(settledGlob({ resultView: noPaths }))).toBeNull()
+ }
+ expect(searchCardModel(settledGrep({ meta: badFile }))).toBeNull()
+ const noPaths = { shape: 'paths', truncated: false, total: 0 }
+ expect(searchCardModel(settledGlob({ meta: noPaths }))).toBeNull()
const badPaths = {
- card: 'search', shape: 'paths', truncated: false, total: 1, paths: [42],
- } as unknown as ToolResultView
- expect(searchCardModel(settledGlob({ resultView: badPaths }))).toBeNull()
+ shape: 'paths', truncated: false, total: 1, paths: [42],
+ }
+ expect(searchCardModel(settledGlob({ meta: badPaths }))).toBeNull()
})
it('surfaces the recovery text only when the result was capped', () => {
const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)'
- // The recovery locator lives in the raw tool/result content (the view carries
- // no text), surfaced only when the card capped the result.
+ // The recovery locator lives in raw tool/result content and is surfaced only
+ // when metadata says the card was capped.
const capped = searchCardModel(settledGrep({
content: [{ type: 'text', text: recovery }],
- resultView: resultMatches({ truncated: true, total: 42 }),
+ meta: matchesMeta({ truncated: true, total: 42 }),
}))
expect(capped?.recovery).toBe(recovery)
// Not capped: the card holds every match, so the raw content adds nothing and
// is dropped.
const whole = searchCardModel(settledGrep({
content: [{ type: 'text', text: recovery }],
- resultView: resultMatches({ truncated: false }),
+ meta: matchesMeta({ truncated: false }),
}))
expect(whole?.recovery).toBeUndefined()
// Capped but the block carries no text: nothing to surface.
- const noText = searchCardModel(settledGrep({ content: [], resultView: resultMatches({ truncated: true, total: 42 }) }))
+ const noText = searchCardModel(settledGrep({ content: [], meta: matchesMeta({ truncated: true, total: 42 }) }))
expect(noText?.recovery).toBeUndefined()
})
})
@@ -210,7 +216,7 @@ describe('chat row search body (GenericToolCard fallback)', () => {
it('a non-search result keeps the args-JSON text body', () => {
const view = render()
toggleRow(view)
expect(view.getByText(/"pattern"/)).toBeTruthy()
@@ -221,7 +227,7 @@ describe('chat row search body (GenericToolCard fallback)', () => {
const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)'
const view = render()
toggleRow(view)
expect(searchKindOf(view.container)).toBe('matches')
@@ -266,20 +272,20 @@ describe('SearchRow keyed card', () => {
it('agrees with the summary row about the run state', () => {
const runningView = render()
expect(runningView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('running')
- // No result view yet, so no card even once material could expand.
+ // No result metadata yet, so no card even once material could expand.
expect(searchKindOf(runningView.container)).toBeNull()
cleanup()
const errorView = render()
expect(errorView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('error')
})
it('surfaces the result text through the Output section when an errored search has no card', () => {
- // grep/glob return no presentResult on error → no card; the row shows the
- // first error line as the collapsed summary and the full text once expanded.
+ // Failed search metadata cannot select a success card; the row keeps the
+ // first error line collapsed and the full text once expanded.
const view = render()
expect(searchKindOf(view.container)).toBeNull()
@@ -291,12 +297,9 @@ describe('SearchRow keyed card', () => {
})
it('surfaces the result text for a settled non-error call with no card once expanded', () => {
- // A successful nested run_code sub-dispatch (backend computes no
- // presentationMeta, so resultView is null) or a legacy generic result settles
- // with search === null and state ok. The keyed SearchRow owns the slot, so
- // ToolRow's Output section carries the text; it is only visible expanded.
+ // Missing metadata keeps a successful result on ToolRow's raw Output path.
const view = render()
expect(view.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('ok')
@@ -311,7 +314,7 @@ describe('SearchRow keyed card', () => {
const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)'
const view = render()
toggleRow(view)
expect(searchKindOf(view.container)).toBe('matches')
@@ -327,21 +330,14 @@ describe('SearchRow keyed card', () => {
it('falls back to the error name/code when an errored result has no text block', () => {
const view = render()
// Error state: the derived name/code line is the collapsed summary.
expect(view.getByText('ToolError: timeout')).toBeTruthy()
})
- it('shows the result view\'s replacement title instead of the args summary', () => {
- const view = render()
- expect(view.getByText('3 matches in 2 files')).toBeTruthy()
- })
-
- it('keeps the args-derived summary when the result view has no title', () => {
+ it('keeps the args-derived summary beside the metadata-derived card', () => {
const view = render()
expect(view.getByText('foo')).toBeTruthy()
})
@@ -441,7 +437,7 @@ describe('DetailsPanel Output section (search)', () => {
it('renders the recovery footer below the card for a capped search', () => {
const recovery = 'src/a.ts\nsrc/b.ts\n\n(Showing 2 of 23 paths. Full sorted result stored at: spill://glob-7.)'
const view = mount(snapshot({
- nodes: [settledGlob({ content: [{ type: 'text', text: recovery }], resultView: resultPaths({ truncated: true, total: 23 }) })],
+ nodes: [settledGlob({ content: [{ type: 'text', text: recovery }], meta: pathsMeta({ truncated: true, total: 23 }) })],
}), globTarget)
expect(searchKindOf(view.container)).toBe('paths')
expect(view.getByText(/Full sorted result stored at: spill:\/\/glob-7/)).toBeTruthy()
@@ -449,7 +445,7 @@ describe('DetailsPanel Output section (search)', () => {
it('a non-search result keeps the flattened pre form', () => {
const view = mount(snapshot({
- nodes: [settledGrep({ callView: null, resultView: null })],
+ nodes: [settledGrep({ meta: undefined })],
}), grepTarget)
expect(searchKindOf(view.container)).toBeNull()
const output = view.getByText('输出').closest('section')
diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx
index c7cd3a2ffa..2543f7bfe7 100644
--- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx
+++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx
@@ -11,7 +11,6 @@ import type {
} from '@deepseek-ai/dsh-client-ui-chat/client'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-api-remotes/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { terminalCardModel, terminalFailed } from '../src/client/tool/models/terminal-card-model.ts'
@@ -46,19 +45,13 @@ const SID = 's1' as SessionId
const ARGS = '{"command":"ls -la","description":"List files"}'
-/** The bash tool's own call view for a foreground command. */
-const callTerminal = (over?: Partial>): ToolCallView => ({
- card: 'terminal', title: 'ls -la', description: 'List files', ...over,
-})
-
-/** The bash tool's own result view for a settled foreground command. */
-const resultTerminal = (over?: Partial>): ToolResultView => ({
- card: 'terminal', output: 'a.ts b.ts\nc.ts d.ts\n', exitCode: 0, ...over,
+const shellArgs = (over: Record = {}): string => JSON.stringify({
+ command: 'ls -la', description: 'List files', ...over,
})
const running = (over?: Partial): RunningToolCall => ({
callId: 'c1', name: 'bash', argsRaw: ARGS,
- turn: 1, step: 1, time: 1_000, callView: callTerminal(), subCalls: [], ...over,
+ turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
})
const settled = (over?: Partial): ToolResultNode => ({
@@ -66,12 +59,12 @@ const settled = (over?: Partial): ToolResultNode => ({
call: { name: 'bash', argsRaw: ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'a.ts b.ts\nc.ts d.ts\n' }], isError: false,
- callView: callTerminal(), resultView: resultTerminal(), subCalls: [], ...over,
+ subCalls: [], ...over,
})
describe('terminalCardModel', () => {
- it('derives a running card from the call view alone', () => {
- expect(terminalCardModel(running({ callView: callTerminal({ cwd: '/projects/app' }) }))).toEqual({
+ it('derives a running standard-shell card from raw arguments', () => {
+ expect(terminalCardModel(running({ argsRaw: shellArgs({ workdir: '/projects/app' }) }))).toEqual({
description: 'List files',
card: {
command: 'ls -la', cwd: '/projects/app', output: undefined,
@@ -80,61 +73,59 @@ describe('terminalCardModel', () => {
})
})
- it('derives a settled card from both sides, carrying the exit status', () => {
+ it('derives a settled standard-shell card and removes its final exit marker', () => {
expect(terminalCardModel(settled({
- callView: callTerminal({ cwd: '/projects/app' }),
- resultView: resultTerminal({ output: 'boom\n', exitCode: 2 }),
+ call: { name: 'bash', argsRaw: shellArgs({ workdir: '/projects/app' }) },
+ content: [{ type: 'text', text: 'boom\n[exit code: 2]' }],
}))).toEqual({
description: 'List files',
card: {
- command: 'ls -la', cwd: '/projects/app', output: 'boom\n',
+ command: 'ls -la', cwd: '/projects/app', output: 'boom',
exitCode: 2, signal: undefined, running: false,
},
})
expect(terminalCardModel(settled({
- resultView: { card: 'terminal', output: '', signal: 'SIGTERM' },
- }))?.card.signal).toBe('SIGTERM')
+ content: [{ type: 'text', text: 'gone\n[killed by signal: SIGTERM]' }],
+ }))?.card).toMatchObject({ output: 'gone', signal: 'SIGTERM' })
})
it('flags a failing exit as terminalFailed; clean exits and running cards are not', () => {
// isError stays false on a failing command (the exit status is result
// data), so this predicate is the row's only failure signal.
expect(terminalFailed(terminalCardModel(settled({
- resultView: resultTerminal({ exitCode: 2 }),
+ content: [{ type: 'text', text: 'boom\n[exit code: 2]' }],
}))!)).toBe(true)
expect(terminalFailed(terminalCardModel(settled({
- resultView: { card: 'terminal', output: '', signal: 'SIGTERM' },
+ content: [{ type: 'text', text: 'gone\n[killed by signal: SIGTERM]' }],
}))!)).toBe(true)
expect(terminalFailed(terminalCardModel(settled())!)).toBe(false)
expect(terminalFailed(terminalCardModel(running())!)).toBe(false)
})
- it('takes the result view\'s replacement title over the pending one', () => {
- // The presentation contract defines a result title as REPLACING the pending
- // title, so a tool that rewrites it at settle time must win here.
+ it('keeps status text that has no terminal pill and requires a leading newline', () => {
expect(terminalCardModel(settled({
- callView: callTerminal({ title: 'pnpm run check' }),
- resultView: resultTerminal({ title: 'pnpm run check --filter web' }),
- }))?.card.command).toBe('pnpm run check --filter web')
- // Without one, the call's title is what the card keeps.
- expect(terminalCardModel(settled())?.card.command).toBe('ls -la')
+ content: [{ type: 'text', text: 'timed out\n[timed out after 1000ms]\n[exit code: 2]' }],
+ }))?.card).toMatchObject({ output: 'timed out\n[timed out after 1000ms]', exitCode: 2 })
+ expect(terminalCardModel(settled({
+ content: [{ type: 'text', text: '[exit code: 5]' }],
+ }))?.card).toMatchObject({ output: '[exit code: 5]', exitCode: 0 })
})
- it('resolves the cwd against the session workspace the way the bridge must', () => {
+ it('resolves the raw workdir against the session workspace', () => {
// Omitted workdir — the common bash call — IS the session workspace.
expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app')
// A relative workdir joins under it.
expect(terminalCardModel(settled({
- callView: callTerminal({ cwd: 'packages/ui' }),
+ call: { name: 'bash', argsRaw: shellArgs({ workdir: 'packages/ui' }) },
}), '/w/app')?.card.cwd).toBe('/w/app/packages/ui')
// An absolute one is used as-is.
expect(terminalCardModel(settled({
- callView: callTerminal({ cwd: '/srv/other' }),
+ call: { name: 'bash', argsRaw: shellArgs({ workdir: '/srv/other' }) },
}), '/w/app')?.card.cwd).toBe('/srv/other')
// With no session cwd there is nothing to resolve against: a relative path
// stays as authored and an omitted one stays absent (a bare `$` prompt).
expect(terminalCardModel(settled({
- callView: callTerminal({ cwd: 'packages/ui' }),
+ call: { name: 'bash', argsRaw: shellArgs({ workdir: 'packages/ui' }) },
}))?.card.cwd).toBe('packages/ui')
expect(terminalCardModel(settled())?.card.cwd).toBeUndefined()
// The running arm resolves identically.
@@ -144,97 +135,119 @@ describe('terminalCardModel', () => {
it('normalizes a relative workdir so the label names the directory actually used', () => {
// The bash executor resolves the workdir before running, so `..` against
// /w/app runs in /w — the card must say `w`, not `..`.
- expect(terminalCardModel(settled({
- callView: callTerminal({ cwd: '..' }),
- }), '/w/app')?.card.cwd).toBe('/w')
- expect(terminalCardModel(settled({
- callView: callTerminal({ cwd: '.' }),
- }), '/w/app')?.card.cwd).toBe('/w/app')
- expect(terminalCardModel(settled({
- callView: callTerminal({ cwd: '../sibling' }),
- }), '/w/app')?.card.cwd).toBe('/w/sibling')
- expect(terminalCardModel(settled({
- callView: callTerminal({ cwd: './nested/../other' }),
- }), '/w/app')?.card.cwd).toBe('/w/app/other')
+ expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '..' }) } }), '/w/app')?.card.cwd).toBe('/w')
+ expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '.' }) } }), '/w/app')?.card.cwd).toBe('/w/app')
+ expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '../sibling' }) } }), '/w/app')?.card.cwd).toBe('/w/sibling')
+ expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: './nested/../other' }) } }), '/w/app')?.card.cwd).toBe('/w/app/other')
// A `..` that would climb past the root is dropped, as a filesystem does.
- expect(terminalCardModel(settled({
- callView: callTerminal({ cwd: '../../..' }),
- }), '/w')?.card.cwd).toBe('/')
+ expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '../../..' }) } }), '/w')?.card.cwd).toBe('/')
// An absolute path carrying segments normalizes too.
- expect(terminalCardModel(settled({
- callView: callTerminal({ cwd: '/srv/./app/../other' }),
- }), '/w/app')?.card.cwd).toBe('/srv/other')
+ expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '/srv/./app/../other' }) } }), '/w/app')?.card.cwd).toBe('/srv/other')
// A Windows path keeps its separators.
- expect(terminalCardModel(settled({
- callView: callTerminal({ cwd: 'C:\\ws\\app\\..' }),
- }), '/w')?.card.cwd).toBe('C:\\ws')
+ expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: 'C:\\ws\\app\\..' }) } }), '/w')?.card.cwd).toBe('C:\\ws')
// Without a session cwd a relative `..` has nothing to resolve against, so
// it survives as authored rather than being silently dropped.
- expect(terminalCardModel(settled({
- callView: callTerminal({ cwd: '../elsewhere' }),
- }))?.card.cwd).toBe('../elsewhere')
+ expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '../elsewhere' }) } }))?.card.cwd).toBe('../elsewhere')
})
it('keeps a UNC server and share as an unpoppable root', () => {
// Windows cannot climb above a share, so `..` from the share root stays put.
- expect(terminalCardModel(settled({
- callView: callTerminal({ cwd: '..' }),
- }), '\\\\server\\share')?.card.cwd).toBe('\\\\server\\share')
+ expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '..' }) } }), '\\\\server\\share')?.card.cwd).toBe('\\\\server\\share')
// Below the share it pops normally, keeping the UNC separators.
- expect(terminalCardModel(settled({
- callView: callTerminal({ cwd: '..' }),
- }), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
+ expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '..' }) } }), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
// Several `..` cannot escape the root either.
- expect(terminalCardModel(settled({
- callView: callTerminal({ cwd: '../../..' }),
- }), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
+ expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '../../..' }) } }), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
})
- it('draws a bare $ when the window dropped the call head, rather than guessing', () => {
- // A truncated call carries no cwd anywhere: the result view has none, and
- // the original call may have used an explicit workdir. Falling back to the
- // session workspace here would name a directory the card cannot know.
+ it('supports terminal_send without giving background or failed sends a terminal card', () => {
+ const argsRaw = JSON.stringify({ sessionId: 'pty-3', text: 'make' })
+ const run = running({ name: 'terminal_send', argsRaw })
+ expect(terminalCardModel(run, '/w/app')).toMatchObject({
+ description: 'Terminal pty-3', card: { command: 'make', cwd: '/w/app', running: true },
+ })
+ const done = settled({ call: { name: 'terminal_send', argsRaw }, content: [{ type: 'text', text: 'ok' }] })
+ expect(terminalCardModel(done)?.card).toMatchObject({ command: 'make', output: 'ok', running: false })
expect(terminalCardModel(settled({
- call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }),
- }), '/w/app')?.card.cwd).toBeUndefined()
- // A present call view that omits its cwd still means the workspace.
- expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app')
- })
-
- it('carries the call view\'s description, which the contract renders above the card', () => {
- expect(terminalCardModel(settled())?.description).toBe('List files')
- expect(terminalCardModel(running())?.description).toBe('List files')
- // A presenter that supplies none, and a window-truncated call side, both
- // leave it absent so the row keeps its args-derived summary.
- expect(terminalCardModel(settled({
- callView: { card: 'terminal', title: 'ls' },
- }))?.description).toBeUndefined()
- expect(terminalCardModel(settled({ call: null, callView: null }))?.description).toBeUndefined()
- })
-
- it('a window-truncated call side falls back to the result title, then to an empty command', () => {
- // Truncation drops both the call head and its view (conversation.ts).
- const truncated = { call: null, callView: null }
- expect(terminalCardModel(settled({
- ...truncated, resultView: resultTerminal({ title: 'ls -la' }),
- }))?.card).toMatchObject({ command: 'ls -la', cwd: undefined, running: false })
- expect(terminalCardModel(settled(truncated))?.card).toMatchObject({ command: '', cwd: undefined })
- })
-
- it('returns null for every non-terminal call: no views, generic views, unknown cards', () => {
- expect(terminalCardModel(running({ callView: null }))).toBeNull()
- expect(terminalCardModel(settled({ callView: null, resultView: null }))).toBeNull()
- expect(terminalCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull()
- // A generic result settles a terminal call as a generic card (the bash
- // tool's own execution-error and background paths).
- expect(terminalCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull()
- // A card tag this UI version does not know arrives over the wire; the
- // documented generic-card default takes it, not a crash.
- const future = { card: 'chart', title: 'plot' } as unknown as ToolCallView
- expect(terminalCardModel(running({ callView: future }))).toBeNull()
- expect(terminalCardModel(settled({
- callView: future, resultView: { card: 'chart' } as unknown as ToolResultView,
+ call: { name: 'terminal_send', argsRaw: JSON.stringify({ sessionId: 'pty-3', text: 'make', run_in_background: true }) },
}))).toBeNull()
+ expect(terminalCardModel(settled({ ...done, isError: true }))).toBeNull()
+ })
+
+ it('preserves persistent-shell running cards and settled generic output', () => {
+ const persistent = JSON.stringify({ command: 'pwd' })
+ expect(terminalCardModel(running({ argsRaw: persistent }))).toMatchObject({
+ description: undefined, card: { command: 'pwd', running: true },
+ })
+ expect(terminalCardModel(running({ name: 'pwsh', argsRaw: persistent }))).toMatchObject({
+ description: undefined, card: { command: 'pwd', running: true },
+ })
+ expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: persistent } }))).toBeNull()
+ expect(terminalCardModel(settled({ call: { name: 'pwsh', argsRaw: persistent } }))).toBeNull()
+ })
+
+ it('derives the standard pwsh card from the same raw status markers', () => {
+ expect(terminalCardModel(settled({
+ call: { name: 'pwsh', argsRaw: ARGS },
+ content: [{ type: 'text', text: 'failed\n[exit code: 3]' }],
+ }))).toMatchObject({
+ description: 'List files',
+ card: { command: 'ls -la', output: 'failed', exitCode: 3, running: false },
+ })
+ })
+
+ it('returns null without a paired call and for Code Dispatch children', () => {
+ expect(terminalCardModel(settled({ call: null }))).toBeNull()
+ expect(terminalCardModel(settled({ parentCallId: 'parent' }))).toBeNull()
+ expect(terminalCardModel(running({ parentCallId: 'parent' }))).toBeNull()
+ })
+
+ it('returns null for background, errors, malformed args, unsupported tools, and non-text results', () => {
+ expect(terminalCardModel(running({ argsRaw: shellArgs({ run_in_background: true }) }))).toBeNull()
+ expect(terminalCardModel(settled({ isError: true }))).toBeNull()
+ expect(terminalCardModel(running({ argsRaw: '{' }))).toBeNull()
+ expect(terminalCardModel(running({ name: 'read' }))).toBeNull()
+ expect(terminalCardModel(settled({ content: [] }))).toBeNull()
+ expect(terminalCardModel(settled({ content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }] }))).toBeNull()
+ })
+
+ it.each([
+ ['timeout type', { timeoutMs: '1000' }],
+ ['timeout value', { timeoutMs: 0 }],
+ ['workdir type', { workdir: 7 }],
+ ['background type', { run_in_background: 'yes' }],
+ ['permission type', { sandbox_permissions: 7, justification: 'Need access' }],
+ ['permission value', { sandbox_permissions: 'read-only', justification: 'Need access' }],
+ ['missing justification', { sandbox_permissions: 'workspace-write' }],
+ ['orphan justification', { justification: 'Need access' }],
+ ['blank justification', { sandbox_permissions: 'workspace-write', justification: ' ' }],
+ ])('keeps malformed standard-shell optional fields generic: %s', (_label, fields) => {
+ expect(terminalCardModel(running({ argsRaw: shellArgs(fields) }))).toBeNull()
+ })
+
+ it('accepts valid optional and unknown standard-shell fields on the open parameter root', () => {
+ expect(terminalCardModel(running({ argsRaw: shellArgs({
+ timeoutMs: 1_000,
+ sandbox_permissions: 'workspace-write',
+ justification: 'Write generated output',
+ extension: { version: 1 },
+ }) }))).not.toBeNull()
+ })
+
+ it('validates terminal_send optional fields while retaining open-root extensions', () => {
+ const send = (over: Record) => running({
+ name: 'terminal_send',
+ argsRaw: JSON.stringify({ sessionId: 'pty-1', text: 'make', ...over }),
+ })
+ expect(terminalCardModel(send({ submit: 'yes' }))).toBeNull()
+ expect(terminalCardModel(send({ run_in_background: 'yes' }))).toBeNull()
+ expect(terminalCardModel(send({ submit: false, run_in_background: false }))).not.toBeNull()
+ expect(terminalCardModel(send({ extension: { version: 1 } }))).not.toBeNull()
+ })
+
+ it('keeps persistent shells with open-root extension fields on the running-card path', () => {
+ const argsRaw = JSON.stringify({ command: 'pwd', extension: { version: 1 } })
+ expect(terminalCardModel(running({ argsRaw }))).not.toBeNull()
+ expect(terminalCardModel(running({ name: 'pwsh', argsRaw }))).not.toBeNull()
})
})
@@ -263,7 +276,7 @@ describe('chat row terminal body', () => {
it('a long output renders in full — the scroll container replaces the middle collapse', () => {
const lines = Array.from({ length: 20 }, (_, i) => `line-${i}`)
const view = render()
toggleRow(view)
expect(view.getByText('line-5')).toBeTruthy()
@@ -273,7 +286,7 @@ describe('chat row terminal body', () => {
it('renders a multi-line command as one prompt row per line', () => {
const view = render()
toggleRow(view)
const rows = view.container.querySelectorAll('[class^="_promptLine_"]')
@@ -282,22 +295,17 @@ describe('chat row terminal body', () => {
expect(view.container.querySelectorAll('[data-terminal] [data-state]')).toHaveLength(1)
})
- it('the fallback row shows the presenter description, not the args summary', () => {
- // Any terminal-declaring tool without its own keyed row lands here, so the
- // contract's above-card description has to win at this render site as well.
+ it('the fallback row shows the call description', () => {
const view = render()
expect(view.getByText('Terminal 3')).toBeTruthy()
expect(view.queryByText('List files')).toBeNull()
})
- it('keeps the presenter description visible once the terminal card is expanded', () => {
- // The contract puts the description ABOVE the card. The collapsed summary is
- // hidden while a row is open, so an expanded terminal row has to draw it
- // itself or the description would only ever be visible collapsed.
+ it('keeps the call description visible once the terminal card is expanded', () => {
const view = render()
expect(view.getByText('Terminal 3')).toBeTruthy()
toggleRow(view)
@@ -317,24 +325,24 @@ describe('chat row terminal body', () => {
it('a non-terminal call keeps the args-JSON text body', () => {
const view = render()
toggleRow(view)
expect(view.getByText(/"command"/)).toBeTruthy()
})
- it('a terminal call with no args still expands, through its terminal body alone', () => {
- // Empty args make the text body null; the terminal material carries the row.
+ it('malformed empty args use the generic output body', () => {
const view = render()
toggleRow(view)
- expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
+ expect(view.container.querySelector('[class*="_ioText_"]')?.textContent).toBe('a.ts b.ts\nc.ts d.ts\n')
+ expect(view.container.querySelector('[data-terminal]')).toBeNull()
})
it('a failing exit status surfaces as the collapsed row\'s error state', () => {
const view = render()
expect(view.container.querySelector('[data-state]')?.getAttribute('data-state')).toBe('error')
})
@@ -386,34 +394,32 @@ describe('BashRow terminal card', () => {
it('a failing exit status surfaces as the collapsed row\'s error state', () => {
const view = render()
expect(view.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('error')
})
- it('shows the terminal presenter\'s description instead of the args summary', () => {
- // `terminal_send`-style presenters author a description the args do not
- // repeat; the contract puts it above the card, which is this row's summary.
+ it('shows the call description as the terminal summary', () => {
const view = render()
expect(view.getByText('Terminal 3')).toBeTruthy()
expect(view.queryByText('List files')).toBeNull()
})
- it('keeps the args-derived summary when the presenter authored no description', () => {
+ it('keeps the command summary for a persistent shell with no description', () => {
const view = render()
- expect(view.getByText('List files')).toBeTruthy()
+ expect(view.getByText('ls -la')).toBeTruthy()
})
it('a non-terminal bash call (background start) renders the summary row alone', () => {
const view = render()
- expect(view.getByText('List files')).toBeTruthy()
+ expect(view.getByText('Wait')).toBeTruthy()
expect(view.queryByText(/a\.ts/)).toBeNull()
expect(view.container.querySelector('[data-sample="bash"]')?.getAttribute('role')).toBeNull()
})
@@ -422,8 +428,6 @@ describe('BashRow terminal card', () => {
const view = render()
const row = view.container.querySelector('[data-sample="bash"]')!
expect(row.getAttribute('role')).toBe('button')
@@ -498,7 +502,7 @@ describe('DetailsPanel Output section', () => {
it('resets the card\'s expand state when the selected call changes', () => {
const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
const view = mount(snapshot({
- nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })],
+ nodes: [settled({ content: [{ type: 'text', text: `${long.join('\n')}\n` }] })],
}), target)
fireEvent.click(view.getByRole('button', { name: '展开其余 4 行输出' }))
expect(view.getByRole('button', { name: '收起输出' })).toBeTruthy()
@@ -506,15 +510,15 @@ describe('DetailsPanel Output section', () => {
cleanup()
const second = mount(snapshot({
nodes: [settled({
- callId: 'c2', resultView: resultTerminal({ output: `${long.join('\n')}\n` }),
+ callId: 'c2', content: [{ type: 'text', text: `${long.join('\n')}\n` }],
})],
}), { turnSeq: 10, callId: 'c2', toolName: 'bash' })
expect(second.getByRole('button', { name: '展开其余 4 行输出' })).toBeTruthy()
})
- it('renders the presenter description above the card', () => {
+ it('renders the raw call description above the card', () => {
const view = mount(snapshot({
- nodes: [settled({ callView: callTerminal({ description: 'Terminal 3' }) })],
+ nodes: [settled({ call: { name: 'bash', argsRaw: shellArgs({ description: 'Terminal 3' }) } })],
}), target)
const description = view.getByText('Terminal 3')
const card = view.container.querySelector('[data-terminal]')
@@ -525,14 +529,14 @@ describe('DetailsPanel Output section', () => {
it('resolves the prompt cwd against the session workspace', () => {
const view = mount(snapshot({ nodes: [settled()] }), target, '/w/app')
- // No workdir in the call view: the prompt label is the workspace basename.
+ // No workdir in the call args: the prompt label is the workspace basename.
expect(view.getByText('app')).toBeTruthy()
})
it('renders the terminal card at full height, keeping the JSON Input section', () => {
const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
const view = mount(snapshot({
- nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })],
+ nodes: [settled({ content: [{ type: 'text', text: `${long.join('\n')}\n` }] })],
}), target)
expect(view.getByText(/"command"/)).toBeTruthy()
expect(view.getByText('ls -la')).toBeTruthy()
@@ -549,14 +553,14 @@ describe('DetailsPanel Output section', () => {
})
it('a running non-terminal call keeps the 运行中… placeholder', () => {
- const view = mount(snapshot({ runningCalls: [running({ callView: null })] }), target)
+ const view = mount(snapshot({ runningCalls: [running({ argsRaw: shellArgs({ run_in_background: true }) })] }), target)
expect(view.getByText('运行中…')).toBeTruthy()
})
it('a non-terminal result keeps the flattened pre with its error styling', () => {
const view = mount(snapshot({
nodes: [settled({
- callView: null, resultView: null, isError: true,
+ isError: true,
content: [{ type: 'text', text: 'permission denied' }],
})],
}), target)
@@ -564,23 +568,8 @@ describe('DetailsPanel Output section', () => {
expect(pre?.textContent).toBe('permission denied')
})
- // The panel resolves a sub-dispatch through the same material as a native
- // call, so a sub-call that DID carry terminal views would render the card.
- // The shipped wire cannot produce that yet: `session.ts` folds
- // `tool/code-dispatch(-start)` with `callView: null`/`resultView: null`, and
- // the host's `viewFor` only presents top-level `tool/call`/`tool/result`. This
- // pins the resolution path with views injected directly, and the arm below
- // pins what the shipped path shows.
- 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({
- runningCalls: [running({ callId: 'p1', subCalls: [child] })],
- }), target)
- 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', () => {
- const child = settled({ callId: 'c1', callView: null, resultView: null })
+ it('a Code Dispatch child keeps the flattened form despite valid terminal raw fields', () => {
+ const child = settled({ callId: 'c1', parentCallId: 'p1' })
const view = mount(snapshot({
runningCalls: [running({ callId: 'p1', subCalls: [child] })],
}), target)
@@ -591,20 +580,24 @@ describe('DetailsPanel Output section', () => {
expect(output?.querySelector('pre')?.textContent).toContain('a.ts b.ts')
})
- it('a running run_code sub-dispatch resolves through the running material', () => {
+ it('a running Code Dispatch child keeps the running placeholder', () => {
const view = mount(snapshot({
// The leading non-matching sub-call exercises the scan's skip.
runningCalls: [running({
callId: 'p1',
- subCalls: [running({ callId: 'other' }), running()],
+ subCalls: [
+ running({ callId: 'other', parentCallId: 'p1' }),
+ running({ parentCallId: 'p1' }),
+ ],
})],
}), target)
- expect(view.getByText('ls -la')).toBeTruthy()
+ expect(view.getByText('运行中…')).toBeTruthy()
+ expect(view.container.querySelector('[data-terminal]')).toBeNull()
})
it('a window-truncated call head titles the panel by callId and drops the Input section', () => {
const view = mount(snapshot({
- nodes: [settled({ call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }) })],
+ nodes: [settled({ call: null })],
}), target)
expect(view.getByText('c1')).toBeTruthy()
expect(view.queryByText('输入')).toBeNull()
@@ -674,7 +667,6 @@ describe('DetailsPanel Output section', () => {
it('a non-text result block renders as JSON, and an empty result falls back to its error', () => {
const nonText = mount(snapshot({
nodes: [settled({
- callView: null, resultView: null,
content: [{ type: 'reasoning', text: 'why' }],
})],
}), target)
@@ -685,7 +677,7 @@ describe('DetailsPanel Output section', () => {
cleanup()
const empty = mount(snapshot({
nodes: [settled({
- callView: null, resultView: null, content: [], isError: true,
+ content: [], isError: true,
error: { name: 'ToolError', code: 'interrupted' },
})],
}), target)
diff --git a/packages/client/ui-tool/tests/todo-row.client.spec.tsx b/packages/client/ui-tool/tests/todo-row.client.spec.tsx
index 66739e7149..0012e65263 100644
--- a/packages/client/ui-tool/tests/todo-row.client.spec.tsx
+++ b/packages/client/ui-tool/tests/todo-row.client.spec.tsx
@@ -62,7 +62,7 @@ describe('planSummary', () => {
const resultNode = (argsRaw: string, over?: Partial): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
call: { name: 'todo_write', argsRaw },
- content: [], isError: false, callView: null, resultView: null, subCalls: [], ...over,
+ content: [], isError: false, subCalls: [], ...over,
})
function rowProps(block: unknown): TodoRowProps {
@@ -94,7 +94,7 @@ describe('TodoRow', () => {
it('omits the active clause when no item is in progress and reads running-call args', () => {
const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] })
- render()
+ render()
expect(screen.getByText('1/1 已完成')).toBeTruthy()
})
@@ -107,7 +107,7 @@ describe('TodoRow', () => {
it('keeps non-ok execution states visible through the shared row states', () => {
const args = JSON.stringify({ todos: LIST })
- const running = render()
+ const running = render()
expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull()
running.unmount()
diff --git a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx
index 77fca2e7da..67cf3a7962 100644
--- a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx
+++ b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx
@@ -7,7 +7,7 @@ import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/cl
import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
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 { ToolCallOwnerProps, ToolTreeProps } from '../src/client/contract/slots.ts'
import { ToolCallTree } from '../src/client/tool/ToolCallTree.tsx'
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
@@ -17,18 +17,21 @@ const t: ToolTreeProps['t'] = makeTranslate(zh, commonZh)
const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => ({
kind: 'tool-result', seq: 3, time: 3_000, callId, call, callTime: 2_000,
- content: [], isError: false, callView: null, resultView: null, subCalls: [],
+ content: [], isError: false, subCalls: [],
})
function props(
block: ToolResultNode,
selectedCallId?: string,
description?: HostDescription,
+ owners?: ToolCallOwnerProps[],
): ToolTreeProps {
const snapshot = {} as SessionSnapshot
const useSession = ((selector: (value: SessionSnapshot) => unknown) => selector(snapshot)) as ToolTreeProps['useSession']
- const renderSlot = ((_key: string, _owner: object, options?: { fallback?: React.ReactNode }) =>
- options?.fallback ?? null) as unknown as ToolTreeProps['renderSlot']
+ const renderSlot = ((_key: string, owner: ToolCallOwnerProps, options?: { fallback?: React.ReactNode }) => {
+ owners?.push(owner)
+ return options?.fallback ?? null
+ }) as unknown as ToolTreeProps['renderSlot']
return {
useSession,
renderSlot,
@@ -64,16 +67,21 @@ describe('ToolCallTree', () => {
})
it('recursively renders a selected leaf without selecting its ancestors', () => {
- const leaf = root('parent:code:1:code:1', { name: 'read', argsRaw: '{"path":"a.ts"}' })
+ const owners: ToolCallOwnerProps[] = []
+ const leaf = {
+ ...root('parent:code:1:code:1', { name: 'read', argsRaw: '{"path":"a.ts"}' }),
+ parentCallId: 'parent:code:1',
+ }
const child = {
...root('parent:code:1', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
+ parentCallId: 'parent',
subCalls: [leaf],
}
const block = {
...root('parent', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
subCalls: [child],
}
- const view = render()
+ const view = render()
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"]'))
@@ -81,6 +89,11 @@ describe('ToolCallTree', () => {
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)
+ expect(owners.map(owner => [owner.callId, owner.block.parentCallId ?? null])).toEqual([
+ ['parent', null],
+ ['parent:code:1', 'parent'],
+ ['parent:code:1:code:1', 'parent:code:1'],
+ ])
})
it('abbreviates a POSIX home path in the generic tool summary', () => {
diff --git a/packages/client/ui-tool/tests/tool-details-render.client.tsx b/packages/client/ui-tool/tests/tool-details-render.client.tsx
index 5d8b36e722..b915b39de9 100644
--- a/packages/client/ui-tool/tests/tool-details-render.client.tsx
+++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx
@@ -1,6 +1,6 @@
/** Test adapter for the production conversation.details.tool registration. */
import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client'
-import type { SessionEventEntry, SessionToolCallView } from '@deepseek-ai/dsh-api-session-controller/types'
+import type { SessionEventEntry } from '@deepseek-ai/dsh-api-session-controller/types'
import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-session'
import type {
ChatConversationViewNode, ChatSnapshot, ConversationNode, DetailsSlotProps,
@@ -28,12 +28,6 @@ function jsonFixture(value: unknown): JsonValue {
return value as JsonValue
}
-function sessionCallView(view: NonNullable): SessionToolCallView {
- if (view.card !== 'generic') return view
- const { rawInput, ...wireView } = view
- return rawInput === undefined ? wireView : { ...wireView, rawInput: jsonFixture(rawInput) }
-}
-
/** Build the canonical Chat slice consumed by Tool rows and details tests. */
export function toolChatSnapshot(
settled: readonly ConversationNode[] = [],
@@ -110,7 +104,6 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se
arguments: node.call.argsRaw,
},
},
- ...(node.callView === null ? {} : { view: { for: 'call', view: sessionCallView(node.callView) } }),
}
entries.push(callEntry)
const resultEntry: SessionEventEntry = {
@@ -137,7 +130,6 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se
}),
surfaceOp: 'append',
},
- ...(node.resultView === null ? {} : { view: { for: 'result', view: node.resultView } }),
}
entries.push(resultEntry)
}
diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx
index b891b70bc4..d295259908 100644
--- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx
+++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx
@@ -18,14 +18,14 @@ const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
const running = (over?: Partial): RunningToolCall => ({
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
- turn: 1, step: 1, time: 1_000, callView: null, subCalls: [], ...over,
+ turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
})
const result = (over?: Partial): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
callTime: 1_000,
- content: [], isError: false, callView: null, resultView: null, subCalls: [], ...over,
+ content: [], isError: false, subCalls: [], ...over,
})
describe('tool-call-model', () => {
diff --git a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx
index 2d9c2eb2d0..54a165cf40 100644
--- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx
+++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx
@@ -38,7 +38,7 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: args },
callTime: seq * 1_000 - 500,
- content: [], isError: false, callView: null, resultView: null, subCalls: [],
+ content: [], isError: false, subCalls: [],
})
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
diff --git a/packages/client/ui-tool/tests/web-card.client.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx
index 184c0d6a11..2732f11aab 100644
--- a/packages/client/ui-tool/tests/web-card.client.spec.tsx
+++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx
@@ -8,7 +8,6 @@ import type {
} from '@deepseek-ai/dsh-client-ui-chat/client'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client'
import {
bindSnapshotSelector, conversationSnapshot, sessionSnapshot, workspaceSnapshot,
} from '@deepseek-ai/dsh-client-test-runtime'
@@ -32,12 +31,24 @@ const SID = 's1' as SessionId
const t = makeTranslate(zh, commonZh)
const chatT = makeTranslate(chatZh, commonZh)
-const SEARCH_ARGS = '{"query":"deepseek harness"}'
+const SEARCH_ARGS = '{"queries":["deepseek harness"]}'
const FETCH_ARGS = '{"url":"https://example.com/page"}'
-/** A web_search result view; overrides tune the sources / answer / truncation. */
-const resultSearch = (over?: Partial>): ToolResultView => ({
- card: 'web', kind: 'search', truncated: false,
+interface SearchMeta {
+ sources: { url: string; title?: string; snippet?: string; publishedAt?: string }[]
+ truncated: boolean
+ answer?: string
+}
+
+interface FetchMeta {
+ url: string
+ statusCode: number
+ truncated: boolean
+}
+
+/** Persisted web_search result metadata. */
+const searchMeta = (over?: Partial): SearchMeta => ({
+ truncated: false,
answer: 'A short answer.',
sources: [
{ url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' },
@@ -46,14 +57,14 @@ const resultSearch = (over?: Partial>): ToolResultView => ({
- card: 'web', kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false, ...over,
+/** Persisted web_fetch result metadata. */
+const fetchMeta = (over?: Partial): FetchMeta => ({
+ url: 'https://example.com/page', statusCode: 200, truncated: false, ...over,
})
const runningSearch = (over?: Partial): RunningToolCall => ({
callId: 'c1', name: 'web_search', argsRaw: SEARCH_ARGS,
- turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Search', kind: 'search' }, subCalls: [], ...over,
+ turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
})
const settledSearch = (over?: Partial): ToolResultNode => ({
@@ -61,7 +72,7 @@ const settledSearch = (over?: Partial): ToolResultNode => ({
call: { name: 'web_search', argsRaw: SEARCH_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'search text' }], isError: false,
- callView: { card: 'generic', title: 'Search', kind: 'search' }, resultView: resultSearch(), subCalls: [], ...over,
+ meta: searchMeta(), subCalls: [], ...over,
})
const settledFetch = (over?: Partial): ToolResultNode => ({
@@ -69,53 +80,59 @@ const settledFetch = (over?: Partial): ToolResultNode => ({
call: { name: 'web_fetch', argsRaw: FETCH_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'fetch body' }], isError: false,
- callView: { card: 'generic', title: 'Fetch', kind: 'fetch' }, resultView: resultFetch(), subCalls: [], ...over,
+ meta: fetchMeta(), subCalls: [], ...over,
})
describe('webCardModel', () => {
- it('derives a search card from the result view, projecting every source field', () => {
+ it('derives a search card from result metadata, projecting every source field', () => {
expect(webCardModel(settledSearch())).toEqual({
kind: 'search',
answer: 'A short answer.',
truncated: false,
sources: [
{ url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' },
- { url: 'https://plain.example.org/b', title: undefined, snippet: undefined, publishedAt: undefined },
+ { url: 'https://plain.example.org/b' },
],
})
})
it('carries the search truncation flag and an absent answer', () => {
- const model = webCardModel(settledSearch({ resultView: { card: 'web', kind: 'search', truncated: true, sources: [] } }))
+ const model = webCardModel(settledSearch({ meta: { truncated: true, sources: [] } }))
expect(model).toEqual({ kind: 'search', answer: undefined, truncated: true, sources: [] })
})
- it('derives a fetch card from the result view', () => {
+ it('derives a fetch card from result metadata', () => {
expect(webCardModel(settledFetch())).toEqual({
kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false,
})
- expect(webCardModel(settledFetch({ resultView: resultFetch({ statusCode: 404, truncated: true }) })))
+ expect(webCardModel(settledFetch({ meta: fetchMeta({ statusCode: 404, truncated: true }) })))
.toEqual({ kind: 'fetch', url: 'https://example.com/page', statusCode: 404, truncated: true })
})
it('returns null for a running call, since the web card is result-only', () => {
expect(webCardModel(runningSearch())).toBeNull()
- // Even a running call that somehow carried a web call view stays generic:
- // the derivation reads resultView only.
- expect(webCardModel(runningSearch({ callView: null }))).toBeNull()
})
- it('returns null for a settled call whose result view is not a web card', () => {
- expect(webCardModel(settledSearch({ resultView: null }))).toBeNull()
- expect(webCardModel(settledSearch({ resultView: { card: 'generic' } }))).toBeNull()
- // A card tag this UI version does not know arrives over the wire; the
- // documented generic-card default takes it, not a crash.
- const future = { card: 'chart', kind: 'search' } as unknown as ToolResultView
- expect(webCardModel(settledSearch({ resultView: future }))).toBeNull()
- // A web card whose kind this UI version does not know (a newer host's
- // value) also takes the generic path, not a malformed fetch.
- const futureKind = { card: 'web', kind: 'timeline' } as unknown as ToolResultView
- expect(webCardModel(settledSearch({ resultView: futureKind }))).toBeNull()
+ it('returns null for missing calls, errors, malformed args/meta, unrelated tools, and children', () => {
+ expect(webCardModel(settledSearch({ call: null }))).toBeNull()
+ expect(webCardModel(settledSearch({ isError: true }))).toBeNull()
+ expect(webCardModel(settledSearch({ call: { name: 'web_search', argsRaw: '{' } }))).toBeNull()
+ expect(webCardModel(settledSearch({ meta: undefined }))).toBeNull()
+ expect(webCardModel(settledSearch({ meta: { sources: [], truncated: 'yes' } }))).toBeNull()
+ expect(webCardModel(settledSearch({ call: { name: 'echo', argsRaw: '{}' } }))).toBeNull()
+ expect(webCardModel(settledSearch({ parentCallId: 'parent' }))).toBeNull()
+ })
+
+ it('accepts open-root extensions while validating declared web arguments', () => {
+ expect(webCardModel(settledSearch({
+ call: { name: 'web_search', argsRaw: '{"queries":["deepseek"],"extension":1}' },
+ }))).not.toBeNull()
+ expect(webCardModel(settledSearch({
+ call: { name: 'web_search', argsRaw: '{"queries":[7]}' },
+ }))).toBeNull()
+ expect(webCardModel(settledFetch({
+ call: { name: 'web_fetch', argsRaw: '{"url":" "}' },
+ }))).toBeNull()
})
})
@@ -172,7 +189,7 @@ describe('chat row web body', () => {
it('a failed web call keeps the summary row without the card', () => {
const view = render()
expect(view.getByText('网页搜索')).toBeTruthy()
expect(view.container.querySelector('[data-web]')).toBeNull()
@@ -180,19 +197,19 @@ describe('chat row web body', () => {
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
})
- it('the GenericToolCard fallback also expands to a web card for a web-declaring tool', () => {
+ it('the GenericToolCard fallback does not promote an unknown tool from metadata alone', () => {
const view = render()
- expect(view.container.querySelector('[data-web]')).toBeNull()
toggleRow(view)
- expect(view.getByText('Titled')).toBeTruthy()
- expect(view.container.querySelector('[data-web="search"]')).not.toBeNull()
+ expect(view.container.querySelector('[data-web]')).toBeNull()
+ expect(view.getByText('search text')).toBeTruthy()
})
it('the GenericToolCard fallback keeps the plain row for a non-web call', () => {
const view = render()
expect(view.container.querySelector('[data-web]')).toBeNull()
})
@@ -254,7 +271,7 @@ describe('DetailsPanel web Output section', () => {
expect(view.getByText('Titled')).toBeTruthy()
expect(view.getByText('excerpt')).toBeTruthy()
// The Input JSON section survives beside it.
- expect(view.getByText(/"query"/)).toBeTruthy()
+ expect(view.getByText(/"queries"/)).toBeTruthy()
})
it('renders the fetch card and keeps the fetched body below it', () => {
@@ -270,7 +287,7 @@ describe('DetailsPanel web Output section', () => {
it('a non-web result keeps the flattened pre form', () => {
const view = mount(snapshot({
- nodes: [settledSearch({ callView: null, resultView: null })],
+ nodes: [settledSearch({ meta: undefined })],
}), { turnSeq: 10, callId: 'c1', toolName: 'web_search' })
expect(view.container.querySelector('[data-web]')).toBeNull()
const output = view.getByText('输出').closest('section')
diff --git a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts
index 0a59df4a2e..fb4ca15abc 100644
--- a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts
+++ b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts
@@ -38,7 +38,6 @@ function rootCall(match: ConversationMatch): RunningToolCall {
turn: match.event.data.turn,
step: match.event.data.step,
time: match.event.time,
- callView: match.view?.for === 'call' ? match.view.view : null,
subCalls: [],
}
}
@@ -60,8 +59,6 @@ function rootResult(
isError: result.isError === true,
...(match.event.data.error === undefined ? {} : { error: match.event.data.error }),
meta: match.event.data.meta,
- callView: previous?.callView ?? null,
- resultView: match.view?.for === 'result' ? match.view.view : null,
subCalls: [],
}
}
@@ -79,12 +76,12 @@ function locationStep(match: ConversationMatch): number {
function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall {
return {
callId: data.subCallId,
+ parentCallId: data.parentCallId,
name: data.name,
argsRaw: JSON.stringify(data.arguments),
turn: locationTurn(match),
step: locationStep(match),
time: match.event.time,
- callView: null,
subCalls: [],
}
}
@@ -99,12 +96,11 @@ function childResult(
seq: match.event.seq,
time: match.event.time,
callId: data.subCallId,
+ parentCallId: data.parentCallId,
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
callTime: previous === undefined || 'kind' in previous ? null : previous.time,
content: data.content ?? [],
isError: data.isError === true,
- callView: null,
- resultView: null,
subCalls: [],
}
}
@@ -190,13 +186,12 @@ function projectCall(
seq: interruptedAt.seq - 0.8,
time: interruptedAt.time,
callId: block.callId,
+ ...block.parentCallId === undefined ? {} : { parentCallId: block.parentCallId },
call: { name: block.name, argsRaw: block.argsRaw },
callTime: block.time,
content: [],
isError: true,
error: { name: 'Interrupted', code: 'interrupted' },
- callView: block.callView,
- resultView: null,
subCalls,
}
}
diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts
index c8ae7ec5a9..a454431d01 100644
--- a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts
+++ b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts
@@ -184,7 +184,7 @@ describe('Trajectory conversation Definitions', () => {
}])
})
- it('keeps parallel interrupted roots and nests Code Dispatch results', () => {
+ it('keeps parallel roots, raw Tool facts, and nested Code Dispatch results', () => {
const current = snapshot(assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
@@ -209,16 +209,44 @@ describe('Trajectory conversation Definitions', () => {
arguments: { path: 'README.md' },
content: [{ type: 'text', text: 'contents' }],
}),
- at(7, 'step/end', { turn: 1, step: 1 }),
+ at(7, 'tool/result', {
+ turn: 1,
+ step: 1,
+ message: {
+ id: 'result-root-a',
+ role: 'user',
+ source: { kind: 'tool', callId: 'root-a' },
+ content: [{
+ type: 'tool-result',
+ toolCallId: 'root-a',
+ content: [{ type: 'text', text: 'root failed' }],
+ isError: true,
+ }],
+ },
+ error: { name: 'ToolError', code: 'failed' },
+ meta: { presentation: 'raw' },
+ }, { surfaceOp: 'append' }),
+ at(8, 'step/end', { turn: 1, step: 1 }),
]))
const tools = current.eventNodes.filter(node => node.kind === 'tool-result')
expect(tools.map(node => node.callId).sort()).toEqual(['root-a', 'root-b'])
- expect(tools.find(node => node.callId === 'root-a')?.subCalls).toMatchObject([{
+ expect(tools.find(node => node.callId === 'root-a')).toMatchObject({
kind: 'tool-result',
- callId: 'child',
- call: { name: 'read' },
- }])
+ callId: 'root-a',
+ call: { name: 'code', argsRaw: '{}' },
+ content: [{ type: 'text', text: 'root failed' }],
+ isError: true,
+ error: { name: 'ToolError', code: 'failed' },
+ meta: { presentation: 'raw' },
+ subCalls: [{
+ kind: 'tool-result', callId: 'child', parentCallId: 'root-a', call: { name: 'read' },
+ }],
+ })
+ expect(tools.find(node => node.callId === 'root-b')).toMatchObject({
+ isError: true,
+ error: { name: 'Interrupted', code: 'interrupted' },
+ })
})
it('assembles compaction lifecycle, checkpoint replacement, and orphan interruption', () => {
diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx
index 1cf5062a24..6df422e064 100644
--- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx
+++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx
@@ -87,7 +87,7 @@ describe('deriveTrajectoryLayout', () => {
{
kind: 'tool-result', seq: 3, time: 7_500, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"ls"}' }, callTime: 6_200,
- content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null,
+ content: [{ type: 'text', text: 'a.txt' }], isError: false,
},
] as unknown as LegacyConversationSlice['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
@@ -113,7 +113,7 @@ describe('deriveTrajectoryLayout', () => {
partial: null,
runningCalls: [{
callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
- turn: 1, step: 2, time: 9_000, callView: null, subCalls: [],
+ turn: 1, step: 2, time: 9_000, subCalls: [],
}],
})
expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2'])
@@ -177,7 +177,7 @@ describe('deriveTrajectoryLayout', () => {
partial: { ...partial, blocks: [] },
runningCalls: [{
callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}',
- turn: 1, step: 1, time: 9_000, callView: null, subCalls: [],
+ turn: 1, step: 1, time: 9_000, subCalls: [],
}],
})
@@ -218,12 +218,12 @@ describe('deriveTrajectoryLayout', () => {
{
kind: 'tool-result', seq: 2, time: 2_500, callId: 'a',
call: { name: 'bash', argsRaw: '{}' }, callTime: 1_100,
- content: [], isError: false, callView: null, resultView: null,
+ content: [], isError: false,
},
{
kind: 'tool-result', seq: 3, time: 4_000, callId: 'b',
call: { name: 'bash', argsRaw: '{}' }, callTime: 2_600,
- content: [], isError: false, callView: null, resultView: null,
+ content: [], isError: false,
},
] as unknown as LegacyConversationSlice['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
@@ -447,7 +447,7 @@ describe('deriveTrajectoryLayout', () => {
{
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{}' }, callTime: 2_100,
- content: [], isError: false, callView: null, resultView: null,
+ content: [], isError: false,
},
{
kind: 'context', seq: 4, time: 9_000,
@@ -502,7 +502,7 @@ describe('run_code sub-dispatch cells', () => {
{
kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
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,
subCalls: [],
},
] as unknown as LegacyConversationSlice['nodes']
@@ -511,7 +511,7 @@ describe('run_code sub-dispatch cells', () => {
kind: 'tool-result' as const, seq: 100 + n, time: end,
callId: `p1:code:${n}`,
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,
subCalls: [],
})
@@ -538,7 +538,7 @@ describe('run_code sub-dispatch cells', () => {
it('a running (unsettled) sub-call renders a subtool cell with blank time', () => {
const running = {
callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}',
- turn: 0, step: 0, time: 6_400, callView: null, subCalls: [],
+ turn: 0, step: 0, time: 6_400, subCalls: [],
}
const turns = deriveTrajectoryLayout({ nodes: withSubCalls([running]), partial: null, runningCalls: [] })
const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool')
diff --git a/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts b/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts
index e65d8a22d6..59eec5f679 100644
--- a/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts
+++ b/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts
@@ -160,7 +160,6 @@ describe('TrajectorySnapshotBuilder', () => {
turn: 1,
step: 2,
time: 7,
- callView: null,
subCalls: [],
},
}),
diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx
index a21e303788..cce6673be9 100644
--- a/packages/client/ui-trajectory/tests/views.client.spec.tsx
+++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx
@@ -84,7 +84,7 @@ const NODES: LegacyConversationSlice['nodes'] = [
},
{
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: 2_200,
- content: [], isError: false, callView: null, resultView: null, subCalls: [],
+ content: [], isError: false, subCalls: [],
},
{
kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [],
diff --git a/packages/extensions/ui-cordis/tests/card-model.client.spec.ts b/packages/extensions/ui-cordis/tests/card-model.client.spec.ts
index 2d335202ff..02062489e7 100644
--- a/packages/extensions/ui-cordis/tests/card-model.client.spec.ts
+++ b/packages/extensions/ui-cordis/tests/card-model.client.spec.ts
@@ -10,7 +10,7 @@ const ARGS = '{"name":"clock","purpose":"顶栏时钟","code":{"client":"return
function running(over: Partial = {}): RunningToolCall {
return {
callId: 'call-1', name: 'cordis_define', argsRaw: ARGS, turn: 1, step: 1, time: 1_000,
- callView: null, subCalls: [], ...over,
+ subCalls: [], ...over,
}
}
@@ -19,7 +19,7 @@ function settled(over: Partial = {}): ToolResultNode {
kind: 'tool-result', seq: 2, time: 2_000, callId: 'call-1',
call: { name: 'cordis_define', argsRaw: ARGS }, callTime: 1_000,
content: [{ type: 'text', text: 'defined dyn-1' }], isError: false,
- meta: { pluginId: 'dyn-1', packageId: 'pkg-1' }, callView: null, resultView: null, subCalls: [], ...over,
+ meta: { pluginId: 'dyn-1', packageId: 'pkg-1' }, subCalls: [], ...over,
}
}
diff --git a/packages/extensions/ui-cordis/tests/versioning.client.spec.ts b/packages/extensions/ui-cordis/tests/versioning.client.spec.ts
index 54b39e8b91..568aecf232 100644
--- a/packages/extensions/ui-cordis/tests/versioning.client.spec.ts
+++ b/packages/extensions/ui-cordis/tests/versioning.client.spec.ts
@@ -40,7 +40,6 @@ describe('versioned Cordis card models', () => {
turn: 1,
step: 1,
time: 1,
- callView: null,
subCalls: [],
})
@@ -64,8 +63,6 @@ describe('versioned Cordis card models', () => {
content: [{ type: 'text', text: 'running' }],
isError: false,
meta: { pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN },
- callView: null,
- resultView: null,
subCalls: [],
})
@@ -87,7 +84,6 @@ describe('versioned Cordis card models', () => {
turn: 1,
step: 1,
time: 1,
- callView: null,
subCalls: [],
})
From a99516c3304561a2cd51a199d92e78746aa57bcc Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Sun, 23 Aug 2026 21:13:12 +0800
Subject: [PATCH 4/8] refactor(client): derive deliverables from mutation calls
---
.../ui-deliverables/src/client/index.ts | 4 +-
.../src/client/turn-deliverables.ts | 111 +++++++---
.../tests/produced-files.client.spec.tsx | 190 ++++++++++++++----
3 files changed, 232 insertions(+), 73 deletions(-)
diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts
index 879ed682e4..0e8767016e 100644
--- a/packages/client/ui-deliverables/src/client/index.ts
+++ b/packages/client/ui-deliverables/src/client/index.ts
@@ -2,8 +2,8 @@
* Deliverables plugin, browser half: registers the produced-files row into
* the chat view's turn-tail chain, and provides the `chatFileMentions`
* service that links inline-code mentions of produced files in the closing
- * prose. All policy lives here — the derivation from the mutation tools'
- * `locations`, the mention matching, the chip cap, and the copy — so
+ * prose. All policy lives here — the supported mutation calls, mention
+ * matching, chip cap, and copy — so
* composing this plugin out of cordis.yml removes both surfaces entirely;
* the owning view renders an empty chain and inert prose at zero cost.
*/
diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts
index 604ab099f1..b7f74f305b 100644
--- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts
+++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts
@@ -1,10 +1,10 @@
/**
* Turn-scoped produced-file Definition and readers. Client-only and
- * model-free: the vocabulary is the mutation tools' own follow-along
- * `locations`, never the closing prose.
+ * model-free: the vocabulary comes from successful first-party mutation
+ * calls, never presentation data or the closing prose.
*/
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
-import type { ToolResultNode, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
+import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -27,38 +27,90 @@ declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface DeliverablesState extends DeliverablesTurnData {
readonly turn: number
- readonly calls: ReadonlyMap
+ readonly calls: ReadonlyMap
}
/**
- * Paths a call view reports having created or changed, by render intent rather
- * than tool name: a diff card, or a generic card whose kind is `edit` (the
- * shape `str_replace_editor`'s insert presents). Every other card produces
- * nothing to open — a read looked, a delete removed, a terminal ran. Only
- * root call views enter this Turn accumulator; nested Code Mode dispatches
- * preserve the pre-assembly behavior and do not contribute independently.
+ * Extract the path from a supported first-party mutation call. Session
+ * `tool/call` events are root calls; Code Dispatch children do not enter this
+ * Definition independently.
+ * @param name - wire tool name.
+ * @param argsRaw - model-produced JSON arguments.
+ * @returns the mutation path, or null when the call is not a supported mutation.
*/
-function producedPaths(view: ToolResultNode['callView']): readonly string[] {
- if (view === null) return []
- if (view.card === 'diff') return (view.locations ?? []).map(location => location.path)
- if (view.card === 'generic' && view.kind === 'edit') {
- return (view.locations ?? []).map(location => location.path)
+function mutationPath(name: string, argsRaw: string): string | null {
+ let args: unknown
+ try {
+ args = JSON.parse(argsRaw) as unknown
+ } catch {
+ return null
}
- return []
+ if (!isRecord(args)) return null
+ switch (name) {
+ case 'write':
+ return typeof args.content === 'string' ? pathValue(args.file_path) : null
+ case 'edit':
+ return validEditArgs(args) ? pathValue(args.file_path) : null
+ case 'str_replace_editor':
+ return editorMutationPath(args)
+ default:
+ return null
+ }
+}
+
+/** Validate the fields that an `edit` execution requires. */
+function validEditArgs(args: Readonly>): boolean {
+ return typeof args.old_string === 'string'
+ && args.old_string.length > 0
+ && typeof args.new_string === 'string'
+ && args.old_string !== args.new_string
+ && (args.replace_all === undefined || typeof args.replace_all === 'boolean')
+}
+
+/** Extract a path only from a complete mutating editor command. */
+function editorMutationPath(args: Readonly>): string | null {
+ const path = pathValue(args.path)
+ if (path === null) return null
+ switch (args.command) {
+ case 'create':
+ return typeof args.file_text === 'string' ? path : null
+ case 'str_replace':
+ return typeof args.old_str === 'string'
+ && args.old_str.length > 0
+ && (args.new_str === undefined || typeof args.new_str === 'string')
+ ? path
+ : null
+ case 'insert':
+ return typeof args.insert_line === 'number'
+ && Number.isInteger(args.insert_line)
+ && args.insert_line >= 0
+ && typeof args.new_str === 'string'
+ ? path
+ : null
+ default:
+ return null
+ }
+}
+
+/** A non-blank path preserves the exact spelling supplied to the tool. */
+function pathValue(value: unknown): string | null {
+ return typeof value === 'string' && value.trim().length > 0 ? value : null
+}
+
+/** Narrow parsed JSON to an argument object. */
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/**
* Files produced by one Turn data value.
*
- * The source is the mutation tools' own follow-along `locations`, not the
- * closing prose: a produced file must be listed whether or not the model
- * remembered to name it. A mutation is recognized by render intent, not by
- * tool name — a diff card, or a generic card whose `kind` is `edit` (the shape
- * `str_replace_editor`'s insert presents) — so a new mutation tool joins by
- * declaring what it does. Reads contribute nothing (looking at a file does not
- * produce it), and neither do deletes (there is nothing left to open) or
- * failed calls. Paths keep first-seen order and appear once, so a file written
- * and then edited in the same turn is one entry.
+ * The source is the arguments of successful `write`, `edit`, and mutating
+ * `str_replace_editor` calls, not the closing prose: a produced file must be
+ * listed whether or not the model remembered to name it. Reads, unsupported
+ * tools, malformed calls, and failed results contribute nothing. Paths keep
+ * first-seen order and appear once, so a file written and then edited in the
+ * same turn is one entry.
*
* The Conversation Location index owns turn membership before this function
* runs, so paths cannot spill across turns and this derivation does not infer
@@ -112,7 +164,7 @@ export const deliverablesDefinition: ConversationNodeDefinition ({ seq: match.event.seq, path }))
- return additions.length === 0
+ const path = context.state.calls.get(callId)
+ return path === null || path === undefined
? context.state
- : { ...context.state, produced: [...context.state.produced, ...additions] }
+ : { ...context.state, produced: [...context.state.produced, { seq: match.event.seq, path }] }
},
buildLocationData: (context, scope) => scope !== 'turn' || context.state === undefined
? null
diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx
index 7aef3b3580..4cbfe8c294 100644
--- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx
+++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx
@@ -109,14 +109,12 @@ function at(
seq: number,
type: string,
data: unknown,
- view?: ConversationEventInput['view'],
): ConversationEventInput {
return {
event: {
seq, time: seq * 1_000, type, data,
...(type === 'tool/result' ? { surfaceOp: 'append' } : {}),
} as ConversationEventInput['event'],
- ...(view === undefined ? {} : { view }),
}
}
@@ -124,19 +122,27 @@ function matched(input: ConversationEventInput, role: ConversationMatch['role'])
return { ...input, role, location: { kind: 'unresolved' } }
}
-type WireCallView = Extract, { for: 'call' }>['view']
-
function call(
seq: number,
callId: string,
- view: WireCallView | null,
+ name: string,
+ args: Readonly>,
+ turn = 1,
+): ConversationEventInput {
+ return rawCall(seq, callId, name, JSON.stringify(args), turn)
+}
+
+function rawCall(
+ seq: number,
+ callId: string,
+ name: string,
+ argsRaw: string,
turn = 1,
): ConversationEventInput {
return at(
seq,
'tool/call',
- { turn, step: 1, callId, name: 'fixture', arguments: '{}' },
- { for: 'call', view: view ?? { card: 'generic', title: 'fixture' } },
+ { turn, step: 1, callId, name, arguments: argsRaw },
)
}
@@ -151,18 +157,6 @@ function result(seq: number, callId: string, isError = false, turn = 1): Convers
})
}
-function diff(...paths: string[]): WireCallView {
- return {
- card: 'diff', title: `Write ${paths[0] ?? ''}`,
- diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })),
- locations: paths.map(path => ({ path })),
- }
-}
-
-function edit(path: string): WireCallView {
- return { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] }
-}
-
function assembler(entries: readonly ConversationEventInput[], hasMore = false): ConversationNodeAssembler {
const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
value.replaceWindow(entries, hasMore)
@@ -189,36 +183,148 @@ describe('produced-file Turn data', () => {
expect(selectProducedFiles(tailOwner(undefined, 9, () => {}, 2))).toBeNull()
})
- it('folds successful diff and generic-edit calls while ignoring reads, failures, and missing locations', () => {
+ it('folds successful first-party mutation paths from their raw arguments', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
- call(2, 'write', diff('out/index.html', 'out/app.css')),
+ call(2, 'write', 'write', {
+ file_path: 'out/index.html', path: 'wrong-write.txt', content: '',
+ }),
result(3, 'write'),
- call(4, 'edit', edit('notes.md')),
+ call(4, 'edit', 'edit', {
+ file_path: 'out/app.css', path: 'wrong-edit.txt', old_string: 'red', new_string: 'blue',
+ replace_all: false,
+ }),
result(5, 'edit'),
- call(6, 'read', { card: 'generic', title: 'Read', locations: [{ path: 'input.txt' }] }),
- result(7, 'read'),
- call(8, 'failed', diff('broken.txt')),
- result(9, 'failed', true),
- call(10, 'locationless', { card: 'diff', title: 'Write', diffs: [] }),
- result(11, 'locationless'),
+ call(6, 'create', 'str_replace_editor', {
+ command: 'create', path: 'notes/new.md', file_path: 'wrong-create.txt', file_text: 'new',
+ }),
+ result(7, 'create'),
+ call(8, 'replace', 'str_replace_editor', {
+ command: 'str_replace', path: 'notes/existing.md', old_str: 'old', new_str: 'new',
+ }),
+ result(9, 'replace'),
+ call(10, 'delete-text', 'str_replace_editor', {
+ command: 'str_replace', path: 'notes/deleted-text.md', old_str: 'remove me',
+ }),
+ result(11, 'delete-text'),
+ call(12, 'insert', 'str_replace_editor', {
+ command: 'insert', path: 'notes/inserted.md', insert_line: 1, new_str: 'line',
+ }),
+ result(13, 'insert'),
])
expect(producedForClosing(deliverablesOf(value))).toEqual([
- 'out/index.html', 'out/app.css', 'notes.md',
+ 'out/index.html',
+ 'out/app.css',
+ 'notes/new.md',
+ 'notes/existing.md',
+ 'notes/deleted-text.md',
+ 'notes/inserted.md',
])
})
- it('ignores calls without mutation locations, orphan results, and replacement results', () => {
- const replacement = result(8, 'replacement')
+ it.each([
+ { caseName: 'write omits content', name: 'write', args: { file_path: 'write.txt' } },
+ { caseName: 'write has non-string content', name: 'write', args: { file_path: 'write.txt', content: 1 } },
+ {
+ caseName: 'edit omits old_string', name: 'edit',
+ args: { file_path: 'edit.txt', new_string: 'new' },
+ },
+ {
+ caseName: 'edit has an empty old_string', name: 'edit',
+ args: { file_path: 'edit.txt', old_string: '', new_string: 'new' },
+ },
+ {
+ caseName: 'edit omits new_string', name: 'edit',
+ args: { file_path: 'edit.txt', old_string: 'old' },
+ },
+ {
+ caseName: 'edit does not change the string', name: 'edit',
+ args: { file_path: 'edit.txt', old_string: 'same', new_string: 'same' },
+ },
+ {
+ caseName: 'edit has a non-boolean replace_all', name: 'edit',
+ args: { file_path: 'edit.txt', old_string: 'old', new_string: 'new', replace_all: 'yes' },
+ },
+ {
+ caseName: 'editor create omits file_text', name: 'str_replace_editor',
+ args: { command: 'create', path: 'create.txt' },
+ },
+ {
+ caseName: 'editor create has non-string file_text', name: 'str_replace_editor',
+ args: { command: 'create', path: 'create.txt', file_text: 1 },
+ },
+ {
+ caseName: 'editor replace omits old_str', name: 'str_replace_editor',
+ args: { command: 'str_replace', path: 'replace.txt', new_str: 'new' },
+ },
+ {
+ caseName: 'editor replace has an empty old_str', name: 'str_replace_editor',
+ args: { command: 'str_replace', path: 'replace.txt', old_str: '' },
+ },
+ {
+ caseName: 'editor replace has non-string new_str', name: 'str_replace_editor',
+ args: { command: 'str_replace', path: 'replace.txt', old_str: 'old', new_str: 1 },
+ },
+ {
+ caseName: 'editor insert omits insert_line', name: 'str_replace_editor',
+ args: { command: 'insert', path: 'insert.txt', new_str: 'new' },
+ },
+ {
+ caseName: 'editor insert has a fractional insert_line', name: 'str_replace_editor',
+ args: { command: 'insert', path: 'insert.txt', insert_line: 1.5, new_str: 'new' },
+ },
+ {
+ caseName: 'editor insert has a negative insert_line', name: 'str_replace_editor',
+ args: { command: 'insert', path: 'insert.txt', insert_line: -1, new_str: 'new' },
+ },
+ {
+ caseName: 'editor insert omits new_str', name: 'str_replace_editor',
+ args: { command: 'insert', path: 'insert.txt', insert_line: 1 },
+ },
+ ])('ignores a successful result when $caseName', ({ name, args }) => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
- at(2, 'tool/call', { turn: 1, step: 1, callId: 'no-view', name: 'fixture', arguments: '{}' }),
- result(3, 'no-view'),
- call(4, 'locationless-edit', { card: 'generic', title: 'Edit', kind: 'edit' }),
- result(5, 'locationless-edit'),
- result(6, 'orphan'),
- call(7, 'replacement', diff('replaced.txt')),
+ call(2, 'malformed', name, args),
+ result(3, 'malformed'),
+ ])
+
+ expect(producedForClosing(deliverablesOf(value))).toEqual([])
+ })
+
+ it('ignores editor views, unsupported tools, failures, interruptions, malformed calls, and orphan results', () => {
+ const replacement = result(25, 'replacement')
+ const value = assembler([
+ at(1, 'turn/start', { turn: 1 }),
+ call(2, 'view', 'str_replace_editor', { command: 'view', path: 'viewed.txt' }),
+ result(3, 'view'),
+ call(4, 'read', 'read', { file_path: 'input.txt' }),
+ result(5, 'read'),
+ call(6, 'unknown', 'custom_edit', { file_path: 'custom.txt', path: 'custom.txt' }),
+ result(7, 'unknown'),
+ call(8, 'failed', 'write', { file_path: 'failed.txt', content: 'x' }),
+ result(9, 'failed', true),
+ call(10, 'interrupted', 'edit', {
+ file_path: 'interrupted.txt', old_string: 'old', new_string: 'new',
+ }),
+ rawCall(11, 'invalid-json', 'write', '{'),
+ result(12, 'invalid-json'),
+ rawCall(13, 'null-args', 'write', 'null'),
+ result(14, 'null-args'),
+ rawCall(15, 'array-args', 'edit', '[]'),
+ result(16, 'array-args'),
+ call(17, 'missing-path', 'write', { content: 'x' }),
+ result(18, 'missing-path'),
+ call(19, 'blank-path', 'edit', {
+ file_path: ' ', old_string: 'old', new_string: 'new',
+ }),
+ result(20, 'blank-path'),
+ call(21, 'missing-editor-path', 'str_replace_editor', { command: 'create', file_text: 'x' }),
+ result(22, 'missing-editor-path'),
+ result(23, 'orphan'),
+ call(24, 'replacement', 'str_replace_editor', {
+ command: 'insert', path: 'replaced.txt', insert_line: 0, new_str: 'new',
+ }),
{
...replacement,
event: {
@@ -226,7 +332,7 @@ describe('produced-file Turn data', () => {
surfaceOp: { op: 'replace', start: 1, end: 1 },
} as ConversationEventInput['event'],
},
- at(9, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
+ at(26, 'turn/end', { turn: 1, reason: { kind: 'interrupted' } }),
])
expect(producedForClosing(deliverablesOf(value))).toEqual([])
@@ -255,7 +361,7 @@ describe('produced-file Turn data', () => {
it('replays a tail page once prepend supplies its missing Turn start', () => {
const value = assembler([
- call(10, 'late', diff('history.txt')),
+ call(10, 'late', 'write', { file_path: 'history.txt', content: 'history' }),
result(11, 'late'),
], true)
expect(deliverablesOf(value)).toBeUndefined()
@@ -268,13 +374,15 @@ describe('produced-file Turn data', () => {
it('extends the same Turn data incrementally on live append', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
- call(2, 'first', diff('first.txt')),
+ call(2, 'first', 'write', { file_path: 'first.txt', content: 'first' }),
result(3, 'first'),
])
const first = deliverablesOf(value)
expect(producedForClosing(first)).toEqual(['first.txt'])
- value.append(call(4, 'second', diff('second.txt')))
+ value.append(call(4, 'second', 'edit', {
+ file_path: 'second.txt', old_string: 'before', new_string: 'after',
+ }))
value.append(result(5, 'second'))
value.flush()
expect(producedForClosing(deliverablesOf(value))).toEqual(['first.txt', 'second.txt'])
From d9a071340fb7652ea2b1affb38570b8dd9d46272 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Mon, 24 Aug 2026 02:24:31 +0800
Subject: [PATCH 5/8] fix(client): preserve editor running diffs
---
...client-derived-tool-presentation.i18n.yaml | 4 +-
...-08-23-client-derived-tool-presentation.md | 3 ++
...-23-client-derived-tool-presentation.zh.md | 3 ++
packages/client/ui-tool/README.i18n.yaml | 4 +-
packages/client/ui-tool/README.md | 2 +-
packages/client/ui-tool/README.zh.md | 2 +-
.../src/client/tool/models/diff-card-model.ts | 31 ++++++++++++--
.../ui-tool/tests/diff-card.client.spec.tsx | 41 +++++++++++++++++++
8 files changed, 80 insertions(+), 10 deletions(-)
diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml
index 8245fb8396..f8e974a4cd 100644
--- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
-2026-08-23-client-derived-tool-presentation.md: 3643c003058f2c17c297606357e35fb43f2e5280
-2026-08-23-client-derived-tool-presentation.zh.md: 31d3a9f082b937dd8d8002bfbc30ef85200ba367
+2026-08-23-client-derived-tool-presentation.md: 957d2e6c1a79cb0b0a246066463e6ac960fb3d5b
+2026-08-23-client-derived-tool-presentation.zh.md: 5fa1a350963ff74306520bad6a4fc89765c592a7
diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
index 3643c00305..957d2e6c1a 100644
--- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
+++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
@@ -322,7 +322,10 @@ Standard and persistent providers sharing the same tool name are a special compa
|---|---|
| running `write` | intended added-only diff from `file_path` and `content` |
| running `edit` | intended replacement diff from `file_path`, `old_string`, and `new_string` |
+| running `str_replace_editor create` | intended added-only diff from `path` and `file_text` |
+| running `str_replace_editor str_replace` | intended replacement diff from `path`, `old_str`, and `new_str` |
| successful settled `write`/`edit` | applied contextual hunks from `meta.diffs` |
+| settled `str_replace_editor` | Generic, because the tool defines no result presenter |
| write create or missing/malformed/empty applied metadata | current argument fallback |
| error, malformed arguments, edit with malformed metadata, or Code Dispatch child | Generic |
diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md
index 31d3a9f082..5fa1a35096 100644
--- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md
@@ -322,7 +322,10 @@ TerminalBlock 的 ANSI、光标重放、宽字符、行数上限、展开、复
|---|---|
| running `write` | 从 `file_path` 与 `content` 生成 intended added-only diff |
| running `edit` | 从 `file_path`、`old_string`、`new_string` 生成 intended replacement diff |
+| running `str_replace_editor create` | 从 `path` 与 `file_text` 生成 intended added-only diff |
+| running `str_replace_editor str_replace` | 从 `path`、`old_str` 与 `new_str` 生成 intended replacement diff |
| settled `write`/`edit` success | 从 `meta.diffs` 生成 applied contextual hunks |
+| settled `str_replace_editor` | Generic,因为该工具没有 result presenter |
| write create 或 applied metadata 缺失、畸形、为空 | 当前 args fallback |
| error、畸形 args、edit 的 metadata 畸形、Code Dispatch child | Generic |
diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml
index a9bd41853a..3f895f0eb9 100644
--- a/packages/client/ui-tool/README.i18n.yaml
+++ b/packages/client/ui-tool/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md
-README.md: 16fd06332d24b265ac7d6b7b000870686262b9c5
-README.zh.md: a21acab8ed6e7b52770a9ee6a63219c11b949c3d
+README.md: 2db7d716dc80fbf40a953b217810fb8674e2e98f
+README.zh.md: 79ed5befe751b329984c1320144921339fdf3d3f
diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md
index 16fd06332d..2db7d716dc 100644
--- a/packages/client/ui-tool/README.md
+++ b/packages/client/ui-tool/README.md
@@ -30,7 +30,7 @@ ctx.slots.inject('tool.call.toolview', () =>
The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, and plain `openFile`/`inspect` callbacks. A Code Dispatch block retains its event's `parentCallId`; the field is absent on a root Session call, so row and Details card models preserve the generic flattened form for descendants without another placement flag. Path summaries relativize to the Session cwd first, then replace a leftover POSIX Host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal Session slot runtime share but no React node or Runtime service.
-This package currently owns the generic fallback and the built-in shell/pwsh, read, write/edit, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall`/`presentResult` values never enter the Client. `ui-skill` demonstrates a business-owned registration for `skill`.
+This package currently owns the generic fallback and the built-in shell/pwsh, read, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall`/`presentResult` values never enter the Client. `ui-skill` demonstrates a business-owned registration for `skill`.
Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), and [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md) notes.
diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md
index a21acab8ed..79ed5befe7 100644
--- a/packages/client/ui-tool/README.zh.md
+++ b/packages/client/ui-tool/README.zh.md
@@ -30,7 +30,7 @@ ctx.slots.inject('tool.call.toolview', () =>
owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`,以及普通的 `openFile`、`inspect` 回调。Code Dispatch block 保留其事件已有的 `parentCallId`;root Session call 没有该字段,因此 row 与 Details card model 无需另一项 placement 标志即可让 descendant 保持 generic 压平形态。路径摘要先相对 Session cwd 缩短,再把剩余的 POSIX Host home 写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规 Session slot runtime share,但不会收到 React node 或 runtime service。
-本包当前拥有 generic fallback,以及 shell/pwsh、read、write/edit、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall`/`presentResult` 值不会进入 Client。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
+本包当前拥有 generic fallback,以及 shell/pwsh、read、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall`/`presentResult` 值不会进入 Client。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md) 和 [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) Agent Note 负责。
diff --git a/packages/client/ui-tool/src/client/tool/models/diff-card-model.ts b/packages/client/ui-tool/src/client/tool/models/diff-card-model.ts
index eed7826594..03eaf04a3a 100644
--- a/packages/client/ui-tool/src/client/tool/models/diff-card-model.ts
+++ b/packages/client/ui-tool/src/client/tool/models/diff-card-model.ts
@@ -1,4 +1,4 @@
-/** Pure diff-card derivation from raw write/edit calls and result metadata. @module */
+/** Pure diff-card derivation from raw file-mutation calls and result metadata. @module */
import type { DiffBlockProps, DiffHunk } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolCallBlock } from './tool-call-model.ts'
import { parsedToolCall, validEscalationFields } from './raw-tool-call.ts'
@@ -47,11 +47,31 @@ function narrowDiffs(diffs: unknown): DiffHunk[] | null {
return out
}
-type IntendedDiff = { tool: 'write' | 'edit'; diff: DiffHunk }
+type IntendedDiff = { tool: 'write' | 'edit' | 'str_replace_editor'; diff: DiffHunk }
function intendedDiff(block: ToolCallBlock): IntendedDiff | null {
const parsed = parsedToolCall(block)
if (parsed === null) return null
+ if (parsed.name === 'str_replace_editor') {
+ const { command, path, file_text: fileText, old_str: oldText, new_str: newText } = parsed.args
+ if (typeof path !== 'string' || path.trim() === '') return null
+ if (command === 'create') {
+ if (fileText !== undefined && typeof fileText !== 'string') return null
+ return {
+ tool: 'str_replace_editor',
+ diff: { path, oldText: null, newText: fileText ?? '' },
+ }
+ }
+ if (command === 'str_replace') {
+ if (oldText !== undefined && typeof oldText !== 'string') return null
+ if (newText !== undefined && typeof newText !== 'string') return null
+ return {
+ tool: 'str_replace_editor',
+ diff: { path, oldText: oldText ?? null, newText: newText ?? '' },
+ }
+ }
+ return null
+ }
const { file_path: path } = parsed.args
if (typeof path !== 'string' || path.trim() === '') return null
if (!validEscalationFields(parsed.args)) return null
@@ -77,9 +97,11 @@ function appliedDiffs(meta: unknown): DiffHunk[] | 'empty' | null {
}
/**
- * Derive intended running or applied settled diffs for a root write/edit call.
+ * Derive running diffs for root write/edit and `str_replace_editor`
+ * create/replace calls, plus applied settled diffs for root write/edit calls.
* A successful write with valid empty metadata uses its argument-derived
- * whole-file diff, matching create and identical-overwrite presentation.
+ * whole-file diff, matching create and identical-overwrite presentation;
+ * `str_replace_editor` settles through Generic because it has no result view.
* @param block - running or settled Tool block.
* @returns the diff-card props, or null for the generic path.
*/
@@ -88,6 +110,7 @@ export function diffCardModel(block: ToolCallBlock): DiffCardModel | null {
const intended = intendedDiff(block)
if (intended === null) return null
if (!('kind' in block)) return { card: { diffs: [intended.diff] } }
+ if (intended.tool === 'str_replace_editor') return null
if (block.isError) return null
const applied = appliedDiffs(block.meta)
if (applied === null || applied === 'empty') {
diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx
index 3c113b3d3e..8a263f2983 100644
--- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx
+++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx
@@ -63,6 +63,47 @@ describe('diffCardModel', () => {
})
})
+ it.each([
+ {
+ command: 'create',
+ args: { command: 'create', path: 'notes/new.txt', file_text: 'new file\n' },
+ diff: { path: 'notes/new.txt', oldText: null, newText: 'new file\n' },
+ },
+ {
+ command: 'str_replace',
+ args: { command: 'str_replace', path: 'notes/demo.txt', old_str: 'old', new_str: 'new' },
+ diff: { path: 'notes/demo.txt', oldText: 'old', newText: 'new' },
+ },
+ ])('preserves the running str_replace_editor $command diff', ({ args, diff }) => {
+ expect(diffCardModel(running({
+ name: 'str_replace_editor',
+ argsRaw: JSON.stringify(args),
+ }))).toEqual({ card: { diffs: [diff] } })
+ })
+
+ it('preserves str_replace_editor defaults and its settled Generic result', () => {
+ const argsRaw = JSON.stringify({ command: 'str_replace', path: 'notes/demo.txt' })
+ expect(diffCardModel(running({ name: 'str_replace_editor', argsRaw }))).toEqual({
+ card: { diffs: [{ path: 'notes/demo.txt', oldText: null, newText: '' }] },
+ })
+ expect(diffCardModel(settled({
+ call: { name: 'str_replace_editor', argsRaw },
+ meta: { diffs: [{ path: 'notes/demo.txt', oldText: 'old', newText: 'new' }] },
+ }))).toBeNull()
+ })
+
+ it('keeps unsupported or malformed str_replace_editor calls generic', () => {
+ const editor = (args: Record) => running({
+ name: 'str_replace_editor', argsRaw: JSON.stringify(args),
+ })
+ expect(diffCardModel(editor({ command: 'view', path: 'notes/demo.txt' }))).toBeNull()
+ expect(diffCardModel(editor({ command: 'insert', path: 'notes/demo.txt', new_str: 'x' }))).toBeNull()
+ expect(diffCardModel(editor({ command: 'create', path: '', file_text: 'x' }))).toBeNull()
+ expect(diffCardModel(editor({ command: 'create', path: 'notes/demo.txt', file_text: 1 }))).toBeNull()
+ expect(diffCardModel(editor({ command: 'str_replace', path: 'notes/demo.txt', old_str: 1 }))).toBeNull()
+ expect(diffCardModel(editor({ command: 'str_replace', path: 'notes/demo.txt', new_str: 1 }))).toBeNull()
+ })
+
it('derives a settled card from result metadata, which replaces the intended diff', () => {
expect(diffCardModel(settled({
meta: { diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] },
From bfc145cc7ce73c0542fb5046d319a522184dc22e Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Mon, 24 Aug 2026 02:24:59 +0800
Subject: [PATCH 6/8] docs(tools): link terminal presentation markers
---
.../src/client/tool/models/terminal-card-model.ts | 13 +++++++++++--
packages/shell/shell/src/render.ts | 5 +++--
packages/terminal/tool-terminal/src/index.ts | 2 ++
3 files changed, 16 insertions(+), 4 deletions(-)
diff --git a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts
index a34a56dfc7..548a024562 100644
--- a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts
+++ b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts
@@ -156,8 +156,9 @@ function shellCall(name: string, args: Record): ShellCall | nul
if (background !== undefined && typeof background !== 'boolean') return null
if (!validEscalationFields(args)) return null
if (description === undefined) {
- // Persistent shell providers consume only `command`; parameter roots are
- // open, so unrelated fields do not change their running-card behavior.
+ // Standard dsh-tool-bash and dsh-tool-pwsh schemas require `description`;
+ // persistent shell providers omit it. Their parameter roots stay open, so
+ // unrelated fields do not change their running-card behavior.
return { command, description: undefined, workdir: undefined, persistent: true, background: false }
}
if (typeof description !== 'string' || description.trim() === '') return null
@@ -183,12 +184,20 @@ function terminalSendCall(name: string, args: Record): Terminal
if (submit !== undefined && typeof submit !== 'boolean') return null
if (background !== undefined && typeof background !== 'boolean') return null
return {
+ // Keep this visible fallback aligned with dsh-tool-terminal's
+ // `terminal_send.presentCall` implementation.
command: text || '(send input)',
description: `Terminal ${sessionId}`,
background: background === true,
}
}
+/**
+ * Parse the marker literals owned by `@deepseek-ai/dsh-shell/render` without
+ * importing that Host-only package into the Client dependency graph.
+ * @param text - rendered shell result text.
+ * @returns output with a trailing exit-code or signal marker extracted.
+ */
function parseExitStatus(text: string): { output: string; exitCode?: number; signal?: string } {
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
if (signal?.[1] !== undefined) return { output: text.slice(0, signal.index), signal: signal[1] }
diff --git a/packages/shell/shell/src/render.ts b/packages/shell/shell/src/render.ts
index 3b077d64f1..8fc924ef63 100644
--- a/packages/shell/shell/src/render.ts
+++ b/packages/shell/shell/src/render.ts
@@ -1,7 +1,8 @@
/**
* Shared rendering helpers for the shell tools (`dsh-tool-bash`,
- * `dsh-tool-pwsh`): the exit-status marker contract the tools' renderers
- * emit and the presentation layer parses back.
+ * `dsh-tool-pwsh`): the exit-status marker contract the tools' renderers emit,
+ * Host `presentResult` implementations parse here, and the Web terminal card
+ * model mirrors without importing Host code.
* @module @deepseek-ai/dsh-shell/render
*/
diff --git a/packages/terminal/tool-terminal/src/index.ts b/packages/terminal/tool-terminal/src/index.ts
index e0ed86a289..0ab37fdc31 100644
--- a/packages/terminal/tool-terminal/src/index.ts
+++ b/packages/terminal/tool-terminal/src/index.ts
@@ -284,6 +284,8 @@ export function apply(ctx: Context, config: Config = {}): void {
if (parsed.run_in_background === true) {
return { card: 'generic', title: `Send to terminal ${parsed.sessionId as string} in background`, kind: 'execute', rawInput: parsed.text }
}
+ // Keep this visible fallback aligned with dsh-client-ui-tool's
+ // terminal-card model, which cannot import this Host package.
return { card: 'terminal', title: parsed.text || '(send input)', description: `Terminal ${parsed.sessionId as string}` }
},
presentResult(args, result) {
From 1dd6bf1973a4a4789272b461bd2eb1c716b39b9b Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Mon, 24 Aug 2026 03:53:04 +0800
Subject: [PATCH 7/8] fix(client): localize terminal send presentation
---
...client-derived-tool-presentation.i18n.yaml | 4 +-
...-08-23-client-derived-tool-presentation.md | 2 +
...-23-client-derived-tool-presentation.zh.md | 2 +
.../ui-conversation/src/client/locales.ts | 6 ++
.../ui-tool/src/client/tool/ToolDetails.tsx | 9 +-
.../src/client/tool/components/ToolRow.tsx | 10 ++-
.../client/tool/models/terminal-card-model.ts | 87 +++++++++++++------
.../client/tool/toolviews/GenericToolCard.tsx | 2 +-
.../src/client/tool/toolviews/bash-sample.tsx | 9 +-
.../tests/terminal-card.client.spec.tsx | 71 ++++++++++++---
packages/terminal/tool-terminal/src/index.ts | 4 +-
11 files changed, 152 insertions(+), 54 deletions(-)
diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml
index f8e974a4cd..bfe72f19a4 100644
--- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
-2026-08-23-client-derived-tool-presentation.md: 957d2e6c1a79cb0b0a246066463e6ac960fb3d5b
-2026-08-23-client-derived-tool-presentation.zh.md: 5fa1a350963ff74306520bad6a4fc89765c592a7
+2026-08-23-client-derived-tool-presentation.md: 77fb48552625c1b22908225b0116eeba14c6046c
+2026-08-23-client-derived-tool-presentation.zh.md: 50cc447f9f4ca2e946de10d2df31ca18382d95ef
diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
index 957d2e6c1a..77fb485526 100644
--- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
+++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
@@ -312,6 +312,8 @@ Standard shell results continue parsing trailing `[exit code: N]` and `[killed b
Call `description` remains above the card and overrides the collapsed summary. Workdir continues handling absolute, relative, and missing values. Relative paths resolve against the Session cwd while preserving normalization for `.`, `..`, drive letters, and UNC roots.
+For `terminal_send`, non-empty input and the session id remain verbatim tool data; the empty-input fallback and session label resolve through the render site's conversation locale.
+
Standard and persistent providers sharing the same tool name are a special compatibility point. The Client uses currently valid argument and result features to preserve their delivered differences. Input that cannot be identified unambiguously uses a Generic settled result rather than gaining new presentation.
`TerminalBlock` ANSI handling, cursor replay, wide characters, line limits, expansion, copying, and assistive text remain unchanged.
diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md
index 5fa1a35096..50cc447f9f 100644
--- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md
@@ -312,6 +312,8 @@ Client terminal model 从工具名称、调用参数、结果 content、error、
调用 `description` 继续显示在 card 上方并覆盖折叠摘要。workdir 继续按绝对、相对和缺失三种情况处理;相对路径基于 Session cwd,且保留 `.`、`..`、盘符与 UNC root 的归一化。
+对于 `terminal_send`,非空 input 与 session id 保持为逐字工具数据;空 input fallback 与 session label 通过 render site 的 conversation locale 解析。
+
同名普通与 persistent provider 是特殊兼容点。Client 使用当前有效参数与结果特征保留已交付差异;不足以无歧义识别的输入选择 Generic settled 结果,不增加新表现。
TerminalBlock 的 ANSI、光标重放、宽字符、行数上限、展开、复制与辅助技术文本完全不变。
diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts
index fc0d361ec2..3ab8410590 100644
--- a/packages/client/ui-conversation/src/client/locales.ts
+++ b/packages/client/ui-conversation/src/client/locales.ts
@@ -145,6 +145,8 @@ export const zh = {
'terminal.collapseAria': '收起输出',
'terminal.expandAria': '展开其余 {n} 行输出',
'terminal.expandRest': '… 其余 {n} 行',
+ 'terminal.sendInput': '(发送输入)',
+ 'terminal.session': '终端 {sessionId}',
} satisfies Record
/** The conversation namespace key union. */
@@ -288,4 +290,8 @@ export const en = {
'terminal.collapseAria': 'Collapse output',
'terminal.expandAria': 'Expand the remaining {n} output lines',
'terminal.expandRest': '… {n} more lines',
+ // The Host terminal_send presenter has no locale seat; keep its fallbacks
+ // aligned with these English values.
+ 'terminal.sendInput': '(send input)',
+ 'terminal.session': 'Terminal {sessionId}',
} satisfies Record
diff --git a/packages/client/ui-tool/src/client/tool/ToolDetails.tsx b/packages/client/ui-tool/src/client/tool/ToolDetails.tsx
index 42ddd41824..c946f0ee7d 100644
--- a/packages/client/ui-tool/src/client/tool/ToolDetails.tsx
+++ b/packages/client/ui-tool/src/client/tool/ToolDetails.tsx
@@ -4,7 +4,9 @@ import type { ToolDetailsProps } from '../contract/slots.ts'
import { diffCardModel } from './models/diff-card-model.ts'
import { readCardModel } from './models/read-card-model.ts'
import { searchCardModel } from './models/search-card-model.ts'
-import { terminalBlockLabels, terminalCardModel } from './models/terminal-card-model.ts'
+import {
+ localizeTerminalCardModel, terminalBlockLabels, terminalCardModel,
+} from './models/terminal-card-model.ts'
import {
diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels,
} from './models/primitive-labels.ts'
@@ -22,8 +24,9 @@ export function ToolDetails({
block, cwd, useHostDescription, t,
}: Pick) {
const home = useHostDescription(description => description?.home)
- const terminal = terminalCardModel(block, cwd)
- if (terminal !== null) {
+ const terminalModel = terminalCardModel(block, cwd)
+ if (terminalModel !== null) {
+ const terminal = localizeTerminalCardModel(terminalModel, t)
return (
<>
{terminal.description !== undefined ? (
diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx
index 96b3213a04..8102965a6d 100644
--- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx
+++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx
@@ -7,7 +7,9 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../models/diff-card-model.ts'
import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../models/read-card-model.ts'
import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../models/search-card-model.ts'
-import { terminalBlockLabels, type TerminalCardModel } from '../models/terminal-card-model.ts'
+import {
+ localizeTerminalCardModel, terminalBlockLabels, type TerminalCardModel,
+} from '../models/terminal-card-model.ts'
import {
diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels,
} from '../models/primitive-labels.ts'
@@ -106,7 +108,9 @@ export function ToolRow({
const readLabels = useMemo(() => readBlockLabels(t), [t])
const searchLabels = useMemo(() => searchBlockLabels(t), [t])
const webLabels = useMemo(() => webBlockLabels(t), [t])
- const terminalBody = terminal ?? null
+ const terminalBody = terminal === undefined || terminal === null
+ ? null
+ : localizeTerminalCardModel(terminal, t)
const diffBody = diff ?? null
const readBody = read ?? null
const searchBody = search ?? null
@@ -118,7 +122,7 @@ export function ToolRow({
const status = stateStatus(state, t)
// A failure must replace, not supplement, the normal summary.
const failureLine = state === 'error' ? errorSummary ?? null : null
- const summaryText = failureLine ?? summary
+ const summaryText = failureLine ?? terminalBody?.description ?? summary
const suffix = failureLine === null ? summarySuffix ?? null : null
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
const toggleExpand = () => {
diff --git a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts
index 548a024562..1956fe232e 100644
--- a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts
+++ b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts
@@ -37,16 +37,48 @@ export function terminalBlockLabels(t: TranslateNS<'conversation'>): TerminalBlo
*/
export interface TerminalCardModel {
/**
- * The props {@link TerminalBlock} draws. Held as a nested object so a render
- * site spreads exactly the primitive's own surface and can never leak a
- * neighbouring field into it.
+ * The locale-neutral props {@link TerminalBlock} draws. The render site adds
+ * `command` after resolving {@link copy} through its locale seat.
*/
- card: Pick
+ card: Pick
/**
- * The model-authored call description rendered above the card. Absent for
- * persistent shells, whose parameter set has no description.
+ * Verbatim Tool data or semantic `terminal_send` data. Product copy stays
+ * unresolved until a render site supplies its locale seat.
*/
- description: string | undefined
+ copy:
+ | { readonly kind: 'shell'; readonly command: string; readonly description: string | undefined }
+ | { readonly kind: 'terminal-send'; readonly text: string; readonly sessionId: string }
+}
+
+interface LocalizedTerminalCardModel {
+ readonly card: Pick
+ readonly description: string | undefined
+}
+
+/**
+ * Resolve locale-owned `terminal_send` copy while preserving Tool-authored
+ * shell commands and descriptions verbatim.
+ * @param model - locale-neutral terminal card data.
+ * @param t - the render site's conversation locale seat.
+ * @returns terminal props and description ready for rendering.
+ */
+export function localizeTerminalCardModel(
+ model: TerminalCardModel,
+ t: TranslateNS<'conversation'>,
+): LocalizedTerminalCardModel {
+ if (model.copy.kind === 'shell') {
+ return {
+ card: { command: model.copy.command, ...model.card },
+ description: model.copy.description,
+ }
+ }
+ return {
+ card: {
+ command: model.copy.text === '' ? t('terminal.sendInput') : model.copy.text,
+ ...model.card,
+ },
+ description: t('terminal.session', { sessionId: model.copy.sessionId }),
+ }
}
/**
@@ -140,6 +172,7 @@ function collapse(body: string, rooted: boolean, separator = '/'): string {
}
interface ShellCall {
+ kind: 'shell'
command: string
description: string | undefined
workdir: string | undefined
@@ -159,10 +192,11 @@ function shellCall(name: string, args: Record): ShellCall | nul
// Standard dsh-tool-bash and dsh-tool-pwsh schemas require `description`;
// persistent shell providers omit it. Their parameter roots stay open, so
// unrelated fields do not change their running-card behavior.
- return { command, description: undefined, workdir: undefined, persistent: true, background: false }
+ return { kind: 'shell', command, description: undefined, workdir: undefined, persistent: true, background: false }
}
if (typeof description !== 'string' || description.trim() === '') return null
return {
+ kind: 'shell',
command,
description,
workdir,
@@ -172,8 +206,9 @@ function shellCall(name: string, args: Record): ShellCall | nul
}
interface TerminalSendCall {
- command: string
- description: string
+ kind: 'terminal-send'
+ text: string
+ sessionId: string
background: boolean
}
@@ -184,10 +219,9 @@ function terminalSendCall(name: string, args: Record): Terminal
if (submit !== undefined && typeof submit !== 'boolean') return null
if (background !== undefined && typeof background !== 'boolean') return null
return {
- // Keep this visible fallback aligned with dsh-tool-terminal's
- // `terminal_send.presentCall` implementation.
- command: text || '(send input)',
- description: `Terminal ${sessionId}`,
+ kind: 'terminal-send',
+ text,
+ sessionId,
background: background === true,
}
}
@@ -213,7 +247,7 @@ function parseExitStatus(text: string): { output: string; exitCode?: number; sig
* the generic path.
* @param block - running or settled Tool block.
* @param sessionCwd - session workspace root used to resolve workdir.
- * @returns the terminal-card props, or null for the generic path.
+ * @returns locale-neutral terminal-card data, or null for the generic path.
*/
export function terminalCardModel(
block: ToolCallBlock,
@@ -222,19 +256,17 @@ export function terminalCardModel(
if (block.parentCallId !== undefined) return null
const parsed = parsedToolCall(block)
if (parsed === null) return null
- const shell = shellCall(parsed.name, parsed.args)
- const send = terminalSendCall(parsed.name, parsed.args)
- if (shell === null && send === null) return null
- if (shell?.background === true || send?.background === true) return null
+ const call = shellCall(parsed.name, parsed.args) ?? terminalSendCall(parsed.name, parsed.args)
+ if (call === null || call.background) return null
- const command = shell?.command ?? send?.command ?? ''
- const description = shell?.description ?? send?.description
- const cwd = resolveTerminalCwd(shell?.workdir, sessionCwd)
+ const copy: TerminalCardModel['copy'] = call.kind === 'shell'
+ ? { kind: 'shell', command: call.command, description: call.description }
+ : { kind: 'terminal-send', text: call.text, sessionId: call.sessionId }
+ const cwd = resolveTerminalCwd(call.kind === 'shell' ? call.workdir : undefined, sessionCwd)
if (!('kind' in block)) {
return {
- description,
+ copy,
card: {
- command,
cwd,
output: undefined,
exitCode: undefined,
@@ -243,14 +275,13 @@ export function terminalCardModel(
},
}
}
- if (block.isError || shell?.persistent === true) return null
+ if (block.isError || (call.kind === 'shell' && call.persistent)) return null
const output = singleResultText(block)
if (output === undefined) return null
- const status = shell === null ? { output } : parseExitStatus(output)
+ const status = call.kind === 'terminal-send' ? { output } : parseExitStatus(output)
return {
- description,
+ copy,
card: {
- command,
cwd,
output: status.output,
exitCode: status.exitCode,
diff --git a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx
index 0fe0883e0a..41b13e20d6 100644
--- a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx
+++ b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx
@@ -47,7 +47,7 @@ export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect,
toolName={toolName}
icon={VARIANT_ICONS[model.variant]}
title={t(model.titleKey)}
- summary={terminal?.description ?? model.summary}
+ summary={model.summary}
// Single-file tools never expose an args body — the path link is the only
// args interaction. A card is not an args body: a read/write/edit row is
// single-file AND carries a card, so the card expands under the path link.
diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx
index eaba61f015..321b156078 100644
--- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx
+++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx
@@ -6,7 +6,9 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolCallViewProps } from '../../contract/slots.ts'
-import { terminalBlockLabels, terminalCardModel, terminalFailed } from '../models/terminal-card-model.ts'
+import {
+ localizeTerminalCardModel, terminalBlockLabels, terminalCardModel, terminalFailed,
+} from '../models/terminal-card-model.ts'
import { toolRowModel, type ToolRowState } from '../models/tool-call-model.ts'
import { CONVERSATION_NS as NS } from '../../locale.ts'
import css from './bash-sample.module.css'
@@ -38,10 +40,11 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
// An omitted shell workdir is the session workspace; relative values resolve
// against it before reaching the terminal primitive.
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
- const terminal = terminalCardModel(block, cwd)
+ const terminalModel = terminalCardModel(block, cwd)
+ const terminal = terminalModel === null ? null : localizeTerminalCardModel(terminalModel, t)
// A failing exit status is the terminal card's own error signal (the call
// itself settles isError:false), surfaced as the row's red state dot.
- const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
+ const state = model.state === 'ok' && terminalModel !== null && terminalFailed(terminalModel)
? 'error'
: model.state
const status = stateStatus(state, t)
diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx
index 2543f7bfe7..8d57084ab3 100644
--- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx
+++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx
@@ -12,19 +12,23 @@ import type {
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
-import { terminalCardModel, terminalFailed } from '../src/client/tool/models/terminal-card-model.ts'
+import {
+ localizeTerminalCardModel, terminalCardModel, terminalFailed,
+} from '../src/client/tool/models/terminal-card-model.ts'
import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx'
import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx'
import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx'
-import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
+import { en, zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts'
type BashRowProps = Parameters[0]
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
+const enT: GenericToolCardProps['t'] = makeTranslate(en, commonEn)
const chatT = makeTranslate(chatZh, commonZh)
afterEach(cleanup)
@@ -65,9 +69,9 @@ const settled = (over?: Partial): ToolResultNode => ({
describe('terminalCardModel', () => {
it('derives a running standard-shell card from raw arguments', () => {
expect(terminalCardModel(running({ argsRaw: shellArgs({ workdir: '/projects/app' }) }))).toEqual({
- description: 'List files',
+ copy: { kind: 'shell', command: 'ls -la', description: 'List files' },
card: {
- command: 'ls -la', cwd: '/projects/app', output: undefined,
+ cwd: '/projects/app', output: undefined,
exitCode: undefined, signal: undefined, running: true,
},
})
@@ -78,9 +82,9 @@ describe('terminalCardModel', () => {
call: { name: 'bash', argsRaw: shellArgs({ workdir: '/projects/app' }) },
content: [{ type: 'text', text: 'boom\n[exit code: 2]' }],
}))).toEqual({
- description: 'List files',
+ copy: { kind: 'shell', command: 'ls -la', description: 'List files' },
card: {
- command: 'ls -la', cwd: '/projects/app', output: 'boom',
+ cwd: '/projects/app', output: 'boom',
exitCode: 2, signal: undefined, running: false,
},
})
@@ -163,10 +167,13 @@ describe('terminalCardModel', () => {
const argsRaw = JSON.stringify({ sessionId: 'pty-3', text: 'make' })
const run = running({ name: 'terminal_send', argsRaw })
expect(terminalCardModel(run, '/w/app')).toMatchObject({
- description: 'Terminal pty-3', card: { command: 'make', cwd: '/w/app', running: true },
+ copy: { kind: 'terminal-send', text: 'make', sessionId: 'pty-3' },
+ card: { cwd: '/w/app', running: true },
})
const done = settled({ call: { name: 'terminal_send', argsRaw }, content: [{ type: 'text', text: 'ok' }] })
- expect(terminalCardModel(done)?.card).toMatchObject({ command: 'make', output: 'ok', running: false })
+ expect(localizeTerminalCardModel(terminalCardModel(done)!, enT)).toMatchObject({
+ description: 'Terminal pty-3', card: { command: 'make', output: 'ok', running: false },
+ })
expect(terminalCardModel(settled({
call: { name: 'terminal_send', argsRaw: JSON.stringify({ sessionId: 'pty-3', text: 'make', run_in_background: true }) },
}))).toBeNull()
@@ -176,10 +183,10 @@ describe('terminalCardModel', () => {
it('preserves persistent-shell running cards and settled generic output', () => {
const persistent = JSON.stringify({ command: 'pwd' })
expect(terminalCardModel(running({ argsRaw: persistent }))).toMatchObject({
- description: undefined, card: { command: 'pwd', running: true },
+ copy: { kind: 'shell', command: 'pwd', description: undefined }, card: { running: true },
})
expect(terminalCardModel(running({ name: 'pwsh', argsRaw: persistent }))).toMatchObject({
- description: undefined, card: { command: 'pwd', running: true },
+ copy: { kind: 'shell', command: 'pwd', description: undefined }, card: { running: true },
})
expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: persistent } }))).toBeNull()
expect(terminalCardModel(settled({ call: { name: 'pwsh', argsRaw: persistent } }))).toBeNull()
@@ -190,8 +197,22 @@ describe('terminalCardModel', () => {
call: { name: 'pwsh', argsRaw: ARGS },
content: [{ type: 'text', text: 'failed\n[exit code: 3]' }],
}))).toMatchObject({
- description: 'List files',
- card: { command: 'ls -la', output: 'failed', exitCode: 3, running: false },
+ copy: { kind: 'shell', command: 'ls -la', description: 'List files' },
+ card: { output: 'failed', exitCode: 3, running: false },
+ })
+ })
+
+ it('keeps terminal_send copy semantic until the render locale is known', () => {
+ const model = terminalCardModel(running({
+ name: 'terminal_send',
+ argsRaw: JSON.stringify({ sessionId: 'pty-3', text: '' }),
+ }))!
+ expect(model.copy).toEqual({ kind: 'terminal-send', text: '', sessionId: 'pty-3' })
+ expect(localizeTerminalCardModel(model, t)).toMatchObject({
+ description: '终端 pty-3', card: { command: '(发送输入)' },
+ })
+ expect(localizeTerminalCardModel(model, enT)).toMatchObject({
+ description: 'Terminal pty-3', card: { command: '(send input)' },
})
})
@@ -323,6 +344,20 @@ describe('chat row terminal body', () => {
expect(runStateOf(view.container)).toBe('ongoing')
})
+ it.each([
+ { locale: 'zh', translate: t, description: '终端 pty-3', command: '(发送输入)' },
+ { locale: 'en', translate: enT, description: 'Terminal pty-3', command: '(send input)' },
+ ])('renders terminal_send copy through the $locale locale', ({ translate, description, command }) => {
+ const block = running({
+ name: 'terminal_send',
+ argsRaw: JSON.stringify({ sessionId: 'pty-3', text: '' }),
+ })
+ const view = render()
+ expect(view.getByText(description)).toBeTruthy()
+ toggleRow(view)
+ expect(view.getByText(command)).toBeTruthy()
+ })
+
it('a non-terminal call keeps the args-JSON text body', () => {
const view = render( {
expect(description.compareDocumentPosition(card!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
+ it('localizes terminal_send copy in Details', () => {
+ const argsRaw = JSON.stringify({ sessionId: 'pty-3', text: '' })
+ const view = mount(snapshot({
+ nodes: [settled({
+ call: { name: 'terminal_send', argsRaw },
+ content: [{ type: 'text', text: 'ok' }],
+ })],
+ }), { ...target, toolName: 'terminal_send' })
+ expect(view.getByText('终端 pty-3')).toBeTruthy()
+ expect(view.getByText('(发送输入)')).toBeTruthy()
+ })
+
it('resolves the prompt cwd against the session workspace', () => {
const view = mount(snapshot({ nodes: [settled()] }), target, '/w/app')
// No workdir in the call args: the prompt label is the workspace basename.
diff --git a/packages/terminal/tool-terminal/src/index.ts b/packages/terminal/tool-terminal/src/index.ts
index 0ab37fdc31..b08938d5e9 100644
--- a/packages/terminal/tool-terminal/src/index.ts
+++ b/packages/terminal/tool-terminal/src/index.ts
@@ -284,8 +284,8 @@ export function apply(ctx: Context, config: Config = {}): void {
if (parsed.run_in_background === true) {
return { card: 'generic', title: `Send to terminal ${parsed.sessionId as string} in background`, kind: 'execute', rawInput: parsed.text }
}
- // Keep this visible fallback aligned with dsh-client-ui-tool's
- // terminal-card model, which cannot import this Host package.
+ // Keep these Host-only fallbacks aligned with the conversation locale
+ // keys `terminal.sendInput` and `terminal.session` used by Web.
return { card: 'terminal', title: parsed.text || '(send input)', description: `Terminal ${parsed.sessionId as string}` }
},
presentResult(args, result) {
From 8fbd1650a32772692320aaf31bee372e63c5432c Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Mon, 24 Aug 2026 03:53:38 +0800
Subject: [PATCH 8/8] docs(session): record cold projection composition rule
---
.../2026-08-23-client-derived-tool-presentation.i18n.yaml | 4 ++--
.../2026-08-23-client-derived-tool-presentation.md | 4 ++++
.../2026-08-23-client-derived-tool-presentation.zh.md | 4 ++++
3 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml
index bfe72f19a4..dc2902d1ca 100644
--- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
-2026-08-23-client-derived-tool-presentation.md: 77fb48552625c1b22908225b0116eeba14c6046c
-2026-08-23-client-derived-tool-presentation.zh.md: 50cc447f9f4ca2e946de10d2df31ca18382d95ef
+2026-08-23-client-derived-tool-presentation.md: 5598c1fbc6073a71f63a20274cd5a791b6b5e6b3
+2026-08-23-client-derived-tool-presentation.zh.md: 5a87433377af58b1ca9d378b76393dada4ca4a1e
diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
index 77fb485526..5598c1fbc6 100644
--- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
+++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
@@ -654,6 +654,10 @@ A raw event records the tool name but not the specific ToolDefinition. The Clien
Old Sessions may lack fields, and manually edited logs may contain malformed values. Each Client model must narrow locally and cannot pass unknown arrays or objects directly into UI primitives.
+### Preset-owned projection availability
+
+History does not compensate for projection units absent from the current composition. A preset-owned unit that must remain visible across a cold read requires the shared Session preparation/projection composition to make its definition available before restore; history must not regain a preset-mount or presenter setup branch.
+
### Two targets must stay synchronized
Chat and Trajectory have separate Tool Definitions and both carry the raw fields. Card derivation remains only in `ui-tool` and cannot be copied into either Definition.
diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md
index 50cc447f9f..5a87433377 100644
--- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md
@@ -654,6 +654,10 @@ raw event 只记录 tool name,不记录具体 ToolDefinition。Client 使用
旧 Session 可能缺字段,手工修改日志可能带畸形值。每个 Client model 必须局部收窄,不能把未知数组或对象直接传给 UI primitive。
+### preset-owned projection 可用性
+
+history 不为当前组合中缺失的 projection unit 补偿。需要在冷读中保持可见的 preset-owned unit,必须由共享的 Session preparation/projection 组合在 restore 前提供其定义;history 不得重新增加 preset mount 或 presenter setup 分支。
+
### 双 target 同步
Chat 与 Trajectory 各有独立 Tool Definition,两者都携带 raw fields;card derivation 只能留在 `ui-tool`,不能复制进两个 Definition。