Merge pull request #5211 from deepseek-harness/worktree-partialtool

feat(web): 在工具准备阶段显示已解析参数
This commit is contained in:
imccyu authored and GitHub committed 2026-10-01 00:57:28 +08:00
commit 799f3f2b48
133 files changed
+4457 -1008

No files matched your search

@@ -12,17 +12,17 @@
en: a15c7c13f167e5ac
zh: 63bcc2ef60c2f344
/agent-note-tool-calls-own-preparation-dispatch-and-results-by-callid/decision/one-tool-owns-three-stages:
en: ea65da88c3df35bb
zh: a71afb4683a75290
en: c4bac1088a4fd3bc
zh: 549348deae5485c1
/agent-note-tool-calls-own-preparation-dispatch-and-results-by-callid/decision/start-marks-an-initialization-candidate:
en: 696a180246e23391
zh: aeb2b1fdcfdf9631
en: 7771032e6ad227e7
zh: 313d32ab5312d965
/agent-note-tool-calls-own-preparation-dispatch-and-results-by-callid/decision/converge-on-final-state-without-replaying-preparation:
en: 603cbcf3379ad4f7
zh: e34d87ea348c756b
/agent-note-tool-calls-own-preparation-dispatch-and-results-by-callid/decision/shared-rows-reuse-their-existing-presentation:
en: 24536588cd0c979b
zh: 992d4c4657d6d034
en: a5741c967212250b
zh: de6288916a054c6e
/agent-note-tool-calls-own-preparation-dispatch-and-results-by-callid/decision/relationship-to-existing-records:
en: bd4984f66c507785
zh: 4d2372fdbc4c3cec
@@ -30,5 +30,5 @@
en: 505095bec889700e
zh: b77c492de3e8641a
/agent-note-tool-calls-own-preparation-dispatch-and-results-by-callid/consequences:
en: 3cf36b54240013da
zh: 67e663b431bc67d2
en: dd0e4561c50b1188
zh: 0ec83177ef570a78
@@ -18,7 +18,7 @@ The [Tool Definition](../../../../packages/client/ui-chat/src/client/conversatio
| Stage | Creation or update evidence | Available data and presentation |
|---|---|---|
| `preparing` | A live delta with a call ID and tool name | Call identity, name, and time; no complete arguments, and one non-expandable row. An opt-in Hook can read the raw argument prefix. |
| `preparing` | A live delta with a call ID and tool name | Call identity, name, time, and a lazy partial-argument view; one non-expandable row. |
| `start` | `tool/call` | Complete arguments; enables the tool's existing call presentation. |
| `result` | `tool/result` | The result and any complete arguments paired within the loaded window. |
@@ -28,7 +28,7 @@ An argument block ending does not trigger dispatch presentation. Showing prepara
The [Conversation Definition](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) retains its match/start/update interface and existing roles. Durable events and transient deltas may both match as start. The earliest start in the current event sequence initializes State; every later Match, including another start for the same ID, goes through update.
A named tool delta and `tool/call` are both initialization candidates. A live delta can create preparation; a historical `tool/call` creates the dispatched stage directly. Matching still reads only the current event, without Context lookup, additional roles, registries, or separate caches.
Every tool delta and `tool/call` are initialization candidates. A named live delta creates the preparing row; a historical `tool/call` creates the dispatched stage directly. Matching still reads only the current event, without Context lookup, additional roles, registries, or separate caches.
Successful stream settlement publishes the final Assistant message while retaining transient deltas until `step/end`. Each `tool/call` updates its existing callId; the Assistant keeps its live ordering anchor until the same cleanup. Failed, interrupted, or abandoned streams retire their deltas immediately. On retirement the Assembler reselects starts and recomputes State from remaining Matches. Undispatched preparations hide without synthetic execution results.
@@ -40,9 +40,9 @@ Groups include Tool nodes from preparation onward and classify and count them by
### Shared rows reuse their existing presentation
[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) reuses the existing icon, title, and row component across all stages. The shared row model selects the tool title and combines any generic tool-name prefix with the available argument summary. Preparation has no complete arguments or result, and the shared argument parser returns null without parsing JSON. The existing slot Hook binding optionally exposes a call-scoped raw prefix; it adds no preparation registration mechanism. ToolRow prevents preparation rows from expanding.
[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) reuses the existing icon, title, and row component across all stages. The shared row model selects the tool title and combines any generic tool-name prefix with the available argument summary. All stages expose the [lazy argument view](2026-09-24-preparing-tool-arguments.md); cards requiring dispatched arguments still return no call during preparation. ToolRow prevents preparation rows from expanding.
Read, read_image, write/edit, search, web, todo, question, details, and the generic fallback use this path. Custom rows such as Bash, Skill, Present, and Cordis keep their own preparation branches. No automatic/custom registration declaration or second tool-classification list in ToolTree is introduced.
Read, read_image, write/edit, search, web, todo, question, details, and the generic fallback use this path. Bash shares its component across stages; Skill, Present, and Cordis retain their own preparation branches. No automatic/custom registration declaration or second tool-classification list in ToolTree is introduced.
### Relationship to existing records
@@ -60,8 +60,8 @@ This decision replaces the [business-node assembly](2026-08-09-client-conversati
## Consequences
- A tool may appear before its arguments are complete, but preparation neither implies execution nor exposes interactions requiring arguments.
- A tool may appear before its arguments are complete, but preparation never implies execution. Interactions use only fields already available.
- Multiple starts with one ID belong to one lifecycle. Independent calls must use distinct IDs; a second-start error no longer detects identity reuse.
- The shared row primitive is reused across stages. Write/edit separate preparing and dispatched components so only the former subscribes to raw arguments; custom rows may also switch internal components by stage.
- Partial JSON parsing and `useToolCallDelta` are not provided. `useToolCallArgumentsPartial` exposes the existing Step's raw prefix without a second accumulator; write/edit use only its length for preparation progress.
- The shared row primitive is reused across stages. Write/edit and Bash keep their component identity; custom rows may switch internal components by stage.
- Partial-argument access and its update policy follow the lazy argument view; no separate raw-prefix Hook is required.
- Assembly tests separately cover live preparation, withdrawal, active-stream reconstruction, durable history, and paged convergence. Component tests cover argument-free presentation and stage changes; a recorded session covers browser preparation and reload results.
@@ -18,7 +18,7 @@ Status: implemented
| 阶段 | 创建或更新依据 | 可用数据与展示 |
|---|---|---|
| `preparing` | 带调用 ID 和工具名的实时 delta | 调用身份、名称和时间;不提供完整参数,只显示不可展开的一行。可选钩子可读取原始参数前缀。 |
| `preparing` | 带调用 ID 和工具名的实时 delta | 调用身份、名称、时间和懒计算的部分参数视图;显示不可展开的一行。 |
| `start` | `tool/call` | 完整参数;启用工具原有的调用展示。 |
| `result` | `tool/result` | 调用结果,以及当前窗口内可配对的完整参数。 |
@@ -28,7 +28,7 @@ Status: implemented
沿用 [Conversation Definition](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) 的 `match/start/update` 接口和既有角色。持久事件与瞬态 delta 都可以匹配为 start;当前事件序列中最早的 start 初始化 State,后续 Match,包括同 ID 的其他 start,都交给 update。
Tool 的具名 delta 和 `tool/call` 都是创建入口。实时先收到 delta,就从 preparing 创建;历史先收到 `tool/call`,就从 start 创建。匹配函数仍然只读取当前事件,不查询 Context,不增加角色、注册表或旁路缓存。
Tool 的每个 delta 和 `tool/call` 都是创建入口。具名实时 delta 创建准备行;历史 `tool/call` 直接创建派发阶段。匹配函数仍然只读取当前事件,不查询 Context,不增加角色、注册表或旁路缓存。
成功流结束时发布最终 Assistant 消息,瞬态 delta 保留到 `step/end`。每个 `tool/call` 更新已有 callId;Assistant 的实时排序锚点也保留到同一次清理。失败、中断或废弃的流立即移除 delta。清理时,Assembler 从剩余 Match 重新选择 start 并重算 State;未派发的准备节点隐藏,不合成执行结果。
@@ -40,9 +40,9 @@ Group 从准备阶段起就聚合 Tool 节点,按工具名分类和计数。As
### 通用行复用现有外壳
[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) 在三个阶段复用既有图标、标题和行组件。公共行模型选择工具标题,并组合通用工具名前缀与已有参数摘要。准备阶段没有完整参数或结果,共享参数解析入口直接返回 null,不解析 JSON。既有 slot 钩子绑定可选地提供当前调用的原始前缀,不增加准备态注册机制;ToolRow 禁止准备行展开。
[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) 在三个阶段复用既有图标、标题和行组件。公共行模型选择工具标题,并组合通用工具名前缀与已有参数摘要。所有阶段都提供[懒计算参数视图](2026-09-24-preparing-tool-arguments.zh.md);需要完整派发参数的卡片在准备阶段仍返回无调用。ToolRow 禁止准备行展开。
read、read_image、write/edit、search、web、todo、question、details 和通用回退使用这条路径。Bash、Skill、Present、Cordis 等自定义外壳保留自己的准备态分支。不增加自动/自管注册声明,不在 ToolTree 维护另一份工具分类名单。
read、read_image、write/edit、search、web、todo、question、details 和通用回退使用这条路径。Bash 在各阶段共用组件;Skill、Present、Cordis 保留自己的准备态分支。不增加自动/自管注册声明,不在 ToolTree 维护另一份工具分类名单。
### 与已有记录的关系
@@ -60,8 +60,8 @@ read、read_image、write/edit、search、web、todo、question、details 和通
## 影响
- 工具可以在参数完整前出现,但准备展示不表示工具已经执行,也不暴露依赖参数的交互。
- 工具可以在参数完整前出现,但准备展示不表示工具已经执行;交互只使用已可用的字段。
- 同一 ID 的多个 start 被视为同一个生命周期;业务必须为独立调用提供不同 ID,不能再依赖第二个 start 报错检测身份复用。
- 各阶段复用通用行基础组件。write/edit 分离准备态与派发后组件,只有前者订阅原始参数;自定义外壳也可按阶段切换内部组件。
- 不提供部分 JSON 解析或 `useToolCallDelta`。`useToolCallArgumentsPartial` 暴露既有 Step 的原始前缀,不另建累积器;write/edit 只使用其长度展示准备进度。
- 各阶段复用通用行基础组件。write/edit 和 Bash 保持组件身份;自定义外壳可按阶段切换内部组件。
- 部分参数的读取与更新策略遵循懒计算参数视图,不需要独立的原始前缀钩子。
- 组装测试分别覆盖实时准备态、撤回、活跃流重建、持久历史与分页收敛;组件测试检查无参数展示和阶段切换,录制会话覆盖浏览器中的准备态与重载结果。
@@ -0,0 +1,31 @@
# Bilingual-pair consistency record for 2026-09-24-preparing-tool-arguments.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-24-preparing-tool-arguments.md
/agent-note-incremental-tool-argument-scanning-and-argument-order-during-preparation:
en: 07f6420c67ea2700
zh: b9b53250e050cf6e
/agent-note-incremental-tool-argument-scanning-and-argument-order-during-preparation/problem:
en: c97b3edd4ef1d063
zh: 5c8b6065e662f326
/agent-note-incremental-tool-argument-scanning-and-argument-order-during-preparation/decision:
en: a15c7c13f167e5ac
zh: 63bcc2ef60c2f344
/agent-note-incremental-tool-argument-scanning-and-argument-order-during-preparation/decision/a-lazily-computed-argument-view-lives-in-dsh-util-values:
en: eb004194b0c08464
zh: d09c6353ed7138de
/agent-note-incremental-tool-argument-scanning-and-argument-order-during-preparation/decision/parsing-happens-in-the-tool-definition-not-in-react:
en: 518206f94d82993d
zh: c6cab3b2f71dd18b
/agent-note-incremental-tool-argument-scanning-and-argument-order-during-preparation/decision/argument-declaration-order:
en: 0fd7c9f0b8cf6a28
zh: 7d410403283b9996
/agent-note-incremental-tool-argument-scanning-and-argument-order-during-preparation/alternatives-considered:
en: 7a033f81c557c58f
zh: 2ec66b0db6cf5d9e
/agent-note-incremental-tool-argument-scanning-and-argument-order-during-preparation/verification:
en: c1e09085a137be1c
zh: ec79806f84f59dec
/agent-note-incremental-tool-argument-scanning-and-argument-order-during-preparation/consequences:
en: f1c44f9d58d36920
zh: bb297337882ec407
@@ -0,0 +1,88 @@
# Agent Note: Incremental tool-argument scanning and argument order during preparation
Status: implemented
English | [中文](2026-09-24-preparing-tool-arguments.zh.md)
## Problem
[Three-stage tool calls](2026-09-22-tool-call-three-phases.md) make tool identity visible before `tool/call`. A raw argument prefix alone cannot supply field-aware presentation: write/edit need a complete path and decoded content length, while bash/run_code need the description before the command or program finishes streaming.
The order in which the model generates arguments also decides what can appear first. On 2026-09-24, 310 controlled requests against the real DeepSeek V4 API exercised bash/run_code/write/edit:
| Variant | Change | bash first key is description | run_code first key is description |
|---|---|---|---|
| Baseline | — | 0/20 | 1/20 |
| `properties` order only | description moved first | 0/20 | 0/20 |
| `properties` + `required` together | Same plus reordered `required` | Forced tool_choice 10/10; free calling Pro 7/10, Flash 0/10 | Forced 10/10; free 20/20 |
| Together + one ordering instruction in the description text | Appends "Provide `description` before `command` in the arguments." | Free calling Flash 19/20, Pro 20/20 | 20/20 |
In these samples, reordering `required` changes argument order where reordering `properties` alone does not; V4 Flash's free bash calls also need the explicit instruction. The `dsh-tools` schema DSL generates `required` in property declaration order, so changing the declaration order in source reorders both. The probe uses a minimal system prompt, not the complete Harness prompt.
## Decision
### A lazily computed argument view lives in `dsh-util-values`
`PartialArguments` is a tool-independent lazy reader. It retains separate argument fragments and a top-level key/value range index, or borrows an already parsed PTC payload. Reads index only newly received boundaries; unrequested values are neither decoded, counted, nor parsed. Requested fields materialize from their own ranges, without a per-tool field specification or registry.
The boundary scanner tracks quotes, backslash parity, and matching nested brackets across fragments. Key names are decoded for lookup, while value contents are deferred. `complete()` reports a closing delimiter, not validated content; `invalid` reports errors already found by indexing or a content read. Content errors do not prevent indexing subsequent fields. Formal tool-input validation remains outside this presentation reader.
Requested unescaped lengths derive directly from offsets. Escaped strings maintain content cursors only for requested length, text, or bounded-prefix reads; a limit query stops once its answer is known. Closed strings use native decoding when their full text is first requested. Arbitrary late reads require retaining the original fragments until authoritative complete text replaces them.
The view judges change itself: `append(delta)` only retains the fragment, and `refresh()` compares remembered answers at publication time. With nothing read yet, it does not scan or report a change. Only refresh advances an existing answer's comparison baseline; an intervening read cannot suppress another consumer's update. The business states its granularity as it reads: `stringLength('content', { step: 1024 })` makes only a kilobyte crossing count, `text('description')` makes every character count, and `textPrefix()` limits decoded text to the requested UTF-16 prefix. An `offset` includes completed strings when edit progress combines old and new text.
### Parsing happens in the Tool Definition, not in React
The [Tool Definition](../../../../packages/client/ui-chat/src/client/conversation-nodes/tool.ts) matches every `tool-call-delta` carrying an id as a start candidate: the earliest opens the Context whether or not it is named, later ones fold as updates. The named delta creates the preparing root with a fresh streaming view; every delta only calls `args.append(delta)`. At the `animation-frame` publication point, `buildViewNode()` refreshes observed answers and replaces the root only when they change; otherwise it reuses the currently published root. The Context and its published Node own these identities without a separate preparation cache. A stream whose arguments precede the name is not scanned: a view opened late sees a non-object prefix, turns invalid, and reports no fields.
Every stage's block exposes `name` and `args`; legacy `argsRaw` and result `call: { name, argsRaw } | null` remain available. `block-end` and `tool/call` reconcile argument fragments against their authoritative full text with length and exact per-fragment comparisons. Matching text seals the same reader and preserves its indexed fields; missing or conflicting deltas create a new `fromText()` view. No cumulative string or probabilistic hash is needed. PTC readers use `fromObject(payload)`, results reuse the started reader, and an unpaired result uses the shared empty view. Card models continue to read finalized `argsRaw`.
Assistant blocks retain tool identity and first-token timing, not argument deltas. Complete argument text comes from `block-end` or the durable message. Parameter-only deltas preserve Assistant State, while the Tool Definition owns preparing reads and notifications.
The Assembler shares one immutable Match per input and lifecycle role, including its Location, while each Definition retains independent State. Empty dependency sets require no replay work.
Rows use the same readers across stages: read/write/edit show an openable path once `file_path` closes; write/edit show decoded content length in 1024-character units while content streams; bash/pwsh/run_code show the description prefix. Chat group detail reads the same view using its existing field priority. Without usable detail it stays empty while fields can still arrive, then falls back to the tool name once `closed()` is true, for every category.
Group detail normalizes an initial 512-unit decoded prefix and expands it only when whitespace or multi-unit grapheme clusters prevent deciding the 160-cluster result. A filled prefix remains unchanged as its field grows, while a later higher-priority field still republishes the detail. String-length checks do not materialize the full text. Detail already within 160 UTF-16 units requires no grapheme traversal.
Third-party tools receive the same view without registration or a separate subscription. Write/edit and Bash share their component across stages. The mutable argument reader is a narrow exception to JSON-compatible owner data: its Definition publishes a new block reference when an observed answer changes. Scan caches use private fields so read history does not affect structural comparison of equal sources.
### Argument declaration order
`tool-bash` and `tool-pwsh` declare `description` before `command` and include the ordering instruction. Both `parameters` declarations of `run_code` put `description` before `code` and share a parameter description requesting that generation order for TypeScript and Python. Write/edit declare `file_path` first and ask for it before `content` or `old_string`/`new_string` in the path parameter description. Read retains its leading `file_path`; other tools retain their argument order.
## Alternatives considered
**Parse inside a hook closure (a selector subscribing to the Step source).** Only the subscribing row sees the parsed result, so group titles need another subscription and each row owns parser lifetime. Definition-owned views share parsed fields across consumers.
**Keeping a per-tool field specification in the Definition (built-in table plus a runtime registry).** The Definition is a pure function without ctx, so a runtime registry could only reach it through a factory closure, and a third-party tool would register in two places, the slot and the registry; the specification only ever said which fields keep text, information that on-demand decoding from the raw text no longer needs.
**Third-party `partial-json`.** It reparses the cumulative input each frame and does not expose string completion separately from the decoded value.
**Eagerly parse every field.** Large fields such as `content` usually need only a length; eagerly materializing their text adds decoding and memory costs before a consumer asks for it. Observing the entire key list also republishes a block when an unused argument appears; detail keeps field-specific observations.
**Slice the normalized field for a truncated detail.** A V8 substring can retain the entire field. Joining the bounded selection of grapheme clusters keeps the displayed prefix independent of that source.
**Reorder `properties` only.** Measured 0/40 effective; the model follows the `required` order.
## Verification
- Parser tests cover lazy scanning, observed-answer changes, empty and repeated keys, split escapes, non-string values, invalid input, sealed views, and read-independent structural equality.
- Row and grouping tests cover streamed paths and descriptions, content length, shared row identity, name fallback only when arguments cannot grow, and at most 161 clusters per truncation pass without changing whitespace or Unicode handling.
- Negative controls restore read-time acknowledgement, repeated Match allocation, and short-text segmentation; four focused tests fail at their corresponding assertions and pass after restoration. Definitions retain independent State; Turn and Step records remain Session-owned.
- Additional controls eagerly parse unread containers, accept same-length conflicting text, or rebuild Assistant State for parameter-only deltas. Each fails its owning assertion; the boundary-index implementation passes after restoration.
The required [conversation-fold benchmark](../../../../benchmarks/conversation-fold/conversation-fold.bench.client.ts) drives the real Tool Definition, Assembler, Chat groups, and published write-row reads with 16-character fragments, flushing every 64 fragments. Three fresh compiled Node workers report all samples and their median; setup and forced GC are outside timing, and retained heap is measured with the Assembler and argument view still reachable. Row reads validate decoded progress and the file path; the worker also reports process peak RSS.
Local Chat-only measurements on 2026-09-29 compare the pre-feature baseline with the optimized implementation for 512 KiB write content and 32,769 body deltas. On Linux x64, AMD EPYC 7763, Node 24.18.0, median unprofiled processing time is 212.16 → 176.12 ms, cumulative allocation including collected objects is 132.06 → 76.65 MiB, and retained heap is 24.89 → 21.84 MiB. These measurements exclude model, network, browser rendering, and non-Chat targets; they do not establish whole-page or CI-runner latency.
Match calls remain 393,228; update calls rise from 98,307 to 131,076 because the Tool Definition consumes every argument delta. The baseline displays raw-length write progress rather than decoded argument reads.
The CI budgets remain 750/375 ms and 37.5/10 MiB: reference expectations of 300/150 ms use the shared 2× time scale and 1.25× headroom, while 30/8 MiB heap expectations use headroom only. Historical cumulative-indexing/full-segmentation controls take 3984.9–4035.5 ms for write and 1471.2–1494.9 ms for bash, exceeding those unchanged budgets.
## Consequences
- Observed argument changes replace preparing blocks; unread calls retain their block reference. Views retain source text, and group headers can request fields independently of tool rows. Started and settled views scan on first read without publishing updates.
- Schema order and description instructions encourage label-first generation but do not guarantee it. Rows show usable fields as they arrive; a late path delays its display and the accompanying content progress.
- Presentation remains Client-derived; argument scanning adds no Session event or persistence format.
- A stream whose name arrives after its arguments gives up scanning; that call's preparing row falls back to the title alone.
@@ -0,0 +1,88 @@
# Agent Note: 准备阶段的工具参数增量扫描与参数顺序
Status: implemented
[English](2026-09-24-preparing-tool-arguments.md) | 中文
## 问题
[三阶段工具调用](2026-09-22-tool-call-three-phases.zh.md)让工具身份在 `tool/call` 之前就可见。只有原始参数前缀不足以按字段展示:write/edit 需要完整路径和解码后的内容长度,bash/run_code 需要在命令或程序流入结束前显示描述。
模型生成参数的顺序也决定了什么能先显示。2026-09-24 用真实 DeepSeek V4 API 对 bash/run_code/write/edit 做了 310 次受控请求:
| 变体 | 改了什么 | bash 首键为 description | run_code 首键为 description |
|---|---|---|---|
| 现状 | — | 0/20 | 1/20 |
| 只调 `properties` 顺序 | description 提前 | 0/20 | 0/20 |
| `properties` + `required` 同调 | 同上并调 `required` | 强制 tool_choice 10/10;自由调用 pro 7/10、flash 0/10 | 强制 10/10;自由 20/20 |
| 同调 + 描述文本加一句顺序指令 | 追加「Provide `description` before `command` in the arguments.」 | 自由调用 flash 19/20、pro 20/20 | 20/20 |
在这些样本中,单调 `properties` 无效时,调整 `required` 能改变参数顺序;V4 Flash 自由调用 bash 还需要显式指令。`dsh-tools` 的 schema DSL 按属性声明顺序生成 `required`,所以改源码声明顺序会同时调整两者。探针使用极简系统提示词,不是完整 Harness 提示词。
## 决策
### 懒计算的参数视图住在 `dsh-util-values`
`PartialArguments` 是一个与工具无关的懒读器。它分别保留参数分片和顶层键值范围索引,或借用已解析的 PTC 参数。读取只索引新收到的边界;未请求的值不解码、不计数、不解析。请求字段时仅从该字段范围生成值,不需要每工具字段规格或注册表。
边界扫描器跨分片跟踪引号、反斜杠奇偶和嵌套括号配对。键名为查找而解码,值内容延后处理。`complete()` 表示结束分隔符已到达,不代表内容已校验;`invalid` 报告索引或内容读取已经发现的错误。内容错误不阻止后续字段建立索引。正式工具入参校验仍位于此展示读器之外。
被请求的无转义字符串长度直接由偏移量计算。转义字符串仅为已请求的长度、文本或有界前缀维护内容游标;上限查询在答案确定后停止。完整字符串首次被请求全文时使用原生解码。支持任意晚到的读取,需要保留原分片,直到权威完整文本替换它们。
变化由视图自己判定:`append(delta)` 只保留分片,`refresh()` 在发布时比较已观察的答案。没人读过时,不扫描也不报告变化。只有 refresh 推进已有答案的比较基线;中途读取不能吞掉其他消费方的更新。业务在读的时候说明粒度——`stringLength('content', { step: 1024 })` 让跨 KB 才算变化,`text('description')` 让每个字符都算变化,`textPrefix()` 将解码文本限制在请求的 UTF-16 前缀内。edit 进度合计新旧文本时,`offset` 计入已完成的字符串。
### 解析在 Tool Definition 层,不在 React
[Tool Definition](../../../../packages/client/ui-chat/src/client/conversation-nodes/tool.ts) 把每个带 id 的 `tool-call-delta` 都匹配为 start 候选:最早的一个打开 Context(无论是否带名),后续折叠为 update。带名 delta 创建 preparing 根并挂上一个新的流式视图;每个 delta 只做 `args.append(delta)`。在 `animation-frame` 发布点,`buildViewNode()` 刷新已观察答案,仅在变化时替换根块,否则复用当前已发布根块。Context 和已发布 Node 持有这些身份,不另建准备阶段缓存。参数在名字之前到达的流不扫描:迟开的视图看到非对象前缀即 invalid、零字段。
三个阶段的块都提供 `name` 和 `args`,原有 `argsRaw` 和 result 的 `call: { name, argsRaw } | null` 仍可使用。`block-end` 和 `tool/call` 通过长度及逐分片精确比较,将流式参数与权威全文核对。文本一致时封存原读器并保留字段索引,分片缺失或冲突时创建新的 `fromText()` 视图。不需要累加字符串或概率性 hash。PTC 读器使用 `fromObject(payload)`,结果复用已派发读器,未配对结果使用共享空视图。卡片模型继续读取最终 `argsRaw`。
Assistant 块保留工具身份和首 token 时间,不保留参数 delta。完整参数文本来自 `block-end` 或持久消息。纯参数 delta 保持 Assistant State 不变,preparing 读取与通知由 Tool Definition 负责。
Assembler 为同一输入和生命周期角色共享一个不可变 Match 及其 Location,各 Definition 仍保留独立 State。空依赖集合无需执行回放。
工具行在各阶段使用相同读器:read/write/edit 在 `file_path` 闭合后显示可打开的路径;write/edit 在内容流入时按 1024 字符单位显示解码后长度;bash/pwsh/run_code 显示描述前缀。Chat 分组详情按既有字段优先级读取同一视图。没有可用详情时,只要字段还可能到达就留空;`closed()` 为真后才回退工具名,所有类别一致。
分组详情先规范化 512 单元的解码前缀,仅当空白或多单元字素簇使其无法判定 160 字素簇的结果时才扩大前缀。前缀填满后,字段继续增长不会改变它,但稍后出现的更高优先级字段仍会发布新详情。字符串长度检查不会物化全文。详情不超过 160 个 UTF-16 单元时,无需遍历字素。
三方工具无需注册或独立订阅就能取得同一视图。write/edit 和 Bash 在各阶段共用组件。可变参数读器是 owner 数据要求 JSON 兼容的一项限定例外:已读取答案变化时,所属 Definition 发布新的块引用。扫描缓存使用私有字段,同源视图的结构比较不受读取历史影响。
### 参数声明顺序
`tool-bash`、`tool-pwsh` 在 `command` 前声明 `description`,并包含顺序指令。`run_code` 两处 `parameters` 将 `description` 放在 `code` 前,共用的参数描述要求 TypeScript 和 Python 都按此顺序生成。write/edit 首先声明 `file_path`,并在路径参数描述中要求先生成它,再生成 `content` 或 `old_string`/`new_string`。read 保留首位的 `file_path`;其他工具保留原有参数顺序。
## 考虑过的替代方案
**在 hook 闭包里解析(选择器订阅 Step source)。** 只有订阅行能看到解析结果,分组标题需要另一份订阅,每行还要负责解析器生命周期。Definition 持有的视图让消费方共享已解析字段。
**在 Definition 里维护每工具字段规格(内建表 + 运行时注册表)。** Definition 是纯函数、拿不到 ctx,运行时注册表只能经工厂闭包注入,三方工具要在坑位与注册表两处登记;而规格表本质只是"哪些字段保留文本",懒计算按需从原始文本切出原文后这条信息不再需要。
**第三方 `partial-json`。** 每帧重解析累计输入,且不独立于解码值暴露字符串完成状态。
**提前解析所有字段。** `content` 等大字段通常只需要长度;在消费方请求前构造全文,会增加解码和内存成本。观察整个键列表还会在无关参数出现时重新发布块,因此详情保持逐字段观察。
**从规范化字段切出截断详情。** V8 的子字符串可能保留整个字段。拼接有界数量的字素簇,使展示前缀不再依赖该原文。
**只调 `properties` 顺序。** 实测 0/40 生效;模型跟随 `required` 顺序。
## 验证
- 解析器测试覆盖懒扫描、已读取答案变化、空键与重复键、跨片转义、非字符串值、非法输入、封存视图,以及不受读取历史影响的结构相等。
- 工具行和分组测试覆盖流式路径与描述、内容长度、共用行身份、参数不再增长后才回退工具名,以及不改变空白或 Unicode 处理语义的每次截断遍历最多 161 个字素簇。
- 负控恢复读取时确认变化、重复分配 Match 和短文本字素遍历;四项聚焦测试在对应断言失败,恢复后通过。各 Definition 保留独立 State,Turn 与 Step 记录仍归各 Session 所有。
- 另有负控提前解析未读容器、接受同长度的冲突文本,或为纯参数 delta 重建 Assistant State。每项都在所属断言失败,恢复边界索引实现后通过。
必跑的 [conversation-fold benchmark](../../../../benchmarks/conversation-fold/conversation-fold.bench.client.ts) 使用真实 Tool Definition、Assembler、Chat 分组及发布后的 write 行读取,以 16 字符分片输入,每 64 片 flush。三个独立的已编译 Node worker 报告全部样本及中位数;准备工作和显式 GC 不计时,保留 Assembler 与参数视图后测量驻留堆。行读取校验解码进度与文件路径;worker 还报告进程峰值 RSS。
2026-09-29 的本地 Chat 专项测量,以 512 KiB write 内容和 32,769 个正文 delta 对比功能改造前基线与优化实现。在 Linux x64、AMD EPYC 7763、Node 24.18.0 上,无探针处理耗时中位数为 212.16 → 176.12 ms,包含已回收对象的累计分配为 132.06 → 76.65 MiB,驻留堆为 24.89 → 21.84 MiB。测量不含模型、网络、浏览器渲染和非 Chat 目标,不代表整页或 CI runner 延迟。
Match 调用保持 393,228 次;update 从 98,307 增至 131,076 次,因为 Tool Definition 消费每个参数 delta。基线按原文长度显示 write 进度,不读取解码后的参数。
CI 预算保持 750/375 ms 和 37.5/10 MiB:300/150 ms 参考预期使用共享的 2× 时间倍率和 1.25× 余量,30/8 MiB 堆预期只使用余量。历史累计索引/全文分段负控中,write 耗时为 3984.9–4035.5 ms,bash 为 1471.2–1494.9 ms,均超过未调整的预算。
## 影响
- 已读取参数变化时替换准备态块,无人读取的调用保留块引用。视图保留源文本,分组标题可以独立于工具行请求字段。已派发和已结算视图在首次读取时扫描,不发布更新。
- schema 顺序和描述指令引导模型先生成展示标签,但不保证生成顺序。工具行在字段可用时显示它;路径晚到会推迟路径及其后的内容进度展示。
- 展示仍由 Client 派生;参数扫描不新增 Session 事件或持久化格式。
- 名字晚于参数到达的流放弃扫描,该调用的准备行退回只显示标题。
@@ -18,8 +18,8 @@
en: d4d6aebafcdb8c12
zh: 112d3ca1a4ea8ae1
/agent-note-frontend-large-session-performance-budgets/calibration/standard-hosted-expectations-and-input-scheduling:
en: 8cb6299dbb42a9fe
zh: 51ef49d4fcaeb92f
en: 346cd1b3d5acf0ea
zh: 42e08cd387f982c8
/agent-note-frontend-large-session-performance-budgets/alternatives-considered:
en: 1472bd425ef0daec
zh: a89811758d70331e
@@ -58,7 +58,9 @@ All six browser samples report `inputOverlapped: true` and finish after the 241s
### Standard hosted expectations and input scheduling
Run 34033336246, job 101487216170 on standard hosted Ubuntu with two CPUs records reconnect replacements of 46.574411, 46.067910, and 44.193704 ms, with 23.028 MiB retained heap. The endpoint-specific expectation is 50 ms; the existing 1.25× headroom gives a 63 ms integer ceiling. The 30 MiB memory budget and shared machine factor remain unchanged. Browser open records 681.276514 and 541.051233 ms before the third sample fails input overlap; both exceed the historical 500 ms limit. Repeated hosted open measurements below set its expectation and ceiling. Deterministic controls pass these recorded values and reject values above the new ceilings through the same assertions as the measured verdicts. Complete repeated hosted verdicts remain required; the two open values are not a three-sample median.
Reconnect uses a 50 ms reference-machine allowance through the shared 2× CI time scale and 1.25× headroom, giving a 125 ms ceiling. Two isolated three-sample runs on Linux x64 EPYC 7763 / Node 24.18.0 measure 47.13 and 43.08 ms medians. Hosted Node 24.21.0 runs 36619857675 and 36621172285 measure 55.47 ms on Xeon 8573C and 65.69 ms on EPYC 9V74 with two available CPUs; their measured production path is unchanged. The latter run is 1.39–1.53× the local reconnect medians; its preparing-write and preparing-bash medians are 1.54–1.55× and 1.34–1.42× their local counterparts. The shared 2× factor covers these observed ratios; the raw samples do not isolate hardware from Node-version or scheduling effects. Both local and hosted reconnect runs retain 23.03 MiB, so the 30 MiB memory ceiling is unchanged. The same time assertion accepts recorded hosted medians, rejects the 65.69 ms median at the former 63 ms ceiling, and rejects synthetic 126 and 150 ms values at 125 ms. This is budget calibration, not a product speedup or a passing future CI verdict.
Run 34033336246, job 101487216170 on standard hosted Ubuntu with two CPUs records reconnect replacements of 46.574411, 46.067910, and 44.193704 ms, with 23.028 MiB retained heap. Browser open records 681.276514 and 541.051233 ms before the third sample fails input overlap; both exceed the historical 500 ms limit. Repeated hosted open measurements below set its expectation and ceiling. Deterministic controls pass these recorded values and reject values above the new ceilings through the same assertions as the measured verdicts. Complete repeated hosted verdicts remain required; the two open values are not a three-sample median.
A local diagnostic with temporary 3× Chromium CPU throttling reproduces the overlap failure: the first marker becomes visible at 1321 ms, two animation frames finish at 1370 ms, and the composer click finishes at 1660 ms; the actual input is trusted but already sees DONE. Removing the frame wait and installing the witness before Send still leaves a run with first visibility at 1415 ms and click completion at 1726 ms, after the original 992 ms scripted stream. The fixed 16 ms cadence keeps the same 120 deltas and payload, providing 1984 ms of scripted pacing for this workload. Only that pacing term changes in the complete-wall allowance (4484 ms); input, first-reply, and main-thread overhead allowances remain unchanged. With the same diagnostic slowdown, three 16 ms samples reach first visibility at 1307/1479/1599 ms and accept trusted input before DONE; their post-DONE controls reject it. The diagnostic is not a CPU-ratio calibration. Each measured sample still requires trusted input while FIRST is present and DONE absent; a post-measurement trusted key after DONE must fail that same assertion. Host settlement and the 241st rendered turn-tail remain completion witnesses.
@@ -58,7 +58,9 @@ Node 对话折叠很快,并不能证明浏览器能绘制长对话或在流式
### 标准托管预期与输入调度
运行 34033336246,job 101487216170 在双 CPU 标准托管 Ubuntu 上记录重连替换时间 46.574411、46.067910 和 44.193704 ms,保留 heap 为 23.028 MiB。该终点的预期为 50 ms;现有 1.25× 余量产生向上取整后的 63 ms 上限。30 MiB 内存预算及共享机器倍率不变。浏览器打开记录 681.276514 和 541.051233 ms,第三个样本因输入重叠失败而中止;两个值均超过历史 500 ms 上限。下文的托管打开重复测量决定其预期与上限。确定性对照通过这些记录值,并使用与测量判定相同的断言拒绝超过新上限的值。仍需完整的托管重复运行判定;这两个打开值不是三样本中位数。
重连使用 50 ms 参考机器额度,通过共享的 2× CI 时间倍率与 1.25× 余量得到 125 ms 上限。Linux x64 EPYC 7763 / Node 24.18.0 上两轮独立三样本运行的中位数为 47.13 和 43.08 ms。托管 Node 24.21.0 运行 36619857675 与 36621172285 均有两个可用 CPU,分别在 Xeon 8573C 和 EPYC 9V74 上测得 55.47 与 65.69 ms;两次被测的生产路径没有变化。后一轮为本地重连中位数的 1.39–1.53×,其中 preparing-write 与 preparing-bash 中位数分别为本地对应值的 1.54–1.55× 和 1.34–1.42×。共享 2× 系数覆盖这些实测比例;原始样本未区分硬件、Node 版本或调度各自的影响。本地和托管重连都保留 23.03 MiB,因此 30 MiB 内存上限不变。同一个时间断言接受托管记录中位数,在旧 63 ms 上限下拒绝 65.69 ms 中位数,并在 125 ms 上限下拒绝合成的 126 与 150 ms 值。这是预算校准,不代表产品提速,也不代表后续 CI 已通过。
运行 34033336246,job 101487216170 在双 CPU 标准托管 Ubuntu 上记录重连替换时间 46.574411、46.067910 和 44.193704 ms,保留 heap 为 23.028 MiB。浏览器打开记录 681.276514 和 541.051233 ms,第三个样本因输入重叠失败而中止;两个值均超过历史 500 ms 上限。下文的托管打开重复测量决定其预期与上限。确定性对照通过这些记录值,并使用与测量判定相同的断言拒绝超过新上限的值。仍需完整的托管重复运行判定;这两个打开值不是三样本中位数。
临时使用 3× Chromium CPU 降速的本地诊断复现重叠失败:首个标记在 1321 ms 可见,两次动画帧在 1370 ms 结束,输入框点击在 1660 ms 完成;实际输入是真实事件,但已看到 DONE。移除帧等待并在发送前安装观察器后,一次运行仍在 1415 ms 才看到首个标记,点击在 1726 ms 完成,晚于原先 992 ms 的脚本流。固定 16 ms 节奏保留相同的 120 个 delta 和负载,为该工作负载提供 1984 ms 脚本节奏。完整壁钟额度仅改变该节奏项(4484 ms);输入、首段回复及主线程额外开销额度不变。在相同诊断降速下,三个 16 ms 样本在 1307/1479/1599 ms 达到首段可见状态,并接受 DONE 之前的真实输入;其 DONE 之后的对照拒绝该输入。该诊断不是 CPU 比率校准。每个测量样本仍要求真实输入发生时 FIRST 存在且 DONE 不存在;测量后在 DONE 之后发送的真实按键必须无法通过同一个断言。Host 结算和第 241 个已渲染 turn-tail 仍是完成证据。
+1
View File
@@ -46,6 +46,7 @@
"@deepseek-ai/dsh-experimental-webworker-packer": "workspace:*",
"@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:*",
"@deepseek-ai/dsh-remote-mock": "workspace:*",
"@deepseek-ai/dsh-util-values": "workspace:*",
"@types/node": "^22.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
+39 -10
View File
@@ -9,6 +9,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFinished } from 'vites
import type {} from '@deepseek-ai/dsh-workspace-changes'
import type { ChangesSummary } from '@deepseek-ai/dsh-client-ui-deliverables/src/changes.ts'
import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
import {
assertFinalWorkspaceSnapshot, captureExpandedTurnProcessAria, compareOrRefreshGolden,
fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole,
@@ -76,7 +77,7 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
}
scaffold = await launchWebScaffold({
developerTools: false,
compareReplaySession: true,
compareReplaySession: 'read-only',
extraOverlayPath: fileURLToPath(new URL('./changed-files-turn.overlay.yml', import.meta.url)),
...(replayOverride === undefined ? {} : { replayFixture: FIXTURE, replayOverride }),
})
@@ -109,8 +110,11 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
const settled = scaffold.whenTurnSettled()
const preparations = ['edit', 'write'].map(name => ({
name, ready: Promise.withResolvers<{ callId: string; kilobytes: number }>(),
name, callId: '', args: new PartialArguments(), ready: Promise.withResolvers<{ callId: string }>(),
release: Promise.withResolvers<undefined>(), held: false,
contentField: name === 'write' ? 'content' : 'old_string',
contentReady: Promise.withResolvers<undefined>(), contentRelease: Promise.withResolvers<undefined>(),
contentHeld: false,
}))
const names = new Map<string, string>()
const dispose = scaffold.ctx.on('llm/stream', async function* (_options, next) {
@@ -119,32 +123,57 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
if (chunk.type !== 'tool-call-delta') continue
if (chunk.name !== undefined) names.set(chunk.id, chunk.name)
const preparation = preparations.find(value => value.name === names.get(chunk.id))
if (preparation === undefined || preparation.held || chunk.argumentsDelta.length === 0) continue
preparation.held = true
preparation.ready.resolve({ callId: chunk.id, kilobytes: Math.ceil(chunk.argumentsDelta.length / 1024) })
await preparation.release.promise
if (preparation === undefined || preparation.contentHeld || chunk.argumentsDelta.length === 0) continue
if (preparation.callId === '') preparation.callId = chunk.id
if (preparation.callId !== chunk.id) continue
preparation.args.append(chunk.argumentsDelta)
if (!preparation.held) {
if (!preparation.args.complete('file_path')) continue
preparation.held = true
preparation.ready.resolve({ callId: chunk.id })
await preparation.release.promise
}
if (!preparation.args.has(preparation.contentField) || preparation.args.complete(preparation.contentField)) continue
const length = preparation.args.stringLength(preparation.contentField)
if (length === undefined || length === 0) continue
preparation.contentHeld = true
preparation.contentReady.resolve(undefined)
await preparation.contentRelease.promise
}
}, { prepend: true })
releasePreparations = () => {
for (const preparation of preparations) preparation.release.resolve(undefined)
for (const preparation of preparations) {
preparation.release.resolve(undefined)
preparation.contentRelease.resolve(undefined)
}
dispose()
}
const input = page.locator('[data-composer-input]').first()
await input.fill(PROMPT)
await input.press('Enter')
const observations = preparations.map(async (preparation) => {
const { callId, kilobytes } = await Promise.race([
const { callId } = await Promise.race([
preparation.ready.promise,
settled.then(() => { throw new Error(`No ${preparation.name} argument prefix was streamed`) }),
])
const row = page.locator(`[data-chat-call-id="${callId}"] [data-state="preparing"]`)
await row.waitFor({ state: 'attached' })
await expandOwningTurnProcess(page, row)
await row.getByText(`正在准备内容 ${kilobytes}KB`, { exact: true }).waitFor()
expect(await row.getByRole('button').count()).toBe(0)
await row.getByRole('button').waitFor()
expect(await row.locator('[aria-expanded]').count()).toBe(0)
expect(await row.locator('pre').count()).toBe(0)
expect(await row.getByText(/^\d+KB$/).count()).toBe(0)
await compareOrRefreshGolden(join(DIR, `preparing-${preparation.name}.expected.md`), await row.ariaSnapshot(), MODE)
preparation.release.resolve(undefined)
await Promise.race([
preparation.contentReady.promise,
settled.then(() => { throw new Error(`No open ${preparation.name} content prefix was streamed`) }),
])
await row.getByText('1KB', { exact: true }).waitFor()
expect(await row.locator('[aria-expanded]').count()).toBe(0)
expect(await row.locator('pre').count()).toBe(0)
await compareOrRefreshGolden(join(DIR, `preparing-${preparation.name}-content.expected.md`), await row.ariaSnapshot(), MODE)
preparation.contentRelease.resolve(undefined)
})
const [sessionId] = await Promise.all([settled, ...observations]).finally(() => { releasePreparations?.() })
const session = scaffold.ctx.agents.get(sessionId)?.session
@@ -10,30 +10,30 @@
- status: Completed
- button "Completed in {{duration}}" [expanded]
- button "Wrote files, called tools, searched code, etc." [expanded]
- button "Write site/report.html +1 -0":
- button "Write site/report.html 1KB +1 -0":
- text: Write
- button "site/report.html"
- text: +1 -0
- button "Write a/style.css +1 -0":
- text: 1KB +1 -0
- button "Write a/style.css 1KB +1 -0":
- text: Write
- button "a/style.css"
- text: +1 -0
- button "Write b/style.css +1 -0":
- text: 1KB +1 -0
- button "Write b/style.css 1KB +1 -0":
- text: Write
- button "b/style.css"
- text: +1 -0
- button "Write site/index.html +1 -0":
- text: 1KB +1 -0
- button "Write site/index.html 1KB +1 -0":
- text: Write
- button "site/index.html"
- text: +1 -0
- button "Write site/app.js +1 -0":
- text: 1KB +1 -0
- button "Write site/app.js 1KB +1 -0":
- text: Write
- button "site/app.js"
- text: +1 -0
- button "Edit src/tokens.css +1 -1" [expanded]:
- text: 1KB +1 -0
- button "Edit src/tokens.css 1KB +1 -1" [expanded]:
- text: Edit
- button "src/tokens.css"
- text: +1 -1
- text: 1KB +1 -1
- text: css
- button "Wrap lines"
- button "Copy"
@@ -3,5 +3,5 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write benchmarks/active-stream-reconnect/README.md
/active-assistant-reconnect-benchmark:
en: 74be7a07bbc676b8
zh: e6aeac1f27343c24
en: 3fe52f5c243a6fde
zh: 89c0c51aa79f0604
+1 -1
View File
@@ -2,6 +2,6 @@
English | [中文](README.zh.md)
[reconnect.bench.client.ts](reconnect.bench.client.ts) measures the production Client fold when a reconnect carries an unfinished 100,000-delta reasoning prefix. A compiled private adapter reaches `ClientAssistantStream.replace()` without adding product exports. Three fresh plain-Node workers synthesize the compact baseline before timing; replacement time and retained heap after forced GC have separate median budgets. The next dense live frame must still be accepted. Standard hosted CI uses a 50 ms replacement expectation with the shared 1.25× headroom (63 ms ceiling); the retained-heap budget remains 30 MiB. Recorded-sample and synthetic-regression controls exercise the same time assertion as the worker verdict.
[reconnect.bench.client.ts](reconnect.bench.client.ts) measures the production Client fold when a reconnect carries an unfinished 100,000-delta reasoning prefix. A compiled private adapter reaches `ClientAssistantStream.replace()` without adding product exports. Three fresh plain-Node workers synthesize the compact baseline before timing; replacement time and retained heap after forced GC have separate median budgets. The next dense live frame must still be accepted. The 50 ms reference-machine expectation uses the shared 2× CI time scale and 1.25× headroom, giving a 125 ms ceiling; the retained-heap budget remains 30 MiB without time scaling. Recorded-sample and synthetic-regression controls exercise the same time assertion as the worker verdict.
Build with `pnpm run build:bench`, then select `benchmarks/active-stream-reconnect` in `vitest.bench.config.ts`. This focused Node workload neither builds nor measures browser rendering. [Frontend performance budgets](../../.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md) records calibration and exclusions.
@@ -2,6 +2,6 @@
[English](README.md) | 中文
[reconnect.bench.client.ts](reconnect.bench.client.ts) 测量重连携带一个包含 100,000 个 delta、尚未完成的 reasoning 前缀时,生产 Client 的折叠成本。编译后的私有适配器调用 `ClientAssistantStream.replace()`,不增加产品导出。三个全新的纯 Node worker 在计时前合成紧凑基线;替换时间与强制 GC 后的保留堆分别执行中位数预算检查。下一个稠密序号的实时 frame 仍须被接受。标准托管 CI 使用 50 ms 替换预期及共享的 1.25× 余量(上限为 63 ms);保留 heap 预算仍为 30 MiB。实测样本和合成回归对照使用与 worker 判定相同的时间断言。
[reconnect.bench.client.ts](reconnect.bench.client.ts) 测量重连携带一个包含 100,000 个 delta、尚未完成的 reasoning 前缀时,生产 Client 的折叠成本。编译后的私有适配器调用 `ClientAssistantStream.replace()`,不增加产品导出。三个全新的纯 Node worker 在计时前合成紧凑基线;替换时间与强制 GC 后的保留堆分别执行中位数预算检查。下一个稠密序号的实时 frame 仍须被接受。50 ms 参考机器预期使用共享的 2× CI 时间倍率与 1.25× 余量,得到 125 ms 上限;保留 heap 预算仍为 30 MiB,不应用时间倍率。实测样本和合成回归对照使用与 worker 判定相同的时间断言。
通过 `pnpm run build:bench` 构建,再在 `vitest.bench.config.ts` 中选择 `benchmarks/active-stream-reconnect`。这项针对 Node 的工作负载既不构建也不测量浏览器渲染。[前端性能预算](../../.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md)记录校准与排除项。
@@ -5,8 +5,8 @@ import { runBuiltBenchmarkWorker } from '../support/built-worker.ts'
import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts'
import type { ReconnectReport } from './reconnect.worker.client.ts'
const EXPECTED_REPLACE_CI_MS = 50
const REPLACE_BUDGET_MS = Math.ceil(EXPECTED_REPLACE_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
const REFERENCE_REPLACE_MS = 50
const REPLACE_BUDGET_MS = ciTimeBudget(REFERENCE_REPLACE_MS)
const REFERENCE_RETAINED_MB = 24
const SAMPLES = 3
@@ -15,11 +15,17 @@ function expectReplacementWithinBudget(value: number, budget: number): void {
}
it('accepts recorded hosted reconnect samples and rejects replacement regressions', () => {
const recordedMedian = [46.574411, 46.067910, 44.193704].toSorted((a, b) => a - b)[1]!
expect(() => expectReplacementWithinBudget(recordedMedian, ciTimeBudget(16))).toThrow()
expectReplacementWithinBudget(recordedMedian, REPLACE_BUDGET_MS)
expect(REPLACE_BUDGET_MS).toBe(63)
expect(() => expectReplacementWithinBudget(75, REPLACE_BUDGET_MS)).toThrow()
const recordedSamples = [
[46.574411, 46.067910, 44.193704],
[55.995471, 55.187582, 55.474573],
[64.954055, 67.441804, 65.694876],
]
const recordedMedians = recordedSamples.map(samples => samples.toSorted((a, b) => a - b)[1]!)
expect(() => expectReplacementWithinBudget(recordedMedians[0]!, ciTimeBudget(16))).toThrow()
expect(() => expectReplacementWithinBudget(recordedMedians[2]!, 63)).toThrow()
for (const median of recordedMedians) expectReplacementWithinBudget(median, REPLACE_BUDGET_MS)
expect(REPLACE_BUDGET_MS).toBe(125)
expect(() => expectReplacementWithinBudget(150, REPLACE_BUDGET_MS)).toThrow()
expect(() => expectReplacementWithinBudget(REPLACE_BUDGET_MS + 1, REPLACE_BUDGET_MS)).toThrow()
})
@@ -42,7 +48,7 @@ it('reconstructs a 100000-delta live prefix within baseline time and retained-me
const retainedMb = samples.map(sample => sample.retainedMb).toSorted((a, b) => a - b)[1]!
const budgetMs = REPLACE_BUDGET_MS
const budgetMb = REFERENCE_RETAINED_MB * PERFORMANCE_BUDGET_HEADROOM
console.log(JSON.stringify({ benchmark: 'active-stream-reconnect', samples, median: { replaceMs, retainedMb }, expectedCiMs: EXPECTED_REPLACE_CI_MS, referenceMb: REFERENCE_RETAINED_MB, budgetMs, budgetMb }))
console.log(JSON.stringify({ benchmark: 'active-stream-reconnect', samples, median: { replaceMs, retainedMb }, referenceMs: REFERENCE_REPLACE_MS, referenceMb: REFERENCE_RETAINED_MB, budgetMs, budgetMb }))
expectReplacementWithinBudget(replaceMs, budgetMs)
expect.soft(retainedMb).toBeLessThanOrEqual(budgetMb)
})
@@ -1,4 +1,4 @@
/** Required performance budget for the compiled cold Client conversation fold. */
/** Required performance budgets for compiled Client history folding and tool preparation. */
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
@@ -10,7 +10,7 @@ import {
ciTimeBudget,
PERFORMANCE_BUDGET_HEADROOM,
} from '../support/calibration.ts'
import type { ConversationFoldWorkerReport } from './conversation-fold.worker.client.ts'
import type { ConversationFoldWorkerReport, PreparingToolWorkerReport } from './conversation-fold.worker.client.ts'
/** Replies in the folded window; each carries one reasoning block and one text block. */
const TURNS = 200
@@ -39,6 +39,12 @@ const LARGE_FOLD_BUDGET_MS = ciTimeBudget(EXPECTED_LARGE_FOLD_MS)
const EXPECTED_DELTA_SCALING = 2.5
const MAX_DELTA_SCALING = EXPECTED_DELTA_SCALING * PERFORMANCE_BUDGET_HEADROOM
const PREPARING_SAMPLES = 3
const PREPARING_CASES = [
{ tool: 'write', characters: 512 * 1024, expectedMs: 300, expectedMb: 30 },
{ tool: 'bash', characters: 128 * 1024, expectedMs: 150, expectedMb: 8 },
] as const
const WORKER = join(
import.meta.dirname,
'..',
@@ -47,9 +53,9 @@ const WORKER = join(
'conversation-fold.worker.js',
)
function requireReport(
run: BuiltBenchmarkWorkerRun<ConversationFoldWorkerReport>,
): ConversationFoldWorkerReport {
function requireReport<Report>(
run: BuiltBenchmarkWorkerRun<Report>,
): Report {
if (run.report !== undefined) return run.report
const stderrLines = run.stderr.trim().split('\n')
throw new Error(
@@ -76,3 +82,33 @@ describe('cold Chat fold of a large v2 history window', () => {
expect(report.scaling).toBeLessThanOrEqual(MAX_DELTA_SCALING)
})
})
describe('preparing tool arguments', () => {
it.each(PREPARING_CASES)('publishes streamed $tool arguments within bounded CPU and heap costs', async (workload) => {
const samples: PreparingToolWorkerReport[] = []
for (let index = 0; index < PREPARING_SAMPLES; index++) {
const run = await runBuiltBenchmarkWorker<PreparingToolWorkerReport>({
worker: WORKER, args: ['preparing', workload.tool, String(workload.characters)],
exposeGc: true, timeoutMs: WORKER_TIMEOUT_MS,
})
expect(run.timedOut, run.stderr).toBe(false)
expect(run.signal, run.stderr).toBeNull()
expect(run.exitCode, run.stderr).toBe(0)
const report = requireReport(run)
expect(report.characters).toBe(workload.characters)
if (workload.tool === 'write') {
expect(report.progressKb).toBe(workload.characters / 1024)
expect(report.filePath).toBe('preview.md')
}
expect(report.detail).toBe(workload.tool === 'write' ? 'preview.md' : `${'abcdefghijklmno '.repeat(10).slice(0, 159)}…`)
samples.push(report)
}
const medianMs = samples.map(sample => sample.elapsedMs).toSorted((a, b) => a - b)[1]!
const retainedMb = samples.map(sample => sample.retainedMb).toSorted((a, b) => a - b)[1]!
const budgetMs = ciTimeBudget(workload.expectedMs)
const budgetMb = workload.expectedMb * PERFORMANCE_BUDGET_HEADROOM
console.log(JSON.stringify({ benchmark: `conversation-fold/preparing-${workload.tool}`, samples, medianMs, retainedMb, budgetMs, budgetMb }))
expect(medianMs).toBeLessThanOrEqual(budgetMs)
expect(retainedMb).toBeLessThanOrEqual(budgetMb)
})
})
@@ -1,32 +1,33 @@
/** Compiled worker for the cold Client conversation-fold benchmark. */
/** Compiled worker for Client history folding and live tool preparation. */
import { performance } from 'node:perf_hooks'
import { Context } from '@deepseek-ai/cordis'
import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream'
import { LlmAttemptId, ToolCallId } from '@deepseek-ai/dsh-llm/brand'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { ChatNode, ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { SessionEventLikeEntry } from '@deepseek-ai/dsh-api-session-controller/client'
// These Client-only fold modules have no plain-Node package export and are compiled into this worker.
import { ConversationNodeAssembler } from '../../packages/client/ui-conversation/src/client/conversation/assembler.ts'
import { ConversationEventRegistry } from '../../packages/client/ui-conversation/src/client/conversation/event-registry.ts'
import { inspectRequestPrompt } from '../../packages/client/ui-conversation/src/client/contract/request-inspection.ts'
import type {
ConversationNodeDefinition,
ConversationViewDefinition,
} from '../../packages/client/ui-conversation/src/client/contract/conversation.ts'
import { assistantDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/assistant.ts'
import type { ConversationViewDefinition } from '../../packages/client/ui-conversation/src/client/contract/conversation.ts'
import { registerAssistantConversationNode } from '../../packages/client/ui-chat/src/client/conversation-nodes/assistant.ts'
import { chatViewDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts'
import { commandDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/command.ts'
import { compactionDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/compaction.ts'
import { unknownFallbackDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/fallback.ts'
import { nextStepInboxDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts'
import { messageDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/message.ts'
import { processGroupDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts'
import { requestPromptDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts'
import { retryDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/retry.ts'
import { toolDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/tool.ts'
import { registerToolConversationNode } from '../../packages/client/ui-chat/src/client/conversation-nodes/tool.ts'
import { turnErrorDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts'
import { turnMaxTokensDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts'
import { turnProcessDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts'
import { turnTailDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts'
import { registerTurnProcess } from '../../packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts'
import { registerTurnTailConversationNode } from '../../packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts'
import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts'
const TIME_ZERO = 1_700_000_000_000
@@ -42,29 +43,38 @@ export interface ConversationFoldWorkerReport {
readonly scaling: number
}
class BenchEventDefinitions {
readonly definitions: readonly ConversationNodeDefinition[] = [
nextStepInboxDefinition,
messageDefinition,
requestPromptDefinition(inspectRequestPrompt),
assistantDefinition,
turnProcessDefinition,
toolDefinition,
commandDefinition,
compactionDefinition,
retryDefinition,
turnErrorDefinition,
turnMaxTokensDefinition,
turnTailDefinition,
]
/** Incremental argument folding through the Tool Definition and Chat group publication. */
export interface PreparingToolWorkerReport {
readonly tool: 'write' | 'bash'
readonly characters: number | undefined
readonly fragments: number
readonly elapsedMs: number
readonly retainedMb: number
readonly maxRssMb: number
readonly rowReads: number
readonly progressKb: number | undefined
readonly filePath: string | undefined
readonly detail: string | undefined
}
entries(): readonly ConversationNodeDefinition[] {
return this.definitions
}
fallbackEntry(): ConversationNodeDefinition {
return unknownFallbackDefinition
}
function registerEventDefinitions(ctx: Context): ConversationEventRegistry {
const events = new ConversationEventRegistry(ctx)
// Only the service holder is an adapter; registrations and routing use production code.
Object.defineProperty(ctx, 'uiConversation', { value: { events } })
events.register(nextStepInboxDefinition)
events.register(messageDefinition)
events.register(requestPromptDefinition(inspectRequestPrompt))
registerAssistantConversationNode(ctx)
registerTurnProcess(ctx)
registerToolConversationNode(ctx)
events.register(commandDefinition)
events.register(compactionDefinition)
events.register(retryDefinition)
events.register(turnErrorDefinition)
events.register(turnMaxTokensDefinition)
registerTurnTailConversationNode(ctx)
events.registerFallback(unknownFallbackDefinition)
return events
}
class BenchViewDefinitions {
@@ -146,9 +156,9 @@ function synthesizeWindow(
return { entries, records }
}
function foldOnce(entries: readonly SessionEventLikeEntry[]): { readonly ms: number; readonly nodes: number } {
function foldOnce(entries: readonly SessionEventLikeEntry[], definitions: ConversationEventRegistry): { readonly ms: number; readonly nodes: number } {
const started = performance.now()
const assembler = new ConversationNodeAssembler(new BenchEventDefinitions(), new BenchViewDefinitions())
const assembler = new ConversationNodeAssembler(definitions, new BenchViewDefinitions())
assembler.replaceWindow(entries, false)
assembler.activateTarget('chat')
const snapshot = assembler.snapshot('chat') as ChatSnapshot | undefined
@@ -158,10 +168,11 @@ function foldOnce(entries: readonly SessionEventLikeEntry[]): { readonly ms: num
function bestOf(
entries: readonly SessionEventLikeEntry[],
attempts: number,
definitions: ConversationEventRegistry,
): { readonly ms: number; readonly nodes: number } {
let best = foldOnce(entries)
let best = foldOnce(entries, definitions)
for (let attempt = 1; attempt < attempts; attempt += 1) {
const next = foldOnce(entries)
const next = foldOnce(entries, definitions)
if (next.ms < best.ms) best = next
}
return best
@@ -173,31 +184,119 @@ function positiveInteger(value: string | undefined, label: string): number {
return parsed
}
function preparingTool(tool: 'write' | 'bash', characters: number, definitions: ConversationEventRegistry): PreparingToolWorkerReport {
if (globalThis.gc === undefined) throw new Error('preparing benchmark requires --expose-gc')
const chunkSize = 16
const chunksPerPublication = 64
const payload = 'abcdefghijklmno '.repeat(Math.ceil(characters / chunkSize)).slice(0, characters)
const tail = `${payload}"}`
const callId = ToolCallId('preparing-benchmark')
const attemptId = LlmAttemptId('preparing-benchmark')
const delta = (index: number, argumentsDelta: string): SessionEventLikeEntry => ({
type: 'transient',
event: {
type: 'assistant/live-chunk', seq: 2 + index / (tail.length + 1), time: TIME_ZERO + index,
data: { turn: 1, step: 1, attemptId, chunk: {
type: 'tool-call-delta', index: 0, id: callId, argumentsDelta,
...index === 0 ? { name: tool } : {},
} },
},
})
const chunks: SessionEventLikeEntry[] = []
for (let at = 0; at < tail.length; at += chunkSize) chunks.push(delta(at + 1, tail.slice(at, at + chunkSize)))
const assembler = new ConversationNodeAssembler(
definitions, new BenchViewDefinitions(),
{ entries: () => [processGroupDefinition], forTarget: target => target === 'chat' ? processGroupDefinition : undefined },
)
assembler.replaceWindow([
entry(0, 'turn/start', { turn: 1 }),
entry(1, 'step/start', { turn: 1, step: 1 }),
delta(0, tool === 'write' ? '{"file_path":"preview.md","content":"' : '{"command":"'),
], false)
assembler.activateTarget('chat')
const snapshot = assembler.get('chat')
const node = snapshot?.nodes.values().find((candidate): candidate is ChatNode<'tool-call'> => candidate.kind === 'tool-call')
if (snapshot === undefined || node === undefined) throw new Error('preparing benchmark did not create its Tool node')
const args = node.data.root.args
const source = snapshot.nodes.source(node.key)
let rowReads = 0
let progressKb: number | undefined
let filePath: string | undefined
const readRow = (): void => {
const current = source.getSnapshot()
if (current?.kind !== 'tool-call') throw new Error('preparing benchmark lost its Tool node')
const currentArgs = (current as ChatNode<'tool-call'>).data.root.args
if (tool === 'write') {
const length = currentArgs.stringLength('content', { step: 1024 })
progressKb = length === undefined ? undefined : Math.ceil(length / 1024)
filePath = currentArgs.text('file_path')
}
rowReads++
}
readRow()
const unsubscribe = tool === 'write' ? source.subscribe(readRow) : () => {}
globalThis.gc()
const before = process.memoryUsage().heapUsed
const start = performance.now()
for (let index = 0; index < chunks.length; index++) {
assembler.append(chunks[index]!)
if ((index + 1) % chunksPerPublication === 0) assembler.flush()
}
assembler.flush()
const elapsedMs = performance.now() - start
unsubscribe()
globalThis.gc()
const retainedMb = (process.memoryUsage().heapUsed - before) / 1048576
const groups = assembler.grouped('chat')
const group = groups?.entries.find(reference => reference.kind === 'group')
return {
tool, characters: args.stringLength(tool === 'write' ? 'content' : 'command'), fragments: chunks.length,
elapsedMs, retainedMb, maxRssMb: process.resourceUsage().maxRSS / 1024,
rowReads, progressKb, filePath,
detail: group === undefined ? undefined : groups?.groupSource(group.key).getSnapshot()?.data.summary.runningDetail,
}
}
assertBuiltBenchmarkRuntime(import.meta.url, {
'@deepseek-ai/dsh-brand': import.meta.resolve('@deepseek-ai/dsh-brand'),
'@deepseek-ai/dsh-client-store': import.meta.resolve('@deepseek-ai/dsh-client-store'),
'@deepseek-ai/dsh-llm/assistant-stream': import.meta.resolve('@deepseek-ai/dsh-llm/assistant-stream'),
'@deepseek-ai/dsh-session/surface': import.meta.resolve('@deepseek-ai/dsh-session/surface'),
'@deepseek-ai/dsh-token-meter/client': import.meta.resolve('@deepseek-ai/dsh-token-meter/client'),
'@deepseek-ai/dsh-util-values': import.meta.resolve('@deepseek-ai/dsh-util-values'),
})
const [turnsValue, smallDeltasValue, largeDeltasValue, attemptsValue] = process.argv.slice(2)
const turns = positiveInteger(turnsValue, 'turns')
const smallDeltas = positiveInteger(smallDeltasValue, 'small deltas')
const largeDeltas = positiveInteger(largeDeltasValue, 'large deltas')
const attempts = positiveInteger(attemptsValue, 'attempts')
const small = synthesizeWindow(turns, smallDeltas)
const large = synthesizeWindow(turns, largeDeltas)
if (large.entries.length !== small.entries.length || large.records !== small.records) {
throw new Error('conversation-fold workloads must have matching event and compact-record counts')
const scope = new Context()
try {
const definitions = registerEventDefinitions(scope)
if (process.argv[2] === 'preparing') {
const tool = process.argv[3]
if (tool !== 'write' && tool !== 'bash') throw new Error('preparing benchmark tool must be write or bash')
const report = preparingTool(tool, positiveInteger(process.argv[4], 'characters'), definitions)
process.stdout.write(`${JSON.stringify(report)}\n`)
} else {
const [turnsValue, smallDeltasValue, largeDeltasValue, attemptsValue] = process.argv.slice(2)
const turns = positiveInteger(turnsValue, 'turns')
const smallDeltas = positiveInteger(smallDeltasValue, 'small deltas')
const largeDeltas = positiveInteger(largeDeltasValue, 'large deltas')
const attempts = positiveInteger(attemptsValue, 'attempts')
const small = synthesizeWindow(turns, smallDeltas)
const large = synthesizeWindow(turns, largeDeltas)
if (large.entries.length !== small.entries.length || large.records !== small.records) {
throw new Error('conversation-fold workloads must have matching event and compact-record counts')
}
const smallFold = bestOf(small.entries, attempts, definitions)
const largeFold = bestOf(large.entries, attempts, definitions)
const report: ConversationFoldWorkerReport = {
events: large.entries.length,
compactRecords: large.records,
streamedDeltas: turns * (largeDeltas + Math.floor(largeDeltas / 4)),
chatNodes: largeFold.nodes,
smallFoldMs: Math.round(smallFold.ms * 10) / 10,
largeFoldMs: Math.round(largeFold.ms * 10) / 10,
scaling: Math.round((largeFold.ms / Math.max(smallFold.ms, 1)) * 100) / 100,
}
process.stdout.write(`${JSON.stringify(report)}\n`)
}
} finally {
await scope.fiber.dispose()
}
const smallFold = bestOf(small.entries, attempts)
const largeFold = bestOf(large.entries, attempts)
const report: ConversationFoldWorkerReport = {
events: large.entries.length,
compactRecords: large.records,
streamedDeltas: turns * (largeDeltas + Math.floor(largeDeltas / 4)),
chatNodes: largeFold.nodes,
smallFoldMs: Math.round(smallFold.ms * 10) / 10,
largeFoldMs: Math.round(largeFold.ms * 10) / 10,
scaling: Math.round((largeFold.ms / Math.max(smallFold.ms, 1)) * 100) / 100,
}
process.stdout.write(`${JSON.stringify(report)}\n`)
+1
View File
@@ -16,6 +16,7 @@
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:*",
"@deepseek-ai/dsh-agent-preset-registry": "workspace:*",
"@deepseek-ai/dsh-api-session-controller": "workspace:*",
"@deepseek-ai/dsh-brand": "workspace:*",
"@deepseek-ai/dsh-client-store": "workspace:*",
"@deepseek-ai/dsh-client-ui-chat": "workspace:*",
"@deepseek-ai/dsh-deque": "workspace:*",
+4 -4
View File
@@ -15,11 +15,11 @@
en: 62c0a5ae4ed7e1aa
zh: 787ad4d04b79adaf
/conversation-assembly/replayable-event-families:
en: 17f9e11d512b2b8e
zh: 6b7a9c1139eededb
en: c216e2df128b8cb9
zh: e61312f1f280332d
/conversation-assembly/definition-and-typed-chat-payload:
en: c7ea2c9192f80274
zh: 742b5c31af1e4ade
en: 5f24557a050d1df0
zh: 47735627f802b6fa
/conversation-assembly/predecessor-reads:
en: da849b3b5da81da8
zh: 37a647a439bc24b1
+3 -1
View File
@@ -69,7 +69,7 @@ Use the producer-owned branded id type across the process boundary. Put the `Ses
Incremental events are supported. Prefer whole-value checkpoints when the producer can emit them cheaply, because they remain useful when the start is outside the loaded window. Each delta must carry the stable id and produce deterministic State when replayed in ascending log `seq`; it must not depend on live-only memory. If the current history window contains only updates, the assembler keeps a pending Context and builds no State until an older page supplies the start. If the product must render before the start is loaded, a terminal or checkpoint event must carry enough whole fallback state for the Definition to build that result directly; do not recover it by scanning unrelated events.
Live Assistant deltas arrive as Client-only `assistant/live-chunk` events. Reconnect baselines expand the active process-local compact stream into the same transient events, while durable `assistant/message` and `assistant/attempt` events embed complete compact streams for history replay. A transient event may initialize a Context. A named tool delta and its later tool/call can both match as start under the same callId; only the earliest calls start(), and later Matches call update(). Historical pages do not expand settled messages into live deltas. A Definition that consumes Assistant output handles live chunks and durable settlements in the same `match()` and `update()` methods, while unrelated Definitions return `null` without expanding a stream.
Live Assistant deltas arrive as Client-only `assistant/live-chunk` events. Reconnect baselines expand the active process-local compact stream into the same transient events, while durable `assistant/message` and `assistant/attempt` events embed complete compact streams for history replay. A transient event may initialize a Context. Every tool delta and its later `tool/call` can match as start under the same callId, even before the name arrives; only the earliest calls `start()`, and later Matches call `update()`. A preparing Tool node becomes visible only after its name is available. Historical pages do not expand settled messages into live deltas. A Definition that consumes Assistant output handles live chunks and durable settlements through the same matching and update interfaces, while unrelated Definitions return `null` without expanding a stream.
## Definition and typed Chat payload
@@ -245,6 +245,8 @@ export function apply(ctx: ClientContext): void {
`match(event)` is an identity extractor, not a fold: it receives only the current `SessionEventLike` and returns the Definition-local id and lifecycle role. After a match, the assembler locates the Context by `(kind, id)` and initializes from its earliest loaded start, whether durable or transient. Every later Match calls `update`, including another start. Removing transient Matches reselects the start from remaining evidence and recomputes State; no remaining start leaves State undefined. Both functions return the State that the engine adopts; returning a new immutable value is preferred, but a function that mutates and returns the same object has the same adoption semantics.
`ConversationNodeDefinitionInput` accepts either this function or a read-only table from event type to matching function. Function-form registrations receive every event; table registrations receive only their listed types. The Registry copies table entries and precomputes ordered candidate Sets when registrations change, keeping candidate construction out of per-event dispatch. Replace a table by unregistering and registering again. Resolved `ConversationNodeDefinition` values retain a callable `match(event)` in both cases.
`buildLocationData(context, scope)` optionally publishes Definition-owned data onto an engine-owned Turn or Step. Use declaration merging to give each key a precise value type. Another Node in the same Location can consume that value through its constrained slot hook, such as `useTurnData(key)`, without receiving the Session or scanning `snapshot.chat.nodes`.
`target` and `buildViewNode(context)` declare one target-owned rendering contribution and must appear together. Preserve `context.key` as the React-facing identity, choose `anchorSeq` from the matched event ordering, and return only renderer-ready data. Once a target Node has been published, keep returning the same key; use `visibility: 'hidden'` when it must temporarily leave the visible flow rather than withdrawing it with `null`.
+3 -1
View File
@@ -69,7 +69,7 @@ Group 存储在安装前校验完整提交结果:根 Group 引用与记录一
系统支持增量事件。如果生产方能以较低成本发出 whole-value checkpoint,应优先采用,因为 start 位于已加载窗口之外时它仍可直接使用。每条 delta 都必须携带稳定 id,并且按照日志 `seq` 升序回放时能够确定性地产生 State;它不能依赖只存在于实时内存中的状态。如果当前历史窗口只有 update,Assembler 会保留一个 pending Context,并在更早分页补齐 start 前不构造 State。如果产品必须在 start 尚未加载时渲染,terminal 或 checkpoint 事件就必须携带足够的完整 fallback 状态,让 Definition 能直接构造结果;不要通过扫描无关事件恢复它。
实时 Assistant delta 作为 Client-only `assistant/live-chunk` event 到达。重连 baseline 会把活跃的进程内紧凑 stream 展开为相同的瞬态 event,持久 `assistant/message` 与 `assistant/attempt` event 则嵌入完整紧凑 stream 供历史回放。瞬态 event 可以初始化 Context。具名工具 delta 和后续 tool/call 可以按同一个 callId 匹配为 start;只有最早的 Match 调用 start(),后续 Match 调用 update()。历史分页不把已结束消息展开为实时 delta。消费 Assistant 输出的 Definition 在同一组 `match()` 与 `update()` 方法里处理 live chunk 与持久 settlement,其他 Definition 直接返回 `null`,无需展开 stream。
实时 Assistant delta 作为 Client-only `assistant/live-chunk` event 到达。重连 baseline 会把活跃的进程内紧凑 stream 展开为相同的瞬态 event,持久 `assistant/message` 与 `assistant/attempt` event 则嵌入完整紧凑 stream 供历史回放。瞬态 event 可以初始化 Context。每条工具 delta 和后续 `tool/call` 都可以按同一个 callId 匹配为 start,即使名称尚未到达;只有最早的 Match 调用 `start()`,后续 Match 调用 `update()`。准备态 Tool 节点只有在名称可用后才显示。历史分页不把已结束消息展开为实时 delta。消费 Assistant 输出的 Definition 通过同一组匹配和更新接口处理 live chunk 与持久 settlement,其他 Definition 直接返回 `null`,无需展开 stream。
## Definition 与类型化 Chat payload
@@ -245,6 +245,8 @@ export function apply(ctx: ClientContext): void {
`match(event)` 是身份提取器,不是 fold:它只能收到当前 `SessionEventLike`,并返回 Definition 内部 id 与生命周期角色。命中后,Assembler 通过 `(kind, id)` 定位 Context;当前最早的 start 初始化 State,不论它是持久事件还是瞬态事件。后续所有 Match,包括其他 start,都调用 `update`。移除瞬态 Match 后,从剩余事件重新选择 start 并重算 State;没有剩余 start 时 State 为 undefined。两个函数都必须返回引擎随后采用的 State;推荐返回新的 immutable value,但函数原地修改后返回同一对象时,采用语义也相同。
`ConversationNodeDefinitionInput` 接受上述函数,或事件类型到匹配函数的只读表。函数形式的注册接收所有事件;表形式只接收列出的类型。Registry 在注册关系变化时复制表条目并预计算有序候选 Set,不在逐事件分发时构造候选集合。替换表时先注销,再重新注册。两种形式解析后的 `ConversationNodeDefinition` 都保留可调用的 `match(event)`。
`buildLocationData(context, scope)` 可以把 Definition 拥有的数据发布到引擎拥有的 Turn 或 Step 上。通过 declaration merging 为每个 key 指定精确 value 类型。同一 Location 内的另一个 Node 可以使用受限 slot hook(例如 `useTurnData(key)`)读取该值,无须取得 Session,也无须扫描 `snapshot.chat.nodes`。
`target` 与 `buildViewNode(context)` 必须同时声明一项由 target 拥有的渲染贡献。把 `context.key` 保留为 React 侧身份,根据持久排序证据选择 `anchorSeq`,并且只返回 renderer 可以直接使用的数据。某个 target Node 一旦发布,就要继续返回同一个 key;需要暂时离开可见流时使用 `visibility: 'hidden'`,不要改为返回 `null` 撤回它。
+1 -1
View File
@@ -24,7 +24,7 @@ How live data reaches render code, and what UI domains may share:
1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes.
2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`.
3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use<Name>`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework extension point and needs main-thread arbitration.
4. **UI domains share JSON-compatible data and callbacks.** Owner props, injected values, store state, and provide contributions use these values. Transient file-read and document-preview content may also carry `Uint8Array`: published buffers are borrowed read-only, copied before Worker transfer, and never persisted in layout or Session JSON. The injected `hooks` compartment is the only place for bare observables, and components never receive those sources directly. Route ReactNode content through a slot; do not add ReactNode-valued owner props or injected members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` fields remain until they move to slots).
4. **UI domains share JSON-compatible data and callbacks.** Owner props, injected values, store state, and provide contributions use these values. Tool `args` may carry the [lazy argument reader](../../.agents/notes/implemented/architecture/2026-09-24-preparing-tool-arguments.md); its Definition republishes the block when an observed answer changes. Transient file-read and document-preview content may also carry `Uint8Array`: published buffers are borrowed read-only, copied before Worker transfer, and never persisted in layout or Session JSON. The injected `hooks` compartment is the only place for bare observables, and components never receive those sources directly. Route ReactNode content through a slot; do not add ReactNode-valued owner props or injected members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` fields remain until they move to slots).
5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves).
6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering.
+2 -2
View File
@@ -33,8 +33,8 @@
en: 0ca3e8c4eab1ca78
zh: 1a1c71bab151eb7d
/deepseek-ai-dsh-client-ui-chat/grouped-rendering:
en: 1dc0ab19a6469e37
zh: 75c788f1b7b214e5
en: bb4661cd2b435e2b
zh: bde68d03839f7da8
/deepseek-ai-dsh-client-ui-chat/scroll-ownership:
en: 69b021c5a5d8b5a7
zh: 091ae7986c7a8a20
+1 -1
View File
@@ -91,7 +91,7 @@ Chat registers its process Group Definition through `uiConversation.groups`. Rea
`groupPart` selects reasoning or response in the Assistant renderer without copying Node payloads. A Tool node owns its preparing, dispatched, and result stages under one callId. Each part has a distinct DOM anchor for reading-position restoration; Turn navigation addresses the original Node key and lands on its first visible part. Group sources, member parents, and keys survive display-mode changes and newly loaded prefixes that extend an intact group. The source Node Store remains the only Node-data owner, and a replaced Builder rebinds keyed subscriptions without remounting seats. Mode changes retain size observers and reuse the Turn-state selector.
Live tool deltas share reasoning's frame-batched publication; durable calls and results publish immediately. Repeated named deltas retain the Tool node and its data when the projected call, anchor, location, and visibility are unchanged.
Live tool deltas share reasoning's frame-batched publication; durable calls and results publish immediately. Repeated deltas retain the Tool node when observed argument answers, the anchor, location, and visibility are unchanged. The Definition supplies the same lazy argument reader to tool rows and group detail, without tool-specific registration.
The process group uses a stable `div` layout box, a scroll body, and an uncapped content box that reports growth inside the body. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception. CSS variables do not belong in the Group Definition.
+1 -1
View File
@@ -91,7 +91,7 @@ Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过
`groupPart` 在 Assistant 渲染器中选择推理或回复,不复制 Node 载荷。同一个 callId 的准备、派发与结果阶段由 Tool 节点自己拥有。每个部分有独立的 DOM 锚点用于恢复阅读位置;轮次导航使用原 Node key,落到它的第一个可见部分。展示模式切换以及为完整旧组补入更早成员时,保留组来源、成员父级及 key。原 Node Store 仍是唯一节点数据所有者,替换 Builder 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。
实时工具 delta 与推理共用按帧合并的发布节奏;持久调用和结果立即发布。重复具名 delta 在投影调用、锚点、位置及可见性均未变化时保留 Tool 节点及其数据引用。
实时工具 delta 与推理共用按帧合并的发布节奏;持久调用和结果立即发布。当已读取参数的答案、锚点、位置及可见性均未变化时,重复 delta 保持 Tool 节点不变。Definition 向工具行和分组详情提供同一个懒计算参数读器,无需按工具注册。
过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。CSS 变量不属于 Group Definition。
@@ -9,7 +9,7 @@ export type {
AssistantBlock, AssistantMessageNode, AssistantProviderMetadataView, AssistantRequestConfig,
AssistantTiming, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode,
ModelRetryNode, PartialAssistant, PreparingToolCall, RunningToolCall, StartedToolCall, SteeringMessageNode, TodoItem,
ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode,
ToolArgs, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode,
UserMessageNode,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -33,8 +33,8 @@
en: a47e16132ba3c736
zh: fbed2b6f43a93287
/chat-conversation-node-rules/display-modes/group-title-rules:
en: e4b8384f32e4ff98
zh: e8a42f078b38f41b
en: 5b40d65e660549b7
zh: 09b6894aa00154e7
/chat-conversation-node-rules/process-grouping:
en: c2650489ded57503
zh: f2554d74e75ae0f9
@@ -54,5 +54,5 @@
en: e7796e7ba825c3d6
zh: c377d9eab0a8869f
/chat-conversation-node-rules/activity-summaries/live-activity-and-detail:
en: d4256eadd2e9ba96
zh: ff6eeb1187ac683a
en: e0ca09c305690be7
zh: fb35d0238b1a827b
@@ -134,7 +134,7 @@ Reading a default does not automatically save configuration. When the user chang
A closed group's header names the first three categories from its ranked summary, without displaying counts. A group without categories uses the thinking label. A running header names its live tool category, otherwise thinking; Standard appends live detail. Live titles remain visible for at least 150ms, retaining only the newest pending title.
All three stages share the tool-name classification below. A preparing Tool node uses its category's preparation label: read files for `read`, read images for `read_image`, write files for `write`, edit files for `edit` and `apply_patch`, and update the plan for `todo_write` and goal tools. Only the generic “Preparing tool calls” category appends the wire tool name in Standard mode; other categories omit it. It contributes one call without parsing arguments and renders one non-expandable row. A named live delta can create this node; historical calls start directly from tool/call without replaying preparation.
All three stages share the tool-name classification below. A preparing Tool node uses its category's preparation label: read files for `read`, read images for `read_image`, write files for `write`, edit files for `edit` and `apply_patch`, and update the plan for `todo_write` and goal tools. Standard mode appends available argument detail using the same readers as dispatched calls. It contributes one call and renders one non-expandable row. A named live delta can create this node; historical calls start directly from tool/call without replaying preparation.
The labels below describe recorded activity, not successful outcomes. For example, a failed read still participates in the “Read files” category.
@@ -276,7 +276,11 @@ Exact-name rules also mean that a recorded name such as `functions.read`, `mcp.r
### Live activity and detail
Preparing calls use their first named delta time; only the generic tool category provides the tool name as detail. Dispatched calls use their tool/call time and complete arguments.
Preparing calls use their first named delta time; dispatched calls use their tool/call time. Both read detail from the block's lazy `args` view.
The Tool Definition alone retains streamed argument fragments. Assistant blocks keep tool identity and timing without accumulating argument deltas; `block-end` and the durable message supply their complete text. A Tool reader compares its fragments directly against `block-end` and `tool/call` arguments, retaining its indexed fields only for an exact match. Final text replaces missing or conflicting deltas.
TurnProcess and TurnTail retain tool-argument Matches as Location evidence when the Turn start is outside the loaded window, even when those fragments do not change their State.
Among running calls, the greatest `time` selects the live category and detail; equal times select the later visited call. With no running call, the category is absent and detail comes from the last nonempty reasoning paragraph of the latest running Assistant with nonempty reasoning, in member order. Reasoning detail removes `**` markers and does not require a newline-terminated first line; the individual reasoning-row preview has separate rules.
@@ -291,7 +295,7 @@ Live selection uses call start `time`, not the latest output/progress time. A ca
| No running tool or usable running reasoning; the group is not closed | No live tool category and empty detail, even when category counts are nonzero. |
| A reply or another group boundary closes the group while a tool still lacks a result | Published live category and detail are cleared. Group closure takes precedence over the unfinished call. |
Tool detail uses the first nonempty supported argument field in this priority order: `title`, `description`, `objective`, `task`, `task_name`, `name`, `question`, `questions`, `prompt`, `message`, `command`, `cmd`, `queries`, `query`, `pattern`, `url`, `uri`, `file_path`, `path`, `target`, `action`, `status`. Strings and arrays consisting entirely of strings are supported; arrays join with `, `. The `questions` field instead selects the first nonempty `question` in its object array. Invalid, partial, or free-form JSON and arguments without usable detail fall back to the tool name.
Tool detail uses the first nonempty supported argument field in this priority order: `title`, `description`, `objective`, `task`, `task_name`, `name`, `question`, `questions`, `prompt`, `message`, `command`, `cmd`, `queries`, `query`, `pattern`, `url`, `uri`, `file_path`, `path`, `target`, `action`, `status`. Strings may still be streaming; arrays become available when complete and must contain only strings, joined with `, `. The `questions` field instead selects the first nonempty `question` in its object array. Without usable detail, every category stays empty while fields can still arrive and falls back to the tool name only once the view is closed, invalid, or sealed after dispatch.
The priority list is shared by every tool, not specialized per category. For example, `description` can override `command` on a shell call, and `title` can override a file path. The first field whose supported value remains nonempty after whitespace normalization wins; unsupported values are skipped.
@@ -309,6 +313,6 @@ The priority list is shared by every tool, not specialized per category. For exa
| `run_code` with only `{"code":"print(1)"}` | `run_code`; `code` is not in the detail-field list. |
| `apply_patch` with raw patch text rather than a JSON object | `apply_patch`; no patch-path extraction. |
| Any tool with `{"description":" ","command":42,"path":"src/app.ts"}` | `src/app.ts`; whitespace-only strings and numbers are skipped. |
| Partial JSON, a JSON scalar, a mixed-type array, or only unsupported fields | Use the tool name if no other usable field exists. No partial-argument guessing. |
| No usable field yet | Empty while arguments can grow; otherwise the tool name. |
All live detail collapses whitespace, trims its ends, and is limited to 160 grapheme clusters, including a final `…` when truncated. A closed group's published summary clears its live category and detail while retaining category counts.
All live detail collapses whitespace, trims its ends, and is limited to 160 grapheme clusters, including a final `…` when truncated. String arguments initially supply at most 512 decoded UTF-16 units; normalization expands that prefix only when whitespace or multi-unit clusters leave too little text to determine truncation. Each truncation pass reads at most 161 clusters. A closed group's published summary clears its live category and detail while retaining category counts.
@@ -134,7 +134,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
已结束组的组头展示排序摘要的前三类,不显示次数;没有类别时使用思考标题。运行中的组头展示实时工具类别,没有运行工具时使用思考标题;标准模式附加实时详情。实时标题至少保留 150ms,只保留最新待显示标题。
三个阶段共用下方工具名分类。准备中的 Tool 节点使用对应类别的准备文案;`read` 为「准备读取文件」,`read_image` 为「准备读取图片」,`write` 为「准备写入文件」,`edit` 和 `apply_patch` 为「准备编辑文件」,`todo_write` 及目标工具为「准备更新计划」。只有通用类别的「准备调用工具」在标准模式中追加协议工具名,其他类别不追加。它计为一次调用,不解析参数,只渲染不可展开的一行。具名实时 delta 可以创建该节点;历史调用直接从 tool/call 开始,不重现准备过程。
三个阶段共用下方工具名分类。准备中的 Tool 节点使用对应类别的准备文案;`read` 为「准备读取文件」,`read_image` 为「准备读取图片」,`write` 为「准备写入文件」,`edit` 和 `apply_patch` 为「准备编辑文件」,`todo_write` 及目标工具为「准备更新计划」。标准模式通过与已派发调用相同的读器追加可用参数详情。它计为一次调用,只渲染不可展开的一行。具名实时 delta 可以创建该节点;历史调用直接从 tool/call 开始,不重现准备过程。
以下文案描述已记录的活动,不保证操作成功;例如失败的读取仍参与「已读取文件」类别。
@@ -276,7 +276,11 @@ Subagent 工具遵循普通分组边界,不会仅因委派任务就单独成
### 实时活动与详情
准备中的调用使用首个具名 delta 的时间,只有通用工具类别提供工具名详情。已派发调用使用 tool/call 的时间和完整参数。
准备中的调用使用首个具名 delta 的时间,已派发调用使用 tool/call 的时间。两者都从块的懒计算 `args` 视图读取详情。
仅 Tool Definition 保留流式参数分片。Assistant 块保留工具身份和时间,不累加参数 delta;完整文本由 `block-end` 和持久消息提供。Tool 读器将分片直接与 `block-end` 和 `tool/call` 的参数比较,仅在完全一致时保留字段索引。最终文本替换缺失或冲突的分片。
TurnProcess 和 TurnTail 保留工具参数 Match,作为 Turn 起始事件在已加载窗口之外时的 Location 证据,即使这些片段不改变其 State。
运行中的调用以最大 `time` 决定实时类别和详情;时间相同时取后遍历的调用。没有运行调用时,类别为空,详情取成员顺序中最近一个含有非空推理段落的运行中 Assistant 的最后一个非空段落。推理详情移除 `**` 标记,不要求首行以换行结束;单个推理行的预览另有规则。
@@ -291,7 +295,7 @@ Subagent 工具遵循普通分组边界,不会仅因委派任务就单独成
| 没有运行工具或可用的运行中推理,组尚未结束 | 实时工具类别为空、详情为空,即使类别次数非零。 |
| 工具尚无结果,但回复或其他分组边界已结束该组 | 发布的实时类别与详情清空;组结束优先于调用未完成。 |
工具详情按以下优先级取第一个非空且受支持的参数字段:`title`、`description`、`objective`、`task`、`task_name`、`name`、`question`、`questions`、`prompt`、`message`、`command`、`cmd`、`queries`、`query`、`pattern`、`url`、`uri`、`file_path`、`path`、`target`、`action`、`status`。支持字符串及全部元素均为字符串的数组,数组使用 `, ` 连接。`questions` 字段则取对象数组中首个非空的 `question`。无效、不完整或自由文本形式的 JSON,以及没有可用详情的参数,均回退到工具名。
工具详情按以下优先级取第一个非空且受支持的参数字段:`title`、`description`、`objective`、`task`、`task_name`、`name`、`question`、`questions`、`prompt`、`message`、`command`、`cmd`、`queries`、`query`、`pattern`、`url`、`uri`、`file_path`、`path`、`target`、`action`、`status`。字符串可仍在流入;数组完整后才可用,且必须全部为字符串,使用 `, ` 连接。`questions` 字段则取对象数组中首个非空的 `question`。没有可用详情时,所有类别在字段仍可能到达期间留空,只有视图已闭合、非法或随派发封存后才回退工具名。
这份优先级对所有工具相同,不按类别定制。例如 shell 调用的 `description` 可以覆盖 `command`,`title` 可以覆盖文件路径。按顺序选择受支持且合并空白后仍非空的首个字段,不支持的值会跳过。
@@ -309,6 +313,6 @@ Subagent 工具遵循普通分组边界,不会仅因委派任务就单独成
| `run_code`,仅有 `{"code":"print(1)"}` | `run_code`,`code` 不在详情字段列表中。 |
| `apply_patch`,参数为原始补丁文本而非 JSON 对象 | `apply_patch`,不从补丁中提取路径。 |
| 任意工具,`{"description":" ","command":42,"path":"src/app.ts"}` | `src/app.ts`,跳过纯空白字符串和数值。 |
| 不完整 JSON、JSON 标量、混合类型数组,或仅有不支持的字段 | 没有其他可用字段时使用工具名,不猜测不完整参数。 |
| 暂无可用字段 | 参数仍可增长时留空,否则使用工具名。 |
所有实时详情都合并空白、去除首尾空白,并限制为 160 个字素簇;截断时末尾的 `…` 计入上限。已结束组发布的摘要清空实时类别和详情,保留类别次数。
所有实时详情都合并空白、去除首尾空白,并限制为 160 个字素簇;截断时末尾的 `…` 计入上限。字符串参数起初最多提供 512 个已解码 UTF-16 单元;只有空白或包含多个单元的字素簇使文本不足以判定截断时,规范化才扩大该前缀。每次截断遍历最多读取 161 个字素簇。已结束组发布的摘要清空实时类别和详情,保留类别次数。
@@ -92,6 +92,12 @@ function updateChunk(
seq: number,
time: number,
): AssistantState {
if (chunk.type === 'tool-call-delta') {
const previous = state.blocks[chunk.index]
if (previous?.kind === 'tool-call' && previous.callId !== ''
&& (chunk.name === undefined || chunk.name === previous.name)
&& (state.firstTokenTime !== undefined || !isTokenDelta(chunk))) return state
}
const blocks = [...state.blocks]
let changedIndex = -1
let previousVisible = false
@@ -119,14 +125,12 @@ function updateChunk(
const previous = blocks[chunk.index]
changedIndex = chunk.index
previousVisible = blockIsVisible(previous)
const base = previous?.kind === 'tool-call'
? previous
: { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' }
// Tool Nodes own streamed arguments; Assistant protocol blocks retain identity until full settlement.
blocks[chunk.index] = {
kind: 'tool-call',
callId: base.callId || String(chunk.id),
name: chunk.name ?? base.name,
argsRaw: base.argsRaw + chunk.argumentsDelta,
callId: (previous?.kind === 'tool-call' ? previous.callId : '') || String(chunk.id),
name: chunk.name ?? (previous?.kind === 'tool-call' ? previous.name : ''),
argsRaw: previous?.kind === 'tool-call' ? previous.argsRaw : '',
}
break
}
@@ -356,5 +360,14 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
* @param ctx - owning UI Conversation context.
*/
export function registerAssistantConversationNode(ctx: Context): void {
ctx.uiConversation.events.register(assistantDefinition)
const match = assistantDefinition.match.bind(assistantDefinition)
ctx.uiConversation.events.register({
...assistantDefinition,
match: {
'step/start': match,
'assistant/live-chunk': match,
'assistant/message': match,
'llm/retry': match,
},
})
}
@@ -1,101 +0,0 @@
import type { StreamChunk } from '@deepseek-ai/dsh-llm/types'
import type {
AssistantBlock, PartialAssistant,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { emptyAssistantBlock, toAssistantBlock } from './event-projection.ts'
/**
* Whether a stream chunk changes the partial assistant projection shown by the UI.
* @param type - Stream chunk discriminant.
* @returns Whether publishing the accumulated partial can change the visible snapshot.
*/
export function isVisibleAssistantChunk(type: string): boolean {
return type === 'block-start'
|| type === 'text-delta'
|| type === 'reasoning-delta'
|| type === 'tool-call-delta'
|| type === 'block-end'
}
/** Live Assistant-frame accumulator: folds StreamChunks into AssistantBlock[] with block-level immutability. */
export class PartialAccumulator {
// Sparse on purpose: block-start may arrive out of order, leaving holes until compaction.
private blocks: (AssistantBlock | undefined)[] = []
private changed = true
private snapshot: PartialAssistant
/**
* @param turn - Owning agent turn.
* @param step - Owning model step.
* @param initialBlocks - Materialized prefix when accumulation begins after history replay.
*/
constructor(
readonly turn: number,
readonly step: number,
initialBlocks: readonly AssistantBlock[] = [],
) {
this.blocks = [...initialBlocks]
this.snapshot = { turn, step, blocks: initialBlocks }
}
/**
* Fold one chunk.
* @param chunk - the stream chunk.
* @returns whether it caused a visible change (usage/finish return false, skipping notification).
*/
push(chunk: StreamChunk): boolean {
switch (chunk.type) {
case 'block-start': {
this.blocks[chunk.index] = emptyAssistantBlock(chunk.blockType)
this.changed = true
return true
}
case 'text-delta': {
const prev = this.blocks[chunk.index]
this.blocks[chunk.index] = { kind: 'text', text: (prev?.kind === 'text' ? prev.text : '') + chunk.text }
this.changed = true
return true
}
case 'reasoning-delta': {
const prev = this.blocks[chunk.index]
this.blocks[chunk.index] = { kind: 'reasoning', text: (prev?.kind === 'reasoning' ? prev.text : '') + chunk.text }
this.changed = true
return true
}
case 'tool-call-delta': {
const prev = this.blocks[chunk.index]
const base = prev?.kind === 'tool-call' ? prev : { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' }
this.blocks[chunk.index] = {
kind: 'tool-call',
callId: base.callId || String(chunk.id),
name: chunk.name ?? base.name,
argsRaw: base.argsRaw + chunk.argumentsDelta,
}
this.changed = true
return true
}
case 'block-end': {
this.blocks[chunk.index] = toAssistantBlock(chunk.block)
this.changed = true
return true
}
default:
// usage / finish / merge-extensible unknown variants: no visible block change
// (finish is immediately followed by the assistant/message that supersedes the partial).
return false
}
}
/**
* Current partial projection.
* @returns the cached snapshot (the blocks array reference only changes after a mutation).
*/
toPartial(): PartialAssistant {
if (this.changed) {
// Compact sparse indexes (out-of-order block-start) into render order.
this.snapshot = { turn: this.turn, step: this.step, blocks: this.blocks.filter((b): b is AssistantBlock => b !== undefined) }
this.changed = false
}
return this.snapshot
}
}
@@ -1,4 +1,5 @@
/** Tool-category and live-detail interpretation owned by Chat grouping. */
import type { ToolArgs } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ProcessActivity, ProcessActivitySummary } from '../contract/process-groups.ts'
import type { ChatNode } from '../contract/chat-nodes.ts'
import { isRunningTool } from '../contract/chat-nodes.ts'
@@ -21,23 +22,64 @@ function activity(name: string): ProcessActivity {
}
const LIVE_TOOL_DETAIL_MAX_CHARS = 160
const LIVE_TOOL_DETAIL_PREFIX_CHARS = 512
const LIVE_TOOL_DETAIL_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
const LIVE_TOOL_DETAIL_KEYS = [
'title', 'description', 'objective', 'task', 'task_name', 'name', 'question', 'questions', 'prompt', 'message',
'command', 'cmd', 'queries', 'query', 'pattern', 'url', 'uri', 'file_path', 'path', 'target', 'action', 'status',
] as const
interface NormalizedDetail {
readonly text: string
readonly truncated: boolean
}
function normalizeLiveToolText(text: string): NormalizedDetail {
const normalized = text.replace(/\s+/g, ' ').trim()
if (normalized.length <= LIVE_TOOL_DETAIL_MAX_CHARS) return { text: normalized, truncated: false }
const chars: string[] = []
for (const { segment } of LIVE_TOOL_DETAIL_SEGMENTER.segment(normalized)) {
if (chars.length === LIVE_TOOL_DETAIL_MAX_CHARS) {
return {
text: `${chars.slice(0, LIVE_TOOL_DETAIL_MAX_CHARS - 1).join('').trimEnd()}…`,
truncated: true,
}
}
chars.push(segment)
}
return { text: normalized, truncated: false }
}
function normalizeLiveToolDetail(value: unknown): string {
const text = typeof value === 'string'
? value
: Array.isArray(value) && value.every(item => typeof item === 'string')
? value.join(', ')
: ''
const normalized = text.replace(/\s+/g, ' ').trim()
const chars = Array.from(LIVE_TOOL_DETAIL_SEGMENTER.segment(normalized), part => part.segment)
return chars.length <= LIVE_TOOL_DETAIL_MAX_CHARS
? normalized
: `${chars.slice(0, LIVE_TOOL_DETAIL_MAX_CHARS - 1).join('').trimEnd()}…`
return normalizeLiveToolText(text).text
}
function argumentTextDetail(args: ToolArgs, key: string): string | undefined {
let limit = LIVE_TOOL_DETAIL_PREFIX_CHARS
while (true) {
let prefix = args.textPrefix(key, limit)
if (prefix === undefined) return undefined
const last = prefix.charCodeAt(prefix.length - 1)
if (last >= 0xd800 && last <= 0xdbff) {
// The extra prefix read also observes a low surrogate arriving in a later delta.
const extended = args.textPrefix(key, limit + 1) as string
const next = extended.charCodeAt(prefix.length)
if (next >= 0xdc00 && next <= 0xdfff) {
prefix = extended
limit++
} else if (extended.length === prefix.length && !args.isSealed && !args.complete(key)) {
prefix = prefix.slice(0, -1)
}
}
const detail = normalizeLiveToolText(prefix)
if (detail.truncated || !args.stringExceeds(key, limit)) return detail.text
limit *= 2
}
}
function questionDetail(value: unknown): string {
@@ -67,23 +109,21 @@ function liveReasoningDetail(nodes: readonly ChatNode[]): string {
return ''
}
function liveToolDetail(name: string, argsRaw: string): string {
let args: unknown
try {
args = JSON.parse(argsRaw)
} catch (_error: unknown) {
// Partial or free-form arguments have no safe one-line task detail.
return normalizeLiveToolDetail(name)
}
if (args === null || typeof args !== 'object') return normalizeLiveToolDetail(name)
/**
* One-line task detail from the argument view, read the same way while the
* arguments stream and after dispatch: the first detail key present with text
* so far or a closed value. Without one, the tool name stands in once no further
* field can arrive; a field still to come is not named early.
*/
function liveToolDetail(name: string, args: ToolArgs): string {
for (const key of LIVE_TOOL_DETAIL_KEYS) {
if (key in args) {
const value: unknown = Reflect.get(args, key)
const detail = key === 'questions' ? questionDetail(value) : normalizeLiveToolDetail(value)
if (detail !== '') return detail
}
if (!args.has(key)) continue
const detail = key === 'questions'
? questionDetail(args.value(key))
: argumentTextDetail(args, key) ?? normalizeLiveToolDetail(args.value(key))
if (detail !== '') return detail
}
return normalizeLiveToolDetail(name)
return args.closed() ? normalizeLiveToolDetail(name) : ''
}
/**
@@ -107,9 +147,7 @@ export function processActivity(nodes: readonly ChatNode[]): ProcessActivitySumm
if (isRunningTool(tool) && tool.time >= runningTime) {
running = kind
preparing = tool.phase === 'preparing'
runningDetail = tool.phase === 'preparing'
? kind === 'tools' ? tool.name : ''
: liveToolDetail(tool.name, tool.argsRaw)
runningDetail = liveToolDetail(tool.name, tool.args)
runningTime = tool.time
}
counts.set(kind, (counts.get(kind) ?? 0) + 1)
@@ -1,10 +1,11 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RunningToolCall, StartedToolCall,
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, PreparingToolCall, StartedToolCall,
ToolCallBlock, ToolResultNode,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
import type {} from '@deepseek-ai/dsh-tools/types'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
import type { ChatNode, ToolChatData } from '../contract/chat-nodes.ts'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode, contextLocation } from './common.ts'
@@ -18,7 +19,8 @@ declare module '../contract/chat-nodes.ts' {
const MAX_DEPTH = 256
interface ToolState {
readonly root: ToolCallBlock
/** Absent until a named delta or `tool/call` identifies the call. */
readonly root: ToolCallBlock | undefined
readonly children: ReadonlyMap<string, readonly ToolCallBlock[]>
readonly parents: ReadonlyMap<string, string>
}
@@ -36,24 +38,16 @@ function jsonArguments(value: unknown): string {
return JSON.stringify(value)
}
function rootCall(match: ConversationMatch): RunningToolCall {
const event = match.event
if (event.type === 'assistant/live-chunk') {
const chunk = event.data.chunk
if (chunk.type !== 'tool-call-delta' || !chunk.name) throw new Error('tool preparation requires a named call delta')
return {
phase: 'preparing',
callId: String(chunk.id), name: chunk.name,
turn: event.data.turn, step: event.data.step, time: event.time,
subCalls: [],
}
}
function rootCall(match: ConversationMatch, previous?: ToolCallBlock): StartedToolCall {
if (match.event.type !== 'tool/call') throw new Error('tool-call start requires tool/call')
return {
phase: 'start',
callId: String(match.event.data.callId),
name: match.event.data.name,
argsRaw: match.event.data.arguments,
args: previous === undefined
? PartialArguments.fromText(match.event.data.arguments)
: previous.args.settle(match.event.data.arguments),
turn: match.event.data.turn,
step: match.event.data.step,
time: match.event.time,
@@ -61,6 +55,48 @@ function rootCall(match: ConversationMatch): RunningToolCall {
}
}
/**
* Retain argument fragments in the named call's lazy view. Root replacement
* belongs to publication, after all pending fragments can be observed together.
*/
function applyDelta(state: ToolState, match: ConversationMatch): ToolState {
const event = match.event
if (event.type !== 'assistant/live-chunk') return state
const chunk = event.data.chunk
const root = state.root
if (chunk.type === 'block-end' && chunk.block.type === 'tool-call') {
if (root === undefined || 'kind' in root || root.phase !== 'preparing') return state
return { ...state, root: { ...root, name: chunk.block.name, args: root.args.settle(chunk.block.arguments) } }
}
if (chunk.type !== 'tool-call-delta') return state
if (root === undefined) {
// An unnamed delta before the name is skipped; a view opened late sees a non-object prefix and reports nothing.
if (!chunk.name) return state
const args = new PartialArguments()
args.append(chunk.argumentsDelta)
return {
...state,
root: {
phase: 'preparing',
callId: String(chunk.id), name: chunk.name,
turn: event.data.turn, step: event.data.step, time: event.time,
subCalls: [],
args,
},
}
}
if ('kind' in root || root.phase !== 'preparing' || root.args.isSealed) return state
root.args.append(chunk.argumentsDelta)
return state
}
function preparingBlock(root: PreparingToolCall, current: ToolCallBlock | undefined): PreparingToolCall {
const changed = root.args.refresh()
if (!changed && current !== undefined && !('kind' in current)
&& current.phase === 'preparing' && current.name === root.name && current.args === root.args) return current
return changed ? { ...root } : root
}
function rootResult(match: ConversationMatch, previous?: StartedToolCall): ToolResultNode | undefined {
if (match.event.type !== 'tool/result') return undefined
const message = match.event.data.message
@@ -69,6 +105,8 @@ function rootResult(match: ConversationMatch, previous?: StartedToolCall): ToolR
seq: match.event.seq,
time: match.event.time,
callId: String(message.source.callId),
name: previous?.name ?? '',
args: previous?.args ?? PartialArguments.EMPTY,
call: previous === undefined ? null : { name: previous.name, argsRaw: previous.argsRaw },
callTime: previous?.time ?? null,
content: message.content,
@@ -96,6 +134,7 @@ function childCall(match: ConversationMatch, data: DispatchData): StartedToolCal
parentCallId: data.parentCallId,
name: data.name,
argsRaw: jsonArguments(data.arguments),
args: PartialArguments.fromObject(data.arguments),
turn: locationTurn(match),
step: locationStep(match),
time: match.event.time,
@@ -110,6 +149,8 @@ function childResult(match: ConversationMatch, data: DispatchData, previous?: To
time: match.event.time,
callId: data.subCallId,
parentCallId: data.parentCallId,
name: data.name,
args: previous !== undefined && !('kind' in previous) ? previous.args : PartialArguments.fromObject(data.arguments),
call: { name: data.name, argsRaw: jsonArguments(data.arguments) },
callTime: previous?.time ?? null,
content: data.content ?? [],
@@ -210,6 +251,8 @@ function projectBlock(
time: interruptedAt.time,
callId: block.callId,
...block.parentCallId === undefined ? {} : { parentCallId: block.parentCallId },
name: block.name,
args: block.args,
call: { name: block.name, argsRaw: block.argsRaw },
callTime: block.time,
content: [],
@@ -249,9 +292,12 @@ export const toolDefinition: ConversationNodeDefinition<ToolState> = {
target: 'chat',
match: (event) => {
if (event.type === 'assistant/live-chunk') {
// Every delta of a call is a start candidate: the earliest opens the Context, later ones fold as updates.
const chunk = event.data.chunk
return chunk.type === 'tool-call-delta' && chunk.name
? { id: String(chunk.id), role: 'start' } : null
if (chunk.type === 'block-end' && chunk.block.type === 'tool-call') {
return { id: String(chunk.block.id), role: 'start' }
}
return chunk.type === 'tool-call-delta' ? { id: String(chunk.id), role: 'start' } : null
}
if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) {
@@ -265,12 +311,16 @@ export const toolDefinition: ConversationNodeDefinition<ToolState> = {
}
return null
},
start: (_context, match) => ({ root: rootCall(match), children: new Map(), parents: new Map() }),
start: (_context, match) => {
const state: ToolState = { root: undefined, children: new Map(), parents: new Map() }
return match.event.type === 'tool/call' ? { ...state, root: rootCall(match) } : applyDelta(state, match)
},
update: (context, match) => {
if (match.event.type === 'tool/call') return { ...context.state, root: rootCall(match) }
if (match.event.type === 'assistant/live-chunk') return applyDelta(context.state, match)
if (match.event.type === 'tool/call') return { ...context.state, root: rootCall(match, context.state.root) }
if (match.event.type === 'tool/result') {
const root = context.state.root
const running = !('kind' in root) && root.phase === 'start' ? root : undefined
const running = root !== undefined && !('kind' in root) && root.phase === 'start' ? root : undefined
const result = rootResult(match, running)
return result === undefined ? context.state : { ...context.state, root: result }
}
@@ -280,11 +330,14 @@ export const toolDefinition: ConversationNodeDefinition<ToolState> = {
buildViewNode: (context) => {
const current = context.current.get('chat') as ChatNode<'tool-call'> | null | undefined
const state = context.state ?? fallbackState(context)
if (state === undefined) {
if (state?.root === undefined) {
return current == null ? null : current.visibility === 'hidden' ? current : { ...current, visibility: 'hidden' }
}
const interruptedAt = interruption(context)
const projected = projectBlock(state.root, state, interruptedAt)
const root = !('kind' in state.root) && state.root.phase === 'preparing'
? preparingBlock(state.root, current?.data.root)
: state.root
const projected = projectBlock(root, state, interruptedAt)
const anchor = context.start?.event.seq
?? ('kind' in state.root ? state.root.seq : context.matches[0]?.event.seq ?? 0)
const preparing = !('kind' in projected) && projected.phase === 'preparing'
@@ -302,5 +355,15 @@ export const toolDefinition: ConversationNodeDefinition<ToolState> = {
* @param ctx - owning UI Conversation context.
*/
export function registerToolConversationNode(ctx: Context): void {
ctx.uiConversation.events.register(toolDefinition)
const match = toolDefinition.match.bind(toolDefinition)
ctx.uiConversation.events.register({
...toolDefinition,
match: {
'assistant/live-chunk': match,
'tool/call': match,
'tool/result': match,
'tool/ptc-dispatch-start': match,
'tool/ptc-dispatch': match,
},
})
}
@@ -305,5 +305,19 @@ export const turnProcessDefinition: ConversationNodeDefinition<TurnProcessState>
* @param ctx - owning UI Conversation context.
*/
export function registerTurnProcess(ctx: Context): void {
ctx.uiConversation.events.register(turnProcessDefinition)
const match = turnProcessDefinition.match.bind(turnProcessDefinition)
ctx.uiConversation.events.register({
...turnProcessDefinition,
match: {
'turn/start': match,
'assistant/live-chunk': match,
'assistant/message': match,
'tool/call': match,
'tool/result': match,
'llm/retry': match,
'step/start': match,
'step/end': match,
'turn/end': match,
},
})
}
@@ -144,5 +144,21 @@ export const turnTailDefinition: ConversationNodeDefinition<TurnTailState> = {
* @param ctx - owning UI Conversation context.
*/
export function registerTurnTailConversationNode(ctx: Context): void {
ctx.uiConversation.events.register(turnTailDefinition)
const match = turnTailDefinition.match.bind(turnTailDefinition)
ctx.uiConversation.events.register({
...turnTailDefinition,
match: {
'turn/start': match,
'turn/end': match,
'tool/call': match,
'tool/result': match,
'assistant/message': match,
'assistant/attempt': match,
'assistant/live-chunk': match,
'step/start': match,
'step/end': match,
'llm/retry': match,
'llm/retry-started': match,
},
})
}
+1 -1
View File
@@ -19,7 +19,7 @@ export type {
ChatSnapshot, ChatTurnNavigationIndex, ChatTurnProcessPresentation,
CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode,
LegacyConversationSlice, ModelRetryNode, PartialAssistant, PreparingToolCall, RunningToolCall, StartedToolCall,
SteeringMessageNode, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode,
SteeringMessageNode, ToolArgs, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode,
TurnNavigationItem, UnknownSurfaceNode, UserMessageNode,
} from './contract/snapshot.ts'
export type {
@@ -3,6 +3,7 @@ import type {} from '@deepseek-ai/dsh-tools/types'
import type {
ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
interface ProjectedBlock {
source: ToolCallBlock
@@ -63,6 +64,7 @@ export class ToolCallTree {
parentCallId: data.parentCallId,
name: data.name,
argsRaw: JSON.stringify(data.arguments),
args: PartialArguments.fromObject(data.arguments),
turn: 0,
step: 0,
time: event.time,
@@ -86,6 +88,8 @@ export class ToolCallTree {
time: event.time,
callId: data.subCallId,
parentCallId: data.parentCallId,
name: data.name,
args: started !== undefined && !('kind' in started) ? started.args : PartialArguments.fromObject(data.arguments),
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
callTime: started?.time ?? null,
content: data.content,
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import type { ChatSnapshot, UseChat } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { ApprovalCommand, commandOf } from '../src/client/chat/ApprovalCommand.tsx'
@@ -30,8 +31,8 @@ describe('ApprovalCommand', () => {
it('renders the running correlated Tool command', () => {
render(<ApprovalCommand {...props([
{ kind: 'assistant-step', data: {} },
{ kind: 'tool-call', data: { root: { phase: 'start', callId: 'other', argsRaw: '{"command":"wrong"}' } } },
{ kind: 'tool-call', data: { root: { phase: 'start', callId: 'call-1', argsRaw: '{"command":"pnpm test"}' } } },
{ kind: 'tool-call', data: { root: { phase: 'start', args: PartialArguments.fromText('{"command":"wrong"}'), callId: 'other', argsRaw: '{"command":"wrong"}' } } },
{ kind: 'tool-call', data: { root: { phase: 'start', args: PartialArguments.fromText('{"command":"pnpm test"}'), callId: 'call-1', argsRaw: '{"command":"pnpm test"}' } } },
] as never)} />)
expect(screen.getByText('pnpm test')).toBeTruthy()
@@ -41,8 +42,8 @@ describe('ApprovalCommand', () => {
const { container, rerender } = render(<ApprovalCommand {...props([
{ kind: 'assistant-step', data: {} },
{ kind: 'tool-call', data: { root: undefined } },
{ kind: 'tool-call', data: { root: { phase: 'start', callId: 'other', argsRaw: '{}' } } },
{ kind: 'tool-call', data: { root: { phase: 'preparing', callId: 'call-1', name: 'bash' } } },
{ kind: 'tool-call', data: { root: { phase: 'start', args: PartialArguments.fromText('{}'), callId: 'other', argsRaw: '{}' } } },
{ kind: 'tool-call', data: { root: { phase: 'preparing', args: PartialArguments.EMPTY, callId: 'call-1', name: 'bash' } } },
{
kind: 'tool-call',
data: { root: { kind: 'tool-result', callId: 'call-1', argsRaw: '{"command":"ignored"}' } },
@@ -51,7 +52,7 @@ describe('ApprovalCommand', () => {
expect(container.textContent).toBe('')
rerender(<ApprovalCommand {...props([
{ kind: 'tool-call', data: { root: { phase: 'start', callId: 'call-1', argsRaw: '{}' } } },
{ kind: 'tool-call', data: { root: { phase: 'start', args: PartialArguments.fromText('{}'), callId: 'call-1', argsRaw: '{}' } } },
] as never)} />)
expect(container.textContent).toBe('')
})
@@ -72,7 +72,7 @@ function nodeSource(node: ChatConversationViewNode): unknown {
}
function toolCallName(call: ToolCallBlock): string | null {
return 'name' in call ? call.name : call.call?.name ?? null
return 'kind' in call ? call.call?.name ?? null : call.name
}
class FixtureSource<Value> {
@@ -8,6 +8,7 @@ import type {
import { bindSnapshotSelector, 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 { PartialArguments } from '@deepseek-ai/dsh-util-values'
import { StatsPills, deriveStats, formatDuration, type StatsPillsProps } from '../src/client/chat/StatsPills.tsx'
import { formatTokens } from '../src/client/chat/token-format.ts'
import { en, zh } from '../src/client/locale.ts'
@@ -68,6 +69,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: [],
name: '', args: PartialArguments.EMPTY,
isError: false, subCalls: [],
}
const stats = deriveStats([tool, assistant(1, 1)])
@@ -86,6 +88,7 @@ describe('deriveStats', () => {
}
const tool: ToolResultNode = {
kind: 'tool-result', seq: 5, time: 7_000, callId: 'c', call: null, callTime: 4_000, content: [],
name: '', args: PartialArguments.EMPTY,
isError: false, subCalls: [],
}
const stats = deriveStats([timed, untimed, tool])
@@ -29,6 +29,7 @@ import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-c
import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
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 { PartialArguments } from '@deepseek-ai/dsh-util-values'
import { createChatStore } from '../src/client/stores.ts'
import { derivePresentationPolicy } from '../src/client/presentation-policy.ts'
import { ChatView } from '../src/client/chat/ChatView.tsx'
@@ -202,12 +203,13 @@ const turnMaxTokens = (seq: number): TurnMaxTokensNode => ({
})
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
name, args: PartialArguments.fromText(`{"command":"cmd-${callId}","description":"run ${callId}"}`),
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
callTime: seq * 1_000 - 500,
content: [], isError: false, subCalls: [],
})
const runningCall = (callId: string, name = 'bash'): StartedToolCall => ({
phase: 'start' as const, callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, subCalls: [],
phase: 'start' as const, args: PartialArguments.fromText(`{"command":"cmd-${callId}"}`), callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, subCalls: [],
})
const command = (over: Partial<CommandNode> = {}): CommandNode => ({
kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'],
@@ -1276,7 +1276,7 @@ describe('built-in conversation node Definitions', () => {
blocks: [
{ kind: 'text', text: ' \tanswer' },
{ kind: 'reasoning', text: 'thinking' },
{ kind: 'tool-call', callId: 'call-1', name: '', argsRaw: '{"x":1}' },
{ kind: 'tool-call', callId: 'call-1', name: '', argsRaw: '' },
],
})
expect(snapshot(packed).legacy.partial).toBeNull()
@@ -1,97 +0,0 @@
/**
* PartialAccumulator: six-variant chunk folding, sparse-index compaction, and
* the block/snapshot reference discipline (a delta swaps only that block).
*/
import { describe, expect, it } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-api-remotes/client'
import { PartialAccumulator } from '../src/client/conversation-nodes/partial.ts'
const chunk = (c: Record<string, unknown>): StreamChunk => c as StreamChunk
describe('PartialAccumulator', () => {
it('builds empty blocks per block-start type, unknown type falls to other', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'block-start', index: 0, blockType: 'text' }))
acc.push(chunk({ type: 'block-start', index: 1, blockType: 'reasoning' }))
acc.push(chunk({ type: 'block-start', index: 2, blockType: 'tool-call' }))
acc.push(chunk({ type: 'block-start', index: 3, blockType: 'no-such' }))
expect(acc.toPartial().blocks).toEqual([
{ kind: 'text', text: '' },
{ kind: 'reasoning', text: '' },
{ kind: 'tool-call', callId: '', name: '', argsRaw: '' },
{ kind: 'other', block: null },
])
})
it('accumulates text deltas, starting from empty when prev is missing or another kind', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'text-delta', index: 0, text: '无 start ' })) // prev missing
acc.push(chunk({ type: 'text-delta', index: 0, text: '也累积' }))
expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '无 start 也累积' }])
acc.push(chunk({ type: 'reasoning-delta', index: 0, text: '换型重起' })) // prev is text → restart
expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '换型重起' }])
})
it('accumulates reasoning deltas on the reasoning lane', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'block-start', index: 0, blockType: 'reasoning' }))
acc.push(chunk({ type: 'reasoning-delta', index: 0, text: '思' }))
acc.push(chunk({ type: 'reasoning-delta', index: 0, text: '考' }))
expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '思考' }])
})
it('continues from a materialized history prefix', () => {
const acc = new PartialAccumulator(1, 0, [{ kind: 'text', text: '已有' }])
acc.push(chunk({ type: 'text-delta', index: 0, text: '增量' }))
expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '已有增量' }])
})
it('folds tool-call deltas: first id pins callId, late name overrides, argsRaw concatenates', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c1', argumentsDelta: '{"a"' }))
acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c2-late', name: 'echo', argumentsDelta: ':1}' }))
expect(acc.toPartial().blocks).toEqual([
{ kind: 'tool-call', callId: 'c1', name: 'echo', argsRaw: '{"a":1}' },
])
})
it('replaces the accumulated block wholesale on block-end', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'text-delta', index: 0, text: '中间态' }))
acc.push(chunk({ type: 'block-end', index: 0, block: { type: 'text', text: '定稿全文' } }))
expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '定稿全文' }])
})
it('returns false (no notification) for usage/finish/unknown variants and keeps blocks', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'text-delta', index: 0, text: 'x' }))
const before = acc.toPartial()
expect(acc.push(chunk({ type: 'usage', usage: {} }))).toBe(false)
expect(acc.push(chunk({ type: 'finish', reason: 'stop' }))).toBe(false)
expect(acc.push(chunk({ type: 'future-variant' }))).toBe(false)
expect(acc.toPartial()).toBe(before) // unchanged: same snapshot reference
})
it('compacts sparse indexes into a dense render-order array', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'block-start', index: 2, blockType: 'text' }))
acc.push(chunk({ type: 'text-delta', index: 2, text: '先到的高位' }))
acc.push(chunk({ type: 'block-start', index: 0, blockType: 'reasoning' }))
const { blocks } = acc.toPartial()
expect(blocks).toHaveLength(2) // no undefined holes
expect(blocks[0]).toEqual({ kind: 'reasoning', text: '' })
expect(blocks[1]).toEqual({ kind: 'text', text: '先到的高位' })
})
it('keeps the snapshot reference stable without changes and swaps it once per mutation', () => {
const acc = new PartialAccumulator(3, 1)
const first = acc.toPartial()
expect(first).toMatchObject({ turn: 3, step: 1, blocks: [] })
expect(acc.toPartial()).toBe(first)
acc.push(chunk({ type: 'text-delta', index: 0, text: 'a' }))
const second = acc.toPartial()
expect(second).not.toBe(first)
expect(acc.toPartial()).toBe(second)
})
})
@@ -2,9 +2,11 @@ import { describe, expect, it, vi } from 'vitest'
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { ConversationTimelineSnapshot, TurnLocation } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
import type { ChatConversationViewNode, ChatNode } from '../src/client/contract/chat-nodes.ts'
import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
import { ProcessState } from '../src/client/conversation-nodes/process-groups.ts'
import { processActivity } from '../src/client/conversation-nodes/process-activity.ts'
import { ConversationGroupStore } from '../../ui-conversation/src/client/conversation/group-store.ts'
import type { ProcessGroupData } from '../src/client/contract/process-groups.ts'
@@ -35,7 +37,7 @@ function tool(key: string, seq: number, name = 'bash', owner = turn): ChatNode<'
return {
key, id: key, kind: 'tool-call', target: 'chat', anchorSeq: seq,
location: { kind: 'turn', turn: owner }, visibility: 'visible',
data: { root: { phase: 'start' as const, callId: key, name, argsRaw: '{"command":"pwd"}', turn: owner.turn, step: 1, time: seq, subCalls: [] } },
data: { root: { phase: 'start' as const, args: PartialArguments.fromText('{"command":"pwd"}'), callId: key, name, argsRaw: '{"command":"pwd"}', turn: owner.turn, step: 1, time: seq, subCalls: [] } },
}
}
@@ -79,10 +81,116 @@ function harness(nodes: readonly ChatConversationViewNode[], currentTimeline = t
}
describe('Definition-owned Chat process groups', () => {
it.each([
['whitespace', ' one\t\n two ', 'one two'],
['empty text', ' \t\n ', 'bash'],
['exact limit', 'x'.repeat(160), 'x'.repeat(160)],
['overflow', 'x'.repeat(161), `${'x'.repeat(159)}…`],
['space at cutoff', `${'x'.repeat(158)} \n yz`, `${'x'.repeat(158)}…`],
['combining marks', 'e\u0301'.repeat(161), `${'e\u0301'.repeat(159)}…`],
['emoji at limit', '👩‍💻'.repeat(160), '👩‍💻'.repeat(160)],
['emoji overflow', '👩‍💻'.repeat(161), `${'👩‍💻'.repeat(159)}…`],
['sealed unpaired surrogate', 'x'.repeat(159) + '\ud83d', 'x'.repeat(159) + '\ud83d'],
])('preserves normalized live detail for %s', (_label, command, expected) => {
const node = tool('call', 2)
const argsRaw = JSON.stringify({ command })
const root = { ...node.data.root, argsRaw, args: PartialArguments.fromText(argsRaw) }
expect(processActivity([{ ...node, data: { root } }]).runningDetail).toBe(expected)
})
it('keeps 160 graphemes when the bounded prefix splits a surrogate pair', () => {
const command = '👩‍💻'.repeat(87) + '☀️' + 'a'.repeat(71) + '👩‍💻'
expect(command).toHaveLength(513)
const argsRaw = JSON.stringify({ command })
const args = PartialArguments.fromText(argsRaw)
const node: ChatNode<'tool-call'> = {
...tool('call', 2),
data: { root: { phase: 'start', argsRaw, args, callId: 'call', name: 'bash', turn: 1, step: 1, time: 2, subCalls: [] } },
}
const prefix = vi.spyOn(args, 'textPrefix')
const text = vi.spyOn(args, 'text')
try {
expect(processActivity([node]).runningDetail).toBe(command)
expect(text).not.toHaveBeenCalled()
expect(prefix.mock.calls.every(([, limit]) => limit <= 513)).toBe(true)
} finally {
prefix.mockRestore()
text.mockRestore()
}
})
it.each([false, true])('waits for a low surrogate across deltas before truncating (escaped=%s)', (escaped) => {
const command = '👩‍💻'.repeat(87) + '☀️' + 'a'.repeat(71) + '👩‍💻'
const args = new PartialArguments()
args.append(`{"command":"${command.slice(0, 511)}${escaped ? '\\ud83d' : command[511]}`)
const node: ChatNode<'tool-call'> = {
...tool('call', 2),
data: { root: { phase: 'preparing', args, callId: 'call', name: 'bash', turn: 1, step: 1, time: 2, subCalls: [] } },
}
expect(processActivity([node]).runningDetail).toBe(command.slice(0, 511))
args.append(escaped ? '\\udcbb' : command[512]!)
expect(args.refresh()).toBe(true)
expect(processActivity([node]).runningDetail).toBe(command)
args.append('x"}')
expect(args.refresh()).toBe(true)
expect(processActivity([node]).runningDetail).toBe(`${command.slice(0, -5)}…`)
})
it.each(['"}', 'x"}'])('counts an unpaired surrogate once its continuation is known (%s)', (ending) => {
const command = '👩‍💻'.repeat(87) + '☀️' + 'a'.repeat(71) + '👩‍\ud83d'
const args = new PartialArguments()
args.append(`{"command":"${command}`)
const node: ChatNode<'tool-call'> = {
...tool('call', 2),
data: { root: { phase: 'preparing', args, callId: 'call', name: 'bash', turn: 1, step: 1, time: 2, subCalls: [] } },
}
expect(processActivity([node]).runningDetail).toBe(command.slice(0, -1))
args.append(ending)
expect(args.refresh()).toBe(true)
expect(processActivity([node]).runningDetail).toBe(`${command.slice(0, -4)}…`)
})
it('bounds grapheme traversal while allowing a later description to replace a long command', () => {
const args = new PartialArguments()
args.append(`{"command":"${'x'.repeat(1024)}`)
const node: ChatNode<'tool-call'> = {
...tool('call', 2),
data: { root: { phase: 'preparing', args, callId: 'call', name: 'bash', turn: 1, step: 1, time: 2, subCalls: [] } },
}
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
const segment = segmenter.segment.bind(segmenter)
let visited = 0
const spy = vi.spyOn(Intl.Segmenter.prototype, 'segment').mockImplementation((input) => {
const segments = segment(input)
const iterator = segments[Symbol.iterator]()
segments[Symbol.iterator] = function* () {
for (const part of iterator) {
visited++
yield part
}
return undefined
}
return segments
})
try {
expect(processActivity([node]).runningDetail).toBe(`${'x'.repeat(159)}…`)
expect(visited).toBeLessThanOrEqual(161)
args.append('y'.repeat(1024))
expect(args.refresh()).toBe(false)
args.append('","description":"Run focused tests"}')
expect(args.refresh()).toBe(true)
spy.mockClear()
expect(processActivity([node]).runningDetail).toBe('Run focused tests')
expect(spy).not.toHaveBeenCalled()
} finally {
spy.mockRestore()
}
})
it('clears preparation from a closed group while retaining its recorded activity', () => {
const preparing: ChatNode<'tool-call'> = {
...tool('call', 2, 'write'),
data: { root: { phase: 'preparing', callId: 'call', name: 'write', turn: 1, step: 1, time: 2, subCalls: [] } },
data: { root: { phase: 'preparing', args: PartialArguments.EMPTY, callId: 'call', name: 'write', turn: 1, step: 1, time: 2, subCalls: [] } },
}
const h = harness([preparing])
const group = h.store.entries[0]!
@@ -103,17 +211,22 @@ describe('Definition-owned Chat process groups', () => {
['list_mcp_resources', 'tools'], ['list_mcp_resource_templates', 'tools'], ['read_mcp_resource', 'tools'],
] as const)('%s keeps its activity category through preparation, dispatch, and result', (name, kind) => {
const started = tool('call', 2, name)
const args = new PartialArguments()
const preparing: ChatNode<'tool-call'> = {
...started,
data: { root: { phase: 'preparing', callId: 'call', name, turn: 1, step: 1, time: 2, subCalls: [] } },
data: { root: { phase: 'preparing', args, callId: 'call', name, turn: 1, step: 1, time: 2, subCalls: [] } },
}
const h = harness([preparing])
const group = h.store.entries[0]!
if (group.kind !== 'group') throw new Error('expected group')
const source = h.store.groupSource(group.key)
expect(source.getSnapshot()?.data.summary).toEqual({
counts: [{ kind, count: 1 }], running: kind, preparing: true, runningDetail: kind === 'tools' ? name : '',
counts: [{ kind, count: 1 }], running: kind, preparing: true, runningDetail: '',
})
args.append('{}')
h.builder.apply({ upserts: [{ ...preparing, data: { root: { ...preparing.data.root } } }], timeline })
h.commit()
expect(source.getSnapshot()?.data.summary.runningDetail).toBe(name)
h.builder.apply({ upserts: [started], timeline })
h.commit()
expect(source.getSnapshot()?.data.summary).toEqual({ counts: [{ kind, count: 1 }], running: kind, runningDetail: 'pwd' })
@@ -121,6 +234,7 @@ describe('Definition-owned Chat process groups', () => {
...started,
data: { root: {
kind: 'tool-result', callId: 'call', seq: 3, time: 3, callTime: 2,
name, args: PartialArguments.fromText('{"command":"pwd"}'),
call: { name, argsRaw: '{"command":"pwd"}' }, content: [], isError: false, subCalls: [],
} },
}
@@ -337,7 +451,7 @@ describe('Definition-owned Chat process groups', () => {
const previous = h.store.groupSource(history.key).getSnapshot()
const live = tool('live', 4)
h.builder.apply({
upserts: [{ ...live, data: { root: { ...live.data.root, argsRaw: '{"command":"ls"}' } } }],
upserts: [{ ...live, data: { root: { ...live.data.root, argsRaw: '{"command":"ls"}', args: PartialArguments.fromText('{"command":"ls"}') } } }],
timeline,
})
const input = h.builder.groupInput()
@@ -1,4 +1,5 @@
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
import { describe, expect, it } from 'vitest'
import type { StartedToolCall, ToolCallBlock } from '../src/client/contract/snapshot.ts'
import {
@@ -20,7 +21,7 @@ const settle = (seq: number, parentCallId: string, subCallId: string): SessionEv
})
const root = (callId: string): StartedToolCall => ({
phase: 'start' as const, callId, name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
phase: 'start' as const, args: PartialArguments.fromText('{}'), callId, name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
time: 1_700_000_000_000, subCalls: [],
})
@@ -43,6 +43,15 @@ function call(seq: number, callId = first, name = 'write', argumentsRaw = args):
data: { turn: 1, step: 1, callId, name, arguments: argumentsRaw } })
}
function blockEnd(seq: number, argumentsRaw: string): SessionEventLikeEntry {
return { type: 'transient', event: {
type: 'assistant/live-chunk', seq, time: seq,
data: { turn: 1, step: 1, attemptId, chunk: {
type: 'block-end', index: 0, block: { type: 'tool-call', id: first, name: 'write', arguments: argumentsRaw },
} },
} }
}
function result(seq: number, callId = first): SessionLiveEventEntry {
return entry({ type: 'tool/result', seq: SessionSeq(seq), time: seq, surfaceOp: 'append', data: {
turn: 1, step: 1, message: createToolResultMessage({ callId, content: [{ type: 'text', text: 'Written' }], isError: false }),
@@ -67,9 +76,14 @@ function settlement(ids = [first, second], name = 'write', argumentsRaw = args,
} }
}
function harness(entries: readonly SessionEventLikeEntry[] = opening, hasMore = false, tool = toolDefinition) {
function harness(
entries: readonly SessionEventLikeEntry[] = opening,
hasMore = false,
tool = toolDefinition,
assistant = assistantDefinition,
) {
const assembler = new ConversationNodeAssembler(
{ entries: () => [assistantDefinition, tool], fallbackEntry: () => undefined },
{ entries: () => [assistant, tool], fallbackEntry: () => undefined },
{ entries: () => [chatViewDefinition] },
{ entries: () => [processGroupDefinition], forTarget: target => target === 'chat' ? processGroupDefinition : undefined },
)
@@ -87,6 +101,102 @@ function harness(entries: readonly SessionEventLikeEntry[] = opening, hasMore =
}
describe('Tool preparation and durable replay', () => {
it('keeps Assistant state unchanged for parameter-only deltas while the Tool reader advances', () => {
const update = vi.fn<typeof assistantDefinition.update>((...values) => assistantDefinition.update(...values))
const h = harness(opening, false, toolDefinition, { ...assistantDefinition, update })
h.assembler.append(delta(2.1, first, '{"file_path":"hello.txt","content":"he'))
const before: unknown = update.mock.results.at(-1)!.value
const view = h.tools()[0]!.data.root.args
expect(view.text('content')).toBe('he')
h.assembler.append(delta(2.2, first, 'llo"}', null))
expect(update.mock.results.at(-1)!.value).toBe(before)
expect(view.text('content')).toBe('hello')
expect(before).toMatchObject({ blocks: [{ kind: 'tool-call', name: 'write', argsRaw: '' }], firstTokenTime: 2.1 })
h.assembler.append(blockEnd(2.3, args))
expect(update.mock.results.at(-1)!.value).toMatchObject({ blocks: [{ argsRaw: args }], firstTokenTime: 2.1 })
})
it('accepts a complete tool block before tool/call when no deltas were observed', () => {
const h = harness()
h.assembler.append(blockEnd(2.1, args))
expect(() => h.assembler.append(call(6))).not.toThrow()
expect(h.phases()).toEqual([[first, 'start']])
expect(h.tools()[0]!.data.root.args.text('content')).toBe('hello')
})
it('retains the argument reader across matching block-end, call, and result', () => {
const h = harness()
h.assembler.append(delta(2.1, first, '{"file_path":"hello.txt","content":"he'))
const before = h.tools()[0]!
const view = before.data.root.args
expect(view.text('content')).toBe('he')
expect(view.complete('content')).toBe(false)
h.assembler.append(delta(2.2, first, 'llo"}', null))
h.assembler.append(blockEnd(2.3, args))
const ended = h.tools()[0]!
expect(ended).not.toBe(before)
expect(ended.data.root.args).toBe(view)
expect(view.text('content')).toBe('hello')
expect(view.complete('content')).toBe(true)
h.assembler.append(call(6))
expect(h.tools()[0]!.data.root.args).toBe(view)
h.assembler.append(result(7))
expect(h.tools()[0]!.data.root.args).toBe(view)
})
it.each([null, 'write'])('ignores late tool deltas after block-end (name=%s)', (name) => {
const h = harness()
h.assembler.append(delta(2.1, first, args))
const view = h.tools()[0]!.data.root.args
h.assembler.append(blockEnd(2.2, args))
const ended = h.tools()[0]!
expect(() => h.assembler.append(delta(2.3, first, ' ignored', name))).not.toThrow()
expect(h.tools()[0]).toBe(ended)
expect(view.text('content')).toBe('hello')
h.assembler.append(call(6))
expect(h.phases()).toEqual([[first, 'start']])
expect(h.tools()[0]!.data.root.args).toBe(view)
h.assembler.append(result(7))
expect(h.phases()).toEqual([[first, 'result']])
expect(h.tools()[0]!.data.root.args).toBe(view)
})
it('accepts deltas after a closing brace until block-end without checking closed()', () => {
const h = harness()
h.assembler.append(delta(2.1, first, args))
const view = h.tools()[0]!.data.root.args
expect(view.closed()).toBe(true)
const closed = vi.spyOn(view, 'closed')
try {
h.assembler.append(delta(2.2, first, ' ', null))
expect(closed).not.toHaveBeenCalled()
} finally {
closed.mockRestore()
}
h.assembler.append(blockEnd(2.3, args + ' '))
expect(h.tools()[0]!.data.root.args).toBe(view)
})
it.each([
'{"file_path":"other.txt","content":"hello"}',
'{"file_path":"hello.txt","content":"he',
args + args,
])('uses authoritative block-end text after conflicting deltas: %s', (streamed) => {
const h = harness()
h.assembler.append(delta(2.1, first, streamed))
const before = h.tools()[0]!.data.root.args
before.text('file_path')
h.assembler.append(blockEnd(2.2, args))
const ended = h.tools()[0]!.data.root.args
expect(ended).not.toBe(before)
expect(ended.text('file_path')).toBe('hello.txt')
expect(ended.text('content')).toBe('hello')
const dispatched = '{"file_path":"final.txt","content":"done"}'
h.assembler.append(call(6, first, 'write', dispatched))
expect(h.tools()[0]!.data.root.args.text('file_path')).toBe('final.txt')
expect(h.tools()[0]!.data.root).toMatchObject({ argsRaw: dispatched })
})
it('batches repeated named deltas and retains the unchanged Tool node', () => {
const start = vi.fn<typeof toolDefinition.start>((...args) => toolDefinition.start(...args))
const h = harness(opening, false, { ...toolDefinition, start })
@@ -104,6 +214,37 @@ describe('Tool preparation and durable replay', () => {
expect(h.phases()).toEqual([[first, 'result']])
})
it('publishes a reread argument change and retains the new node through unchanged details', () => {
const h = harness()
h.assembler.append(delta(2.1, first, '{"command":"a', 'bash'))
const original = h.tools()[0]!
const groups = h.assembler.grouped('chat')!
const group = groups.entries.find(entry => entry.kind === 'group')!
if (group.kind !== 'group') throw new Error('expected a process group')
const source = groups.groupSource(group.key)
const before = source.getSnapshot()
expect(before?.data.summary.runningDetail).toBe('a')
const argumentsView = original.data.root.args
expect(argumentsView.textPrefix('command', 512)).toBe('a')
h.assembler.append(delta(2.2, first, 'b', null))
expect(argumentsView.textPrefix('command', 512)).toBe('ab')
expect(source.getSnapshot()).toBe(before)
const updated = h.tools()[0]!
expect(updated).not.toBe(original)
expect(updated.data.root).not.toBe(original.data.root)
expect(updated.data.root.args).toBe(argumentsView)
expect(updated.key).toBe(original.key)
const after = source.getSnapshot()
expect(after?.data.summary.runningDetail).toBe('ab')
for (const [index, fragment] of ['', '","unread":"x', 'y"}'].entries()) {
h.assembler.append(delta(2.3 + index / 1000, first, fragment, null))
expect(h.tools()[0]).toBe(updated)
expect(source.getSnapshot()).toBe(after)
}
})
it('creates two independent live preparations and updates each call under the same key', () => {
const h = harness()
h.assembler.append(delta(2.1, first, '', null))
@@ -0,0 +1,136 @@
/** Turn-node Locations and Assistant timing across partial windows and transient retirement. */
import { describe, expect, it } from 'vitest'
import { createAssistantMessage, createToolResultMessage, LlmAttemptId, ToolCallId } from '@deepseek-ai/dsh-llm'
import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream'
import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
SessionAssistantSettlementEntry, SessionEventLikeEntry, SessionLiveEventEntry, SessionTransientEventEntry,
} from '@deepseek-ai/dsh-api-session-controller/client'
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client'
import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts'
import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
import { turnProcessDefinition } from '../src/client/conversation-nodes/turn-process.ts'
import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts'
const attemptId = LlmAttemptId('turn-projection-test')
const callId = ToolCallId('turn-projection-call')
const toolBlock = { type: 'tool-call' as const, id: callId, name: 'write', arguments: '{"content":"hello"}' }
function entry(event: SessionEvent): SessionLiveEventEntry {
return { type: 'event', event }
}
const opening = [
entry({ type: 'turn/start', seq: SessionSeq(1), time: 1_000, data: { turn: 1 } }),
entry({ type: 'step/start', seq: SessionSeq(2), time: 2_000, data: { turn: 1, step: 1 } }),
]
const deltas: SessionTransientEventEntry[] = ['', toolBlock.arguments].map((argumentsDelta, index) => ({
type: 'transient', event: {
type: 'assistant/live-chunk', seq: 2.1 + index / 10, time: 2_100 + index * 100,
data: { turn: 1, step: 1, attemptId, chunk: {
type: 'tool-call-delta', index: 0, id: callId, argumentsDelta,
...(index === 0 ? {} : { name: 'write' }),
} },
},
}))
function stream() {
const accumulator = new AssistantStreamAccumulator()
for (const { event } of deltas) accumulator.push({ time: event.time, chunk: event.data.chunk })
return [...accumulator.snapshot()]
}
function harness(entries: readonly SessionEventLikeEntry[], hasMore = true) {
const assembler = new ConversationNodeAssembler(
{ entries: () => [assistantDefinition, turnProcessDefinition, turnTailDefinition], fallbackEntry: () => undefined },
{ entries: () => [chatViewDefinition] },
)
assembler.replaceWindow(entries, hasMore)
assembler.activateTarget('chat')
const read = () => {
assembler.flush()
return assembler.snapshot('chat') as ChatSnapshot
}
return { assembler, read }
}
describe('Turn nodes in partial windows', () => {
it('retains Step Locations and first-token timing through message settlement, replay, and prepend', () => {
const value = harness(deltas)
const committed: SessionAssistantSettlementEntry = { type: 'event', event: {
type: 'assistant/message', seq: SessionSeq(3), time: 3_000, surfaceOp: 'append', data: {
turn: 1, step: 1, stream: stream(),
message: createAssistantMessage({ source: { provider: 'test', model: 'test' }, content: [toolBlock] }),
},
} }
value.assembler.append(committed)
expect(value.read().timeline.turns.get(1)?.steps[0]?.data.get('assistant-step')?.finalNode?.timing)
.toMatchObject({ stepStartTime: null, firstTokenTime: 2_200, completedTime: 3_000 })
const closing = [
entry({ type: 'tool/call', seq: SessionSeq(4), time: 4_000,
data: { turn: 1, step: 1, callId, name: 'write', arguments: toolBlock.arguments } }),
entry({ type: 'tool/result', seq: SessionSeq(5), time: 5_000, surfaceOp: 'append', data: {
turn: 1, step: 1, message: createToolResultMessage({ callId, content: [{ type: 'text', text: 'Written' }], isError: false }),
} }),
entry({ type: 'step/end', seq: SessionSeq(6), time: 6_000, data: { turn: 1, step: 1 } }),
entry({ type: 'step/start', seq: SessionSeq(7), time: 7_000, data: { turn: 1, step: 2 } }),
entry({ type: 'assistant/message', seq: SessionSeq(8), time: 8_000, surfaceOp: 'append', data: {
turn: 1, step: 2, stream: [],
message: createAssistantMessage({ source: { provider: 'test', model: 'test' }, content: [{ type: 'text', text: 'Done' }] }),
} }),
entry({ type: 'step/end', seq: SessionSeq(9), time: 9_000, data: { turn: 1, step: 2 } }),
entry({ type: 'turn/end', seq: SessionSeq(10), time: 10_000, data: { turn: 1, reason: { kind: 'completed' } } }),
]
for (const event of closing) value.assembler.append(event)
const before = value.read()
for (const kind of ['turn-process', 'turn-tail']) {
expect(before.nodes.values().find(node => node.kind === kind)?.location)
.toMatchObject({ kind: 'step', turn: { turn: 1 }, step: { step: 1 } })
}
value.assembler.settleAssistant(attemptId, committed)
const replay = harness([committed, ...closing]).read()
const settled = value.read()
expect(settled.order).toEqual(replay.order)
for (const kind of ['turn-process', 'turn-tail']) {
const node = settled.nodes.values().find(node => node.kind === kind)
expect(node?.location).toMatchObject({ kind: 'step', turn: { turn: 1 }, step: { step: 1 } })
expect(node?.data).toEqual(replay.nodes.values().find(node => node.kind === kind)?.data)
}
expect(settled.timeline.turns.get(1)?.steps[0]?.data.get('assistant-step')?.finalNode?.timing)
.toMatchObject({ firstTokenTime: null })
value.assembler.prepend(opening, false)
const complete = value.read()
for (const kind of ['turn-process', 'turn-tail']) {
expect(complete.nodes.values().find(node => node.kind === kind)?.location)
.toMatchObject({ kind: 'turn', turn: { turn: 1 } })
}
expect(complete.timeline.turns.get(1)?.steps[0]?.data.get('assistant-step')?.finalNode?.timing)
.toEqual({ stepStartTime: 2_000, firstTokenTime: null, completedTime: 3_000 })
})
it.each(['attempt', 'uncommitted'] as const)('retains a missing-start footer after %s stream retirement', (kind) => {
const value = harness(deltas)
// The Turn end can arrive before the Assistant end frame that retires its live chunks.
value.assembler.append(entry({ type: 'turn/end', seq: SessionSeq(4), time: 4_000,
data: { turn: 1, reason: { kind: 'completed' } } }))
const before = value.read()
const tail = before.nodes.values().find(node => node.kind === 'turn-tail')!
expect(tail.location).toMatchObject({ kind: 'step', turn: { turn: 1 }, step: { step: 1 } })
expect(before.timeline.turns.get(1)?.steps[0]?.data.get('assistant-step'))
.toMatchObject({ status: 'interrupted', blocks: [{ kind: 'tool-call', callId, name: 'write' }] })
const committed: SessionAssistantSettlementEntry | undefined = kind === 'attempt' ? { type: 'event', event: {
type: 'assistant/attempt', seq: SessionSeq(3), time: 3_000, data: { turn: 1, step: 1, stream: stream() },
} } : undefined
value.assembler.settleAssistant(attemptId, committed)
const after = value.read()
const current = after.nodes.get(tail.key)!
expect(current.location).toMatchObject(kind === 'attempt'
? { kind: 'step', turn: { turn: 1 }, step: { step: 1 } }
: { kind: 'turn', turn: { turn: 1 } })
expect(current.data).toMatchObject({ turn: 1, seq: 4, closing: null, branchUnavailable: true })
expect(after.timeline.turns.get(1)?.steps[0]?.data.get('assistant-step')).toBeUndefined()
expect(after.nodes.values().find(node => node.kind === 'assistant-step')?.visibility).toBe('hidden')
expect(after.order).toEqual([tail.key])
})
})
@@ -15,8 +15,8 @@
en: 9c1c4905952fea5e
zh: d1506dd6fc1018d1
/deepseek-ai-dsh-client-ui-conversation/conversation-assembly:
en: 13ae68865fa315d7
zh: c152f020abf20f35
en: 1d2a3a7f98c2cc9b
zh: f895dd5e9f91e0a9
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
en: 8fddd7e838da57d8
zh: 98748142be2e61d4
@@ -33,6 +33,14 @@ Desktop product events use the optional [product analytics service](../product-a
The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes durable events from Client-only transient events, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Replacement windows may include both entry variants, while historical prepends carry durable entries and live appends may carry either. Definitions use the same match/start/update interface for durable and transient events. The earliest loaded start initializes State; every later Match, including another start for that identity, updates it. Definitions that do not consume Assistant deltas return `null` for `assistant/live-chunk`. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append, prepend, and Assistant-settlement revisions use incremental assembly. Settlement removes only the named attempt's transient matches, applies its optional durable entry, and recomputes the affected Contexts from their remaining earliest start, refreshing predecessor indexes and dependents without replacing unrelated target nodes. A Context with no remaining start has no State. Its key and previously published nodes remain available to later evidence until a full window rebuild; the owning Definition may hide those nodes. 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`.
Definitions may share the same read-only Match and Location for one input and lifecycle role. Each Definition keeps its own Context, State, and ordered Match list; Match objects are not reused across assembler instances.
Registration accepts the existing callable `match` or a read-only event-type-to-handler table. The Registry copies table entries and precomputes ordered candidate Sets on registration changes. Function-form Definitions keep their callable API and receive every event; table handlers receive only their declared types. Changing a table requires unregistering and registering again. Legacy assembler adapters with only `entries()` and `fallbackEntry()` retain their original dispatch path.
All accepted Matches update State in order. Repeated updates to one Context are coalesced between flushes. A Context retains its key while kind and id match; Definition-facing Context snapshots remain distinct objects. The fallback handles only inputs not claimed by an ordinary Definition for its target.
Location lookup indexes Steps by Turn and Step number, so a non-boundary append does not scan the Turn's Step array. Each input's numeric coordinates and resolved Location share one private index record; published Location values remain immutable. Boundary updates, replacement, and prepend keep the indexes aligned with the current timeline.
A target becomes active when shell selection resolves it or when its source receives a first subscriber. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it and unsubscription does not deactivate it.
`UiConversation.groups` registers one optional business Group Definition per existing target. It consumes projected Node changes, changed Turns, and indexed target positions after Node materialization, including first activation, and owns all segmentation and group data. Indexed Turn reads preserve interruptions from neighbouring Nodes, allowing business updates to stay within affected Turns and groups. The assembler validates and installs root references and keyed group snapshots before publishing Node, Group, and Location data sources. Ungrouped targets retain their original path. [Grouping](../../../docs/subsystems/conversation.md#group-definitions) defines input lifetime, typed registration, atomic updates, and renderer responsibilities.
@@ -33,6 +33,14 @@ kind: "package-reference"
适配器把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分持久事件与 Client-only transient event,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。replacement window 可以包含两种 entry,历史 prepend 携带持久 entry,实时 append 则可以携带任一种。持久与瞬态事件使用同一组 match/start/update 接口。当前最早的 start 初始化 State;后续所有 Match,包括同一身份的其他 start,都用于更新。不消费 Assistant delta 的 Definition 对 `assistant/live-chunk` 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append、prepend 与 Assistant settlement 使用增量组装。settlement 只删除具名 attempt 的 transient match,应用可选持久 entry,并从剩余最早的 start 重算受影响的 Context,刷新前序索引及 dependent,不替换无关 target node。没有剩余 start 的 Context 不保留 State,但其 key 和已发布节点仍保留,供后续证据复用,直到完整窗口重建;所属 Definition 可以隐藏这些节点。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。
多个 Definition 可以为同一输入和生命周期角色共享只读 Match 与 Location。各 Definition 仍持有独立的 Context、State 和有序 Match 列表;Match 对象不会跨 assembler 实例复用。
注册入口接受原有可调用的 `match`,或只读的事件类型到处理函数表。Registry 在注册关系变化时复制表条目并预计算有序候选 Set。函数形式的 Definition 保持原有可调用 API,接收全部事件;表中的处理函数只接收已声明类型。修改表需要注销后重新注册。只有 `entries()` 与 `fallbackEntry()` 的旧 assembler 适配器保留原有分发路径。
所有接受的 Match 按顺序更新 State。两次 flush 之间,同一 Context 的重复更新合批发布。kind 和 id 相同时,Context 保持原有 key;传给 Definition 的 Context 快照仍为独立对象。fallback 只处理未被其目标的普通 Definition 认领的输入。
Location 查询按 Turn 和 Step 编号索引 Step,因此非边界 append 不扫描 Turn 的 Step 数组。每个输入的数值坐标与解析出的 Location 共用一条私有索引记录;已发布的 Location 值保持不可变。边界更新、完整替换和向前补页使这些索引与当前时间线保持一致。
shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。
`UiConversation.groups` 为每个已注册目标注册一个可选的业务 Group Definition。它在节点物化后消费投影后的节点变化、变化轮次及已索引的目标位置,覆盖首次激活,并拥有全部分段规则与组数据。按索引读取 Turn 时保留相邻 Node 造成的分隔,使业务更新可以限制在受影响轮次和组内。assembler 先校验并安装根引用和按键索引的组快照,再发布 Node、Group 和 Location 数据来源。未分组目标保持原有路径。[分组](../../../docs/subsystems/conversation.zh.md#group-definitions)定义输入有效期、类型化注册、原子更新及渲染器职责。
@@ -192,6 +192,13 @@ export type ConversationPublication = 'none' | 'animation-frame' | 'immediate'
/** Engine-owned Location data publication phase. */
export type ConversationLocationDataScope = 'step' | 'turn'
/**
* Extract a stable business identity using only the current event.
* @param event - durable or transient Client event.
* @returns identity and lifecycle role, or null when unrelated.
*/
export type ConversationMatchHandler = (event: SessionEventLike) => ConversationMatchResult | null
/** One independently registered business Event-to-Node state machine. */
export interface ConversationNodeDefinition<State = unknown> {
readonly kind: string
@@ -255,6 +262,12 @@ export interface ConversationNodeDefinition<State = unknown> {
buildViewNode?(context: ConversationNodeContext<State>): ConversationViewNode | null
}
/** Registration accepts a function or an immutable table of own event-type handlers. */
export type ConversationNodeDefinitionInput<State = unknown> =
Omit<ConversationNodeDefinition<State>, 'match'> & {
readonly match: ConversationMatchHandler | Readonly<Record<string, ConversationMatchHandler>>
}
/** Reference-stable Turn/Step facts published beside view Nodes. */
export interface ConversationTimelineSnapshot {
readonly turnOrder: readonly number[]
@@ -9,6 +9,7 @@ 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 { ContextProducerView, KnownContextForm } from './context-producer.ts'
import type { PartialArguments } from '@deepseek-ai/dsh-util-values'
export type { TodoItem }
/** Request configuration recorded for one provider call. */
@@ -160,6 +161,10 @@ export interface ToolResultNode {
callId: string
/** Parent Tool call for a PTC dispatch result; absent on a root Session result. */
parentCallId?: string
/** Wire tool name from the paired tool/call; empty when that call left the loaded window. */
name: string
/** The argument view of the paired call; see {@link ToolArgs}. */
args: ToolArgs
/** 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. */
@@ -269,9 +274,23 @@ interface ToolCallHead {
time: number
/** Child calls owned by this call, in dispatch order. */
subCalls: readonly ToolCallBlock[]
/** The argument view; see {@link ToolArgs}. */
args: ToolArgs
}
/** A named model call whose arguments are not yet available to tool views. */
/**
* Lazy top-level argument readers shared by preparing, start, and result;
* {@link PartialArguments} defines field, prefix, length, and completion reads.
* A preparing view grows in place. Read it during render or view construction,
* not into independent state keyed only by the args or block object identity.
* The Definition publishes the initial named block, then republishes when an
* observed answer changes. Unread fields alone do not request republication.
* Dispatched views are sealed over finished text or parsed values. The view is
* empty for a result whose `tool/call` left the loaded window.
*/
export type ToolArgs = PartialArguments
/** A named model call whose complete arguments are not yet available to tool views. */
export interface PreparingToolCall extends ToolCallHead {
readonly phase: 'preparing'
}
@@ -15,6 +15,7 @@ import type {
ConversationGroupData, ConversationGroupDefinition, ConversationGroupedView,
} from '../contract/groups.ts'
import { ConversationGroupStore } from './group-store.ts'
import type { ConversationEventRegistry } from './event-registry.ts'
import {
ConversationLocationIndex, type ConversationLocationDataChange,
} from './location-index.ts'
@@ -143,7 +144,7 @@ function conversationMatch(
}
/** Event Registry subset consumed by a Session-owned Assembler. */
export interface ConversationEventDefinitions {
export interface ConversationEventDefinitions extends Partial<Pick<ConversationEventRegistry, 'forEvent'>> {
/** @returns ordinary Definitions in registration order. */
entries(): readonly ConversationNodeDefinition[]
/** @returns unmatched-event fallback, when registered. */
@@ -173,7 +174,7 @@ const NO_GROUPS: ConversationGroupDefinitions = { entries: () => [], forTarget:
export class ConversationNodeAssembler implements ConversationViewSnapshotStore {
private readonly contexts = new Map<string, InternalContext>()
private readonly contextsByKind = new Map<string, InternalContext[]>()
private readonly contextsBySeq = new Map<number, Set<InternalContext>>()
private readonly contextsBySeq = new Map<number, InternalContext[]>()
private readonly contextsByTarget = new Map<string, Set<InternalContext>>()
private readonly inputs = new Map<number, SessionEventLikeEntry>()
private readonly locationIndex = new ConversationLocationIndex()
@@ -250,7 +251,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
append(record: SessionEventLikeEntry): ConversationPublication {
const event = record.event
if (this.inputs.has(event.seq)) return 'none'
this.revised.clear()
if (this.revised.size > 0) this.revised.clear()
this.inputs.set(event.seq, record)
let publication: ConversationPublication = 'none'
if (event.type !== 'assistant/live-chunk' && isLocationBoundary(event.type)) {
@@ -267,7 +268,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
}
publication = maximumPublication(publication, this.matchInput(record))
if (this.replayRevisedDependents()) publication = 'immediate'
this.revised.clear()
if (this.revised.size > 0) this.revised.clear()
return publication
}
@@ -456,21 +457,16 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
}
private matchInput(input: SessionEventLikeEntry): ConversationPublication {
return this.dispatchInput(input, (definition, id, role) =>
this.acceptMatch(definition, id, role, input))
// oxlint-disable-next-line typescript/unbound-method -- dispatchInput supplies the assembler receiver
return this.dispatchInput(input, this.acceptMatch)
}
private collectInput(
input: SessionEventLikeEntry,
pending: Map<string, PendingMatch[]>,
): ConversationPublication {
return this.dispatchInput(input, (definition, id, role) => {
return this.dispatchInput(input, (definition, id, match) => {
const key = conversationContextKey(definition.kind, id)
const match = conversationMatch(
input,
role,
this.locationIndex.locationOf(input.event),
)
const matches = pending.get(key) ?? []
matches.push({ definition, id, match })
pending.set(key, matches)
@@ -481,26 +477,60 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
private dispatchInput(
input: SessionEventLikeEntry,
accept: (
this: ConversationNodeAssembler,
definition: ConversationNodeDefinition,
id: string,
role: ConversationMatch['role'],
match: ConversationMatch,
) => ConversationPublication,
): ConversationPublication {
const event = input.event
const matchedTargets = new Set<string>()
let startMatch: ConversationMatch | undefined
let updateMatch: ConversationMatch | undefined
let location: ConversationMatch['location'] | undefined
const matchFor = (role: ConversationMatch['role']): ConversationMatch => {
location ??= this.locationIndex.locationOf(event)
return role === 'start'
? startMatch ??= conversationMatch(input, role, location)
: updateMatch ??= conversationMatch(input, role, location)
}
let firstTarget: string | undefined
let secondTarget: string | undefined
let otherTargets: Set<string> | undefined
let publication: ConversationPublication = 'none'
for (const definition of this.eventDefinitions.entries()) {
const result = definition.match(event)
if (result === null) continue
if (definition.target !== undefined) matchedTargets.add(definition.target)
publication = maximumPublication(publication, accept(definition, result.id, result.role))
const routes = this.eventDefinitions.forEvent?.(event.type)
if (routes === undefined) {
for (const definition of this.eventDefinitions.entries()) {
const result = definition.match(event)
if (result === null) continue
// Inline target tracking avoids an additional closure allocation for each event.
/* jscpd:ignore-start */
if (definition.target !== undefined && definition.target !== firstTarget && definition.target !== secondTarget) {
if (firstTarget === undefined) firstTarget = definition.target
else if (secondTarget === undefined) secondTarget = definition.target
else (otherTargets ??= new Set()).add(definition.target)
}
publication = maximumPublication(publication, accept.call(this, definition, result.id, matchFor(result.role)))
/* jscpd:ignore-end */
}
} else {
for (const { definition, match } of routes) {
const result = match(event)
if (result === null) continue
if (definition.target !== undefined && definition.target !== firstTarget && definition.target !== secondTarget) {
if (firstTarget === undefined) firstTarget = definition.target
else if (secondTarget === undefined) secondTarget = definition.target
else (otherTargets ??= new Set()).add(definition.target)
}
publication = maximumPublication(publication, accept.call(this, definition, result.id, matchFor(result.role)))
}
}
const fallback = this.eventDefinitions.fallbackEntry()
const target = fallback?.target
if (fallback !== undefined && target !== undefined && !matchedTargets.has(target)) {
if (fallback !== undefined && target !== undefined
&& target !== firstTarget && target !== secondTarget && otherTargets?.has(target) !== true) {
const result = fallback.match(event)
if (result !== null) {
publication = maximumPublication(publication, accept(fallback, result.id, result.role))
publication = maximumPublication(publication, accept.call(this, fallback, result.id, matchFor(result.role)))
}
}
return publication
@@ -533,34 +563,32 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
private acceptMatch(
definition: ConversationNodeDefinition,
id: string,
role: ConversationMatch['role'],
input: SessionEventLikeEntry,
match: ConversationMatch,
): ConversationPublication {
const key = conversationContextKey(definition.kind, id)
const latest = this.contextsByKind.get(definition.kind)?.at(-1)
const key = latest?.id === id ? latest.key : conversationContextKey(definition.kind, id)
let context = this.contexts.get(key)
context ??= this.createContext(definition, id, key)
const starting = role === 'start' && context.start === undefined
const match = conversationMatch(
input,
role,
this.locationIndex.locationOf(input.event),
)
const starting = match.role === 'start' && context.start === undefined
const previous = context.matches.at(-1)
if (previous !== undefined && previous.event.seq >= input.event.seq) {
throw new Error(`conversation Context ${key} received non-appended Match ${input.event.seq}`)
if (previous !== undefined && previous.event.seq >= match.event.seq) {
throw new Error(`conversation Context ${key} received non-appended Match ${match.event.seq}`)
}
if (starting && context.matches.length > 0) {
throw new Error(`conversation Context ${key} received an update before its start Match`)
}
context.matches.push(match)
if (starting && match.role === 'start') {
context.startSeq = input.event.seq
if (starting) {
context.startSeq = match.event.seq
context.start = match
this.indexStartedContext(context)
}
const owners = this.contextsBySeq.get(input.event.seq) ?? new Set<InternalContext>()
owners.add(context)
this.contextsBySeq.set(input.event.seq, owners)
let owners = this.contextsBySeq.get(match.event.seq)
if (owners === undefined) {
owners = []
this.contextsBySeq.set(match.event.seq, owners)
}
owners.push(context)
if (starting) {
this.replayContext(context)
@@ -588,9 +616,12 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
if (entry.definition !== context.definition || entry.id !== context.id) {
throw new Error(`conversation Context ${key} received inconsistent Definition identity`)
}
const owners = this.contextsBySeq.get(entry.match.event.seq) ?? new Set<InternalContext>()
owners.add(context)
this.contextsBySeq.set(entry.match.event.seq, owners)
let owners = this.contextsBySeq.get(entry.match.event.seq)
if (owners === undefined) {
owners = []
this.contextsBySeq.set(entry.match.event.seq, owners)
}
owners.push(context)
return entry.match
})
.sort((left, right) => left.event.seq - right.event.seq)
@@ -679,12 +710,16 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
}
private markDirty(context: InternalContext): void {
if (this.dirty.has(context)) return
this.dirty.add(context)
const target = context.definition.target
if (target === undefined || !this.activeTargets.has(target)) return
const contexts = this.dirtyByTarget.get(target) ?? new Set<InternalContext>()
let contexts = this.dirtyByTarget.get(target)
if (contexts === undefined) {
contexts = new Set<InternalContext>()
this.dirtyByTarget.set(target, contexts)
}
contexts.add(context)
this.dirtyByTarget.set(target, contexts)
}
private replaceDependencies(context: InternalContext, dependencies: Map<string, Dependency>): void {
@@ -704,6 +739,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
}
private replayRevisedDependents(): boolean {
if (this.dependents.size === 0) return false
const pending = [...this.revised]
const affected = new Set<InternalContext>()
for (let index = 0; index < pending.length; index++) {
@@ -715,7 +751,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
pending.push(dependent)
}
}
this.replayContexts(affected)
if (affected.size > 0) this.replayContexts(affected)
return affected.size > 0
}
@@ -1,20 +1,31 @@
import type { ConversationNodeDefinition } from '../contract/conversation.ts'
import type {
ConversationMatchHandler, ConversationNodeDefinition, ConversationNodeDefinitionInput,
} from '../contract/conversation.ts'
import { ConversationDefinitionRegistry } from './definition-registry.ts'
/** A registered Definition and its event-type-selected handler. */
export interface ConversationEventRoute {
readonly definition: ConversationNodeDefinition
readonly match: ConversationMatchHandler
}
/** Runtime registry of independently owned Conversation business Definitions. */
export class ConversationEventRegistry extends ConversationDefinitionRegistry<ConversationNodeDefinition> {
private fallback: ConversationNodeDefinition | undefined
private routes = new Map<string, ReadonlySet<ConversationEventRoute>>()
private unrestricted: ReadonlySet<ConversationEventRoute> = new Set()
private readonly tables = new WeakMap<ConversationNodeDefinition, ReadonlyMap<string, ConversationMatchHandler>>()
/**
* Register a uniquely named business Definition for the caller's lifetime.
* @param definition - Definition contribution.
* @returns idempotent disposer.
*/
register(definition: ConversationNodeDefinition): () => void {
register(definition: ConversationNodeDefinitionInput): () => void {
assertDefinitionTarget(definition)
return this.registerDefinition(
definition.kind,
definition,
this.resolve(definition),
`conversation Definition "${definition.kind}" is already registered`,
`uiConversation.events.register(${JSON.stringify(definition.kind)})`,
)
@@ -22,14 +33,15 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry<Co
/**
* Register the sole fallback used only when no ordinary Definition matches.
* @param definition - fallback Definition.
* @param input - fallback Definition.
* @returns idempotent disposer.
*/
registerFallback(definition: ConversationNodeDefinition): () => void {
assertDefinitionTarget(definition)
const target = definition.target
registerFallback(input: ConversationNodeDefinitionInput): () => void {
assertDefinitionTarget(input)
const target = input.target
if (target === undefined) throw new Error('conversation fallback Definition must declare a target')
if (this.fallback !== undefined) throw new Error('conversation fallback Definition is already registered')
const definition = this.resolve(input)
const dispose = this.ctx.effect(() => {
this.fallback = definition
this.refresh()
@@ -49,9 +61,54 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry<Co
fallbackEntry(): ConversationNodeDefinition | undefined {
return this.fallback
}
/**
* Read precomputed candidates in registration order; the returned Set is borrowed read-only.
* @param type - current event type.
* @returns table handlers for this type together with all function-form handlers.
*/
forEvent(type: string): ReadonlySet<ConversationEventRoute> {
return this.routes.get(type) ?? this.unrestricted
}
private resolve(input: ConversationNodeDefinitionInput): ConversationNodeDefinition {
if (typeof input.match === 'function') return input as ConversationNodeDefinition
const table = new Map(Object.entries(input.match))
const definition: ConversationNodeDefinition = {
...input,
match: event => table.get(event.type)?.call(definition, event) ?? null,
}
this.tables.set(definition, table)
return definition
}
protected override refresh(): void {
const routes = new Map<string, Set<ConversationEventRoute>>()
const unrestricted = new Set<ConversationEventRoute>()
for (const definition of this.definitions.values()) {
const table = this.tables.get(definition)
if (table === undefined) {
const route = { definition, match: definition.match.bind(definition) }
unrestricted.add(route)
for (const candidates of routes.values()) candidates.add(route)
} else {
for (const [type, match] of table) {
let candidates = routes.get(type)
if (candidates === undefined) {
candidates = new Set(unrestricted)
routes.set(type, candidates)
}
candidates.add({ definition, match: match.bind(definition) })
}
}
}
this.routes = routes
this.unrestricted = unrestricted
super.refresh()
}
}
function assertDefinitionTarget(definition: ConversationNodeDefinition): void {
function assertDefinitionTarget(definition: ConversationNodeDefinitionInput): void {
if ((definition.target === undefined) !== (definition.buildViewNode === undefined)) {
throw new Error(
`conversation Definition "${definition.kind}" must declare target and buildViewNode together`,
@@ -115,6 +115,7 @@ interface Coordinates {
readonly turn?: number
readonly step?: number
readonly session?: true
location: ConversationLocation | undefined
}
interface StepDraft {
@@ -138,14 +139,19 @@ const UNRESOLVED_LOCATION = { kind: 'unresolved' } as const
function payloadCoordinates(event: SessionEventLike): Coordinates {
const data = event.data as unknown as { turn?: unknown; step?: unknown }
if (data.turn === null) return { session: true }
if (data.turn === null) return { session: true, location: undefined }
const turn = Number.isSafeInteger(data.turn) && (data.turn as number) >= 0
? data.turn as number
: undefined
const step = Number.isSafeInteger(data.step) && (data.step as number) >= 0
? data.step as number
: undefined
return { ...turn === undefined ? {} : { turn }, ...step === undefined ? {} : { step } }
if (turn === undefined) return step === undefined
? { location: undefined }
: { step, location: undefined }
return step === undefined
? { turn, location: undefined }
: { turn, step, location: undefined }
}
function sameReferences<T>(left: readonly T[], right: readonly T[]): boolean {
@@ -177,8 +183,8 @@ function sameLocation(left: ConversationLocation | undefined, right: Conversatio
/** Session-owned Turn/Step timeline and event-to-Location index. */
export class ConversationLocationIndex {
private coordinates = new Map<number, Coordinates>()
private locations = new Map<number, ConversationLocation>()
private seqsByTurn = new Map<number, Set<number>>()
private readonly stepsByTurn = new Map<number, Map<number, StepLocation>>()
private timeline: ConversationTimelineSnapshot = { turnOrder: [], turns: new Map() }
private readonly turnDataStores = new Map<number, MutableLocationDataStore>()
private readonly stepDataStores = new Map<string, MutableLocationDataStore>()
@@ -268,7 +274,7 @@ export class ConversationLocationIndex {
* @returns current Location, falling back to session when it has no Turn/Step affinity.
*/
locationOf(event: SessionEventLike): ConversationLocation {
return this.locations.get(event.seq) ?? SESSION_LOCATION
return this.coordinates.get(event.seq)?.location ?? SESSION_LOCATION
}
/**
@@ -277,7 +283,7 @@ export class ConversationLocationIndex {
* @returns seqs whose resolved Location changed.
*/
rebuild(entries: readonly SessionEventLikeEntry[]): ReadonlySet<number> {
const previousLocations = this.locations
const previousCoordinates = this.coordinates
const turns = new Map<number, TurnDraft>()
const coordinates = new Map<number, Coordinates>()
let currentTurn: number | undefined
@@ -327,6 +333,7 @@ export class ConversationLocationIndex {
coordinates.set(event.seq, {
...turn === undefined ? {} : { turn },
...turn === undefined || step === undefined ? {} : { step },
location: undefined,
})
if (turn !== undefined) turnDraft(turn, event.seq)
if (turn !== undefined && step !== undefined) stepDraft(turn, step, event.seq)
@@ -403,20 +410,26 @@ export class ConversationLocationIndex {
this.timeline = sameMap && turnOrder === this.timeline.turnOrder
? this.timeline
: { turnOrder, turns: nextTurns }
this.stepsByTurn.clear()
for (const [number, turn] of this.timeline.turns) {
this.stepsByTurn.set(number, new Map(turn.steps.map(step => [step.step, step])))
}
this.coordinates = coordinates
this.locations = new Map()
this.seqsByTurn = new Map()
for (const { event } of entries) {
const coordinates = this.coordinates.get(event.seq)
if (coordinates?.turn !== undefined) this.indexTurnSeq(coordinates.turn, event.seq)
this.locations.set(event.seq, this.resolve(event.seq))
const coordinates = this.coordinates.get(event.seq) as Coordinates
if (coordinates.turn !== undefined) this.indexTurnSeq(coordinates.turn, event.seq)
coordinates.location = this.resolve(event.seq)
}
this.currentTurn = currentTurn
this.currentStep = currentStep
const changed = new Set<number>()
for (const { event } of entries) {
if (!sameLocation(previousLocations.get(event.seq), this.locations.get(event.seq))) {
if (!sameLocation(
previousCoordinates.get(event.seq)?.location,
this.coordinates.get(event.seq)?.location,
)) {
changed.add(event.seq)
}
}
@@ -455,6 +468,7 @@ export class ConversationLocationIndex {
this.coordinates.set(event.seq, {
turn: turnNumber,
...stepNumber === undefined ? {} : { step: stepNumber },
location: this.coordinates.get(event.seq)?.location,
})
this.indexTurnSeq(turnNumber, event.seq)
@@ -462,7 +476,8 @@ export class ConversationLocationIndex {
let steps = previousTurn?.steps ?? []
if (event.type === 'step/start' || event.type === 'step/end') {
const number = event.data.step
const previousStep = steps.find(candidate => candidate.step === number)
const indexedSteps = this.stepsByTurn.get(turnNumber) ?? new Map<number, StepLocation>()
const previousStep = indexedSteps.get(number)
const candidate: StepLocation = {
turn: turnNumber,
step: number,
@@ -472,6 +487,8 @@ export class ConversationLocationIndex {
data: this.stepData(turnNumber, number),
}
const nextStep = sameStep(previousStep, candidate) ? previousStep as StepLocation : candidate
indexedSteps.set(number, nextStep)
this.stepsByTurn.set(turnNumber, indexedSteps)
const index = steps.findIndex(step => step.step === number)
steps = index < 0
? [...steps, nextStep]
@@ -498,9 +515,10 @@ export class ConversationLocationIndex {
const changed = new Set<number>()
for (const seq of this.seqsByTurn.get(turnNumber) ?? []) {
const previous = this.locations.get(seq)
const coordinates = this.coordinates.get(seq) as Coordinates
const previous = coordinates.location
const next = this.resolve(seq)
this.locations.set(seq, next)
coordinates.location = next
if (!sameLocation(previous, next)) changed.add(seq)
}
@@ -515,14 +533,13 @@ export class ConversationLocationIndex {
}
/**
* Index one non-boundary tail event without rescanning the window.
* Index one non-boundary tail event without scanning the window or the Turn's Steps.
* @param event - contiguous appended event.
*/
appendNonBoundary(event: SessionEventLike): void {
const explicit = payloadCoordinates(event)
if (explicit.session === true) {
this.coordinates.set(event.seq, {})
this.locations.set(event.seq, SESSION_LOCATION)
this.coordinates.set(event.seq, { location: SESSION_LOCATION })
return
}
if (explicit.turn !== undefined) {
@@ -532,12 +549,14 @@ export class ConversationLocationIndex {
}
const turn = explicit.turn ?? this.currentTurn
const step = explicit.step ?? (turn === this.currentTurn ? this.currentStep : undefined)
this.coordinates.set(event.seq, {
const coordinates: Coordinates = explicit.turn !== undefined && explicit.step !== undefined ? explicit : {
...turn === undefined ? {} : { turn },
...turn === undefined || step === undefined ? {} : { step },
})
location: undefined,
}
this.coordinates.set(event.seq, coordinates)
if (turn !== undefined) this.indexTurnSeq(turn, event.seq)
this.locations.set(event.seq, this.resolve(event.seq))
coordinates.location = this.resolve(event.seq)
}
/**
@@ -553,7 +572,6 @@ export class ConversationLocationIndex {
if (seqs?.size === 0) this.seqsByTurn.delete(turn)
}
this.coordinates.delete(event.seq)
this.locations.delete(event.seq)
}
}
@@ -564,18 +582,23 @@ export class ConversationLocationIndex {
insertAssistantSettlement(
event: SessionEvent<'assistant/message'> | SessionEvent<'assistant/attempt'>,
): void {
this.coordinates.set(event.seq, {
const coordinates: Coordinates = {
turn: event.data.turn,
step: event.data.step,
})
location: undefined,
}
this.coordinates.set(event.seq, coordinates)
this.indexTurnSeq(event.data.turn, event.seq)
this.locations.set(event.seq, this.resolve(event.seq))
coordinates.location = this.resolve(event.seq)
}
private indexTurnSeq(turn: number, seq: number): void {
const current = this.seqsByTurn.get(turn) ?? new Set<number>()
let current = this.seqsByTurn.get(turn)
if (current === undefined) {
current = new Set<number>()
this.seqsByTurn.set(turn, current)
}
current.add(seq)
this.seqsByTurn.set(turn, current)
}
private turnData(turn: number): ConversationLocationDataStore<ConversationTurnDataMap> {
@@ -617,7 +640,7 @@ export class ConversationLocationIndex {
const turn = this.timeline.turns.get(coordinates.turn)
if (turn === undefined) return UNRESOLVED_LOCATION
if (coordinates.step === undefined) return { kind: 'turn', turn }
const step = turn.steps.find(candidate => candidate.step === coordinates.step)
const step = this.stepsByTurn.get(coordinates.turn)?.get(coordinates.step)
return step === undefined ? { kind: 'turn', turn } : { kind: 'step', turn, step }
}
}
@@ -16,8 +16,8 @@ export type {
ConversationContextReader, ConversationLocation,
ConversationLocationData, ConversationLocationDataScope, ConversationLocationDataSource,
ConversationLocationDataStore,
ConversationMatch, ConversationMatchResult, ConversationNodeContext,
ConversationNodeDefinition, ConversationPreviousContext, ConversationPublication,
ConversationMatch, ConversationMatchHandler, ConversationMatchResult, ConversationNodeContext,
ConversationNodeDefinition, ConversationNodeDefinitionInput, ConversationPreviousContext, ConversationPublication,
ConversationStartMatch,
ConversationStepDataMap, ConversationTimelineSnapshot, ConversationTurnDataMap,
ConversationViewBuilder, ConversationViewDefinition, ConversationViewNode,
@@ -31,7 +31,7 @@ export type {
AssistantBlock, AssistantMessageNode, AssistantProviderMetadataView, AssistantRequestConfig,
AssistantTiming, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode,
ModelRetryNode, PartialAssistant, PreparingToolCall, RunningToolCall, StartedToolCall, SteeringMessageNode, TodoItem,
ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode,
ToolCallBlock, ToolArgs, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode,
UserMessageNode,
} from './contract/records.ts'
export type {
@@ -89,7 +89,7 @@ export const zh = {
'tool.title.createGoal': '创建目标',
'tool.title.getGoal': '查看目标',
'tool.title.updateGoal': '更新目标',
'tool.preparing.content': '正在准备内容 {kilobytes}KB',
'tool.preparing.content': '{kilobytes}KB',
'tool.title.createSchedule': '创建定时任务',
'tool.title.listSchedules': '查看定时任务',
'tool.title.deleteSchedule': '删除定时任务',
@@ -465,7 +465,7 @@ export const en = {
'tool.title.createGoal': 'Create goal',
'tool.title.getGoal': 'View goal',
'tool.title.updateGoal': 'Update goal',
'tool.preparing.content': 'Preparing content {kilobytes}KB',
'tool.preparing.content': '{kilobytes}KB',
'tool.title.createSchedule': 'Create reminder',
'tool.title.listSchedules': 'List reminders',
'tool.title.deleteSchedule': 'Delete reminder',
@@ -172,6 +172,145 @@ function fallbackDefinition(start: () => string): ConversationNodeDefinition<str
}
describe('ConversationNodeAssembler', () => {
it('indexes each appended input once and schedules each Context once per flush', () => {
const updates = vi.fn()
const definitions: ConversationNodeDefinition<{ count: number }>[] = ['copy', 'mutable'].map(kind => ({
kind,
target: 'test',
match: event => ({ id: 'one', role: event.seq === 1 ? 'start' : 'update' }),
start: () => ({ count: 0 }),
update: (context) => {
updates()
if (kind === 'copy') return { count: context.state.count + 1 }
context.state.count++
return context.state
},
buildViewNode: context => node(context, context.state?.count),
}))
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions(definitions), new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(SessionSeq(1), 'count/start', {}))], false)
assembler.flush()
// Count index writes separately from the required per-event State updates.
const owners = vi.spyOn(Reflect.get(assembler, 'contextsBySeq') as Map<number, unknown>, 'set')
const dirty = vi.spyOn(Reflect.get(assembler, 'dirty') as Set<unknown>, 'add')
const targets = vi.spyOn(Reflect.get(assembler, 'dirtyByTarget') as Map<string, unknown>, 'set')
try {
for (const batch of [0, 1]) {
for (let offset = 0; offset < 64; offset++) {
assembler.append(input(at(SessionSeq(2 + batch * 64 + offset), 'count/update', {})))
}
expect(owners).toHaveBeenCalledTimes(64)
expect(dirty).toHaveBeenCalledTimes(2)
expect(targets).toHaveBeenCalledTimes(1)
expect(updates).toHaveBeenCalledTimes((batch + 1) * 128)
assembler.flush()
expect([...testSnapshot(assembler)!.nodes.values()].map(value => value.data))
.toEqual([(batch + 1) * 64, (batch + 1) * 64])
owners.mockClear()
dirty.mockClear()
targets.mockClear()
}
} finally {
owners.mockRestore()
dirty.mockRestore()
targets.mockRestore()
}
})
it('resolves nonconsecutive Steps without searching the Turn timeline on each delta', () => {
const index = new ConversationLocationIndex()
const turnStart = at(SessionSeq(1), 'turn/start', { turn: 4 })
const firstStep = at(SessionSeq(2), 'step/start', { turn: 4, step: 7 })
const lastStep = at(SessionSeq(3), 'step/start', { turn: 4, step: 200 })
index.rebuild([input(turnStart), input(firstStep), input(lastStep)])
const turn = index.snapshot().turns.get(4)!
const find = vi.spyOn(turn.steps, 'find')
const turnIndex = vi.spyOn(Reflect.get(index, 'seqsByTurn') as Map<number, unknown>, 'set')
try {
for (const [offset, step] of [7, 200, 7].entries()) {
const { event } = transientChunk(3 + (offset + 1) / 10, 4, step, { type: 'text-delta', index: 0, text: 'a' })
index.appendNonBoundary(event)
expect(index.locationOf(event)).toEqual({
kind: 'step', turn, step: turn.steps[step === 7 ? 0 : 1],
})
}
expect(find).not.toHaveBeenCalled()
expect(turnIndex).not.toHaveBeenCalled()
} finally {
find.mockRestore()
turnIndex.mockRestore()
}
})
it('resolves current Step references after closure, prepend and window replacement', () => {
const index = new ConversationLocationIndex()
const turnStart = at(SessionSeq(1), 'turn/start', { turn: 1 })
const stepStart = at(SessionSeq(2), 'step/start', { turn: 1, step: 9 })
const stepEnd = at(SessionSeq(3), 'step/end', { turn: 1, step: 9 })
const { event } = transientChunk(2.5, 1, 9, { type: 'text-delta', index: 0, text: 'a' })
index.rebuild([input(turnStart), input(stepStart)])
index.appendNonBoundary(event)
const open = index.locationOf(event)
index.appendBoundary(stepEnd)
const closed = index.locationOf(event)
expect(closed).toMatchObject({ kind: 'step', step: { status: 'closed', end: stepEnd } })
expect(closed).not.toEqual(open)
index.rebuild([input(stepEnd)])
index.appendNonBoundary(event)
expect(index.locationOf(event)).toMatchObject({ kind: 'step', step: { start: undefined, end: stepEnd } })
index.rebuild([input(turnStart), input(stepStart), input(stepEnd)])
index.appendNonBoundary(event)
expect(index.locationOf(event)).toMatchObject({ kind: 'step', step: { start: stepStart, end: stepEnd } })
index.rebuild([input(turnStart)])
index.appendNonBoundary(event)
expect(index.locationOf(event)).toEqual({ kind: 'turn', turn: index.snapshot().turns.get(1) })
index.rebuild([])
index.appendNonBoundary(event)
expect(index.locationOf(event)).toEqual({ kind: 'unresolved' })
index.appendBoundary(turnStart)
index.appendBoundary(stepStart)
expect(index.locationOf(event)).toMatchObject({ kind: 'step', step: { start: stepStart, end: undefined } })
})
it('reports a repaired Location while preserving the previously returned value', () => {
const index = new ConversationLocationIndex()
const turnStart = input(at(SessionSeq(1), 'turn/start', { turn: 1 }))
const stepStart = input(at(SessionSeq(2), 'step/start', { turn: 1, step: 9 }))
const delta = transientChunk(2.5, 1, 9, { type: 'text-delta', index: 0, text: 'a' })
index.rebuild([turnStart])
index.appendNonBoundary(delta.event)
const previous = index.locationOf(delta.event)
expect(previous.kind).toBe('turn')
const changed = index.rebuild([turnStart, stepStart, delta])
expect(changed.has(delta.event.seq)).toBe(true)
expect(index.locationOf(delta.event)).toMatchObject({ kind: 'step', step: { step: 9 } })
expect(previous.kind).toBe('turn')
expect(previous).not.toBe(index.locationOf(delta.event))
})
it('keeps equally numbered Steps in different Turns independent', () => {
const index = new ConversationLocationIndex()
index.rebuild([
input(at(SessionSeq(1), 'turn/start', { turn: 1 })),
input(at(SessionSeq(2), 'step/start', { turn: 1, step: 3 })),
input(at(SessionSeq(3), 'turn/start', { turn: 2 })),
input(at(SessionSeq(4), 'step/start', { turn: 2, step: 3 })),
])
for (const turn of [1, 2]) {
const { event } = transientChunk(4 + turn / 10, turn, 3, { type: 'text-delta', index: 0, text: 'a' })
index.appendNonBoundary(event)
const location = index.locationOf(event)
expect(location).toMatchObject({ kind: 'step', step: { turn, step: 3 } })
if (location.kind !== 'step') throw new Error('expected Step location')
expect(location.step).toBe(index.snapshot().turns.get(turn)?.steps[0])
}
})
it('reports boundary changes only when the owning Turn location changes', () => {
const index = new ConversationLocationIndex()
const boundaries = [
@@ -184,7 +323,7 @@ describe('ConversationNodeAssembler', () => {
index.appendBoundary(boundary)
expect(index.takeChangedTurns()).toEqual([1])
const turn = index.snapshot().turns.get(1)
index.appendBoundary(boundary)
expect(index.appendBoundary(boundary)).toEqual(new Set())
expect(index.snapshot().turns.get(1)).toBe(turn)
expect(index.takeChangedTurns()).toEqual([])
}
@@ -371,6 +510,103 @@ describe('ConversationNodeAssembler', () => {
{ callSeq: 1, results: 1 },
{ callSeq: 2, results: 0 },
])
const keys = snapshot?.order
for (const [index, callId] of ['b', 'a', 'b'].entries()) {
assembler.append(input(at(SessionSeq(4 + index), 'tool/result', {
turn: 1, step: 1,
message: { source: { type: 'tool-result', callId }, content: [], isError: false },
})))
}
assembler.flush()
expect(starts).not.toHaveBeenCalled()
expect(updates).toHaveBeenCalledTimes(4)
expect(testSnapshot(assembler)?.order).toEqual(keys)
expect([...testSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)).toEqual([
{ callSeq: 1, results: 2 },
{ callSeq: 2, results: 2 },
])
})
it('shares input Matches by role without sharing Definition State or Session locations', () => {
type State = { readonly seqs: readonly number[] }
const opening = [
input(at(SessionSeq(1), 'turn/start', { turn: 1 })),
input(at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })),
input(at(SessionSeq(3), 'shared/seed', { turn: 1, step: 1 })),
]
const createSession = () => {
const probes = (['start', 'start', 'update', 'update'] as const).map((role, index) => {
const matches: ConversationMatch[] = []
const definition: ConversationNodeDefinition<State> = {
kind: `shared-${index}`,
target: 'test',
match: (event) => {
const type: string = event.type
if (type === 'shared/seed' && role === 'update') return { id: 'one', role: 'start' }
if (type === 'shared/input') return { id: 'one', role }
if (type === 'shared/first-only' && index === 0) return { id: 'one', role: 'update' }
return null
},
start: (_context, match) => {
matches.push(match)
return { seqs: [match.event.seq] }
},
update: (context, match) => {
matches.push(match)
return { seqs: [...context.state.seqs, match.event.seq] }
},
buildViewNode: context => node(context, context.state),
}
return { definition, matches }
})
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions(probes.map(probe => probe.definition)),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow(opening, false)
assembler.flush()
const states = () => {
const nodes = [...testSnapshot(assembler)?.nodes.values() ?? []]
return probes.map(probe => nodes.find(value => value.kind === probe.definition.kind)?.data)
}
return { assembler, probes, states }
}
const first = createSession()
const second = createSession()
const shared = input(at(SessionSeq(4), 'shared/input', { turn: 1, step: 1 }))
const expectedStates = [{ seqs: [4] }, { seqs: [4] }, { seqs: [3, 4] }, { seqs: [3, 4] }]
for (const session of [first, second]) {
session.assembler.append(shared)
session.assembler.flush()
const start = session.probes[0]!.matches.at(-1)!
const update = session.probes[2]!.matches.at(-1)!
expect(start).toBe(session.probes[1]!.matches.at(-1))
expect(update).toBe(session.probes[3]!.matches.at(-1))
expect(start).not.toBe(update)
expect(start.role).toBe('start')
expect(update.role).toBe('update')
expect(start.event).toBe(shared.event)
expect(update.event).toBe(shared.event)
expect(start.location.kind).toBe('step')
expect(start.location).toBe(update.location)
expect(session.states()).toEqual(expectedStates)
}
expect(new Set([...first.states(), ...second.states()]).size).toBe(8)
for (const index of [0, 2]) {
const left = first.probes[index]!.matches.at(-1)!
const right = second.probes[index]!.matches.at(-1)!
expect(left).not.toBe(right)
expect(left.location).not.toBe(right.location)
}
const secondSnapshot = testSnapshot(second.assembler)
first.assembler.append(input(at(SessionSeq(5), 'shared/first-only', { turn: 1, step: 1 })))
first.assembler.flush()
expect(first.probes[0]!.matches.at(-1)).not.toBe(first.probes[2]!.matches.at(-1))
expect(first.probes[2]!.matches.at(-1)!.event).toBe(shared.event)
expect(first.states()).toEqual([{ seqs: [4, 5] }, ...expectedStates.slice(1)])
expect(testSnapshot(second.assembler)).toBe(secondSnapshot)
expect(second.states()).toEqual(expectedStates)
})
it('keeps one Match collection while a long Context appends without replay', () => {
@@ -599,6 +835,64 @@ describe('ConversationNodeAssembler', () => {
.toEqual(['assistant/message', 'llm/retry'])
})
it.each(['update', 'publication'] as const)('retires every accepted transient owner after a %s failure', (failure) => {
const definitions: ConversationNodeDefinition<number>[] = Array.from({ length: 6 }, (_, index) => ({
kind: index === 5 ? 'unrelated' : `owner-${index}`,
target: 'test',
match: event => event.type === 'step/start'
? { id: 'one', role: 'start' }
: index < 5 && event.type === 'assistant/live-chunk' ? { id: 'one', role: 'update' } : null,
start: () => 0,
update: (context) => {
if (index === 4 && failure === 'update') throw new Error('owner failure')
return context.state + 1
},
publication: (match) => {
if (index === 4 && failure === 'publication' && match.event.type === 'assistant/live-chunk') {
throw new Error('owner failure')
}
return 'immediate'
},
buildViewNode: context => node(context, {
count: context.state, seqs: context.matches.map(match => match.event.seq),
}),
}))
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions(definitions), new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(SessionSeq(1), 'step/start', { turn: 1, step: 1 }))], false)
assembler.flush()
const before = testSnapshot(assembler)!
expect(before.order).toHaveLength(6)
const unrelated = before.nodes.get(before.order[5]!)
const delta = transientChunk(1.5, 1, 1, {
type: 'tool-call-delta', index: 0, id: ToolCallId('shared'), name: 'write', argumentsDelta: '{}',
})
expect(() => assembler.append(delta)).toThrow('owner failure')
assembler.flush()
const partial = testSnapshot(assembler)!
for (const [index, key] of before.order.entries()) {
const changed = index < 4 || (index === 4 && failure === 'publication')
expect(partial.nodes.get(key)?.data).toEqual({ count: changed ? 1 : 0, seqs: changed ? [1, 1.5] : [1] })
}
expect(assembler.append(delta)).toBe('none')
assembler.flush()
expect(testSnapshot(assembler)).toBe(partial)
expect(assembler.settleAssistant(LlmAttemptId('test-attempt'))).toBe('immediate')
assembler.flush()
const settled = testSnapshot(assembler)!
for (const key of before.order.slice(0, 5)) {
expect(settled.nodes.get(key)?.data).toEqual({ count: 0, seqs: [1] })
expect(settled.nodes.get(key)).not.toBe(partial.nodes.get(key))
}
expect(settled.nodes.get(before.order[5]!)).toBe(unrelated)
assembler.rebuildRegistry()
assembler.flush()
const rebuilt = testSnapshot(assembler)!
expect(rebuilt.order).toEqual(before.order)
expect([...rebuilt.nodes.values()].map(value => value.data)).toEqual([...before.nodes.values()].map(value => value.data))
})
it('replays one pending transient Match after prepend supplies its durable start', () => {
const starts = vi.fn(() => ({ batches: 0, status: 'unresolved' }))
const updates = vi.fn((
@@ -419,11 +419,13 @@ describe('Conversation registries', () => {
expect(events.entries()).toHaveLength(1)
expect(events.fallbackEntry()).toBeDefined()
expect(events.forEvent('turn/start').size).toBe(1)
expect(views.entries()).toHaveLength(1)
await feature.dispose()
expect(events.entries()).toEqual([])
expect(events.fallbackEntry()).toBeUndefined()
expect(events.forEvent('turn/start').size).toBe(0)
expect(views.entries()).toEqual([])
})
@@ -0,0 +1,182 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import type { SessionEventLike } from '@deepseek-ai/dsh-api-session-controller/client'
import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types'
import {
ConversationEventRegistry, ConversationNodeAssembler,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ConversationMatchHandler, ConversationNodeDefinition, ConversationNodeDefinitionInput,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
function registry(): ConversationEventRegistry {
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose() })
return new ConversationEventRegistry(ctx)
}
function definition(kind: string, match: ConversationNodeDefinitionInput['match'], target?: string): ConversationNodeDefinitionInput<null> {
return {
kind, match, start: () => null, update: context => context.state,
...target === undefined ? {} : { target, buildViewNode: () => null },
}
}
function turn(seq: number, number = 1): SessionEvent<'turn/start'> {
return { type: 'turn/start', seq: SessionSeq(seq), time: seq, data: { turn: number } }
}
describe('Conversation event routing', () => {
it('precomputes mixed function and table routes in registration order', () => {
const events = registry()
const handler = () => null
const a = definition('a', handler)
const b = definition('b', { 'turn/start': handler })
const c = definition('c', handler)
const d = definition('d', { 'step/start': handler, 'turn/start': handler })
events.register(a)
const removeB = events.register(b)
events.register(c)
events.register(d)
const selected = events.forEvent('turn/start')
expect([...selected].map(route => route.definition.kind)).toEqual(['a', 'b', 'c', 'd'])
expect([...events.forEvent('step/start')].map(route => route.definition.kind)).toEqual(['a', 'c', 'd'])
expect([...events.forEvent('unknown')].map(route => route.definition.kind)).toEqual(['a', 'c'])
expect(events.forEvent('unknown')).toBe(events.forEvent('another-unknown'))
expect(events.forEvent('turn/start')).toBe(selected)
removeB()
removeB()
expect([...events.forEvent('turn/start')].map(route => route.definition.kind)).toEqual(['a', 'c', 'd'])
expect([...selected].map(route => route.definition.kind)).toEqual(['a', 'b', 'c', 'd'])
events.register(b)
expect([...events.forEvent('turn/start')].map(route => route.definition.kind)).toEqual(['a', 'c', 'd', 'b'])
})
it('reads table handlers at registration and invokes only candidates for each payload', () => {
const events = registry()
const selected = vi.fn<ConversationMatchHandler>(event => event.type === 'turn/start' && event.data.turn === 2
? { id: 'second', role: 'start' } : null)
const unrelated = vi.fn<ConversationMatchHandler>(() => null)
const unrestricted = vi.fn<ConversationMatchHandler>(() => null)
let handlerReads = 0
const table: Record<string, ConversationMatchHandler> = {
get 'turn/start'() { handlerReads++; return selected },
'step/start': unrelated,
}
events.register(definition('table', table))
events.register(definition('function', unrestricted))
const readsAfterRegistration = handlerReads
const readAllDefinitions = vi.spyOn(events, 'entries')
const assembler = new ConversationNodeAssembler(events, { entries: () => [] })
assembler.replaceWindow([], false)
for (let seq = 1; seq <= 100; seq++) assembler.append({ type: 'event', event: turn(seq, seq) })
expect(handlerReads).toBe(readsAfterRegistration)
expect(readAllDefinitions).not.toHaveBeenCalled()
expect(selected).toHaveBeenCalledTimes(100)
expect(unrestricted).toHaveBeenCalledTimes(100)
expect(unrelated).not.toHaveBeenCalled()
expect(selected.mock.results[0]?.value).toBeNull()
expect(selected.mock.results[1]?.value).toEqual({ id: 'second', role: 'start' })
})
it('keeps callable entries usable by an assembler adapter without indexed routing', () => {
const events = registry()
const table = vi.fn<ConversationMatchHandler>(() => null)
const legacy = vi.fn<ConversationMatchHandler>(() => null)
events.register(definition('table', { 'turn/start': table }))
events.register(definition('legacy', legacy))
expect(events.entries().every(entry => typeof entry.match === 'function')).toBe(true)
const assembler = new ConversationNodeAssembler({
entries: () => events.entries(), fallbackEntry: () => events.fallbackEntry(),
}, { entries: () => [] })
assembler.append({ type: 'event', event: turn(1) })
assembler.append({ type: 'event', event: {
type: 'step/start', seq: SessionSeq(2), time: 2, data: { turn: 1, step: 1 },
} })
expect(table).toHaveBeenCalledOnce()
expect(legacy).toHaveBeenCalledTimes(2)
})
it('preserves the Definition receiver of a function-form matcher', () => {
const events = registry()
const receivers: ConversationNodeDefinition[] = []
const match = function (this: ConversationNodeDefinition, _event: SessionEventLike) {
receivers.push(this)
return null
}
const entry = definition('receiver', match)
events.register(entry)
for (const route of events.forEvent('turn/start')) route.match(turn(1))
expect(receivers).toEqual([entry])
})
it('indexes only own table keys, including names inherited by ordinary objects', () => {
const events = registry()
const entry = definition('own-keys', { ['__proto__']: () => null, constructor: () => null })
events.register(entry)
expect([...events.forEvent('__proto__')].map(route => route.definition.kind)).toEqual(['own-keys'])
expect([...events.forEvent('constructor')].map(route => route.definition.kind)).toEqual(['own-keys'])
expect(events.forEvent('toString').size).toBe(0)
events.register(definition('empty', {}))
expect(events.forEvent('unlisted').size).toBe(0)
})
it('keeps fallback functions callable and normalizes table fallbacks', () => {
const events = registry()
const match = vi.fn<ConversationMatchHandler>(() => null)
const unrestricted = definition('generic-fallback', match, 'chat')
const dispose = events.registerFallback(unrestricted)
expect(events.fallbackEntry()).toBe(unrestricted)
dispose()
dispose()
expect(events.fallbackEntry()).toBeUndefined()
const table = definition('table-fallback', { 'turn/start': match, ['__proto__']: match }, 'chat')
const removeTable = events.registerFallback(table)
const resolved = events.fallbackEntry()!
expect(typeof resolved.match).toBe('function')
resolved.match(turn(1))
expect(match).toHaveBeenCalledOnce()
resolved.match({ type: 'step/start', seq: SessionSeq(2), time: 2, data: { turn: 1, step: 1 } })
expect(match).toHaveBeenCalledOnce()
removeTable()
expect(events.fallbackEntry()).toBeUndefined()
})
it.each(['chat', 'other', undefined] as const)('suppresses fallback only after a real match for its target (%s)', (target) => {
const events = registry()
const match = vi.fn<ConversationMatchHandler>(event => event.type === 'turn/start' && event.data.turn === 2
? { id: 'second', role: 'start' } : null)
const fallback = vi.fn<ConversationMatchHandler>(() => ({ id: 'fallback', role: 'start' }))
events.register(definition('ordinary', { 'turn/start': match }, target))
events.registerFallback(definition('fallback', { 'turn/start': fallback }, 'chat'))
const assembler = new ConversationNodeAssembler(events, { entries: () => [] })
assembler.replaceWindow([], false)
assembler.append({ type: 'event', event: turn(1) })
assembler.append({ type: 'event', event: turn(2, 2) })
expect(match).toHaveBeenCalledTimes(2)
expect(fallback).toHaveBeenCalledTimes(target === 'chat' ? 1 : 2)
})
it.each(['indexed', 'legacy'].flatMap(adapter => (
['first', 'second', 'third', 'unmatched'].map(target => ({ adapter, target }))
)))('checks fallback against every matched target ($adapter, $target)', ({ adapter, target }) => {
const events = registry()
const targets = ['first', 'first', 'second', 'third', 'second']
const matched: string[] = []
for (const [index, ordinaryTarget] of targets.entries()) {
events.register(definition(`ordinary-${index}`, { 'turn/start': () => {
matched.push(ordinaryTarget)
return { id: 'one', role: 'start' }
} }, ordinaryTarget))
}
const fallback = vi.fn<ConversationMatchHandler>(() => ({ id: 'fallback', role: 'start' }))
events.registerFallback(definition('fallback', fallback, target))
const definitions = adapter === 'indexed' ? events : {
entries: () => events.entries(), fallbackEntry: () => events.fallbackEntry(),
}
const assembler = new ConversationNodeAssembler(definitions, { entries: () => [] })
assembler.append({ type: 'event', event: turn(1) })
expect(matched).toEqual(targets)
expect(fallback).toHaveBeenCalledTimes(target === 'unmatched' ? 1 : 0)
})
})
@@ -73,6 +73,7 @@
"@deepseek-ai/dsh-tool-present": "workspace:*",
"@deepseek-ai/dsh-workspace-changes": "workspace:*",
"@deepseek-ai/dsh-api-session-controller": "workspace:*",
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/dsh-util-workspace-path": "workspace:*",
"@deepseek-ai/dsh-api-workspace-files": "workspace:*",
"@deepseek-ai/dsh-fs": "workspace:*",
@@ -4,13 +4,14 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
import { PresentRow } from '../src/client/PresentRow.tsx'
import { en } from '../src/client/locales.ts'
afterEach(cleanup)
type Props = Parameters<typeof PresentRow>[0]
const running: StartedToolCall = { phase: 'start' as const, callId: 'p', name: 'present', argsRaw: '{"files":[{"path":"report.txt"}]}', turn: 1, step: 1, time: 1, subCalls: [] }
const settled: ToolResultNode = { kind: 'tool-result', seq: 2, time: 2, callId: 'p', call: { name: 'present', argsRaw: running.argsRaw }, callTime: 1, content: [{ type: 'text', text: 'Presented report.txt (4 bytes)' }], isError: false, subCalls: [] }
const running: StartedToolCall = { phase: 'start' as const, args: PartialArguments.fromText('{"files":[{"path":"report.txt"}]}'), callId: 'p', name: 'present', argsRaw: '{"files":[{"path":"report.txt"}]}', turn: 1, step: 1, time: 1, subCalls: [] }
const settled: ToolResultNode = { kind: 'tool-result', seq: 2, time: 2, callId: 'p', name: 'present', args: PartialArguments.fromText(running.argsRaw), call: { name: 'present', argsRaw: running.argsRaw }, callTime: 1, content: [{ type: 'text', text: 'Presented report.txt (4 bytes)' }], isError: false, subCalls: [] }
function props(block: Props['block'], inspect?: () => void): Props {
return { ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), callId: 'p', toolName: 'present', openFile: vi.fn(), inspect, t: makeTranslate(en) } as Props
}
@@ -35,7 +36,7 @@ it('discloses the saved result and offers call inspection', () => {
it('shows a non-expandable preparation before delivery arguments exist', () => {
const useDisclosure = vi.fn(() => { throw new Error('preparation must not subscribe to disclosure resets') })
const view = render(<PresentRow {...props({
phase: 'preparing', callId: 'p', name: 'present', turn: 1, step: 1, time: 1, subCalls: [],
phase: 'preparing', args: PartialArguments.EMPTY, callId: 'p', name: 'present', turn: 1, step: 1, time: 1, subCalls: [],
})} useDisclosure={useDisclosure} />)
expect(view.container.querySelector('[data-state="preparing"] svg')).not.toBeNull()
expect(view.queryByRole('button')).toBeNull()
@@ -10,6 +10,7 @@
* only the Turn-level card.
*/
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
import type {
ConversationMatch, ConversationNodeDefinition, ToolResultNode, TurnLocation,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -112,6 +113,8 @@ function createdTask(match: ConversationMatch, call: ScheduleCall): ToolResultNo
seq: match.event.seq,
time: match.event.time,
callId: String(match.event.data.message.source.callId),
name: call.name,
args: PartialArguments.fromText(call.argsRaw),
call: { name: call.name, argsRaw: call.argsRaw },
callTime: call.time,
content: message.content,
@@ -7,6 +7,7 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
import type { ScheduleCatalogEntry, ScheduleId } from '@deepseek-ai/dsh-schedule/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
import { ScheduleCreateCard, type ScheduleCreateCardProps } from '../src/client/ScheduleCreateCard.tsx'
import { narrowScheduleRecord, scheduleCreateCardModel } from '../src/client/schedule-create-card.ts'
import { zoneLabel } from '../src/client/schedule-format.ts'
@@ -40,6 +41,7 @@ afterEach(cleanup)
function running(args: unknown = ARGS): ToolBlock {
return {
phase: 'start',
args: PartialArguments.fromText(typeof args === 'string' ? args : JSON.stringify(args)),
callId: CALL, name: TOOL, argsRaw: typeof args === 'string' ? args : JSON.stringify(args),
turn: 1, step: 1, time: 0, subCalls: [],
}
@@ -53,12 +55,12 @@ function settled(value: unknown, options: {
isError?: boolean
call?: boolean
} = {}): ToolBlock {
const argsRaw = typeof options.args === 'string' ? options.args : JSON.stringify(options.args ?? ARGS)
return {
kind: 'tool-result', seq: 1, time: 1, callId: CALL,
call: options.call === false ? null : {
name: TOOL,
argsRaw: typeof options.args === 'string' ? options.args : JSON.stringify(options.args ?? ARGS),
},
name: options.call === false ? '' : TOOL,
args: options.call === false ? PartialArguments.EMPTY : PartialArguments.fromText(argsRaw),
call: options.call === false ? null : { name: TOOL, argsRaw },
callTime: 0,
content: options.content ?? [{ type: 'text', text: options.text ?? JSON.stringify(value) }],
isError: options.isError ?? false,
@@ -139,7 +141,7 @@ describe('scheduleCreateCardModel', () => {
it('names a still-preparing call by the tool until its arguments arrive', () => {
expect(scheduleCreateCardModel({
phase: 'preparing', callId: CALL, name: TOOL, turn: 1, step: 1, time: 0, subCalls: [],
phase: 'preparing', args: PartialArguments.EMPTY, callId: CALL, name: TOOL, turn: 1, step: 1, time: 0, subCalls: [],
}, TOOL)).toEqual({ task: undefined, title: TOOL, output: null })
})
@@ -17,6 +17,7 @@ import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-control
import { createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { ScheduleCatalogEntry, ScheduleId } from '@deepseek-ai/dsh-schedule/client'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
import { ScheduleTurnCard, type ScheduleTurnCardProps } from '../src/client/ScheduleTurnCard.tsx'
import { zoneLabel } from '../src/client/schedule-format.ts'
import {
@@ -160,6 +161,7 @@ function scheduleOf(value: ConversationNodeAssembler, turn = 1): Readonly<Schedu
function settledBlock(task: unknown, callId = CALL): ToolResultNode {
return {
kind: 'tool-result', seq: 3, time: 3_000, callId,
name: 'schedule_create', args: PartialArguments.fromText(JSON.stringify(ARGS)),
call: { name: 'schedule_create', argsRaw: JSON.stringify(ARGS) },
callTime: 2_000,
content: [{ type: 'text', text: JSON.stringify(task) }],
+1
View File
@@ -65,6 +65,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*",
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/dsh-util-workspace-path": "workspace:*"
},
"files": [
@@ -6,6 +6,7 @@ import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui
import type { ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/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 { PartialArguments } from '@deepseek-ai/dsh-util-values'
import { SkillRow } from '../src/client/SkillRow.tsx'
import { zh } from '../src/client/locales.ts'
@@ -16,12 +17,14 @@ const t: SkillRowProps['t'] = makeTranslate(zh, commonZh)
afterEach(cleanup)
function settled(over: Partial<ToolResultNode> = {}): ToolResultNode {
const call = over.call === undefined ? { name: 'skill', argsRaw: '{"name":"dsh-manage-issues"}' } : over.call
return {
kind: 'tool-result',
seq: 3,
time: 3_000,
callId: 'call-skill',
call: { name: 'skill', argsRaw: '{"name":"dsh-manage-issues"}' },
name: call?.name ?? '', args: call === null ? PartialArguments.EMPTY : PartialArguments.fromText(call.argsRaw),
call,
callTime: 2_000,
content: [{ type: 'text', text: 'Follow the issue workflow.\nKeep project fields in sync.' }],
isError: false,
@@ -32,7 +35,7 @@ function settled(over: Partial<ToolResultNode> = {}): ToolResultNode {
function running(argsRaw = '{"name":"dsh-manage-issues"}'): StartedToolCall {
return {
phase: 'start' as const, callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, subCalls: [],
phase: 'start' as const, args: PartialArguments.fromText(argsRaw), callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, subCalls: [],
}
}
@@ -53,7 +56,7 @@ function props(block: SkillRowProps['block'], inspect?: () => void): SkillRowPro
describe('SkillRow', () => {
it('shows preparation without arguments, instructions, or disclosure', () => {
const view = render(<SkillRow {...props({
phase: 'preparing', callId: 'call-skill', name: 'skill', turn: 1, step: 1, time: 1, subCalls: [],
phase: 'preparing', args: PartialArguments.EMPTY, callId: 'call-skill', name: 'skill', turn: 1, step: 1, time: 1, subCalls: [],
})} />)
expect(view.getByText('准备加载技能')).toBeTruthy()
expect(view.container.querySelector('svg')).not.toBeNull()
+8 -8
View File
@@ -15,20 +15,20 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-tool/use-this-package:
en: be2bc859e0d64fe5
zh: d51a35fb36675dd3
en: 3b9b719ca4e4cc62
zh: a14b63a16476d684
/deepseek-ai-dsh-client-ui-tool/use-this-package/registering-a-business-tool-view:
en: 5332d87e76037f07
zh: db5d06e7e46b8429
en: f19e077425b8f312
zh: 24d88794be439302
/deepseek-ai-dsh-client-ui-tool/use-this-package/built-in-views:
en: 5bdd3af9df9b2b7c
zh: ff3517e71a2be98a
en: 2e06c7de0f7f0139
zh: 9ec0f94c2d7d60bf
/deepseek-ai-dsh-client-ui-tool/understand-the-implementation:
en: 00bc7b0c84edc603
zh: 1cfcf5b435248a91
/deepseek-ai-dsh-client-ui-tool/understand-the-implementation/rendering-contract:
en: 2959b10b01e08a7b
zh: 7084ca22650cbb5b
en: 672e63b2f92e689b
zh: ba950ecb480325a6
/deepseek-ai-dsh-client-ui-tool/understand-the-implementation/cards:
en: d202f30c846543d2
zh: a84656956c66a574
+6 -4
View File
@@ -29,7 +29,7 @@ Tool calls appear in the conversation as cards: a root call tree with its nested
Shared Tool rows and Bash rows retain error and warning colors for failed and stopped summaries, including on hover. Hover darkens only summaries without those states.
Before dispatch, a named model call appears as one non-expandable row with its tool-owned icon and title. A generic row shows `Tool call · <tool name>`. Preparation exposes no complete arguments, file link, result, or parameter-dependent interaction. Write/edit show `Preparing content NKB` in the summary; N is `Math.ceil(raw.length / 1024)`, an integer estimate of the raw argument string length, not the file's byte size. `tool/call` enables the existing call presentation; completing an argument block alone does not start execution.
Before dispatch, a named model call appears as one non-expandable row with its tool-owned icon and title. A generic row shows `Tool call · <tool name>`. Read/write/edit show an openable path once `file_path` closes and decodes without errors; write/edit show `NKB` after that path once content arrives, retaining it during execution and after success, before any diff totals. N rounds the decoded input's UTF-16 length up in units of 1024, summing old and new text for edit; it is not file bytes. Error and stopped rows omit the size and diff totals. Command rows show the streamed description. `tool/call` enables the existing call presentation; completing an argument block alone does not start execution.
### Registering a business tool view
@@ -43,11 +43,13 @@ ctx.slots.inject('tool.call.toolview', () =>
}, BusinessToolRow))
```
The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the `phase` discriminant and its frozen stage-specific `block`, optional `cwd` and `home`, the session-authorized `loadImage` loader (for a view whose result carries durable images), and plain `openFile`/`inspect` callbacks. A PTC dispatch block retains its event's `parentCallId`; a root Session call has no such field, so descendants route through the same keyed dispatch — a registered view such as `read_image` renders its card there, and unregistered descendants keep the generic flattened form. 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.
The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the `phase` discriminant and its stage-specific `block`, optional `cwd` and `home`, the session-authorized `loadImage` loader (for a view whose result carries durable images), and plain `openFile`/`inspect` callbacks. A PTC dispatch block retains its event's `parentCallId`; a root Session call has no such field, so descendants route through the same keyed dispatch — a registered view such as `read_image` renders its card there, and unregistered descendants keep the generic flattened form. 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.
### Built-in views
Every registered view receives the explicit `preparing`, `start`, and `result` props declared in [the Tool slot types](src/client/contract/slots.ts). Shared rows use the same `ToolRow` in all stages. Their row model selects the title and combines any generic tool-name prefix with the available argument summary independently of lifecycle state. Tool-owned titles omit the English name. The shared argument parser returns no call during preparation and does not parse partial JSON. Write/edit use separate preparing and dispatched components, so only the preparing component invokes `useToolCallArgumentsPartial`; start and result share the dispatched component. Custom renderers such as Bash, Skill, and Cordis handle preparation separately; their argument-dependent components accept `StartedToolCallViewProps`.
Every registered view receives the explicit `preparing`, `start`, and `result` props declared in [the Tool slot types](src/client/contract/slots.ts). All blocks expose `name` and a lazy `args` reader; legacy `argsRaw` and result `call` fields remain available. The row model selects the title and combines any generic tool-name prefix with the available argument summary independently of lifecycle state. Tool-owned titles omit the English name. Write/edit and Bash share their component across stages. Custom renderers may retain separate preparation branches; argument-dependent components accept `StartedToolCallViewProps`.
File-tool rows prefer a complete `file_path`; other generic rows, including search and unregistered tools, prefer a non-empty `description` before their variant-specific argument fallback. Read `args` during rendering or view construction: a preparing reader grows in place, and only previously observed answer changes trigger subsequent block publication.
This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and PTC dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running atomic rows share one left-to-right highlight across their titles, separators, summaries, and suffixes, while settled rows stay static. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in PTC dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable, while background acknowledgements remain collapsed. A native or PTC dispatch failure carrying `AUTO_REVIEW_DENIED` shows the Auto review verdict in its collapsed row and one normalized not-executed reason when expanded; a missing or whitespace-only reason uses localized fallback text. `ui-skill` demonstrates a business-owned registration for `skill`.
@@ -72,7 +74,7 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool
Tool owner props forward Chat's stable `useDisclosure` Hook through root and nested calls. Rows invoke it where they own their expanded bodies; intermediate renderers do not subscribe. Each invocation has independent open state that resets when the enclosing Turn collapses, without replacing React identity. Presentation-mode switches preserve it.
The slot-injected `useToolCallArgumentsPartial` Hook lazily subscribes to the owning Step's `assistant-step` source and selects this callId's raw argument prefix. Missing sources or calls return an empty string. Other calls in the same Step may trigger a snapshot check, but an unchanged selected string does not refresh the consumer. Tools that do not invoke the Hook add no subscription; dispatched calls have no argument-prefix source.
Tool Definitions own the [lazy argument view](../../util/values/README.md). Rows choose their fields and update granularity through its readers, without a separate subscription or registration. Deltas append without scanning; frame-batched publication refreshes observed answers and replaces the block only when an answer changes. Card models that need dispatched arguments continue to read `argsRaw`.
### Cards
+6 -4
View File
@@ -29,7 +29,7 @@ kind: "package-reference"
共享工具行和 Bash 行的失败、停止摘要在悬停时仍保留错误色和警告色;只有不处于这两种状态的摘要会在悬停时加深。
派发前,模型已给出名称的调用显示为不可展开的一行,使用工具自己的图标与标题。通用行显示为`工具调用 · <工具名>`。准备阶段不提供完整参数、文件链接、结果或依赖参数的交互。write/edit 的摘要显示「正在准备内容 NKB」;N 为 `Math.ceil(raw.length / 1024)`,是原始参数字符串长度的整数近似值,不是文件字节数。`tool/call` 才启用既有调用展示;参数块结束本身不代表开始执行。
派发前,模型已给出名称的调用显示为不可展开的一行,使用工具自己的图标与标题。通用行显示为 `工具调用 · <工具名>`。read/write/edit 在 `file_path` 闭合且解码无误后显示可打开的路径;write/edit 从内容到达起在该路径后显示 `NKB`,执行中和成功后仍保留,位于差异统计之前。N 按解码后输入的 UTF-16 长度除以 1024 向上取整,edit 合计新旧文本长度;它不是文件字节数。失败和停止的行省略大小与差异统计。命令行显示流入中的描述。`tool/call` 才启用既有调用展示;参数块结束本身不代表开始执行。
### 注册业务工具视图
@@ -43,11 +43,13 @@ ctx.slots.inject('tool.call.toolview', () =>
}, BusinessToolRow))
```
owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、`phase` 判别字段及对应阶段的冻结 `block`、可选 `cwd` 与 `home`、会话授权的 `loadImage` loader(供结果携带持久图像的视图使用),以及普通的 `openFile`/`inspect` 回调。PTC dispatch 块保留事件的 `parentCallId`;根会话调用没有该字段,因此后代调用都走同一条按 key 分发路径:已注册视图的调用(如 `read_image`)也会在嵌套处渲染对应卡片,未注册的后代调用则保持通用压平形式。路径摘要先相对会话 cwd 缩短,再把剩余的 POSIX Host home 写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规的会话 slot 运行时共享数据,但不会收到 React 节点或运行时服务。
owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、`phase` 判别字段及对应阶段的 `block`、可选 `cwd` 与 `home`、会话授权的 `loadImage` loader(供结果携带持久图像的视图使用),以及普通的 `openFile`/`inspect` 回调。PTC dispatch 块保留事件的 `parentCallId`;根会话调用没有该字段,因此后代调用都走同一条按 key 分发路径:已注册视图的调用(如 `read_image`)也会在嵌套处渲染对应卡片,未注册的后代调用则保持通用压平形式。路径摘要先相对会话 cwd 缩短,再把剩余的 POSIX Host home 写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规的会话 slot 运行时共享数据,但不会收到 React 节点或运行时服务。
### 内置视图
每个注册视图都接收[工具 slot 类型](src/client/contract/slots.ts)声明的显式 `preparing`、`start` 和 `result` props。通用行在三个阶段使用同一个 `ToolRow`。行模型统一选择标题,并组合通用工具名前缀与已有参数摘要,不按生命周期阶段改变前缀;专用标题不附带英文名。准备阶段的共享参数解析入口直接返回无调用,不解析部分 JSON。write/edit 将准备态和派发后阶段拆成两个组件,只有准备态组件调用 `useToolCallArgumentsPartial`,start 与 result 共用派发后组件。Bash、Skill、Cordis 等自定义 renderer 分别处理准备态,其依赖参数的组件接收 `StartedToolCallViewProps`。
每个注册视图都接收[工具 slot 类型](src/client/contract/slots.ts)声明的显式 `preparing`、`start` 和 `result` props。所有块都提供 `name` 和懒计算的 `args` 读器,原有 `argsRaw` 和 result 的 `call` 字段仍可使用。行模型统一选择标题,并组合通用工具名前缀与已有参数摘要,不按生命周期阶段改变前缀;专用标题不附带英文名。write/edit 和 Bash 在各阶段共用组件。自定义 renderer 可保留独立准备态分支;依赖参数的组件接收 `StartedToolCallViewProps`。
文件工具行优先使用完整的 `file_path`;其他通用行(包括搜索和未注册的工具)优先使用非空 `description`,再回退到各自类别的参数摘要。应在渲染或构造视图时读取 `args`:准备态读器原地增长,后续仅在已观察答案变化时重新发布块。
本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 PTC dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中的原子工具行以同一道高光从左到右扫过标题、分隔符、摘要与后缀;已完成行保持静态。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 PTC dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
@@ -72,7 +74,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、`phase` 判别字
Tool 所有者属性将 Chat 注入的稳定 `useDisclosure` 钩子传给根调用及嵌套调用。工具行在拥有展开正文的位置调用它,中间 renderer 不订阅。每次调用拥有独立展开状态,外层轮次收起时重置该状态,不替换 React 身份;展示模式切换保留该状态。
slot 注入的 `useToolCallArgumentsPartial` 钩子按需订阅所属 Step 的 `assistant-step` 来源,并选取当前 callId 的原始参数前缀。来源或调用不存在时返回空字符串。同一步骤中的其他调用可能触发快照检查,但选中的字符串未变时不会刷新使用方。不调用钩子的工具不新增订阅,已派发的调用不再提供参数前缀来源。
Tool Definition 拥有[懒计算参数视图](../../util/values/README.zh.md)。工具行通过读器选择字段和更新粒度,不另设订阅或注册。delta 追加时不扫描;按帧合批的发布会刷新已观察答案,仅在答案变化时替换块引用。需要完整派发参数的卡片模型继续读取 `argsRaw`。
### 卡片
+1
View File
@@ -71,6 +71,7 @@
"@deepseek-ai/dsh-client-ui-chat": "workspace:*",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
"@deepseek-ai/dsh-client-ui-session": "workspace:*",
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/dsh-util-workspace-path": "workspace:*",
"@deepseek-ai/dsh-attachment": "workspace:*",
"clsx": "^2.0.0",
+1 -5
View File
@@ -7,7 +7,6 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import { ToolCallTree } from './tool/ToolCallTree.tsx'
import { bindToolCallArgumentsPartial } from './tool/tool-call-arguments-partial.ts'
import { CONVERSATION_NS as NS } from './locale.ts'
import { askQuestionToolview } from './tool/toolviews/ask-question-row.tsx'
import { bashToolviewSample } from './tool/toolviews/bash-sample.tsx'
@@ -37,10 +36,7 @@ export function apply(ctx: ClientContext): void {
key: 'tool-call',
locale: NS,
children: {
'tool.call.toolview': {
kind: 'keyed', scope: 'session',
inject: { hooks: { toolCallArgumentsPartial: bindToolCallArgumentsPartial } },
},
'tool.call.toolview': { kind: 'keyed', scope: 'session' },
},
inject: toolInject,
}, ToolCallTree))
@@ -1,10 +1,10 @@
/** Tool UI slot declarations and their composed component props. */
import type {
HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, SessionIdOf, SlotHookFactory,
HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, SessionIdOf,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client'
import type {
AssistantChatData, OpenFileOptions, PreparingToolCall, StartedToolCall,
OpenFileOptions, PreparingToolCall, StartedToolCall,
ToolResultNode, UseDisclosure,
} from '@deepseek-ai/dsh-client-ui-chat/client'
import type { MessageImageLoader, MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -19,16 +19,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* `key: '<tool name>'`; a typo never renders.
*
* Registering an occupied key replaces its view; unclaimed keys use the
* generic row. The owner supplies the call identity and frozen running
* or settled node through explicit phase props. Preparing blocks have no dispatched
* arguments; useToolCallArgumentsPartial optionally subscribes to their raw prefix.
* generic row. The owner supplies the call identity and running
* or settled node through explicit phase props. Every stage supplies
* `name` and a lazy `args` view; preparing arguments may be incomplete.
*/
'tool.call.toolview': {
kind: 'keyed'
scope: 'session'
owner: ToolCallOwnerProps
hookContext: ToolCallHookContext
inject: ToolCallInjected
}
/**
* Durable images of a settled image-bearing Tool call, rendered through
@@ -47,23 +45,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
}
/** Subscribe to this preparing call's raw argument prefix; other phases return an empty string. */
export type UseToolCallArgumentsPartial = () => string
/** Call-local sources supplied by the Tool tree to the slot's Hook binding. */
export interface ToolCallHookContext {
readonly callId: string
/** This call's Step source, present only while preparing. */
readonly assistant: HostObservable<Readonly<AssistantChatData> | undefined> | undefined
}
/** Framework-bound subscriptions available to atomic Tool views on demand. */
export interface ToolCallInjected {
hooks: {
toolCallArgumentsPartial: SlotHookFactory<'tool.call.toolview', UseToolCallArgumentsPartial>
}
}
/** Owner currency of the Tool image gallery slot: references plus the loader. */
export interface ToolImagesOwnerProps {
/** Durable references or submission-echo previews in result order. */
+1 -2
View File
@@ -2,6 +2,5 @@
export { apply, inject } from './apply.ts'
export type {
StartedToolCallViewProps, ToolCallCommonProps, ToolCallOwnerProps, ToolCallPhaseProps, ToolCallViewProps,
ToolCallHookContext, ToolCallInjected, ToolHostInfoInjected, ToolTreeProps, UseToolCallArgumentsPartial,
UserQuestionPanels, UserQuestionRecord,
ToolHostInfoInjected, ToolTreeProps, UserQuestionPanels, UserQuestionRecord,
} from './contract/slots.ts'
@@ -1,7 +1,7 @@
/** Root/subcall Tool composition with one keyed atomic dispatch path. */
import { memo, useMemo, type ReactNode } from 'react'
import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { ToolCallHookContext, ToolCallOwnerProps, ToolCallPhaseProps, ToolTreeProps } from '../contract/slots.ts'
import type { ToolCallOwnerProps, ToolCallPhaseProps, ToolTreeProps } from '../contract/slots.ts'
import { toolRowModel } from './models/tool-call-model.ts'
import { GenericToolCard } from './toolviews/GenericToolCard.tsx'
import css from './ToolCallTree.module.css'
@@ -18,19 +18,14 @@ function callName(call: ToolCallPhaseProps): string {
/** One atomic call dispatched through the Tool-owned keyed slot. */
const ToolCall = memo(function ToolCall({
renderSlot, callId, toolName, call, assistant, openFile, cwd, home, inspectCall, loadImage, useDisclosure, t, children,
renderSlot, callId, toolName, call, openFile, cwd, home, inspectCall, loadImage, useDisclosure, t, children,
}: Pick<ToolTreeProps, 'renderSlot' | 'openFile' | 'cwd' | 'inspectCall' | 'loadImage' | 'useDisclosure' | 't'> & {
callId: string
toolName: string
call: ToolCallPhaseProps
assistant: ToolCallHookContext['assistant']
home?: string | undefined
children?: ReactNode
}) {
const preparing = call.phase === 'preparing'
const hookContext = useMemo<ToolCallHookContext>(() => ({
callId, assistant: preparing ? assistant : undefined,
}), [assistant, callId, preparing])
const owner: ToolCallOwnerProps = useMemo(() => ({
callId,
toolName,
@@ -56,7 +51,6 @@ const ToolCall = memo(function ToolCall({
? <GenericToolCard {...owner} t={t} />
: renderSlot('tool.call.toolview', owner, {
entryKey: toolName,
hookContext,
fallback: <GenericToolCard {...owner} t={t} />,
})}
{children}
@@ -65,10 +59,9 @@ const ToolCall = memo(function ToolCall({
})
const ToolCallBranch = memo(function ToolCallBranch({
renderSlot, block, assistant, cwd, home, openFile, inspectCall, loadImage, useDisclosure, t,
renderSlot, block, cwd, home, openFile, inspectCall, loadImage, useDisclosure, t,
}: Pick<ToolTreeProps, 'renderSlot' | 'cwd' | 'openFile' | 'inspectCall' | 'loadImage' | 'useDisclosure' | 't'> & {
block: ToolCallBlock
assistant: ToolCallHookContext['assistant']
home?: string | undefined
}) {
const call = useMemo(() => toolCallPhase(block), [block])
@@ -78,7 +71,6 @@ const ToolCallBranch = memo(function ToolCallBranch({
callId={call.block.callId}
toolName={callName(call)}
call={call}
assistant={assistant}
openFile={openFile}
cwd={cwd}
home={home}
@@ -94,7 +86,6 @@ const ToolCallBranch = memo(function ToolCallBranch({
key={child.callId}
renderSlot={renderSlot}
block={child}
assistant={assistant}
cwd={cwd}
home={home}
openFile={openFile}
@@ -120,12 +111,10 @@ export function ToolCallTree({
renderSlot, node, cwd, openFile, inspectCall, loadImage, useDisclosure, useHostInfo, t,
}: ToolTreeProps) {
const home = useHostInfo(info => info.home)
const assistant = node.location.kind === 'step' ? node.location.step.data.source('assistant-step') : undefined
return (
<ToolCallBranch
renderSlot={renderSlot}
block={node.data.root}
assistant={assistant}
cwd={cwd}
home={home}
openFile={openFile}
@@ -1,23 +0,0 @@
/** Tool-owned non-expandable chrome without dispatched argument material. */
import type { ReactNode } from 'react'
import type { ToolCallViewProps } from '../../contract/slots.ts'
import { classifyTool } from '../models/tool-call-model.ts'
import { ToolRow, type ToolRowProps } from './ToolRow.tsx'
/** Inputs contain a tool-owned icon/title and no expandable body. */
type PreparingToolRowProps = Pick<ToolCallViewProps, 'toolName' | 'useDisclosure'> & {
readonly icon: ReactNode
readonly title: string
readonly summary?: string
readonly t: ToolRowProps['t']
}
/**
* Render a non-expandable tool prefix with an optional preparation summary.
* @param props - tool prefix and locale.
* @returns the preparation row.
*/
export function PreparingToolRow({ toolName, useDisclosure, icon, title, summary = '', t }: PreparingToolRowProps) {
return <ToolRow useDisclosure={useDisclosure} t={t} variant={classifyTool(toolName)} toolName={toolName}
icon={icon} title={title} summary={summary} state="preparing" />
}
@@ -39,10 +39,9 @@ export interface ToolRowProps {
summary: string
/**
* Trailing summary fragment rendered outside the ellipsized summary text, so
* a narrow row clips the summary before this. For a fragment whose whole
* value is surviving that clip — the todo row's parallel-active count.
* null/absent = the summary is the whole collapsed content. Dropped on an
* error row, whose collapsed summary is the failure line instead.
* a narrow row clips the summary before this. Appears before diff totals
* when both are present. null/absent omits this fragment. Error and stopped
* rows omit both the suffix and diff totals.
*/
summarySuffix?: string | null | undefined
/** Original argument JSON formatted only while the row is expanded. */
@@ -182,11 +181,11 @@ export const ToolRow = memo(function ToolRow({
// amber while retaining the business icon and hidden state announcement.
const failureLine = state === 'error' ? errorSummary ?? normalSummary : null
const summaryText = failureLine ?? normalSummary
// The tool row keeps the diff's +/- totals visible while its body is collapsed.
// An explicit summarySuffix overrides the diff totals.
// Diff totals remain visible after the optional summary suffix.
const diffStat = useMemo(() => diffBody === null ? null : diffTotals(diffBody.card.diffs), [diffBody])
const settledWithCue = state === 'error' || state === 'stopped'
const suffix = settledWithCue ? null : summarySuffix ?? diffStat
const suffix = settledWithCue ? null : summarySuffix ?? null
const totals = settledWithCue ? null : diffStat
const openFile = useMemo(() => filePath !== undefined && onOpenFile !== undefined && !settledWithCue
? (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
@@ -243,12 +242,15 @@ export const ToolRow = memo(function ToolRow({
</span>
)}
{suffix !== null && (
<TextShimmer className={clsx(css.summarySuffix, typeof suffix !== 'string' && css.diffStat)}>
{typeof suffix === 'string' ? suffix : <><span className={css.diffAdded}>{`+${suffix.added}`}</span>{' '}<span className={css.diffRemoved}>{`-${suffix.removed}`}</span></>}
<TextShimmer className={css.summarySuffix}>{suffix}</TextShimmer>
)}
{totals !== null && (
<TextShimmer className={clsx(css.summarySuffix, css.diffStat)}>
<span className={css.diffAdded}>{`+${totals.added}`}</span>{' '}<span className={css.diffRemoved}>{`-${totals.removed}`}</span>
</TextShimmer>
)}
</>
), [summaryLinkKeyDown, linkHref, openFile, state, suffix, summaryText])
), [summaryLinkKeyDown, linkHref, openFile, state, suffix, totals, summaryText])
const expandedContent = useMemo(() => open ? (
<div className={clsx(css.bodyWrap, detailsBody !== null && css.detailsBodyWrap)}>
{askQuestionBody !== null
@@ -5,7 +5,7 @@
* output and error material from the settled result node. A supported terminal
* call gets its expanded body from `terminalCardModel` instead.
*/
import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { ToolArgs, ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots'
import { abbreviateHomePath, relativizeToCwd } from '@deepseek-ai/dsh-util-workspace-path'
@@ -146,8 +146,9 @@ export interface ToolRowModel {
summary: string
/**
* Filesystem path from args (`path` / `file_path`) when the row is a file
* tool; absent for URL reads and non-file tools. The chat view resolves
* relative values against the session cwd before opening.
* tool; absent for URL reads and non-file tools. A `file_path` argument
* supplies it at every stage once its string is complete. The chat view
* resolves relative values against the session cwd before opening.
*/
filePath: string | undefined
/** Original argument JSON retained for expansion-time body formatting. */
@@ -193,7 +194,7 @@ function parseArgs(argsRaw: string): unknown {
try {
return JSON.parse(argsRaw)
} catch {
// Non-JSON args (mid-stream truncation): summary/body fall back to the raw string.
// Non-JSON args (mid-stream truncation or a rejected call kept verbatim): summary/body fall back to the raw string.
return undefined
}
}
@@ -228,7 +229,7 @@ function deriveSummary(variant: ToolRowVariant, argsRaw: string): string {
if (typeof parsed !== 'object' || parsed === null) return firstLine(argsRaw)
const args = parsed as Record<string, unknown>
if (variant === 'search' && Array.isArray(args.queries)) {
const queries = args.queries.filter((query): query is string => typeof query === 'string' && query !== '')
const queries = args.queries.filter((query: unknown): query is string => typeof query === 'string' && query !== '')
if (queries.length > 0) return queries.map(firstLine).join(', ')
}
const picked = pickString(args, SUMMARY_KEYS[variant])
@@ -245,6 +246,24 @@ const FILE_PATH_KEYS = ['path', 'file_path'] as const
/** File-tool variants whose summary may be an openable workspace path. */
const FILE_PATH_VARIANTS: ReadonlySet<ToolRowVariant> = new Set(['read', 'write', 'edit'])
/**
* Summary material read the same way at every stage from the argument view: a
* complete `file_path` for file tools, otherwise the `description` text so far.
* Empty when the view carries neither, so the caller falls back to the raw text.
*/
function argumentSummary(
variant: ToolRowVariant, args: ToolArgs, cwd?: string, home?: string,
): { summary: string; filePath: string | undefined } {
if (FILE_PATH_VARIANTS.has(variant)) {
const path = args.complete('file_path') ? args.text('file_path') : undefined
// Decoding text can discover an invalid escape and make complete() false.
const filePath = path !== undefined && path !== '' && args.complete('file_path') ? firstLine(path) : undefined
return { summary: filePath === undefined ? '' : abbreviateHomePath(relativizeToCwd(filePath, cwd), home), filePath }
}
const description = args.text('description')
return { summary: description === undefined ? '' : firstLine(description), filePath: undefined }
}
function deriveFilePath(variant: ToolRowVariant, argsRaw: string): string | undefined {
if (!FILE_PATH_VARIANTS.has(variant)) return undefined
const parsed = parseArgs(argsRaw)
@@ -288,7 +307,9 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin
const state: ToolRowState = !done ? block.phase === 'preparing' ? 'preparing' : 'running'
: block.error?.code === 'interrupted' ? 'stopped'
: block.isError ? 'error' : 'ok'
const base = argsRaw === null ? ''
const primary = argumentSummary(variant, block.args, cwd, home)
// The argument view serves every stage; the raw text is the fallback when it carries nothing useful.
const base = primary.summary !== '' || argsRaw === null ? primary.summary
: argsRaw === '' ? block.callId
: abbreviateHomePath(relativizeToCwd(deriveSummary(variant, argsRaw), cwd), home)
const summary = [titleKey === 'tool.title.generic' ? toolName : '', base].filter(Boolean).join(' · ')
@@ -302,7 +323,7 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin
variant,
titleKey,
summary,
filePath: argsRaw === null ? undefined : deriveFilePath(variant, argsRaw),
filePath: primary.filePath ?? (argsRaw === null ? undefined : deriveFilePath(variant, argsRaw)),
bodyRaw,
output,
errorSummary,
@@ -1,25 +0,0 @@
/** Call-local argument subscription bound by the atomic Tool slot. */
import { useSyncExternalStore } from 'react'
import type { ToolCallInjected } from '../contract/slots.ts'
const subscribeEmpty = (): (() => void) => () => {}
/**
* Bind one call without subscribing until its component invokes the Hook.
* @param _standard - framework-provided scope props.
* @param context - the preparing call's Step source and identity.
* @returns a Hook that reads only this call's raw argument prefix.
*/
export const bindToolCallArgumentsPartial: ToolCallInjected['hooks']['toolCallArgumentsPartial'] = (
_standard, context,
) => {
const { assistant, callId } = context
const subscribe = assistant === undefined ? subscribeEmpty : (listener: () => void) => assistant.subscribe(listener)
const getSnapshot = (): string => {
const block = assistant?.getSnapshot()?.blocks.find(candidate => candidate.kind === 'tool-call' && candidate.callId === callId)
return block?.kind === 'tool-call' ? block.argsRaw : ''
}
return function useToolCallArgumentsPartial() {
return useSyncExternalStore(subscribe, getSnapshot)
}
}
@@ -15,8 +15,7 @@ import {
terminalCardModel,
terminalFailed,
} from '../models/terminal-card-model.ts'
import { formatToolBody, toolRowModel, toolTitleKey, type ToolRowState } from '../models/tool-call-model.ts'
import { PreparingToolRow } from '../components/PreparingToolRow.tsx'
import { formatToolBody, toolRowModel, type ToolRowState } from '../models/tool-call-model.ts'
import { CONVERSATION_NS as NS } from '../../locale.ts'
import css from './bash-sample.module.css'
@@ -27,6 +26,7 @@ const BASH_ICON = <IconApiOutlineRegular size={14} />
/** Visually hidden status for the color-only running sweep and error tone. */
function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null {
switch (state) {
case 'preparing': return t('row.preparing')
case 'running': return t('bash.running')
case 'error': return t('bash.failed')
case 'stopped': return t('bash.stopped')
@@ -35,17 +35,12 @@ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null {
}
/**
* Render expandable Bash output with an accessible lifecycle label.
* Render expandable Bash output with an accessible lifecycle label. While the
* call is preparing the row shows the description streamed so far and cannot expand.
* @param props - tool call, Session sources, locale, and inspection callback.
* @returns the Bash output row.
*/
export const BashRow = memo(function BashRow(props: BashRowProps) {
if (props.phase === 'preparing') return <PreparingToolRow {...props}
icon={BASH_ICON} title={props.t(toolTitleKey(props.toolName))} />
return <StartedBashRow {...props} />
})
const StartedBashRow = memo(function StartedBashRow({ toolName, block, sessionId, useSessions, inspect, useDisclosure, t }: Exclude<BashRowProps, { phase: 'preparing' }>) {
export const BashRow = memo(function BashRow({ toolName, block, sessionId, useSessions, inspect, useDisclosure, t }: BashRowProps) {
const model = useMemo(() => toolRowModel(toolName, block), [toolName, block])
// An omitted shell workdir is the session workspace; relative values resolve
// against it before reaching the terminal primitive.
@@ -77,7 +72,7 @@ const StartedBashRow = memo(function StartedBashRow({ toolName, block, sessionId
const settlementLine = state === 'error'
? model.errorSummary ?? normalSummary
: state === 'stopped' ? t('bash.stopped') : null
const running = state === 'running'
const running = state === 'running' || state === 'preparing'
const toggleFromKeyboard = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return
event.preventDefault()
@@ -3,33 +3,38 @@ import { IconEditOutlineRegular } 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 { diffCardModel } from '../models/diff-card-model.ts'
import { toolRowModel, toolTitleKey } from '../models/tool-call-model.ts'
import { toolRowModel } from '../models/tool-call-model.ts'
import { ToolRow } from '../components/ToolRow.tsx'
import { PreparingToolRow } from '../components/PreparingToolRow.tsx'
import { CONVERSATION_NS as NS } from '../../locale.ts'
type FileMutationRowProps = ToolCallViewProps & PropsLocale<'conversation'>
const FILE_MUTATION_ICON = <IconEditOutlineRegular size={14} />
const CONTENT_FIELDS = ['content', 'old_string', 'new_string']
const KILOBYTE = 1024
/** Kilobytes of decoded input at every stage; null before any content field arrives. */
function contentKilobytes(block: FileMutationRowProps['block']): number | null {
const { args } = block
const open = CONTENT_FIELDS.find(key => args.has(key) && !args.complete(key))
const completed = CONTENT_FIELDS.reduce((total, key) => key !== open && args.complete(key)
? total + (args.stringLength(key, { step: KILOBYTE }) ?? 0) : total, 0)
if (open === undefined) {
return CONTENT_FIELDS.some(key => args.has(key)) ? Math.ceil(completed / KILOBYTE) : null
}
const chars = completed + (args.stringLength(open, { step: KILOBYTE, offset: completed }) ?? 0)
return Math.ceil(chars / KILOBYTE)
}
/**
* Lets users expand an applied file diff and open the reported path.
* Shows the path and decoded input size through preparation, execution, and
* settlement, with the applied diff available once the call settles.
*/
export function FileMutationRow(props: FileMutationRowProps) {
return props.phase === 'preparing'
? <PreparingFileMutationRow {...props} />
: <StartedFileMutationRow {...props} />
}
function PreparingFileMutationRow({ toolName, useDisclosure, useToolCallArgumentsPartial, t }: Extract<FileMutationRowProps, { phase: 'preparing' }>) {
const raw = useToolCallArgumentsPartial()
return <PreparingToolRow toolName={toolName} useDisclosure={useDisclosure} icon={FILE_MUTATION_ICON}
title={t(toolTitleKey(toolName))} t={t}
summary={t('tool.preparing.content', { kilobytes: Math.ceil(raw.length / 1024) })} />
}
function StartedFileMutationRow({ toolName, block, cwd, home, openFile, inspect, useDisclosure, t }: Exclude<FileMutationRowProps, { phase: 'preparing' }>) {
export function FileMutationRow({ toolName, block, cwd, home, openFile, inspect, useDisclosure, t }: FileMutationRowProps) {
const model = toolRowModel(toolName, block, cwd, home)
const diff = diffCardModel(block)
const kilobytes = contentKilobytes(block)
const size = kilobytes === null ? null : t('tool.preparing.content', { kilobytes })
return (
<ToolRow
useDisclosure={useDisclosure}
@@ -39,6 +44,7 @@ function StartedFileMutationRow({ toolName, block, cwd, home, openFile, inspect,
icon={FILE_MUTATION_ICON}
title={t(model.titleKey)}
summary={model.summary}
summarySuffix={size !== null && model.summary !== '' ? size : undefined}
output={model.output}
errorSummary={model.errorSummary}
diff={diff}
@@ -19,6 +19,7 @@ import { AskQuestionRow, askQuestionToolview } from '../src/client/tool/toolview
import type { UserQuestionPanels } from '../src/client/contract/slots.ts'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
afterEach(cleanup)
@@ -30,15 +31,19 @@ const READABLE_ARGS = JSON.stringify({ questions: [
] })
const PENDING_RESULT = JSON.stringify({ pending: true, callId: 'c1' })
const resultNode = (argsRaw: string, resultText: string | null, over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
call: { name: 'ask_user_question', argsRaw },
content: resultText === null ? [] : [{ type: 'text', text: resultText }],
isError: false, subCalls: [], ...over,
})
const resultNode = (argsRaw: string, resultText: string | null, over?: Partial<ToolResultNode>): ToolResultNode => {
const call = over?.call === undefined ? { name: 'ask_user_question', argsRaw } : over.call
return {
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
name: call?.name ?? '', args: call === null ? PartialArguments.EMPTY : PartialArguments.fromText(call.argsRaw),
call,
content: resultText === null ? [] : [{ type: 'text', text: resultText }],
isError: false, subCalls: [], ...over,
}
}
const runningCall = (argsRaw: string): ToolCallBlock =>
({ phase: 'start', callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, subCalls: [] })
({ phase: 'start', args: PartialArguments.fromText(argsRaw), callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, subCalls: [] })
const t = makeTranslate(zh, commonZh)
@@ -66,7 +71,6 @@ function rowProps(block: ToolCallBlock, env: RowEnvironment = {}): Parameters<ty
const props = {
useDisclosure, callId: 'c1', toolName: 'ask_user_question', t,
...('kind' in block ? { phase: 'result', block } : { phase: block.phase, block }),
useToolCallArgumentsPartial: () => '',
openFile: vi.fn(),
loadImage: vi.fn(async () => ''),
sessionId: 's1' as SessionId,
@@ -15,6 +15,7 @@ import {
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime, usePinnedBrowserLanguages, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime'
import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts'
// The service reads its initial locale from the browser; these specs assert
@@ -51,19 +52,24 @@ const TODOS: TodoItem[] = [
const todoResult = (seq: number): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`,
name: 'todo_write', args: PartialArguments.fromText(JSON.stringify({ todos: TODOS })),
call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) },
callTime: seq * 1_000 - 500,
content: [], isError: false, subCalls: [],
})
const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
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,
subCalls: [],
...over,
})
const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>): ToolResultNode => {
const call = over?.call === undefined ? { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' } : over.call
return {
kind: 'tool-result', seq, time: seq * 1_000, callId,
name: call?.name ?? '', args: call === null ? PartialArguments.EMPTY : PartialArguments.fromText(call.argsRaw),
call,
callTime: seq * 1_000 - 500,
content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false,
subCalls: [],
...over,
}
}
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
type AppRootProps = PropsRenderSlots<'main'>
@@ -15,6 +15,7 @@ import {
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { en as conversationEn, NS as CONVERSATION_NS, zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { apply as applyChat, inject as injectChat } from '@deepseek-ai/dsh-client-ui-chat/client'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts'
import { toolChatSnapshot } from './tool-fixtures.client.ts'
@@ -44,6 +45,7 @@ const RUN_CODE_ARGS = JSON.stringify({ code: PROGRAM, description: 'List the not
const codeResult = (seq: number, callId: string): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
name: 'run_code', args: PartialArguments.fromText(RUN_CODE_ARGS),
call: { name: 'run_code', argsRaw: RUN_CODE_ARGS },
callTime: seq * 1_000 - 500,
content: [{ type: 'text', text: 'demo.txt' }], isError: false,
@@ -51,7 +53,7 @@ const codeResult = (seq: number, callId: string): ToolResultNode => ({
})
const runningCode = (callId: string): StartedToolCall => ({
phase: 'start' as const, callId, name: 'run_code', argsRaw: RUN_CODE_ARGS, turn: 9, step: 0, time: 9_000,
phase: 'start' as const, args: PartialArguments.fromText(RUN_CODE_ARGS), callId, name: 'run_code', argsRaw: RUN_CODE_ARGS, turn: 9, step: 0, time: 9_000,
subCalls: [],
})
@@ -60,6 +62,7 @@ const subCall = (
): ToolCallBlock => ({
kind: 'tool-result', seq, time: seq * 1_000,
callId: `${parent}:code:${n}`,
name, args: PartialArguments.fromText(JSON.stringify(args)),
parentCallId: parent,
call: { name, argsRaw: JSON.stringify(args) },
callTime: seq * 1_000,
@@ -258,7 +261,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
it('a started-but-unsettled sub-call renders the running state exactly like a native in-flight row', async () => {
const parent = 'call-live'
const runningSub: ToolCallBlock = {
phase: 'start' as const, callId: `${parent}:code:1`, name: 'grep', argsRaw: '{"pattern":"todo"}',
phase: 'start' as const, args: PartialArguments.fromText('{"pattern":"todo"}'), callId: `${parent}:code:1`, name: 'grep', argsRaw: '{"pattern":"todo"}',
parentCallId: parent,
turn: 0, step: 0, time: 21_000, subCalls: [],
}
@@ -274,6 +277,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
const parent = 'call-64'
const plain: ToolResultNode = {
kind: 'tool-result', seq: 10, time: 10_000, callId: parent,
name: 'mystery', args: PartialArguments.fromText('{"n":1}'),
call: { name: 'mystery', argsRaw: '{"n":1}' },
callTime: 9_500,
content: [], isError: false, subCalls: [],
@@ -13,6 +13,7 @@ import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/t
import { ToolRow } from '../src/client/tool/components/ToolRow.tsx'
import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx'
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
type BashRowProps = Parameters<typeof BashRow>[0]
@@ -55,6 +56,7 @@ describe('Tool presentation tails', () => {
it('a settled others-variant row renders the sparkle icon in the leading slot', () => {
const settled: ToolResultNode = {
kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5',
name: 'todo_write', args: PartialArguments.fromText('{"note":"x"}'),
call: { name: 'todo_write', argsRaw: '{"note":"x"}' },
callTime: 1_000,
content: [], isError: false, subCalls: [],
@@ -71,6 +73,7 @@ describe('Tool presentation tails', () => {
it('BashRow summarizes the description without a row click target', () => {
const settled: ToolResultNode = {
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1',
name: 'bash', args: PartialArguments.fromText('{"command":"make build","description":"Build"}'),
call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' },
callTime: 2_000,
content: [], isError: false, subCalls: [],
@@ -84,11 +87,12 @@ describe('Tool presentation tails', () => {
it('BashRow retains its business icon for failed and stopped states', () => {
const running: StartedToolCall = {
phase: 'start' as const, callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',
phase: 'start' as const, args: PartialArguments.fromText('{"command":"ls","description":"List"}'), callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',
turn: 1, step: 1, time: 1_000, subCalls: [],
}
const errorResult: ToolResultNode = {
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
name: 'bash', args: PartialArguments.fromText('{"command":"boom"}'),
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
callTime: 500,
content: [], isError: true, subCalls: [],
@@ -13,12 +13,14 @@ import { formatSpillNotice } from '@deepseek-ai/dsh-spill-policy/notice'
import { detailBadge, detailJson, detailList, detailRecord, inspectionItems, nonempty } from '../src/client/tool/models/detail-model-shared.ts'
import { detailsCardModel } from '../src/client/tool/models/details-card-model.ts'
import { SpillLocator } from '@deepseek-ai/dsh-spill'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
const t = makeTranslate(en, commonEn)
function output(name: string, text: string, args: Record<string, unknown> = {}): ToolResultNode {
return {
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: `${name}-1`,
name, args: PartialArguments.fromText(JSON.stringify(args)),
call: { name, argsRaw: JSON.stringify(args) }, content: [{ type: 'text', text }], isError: false, subCalls: [],
}
}
@@ -10,13 +10,14 @@ import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts
import { detailsCardModel, todosDetail } from '../src/client/tool/models/details-card-model.ts'
import { DetailsRow, detailsToolview } from '../src/client/tool/toolviews/details-row.tsx'
import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
const t = makeTranslate(en, commonEn)
const goal = { id: 'goal-1', revision: 2, objective: 'Ship compact cards', phase: 'active', roundsStarted: 2, maxGoalRounds: 8 }
const schedule = { id: 'schedule-1', prompt: 'Review the build', kind: 'every', everySeconds: 3600, scheduledAt: '2026-09-10T09:00:00.000Z', state: 'scheduled', deliveryMode: 'session-local' }
function result(name: string, value: unknown, args = '{}'): ToolResultNode {
return { kind: 'tool-result', seq: 10, time: 2000, callTime: 1000, callId: 'c1', call: { name, argsRaw: args }, content: [{ type: 'text', text: JSON.stringify(value) }], isError: false, subCalls: [] }
return { kind: 'tool-result', seq: 10, time: 2000, callTime: 1000, callId: 'c1', name, args: PartialArguments.fromText(args), call: { name, argsRaw: args }, content: [{ type: 'text', text: JSON.stringify(value) }], isError: false, subCalls: [] }
}
afterEach(cleanup)
@@ -94,7 +95,7 @@ describe('detailsCardModel', () => {
expect(detailsCardModel({ ...block, call: { name: 'create_goal', argsRaw: '{' } }, t, 'en')).toBeNull()
expect(detailsCardModel({ ...block, content: [{ type: 'text', text: 'partial {' }] }, t, 'en')).toBeNull()
expect(detailsCardModel({ ...block, content: [...block.content, { type: 'text', text: 'Extra result' }] }, t, 'en')).toBeNull()
expect(detailsCardModel({ phase: 'start' as const, callId: 'c1', name: 'create_goal', argsRaw: '{}', turn: 1, step: 1, time: 1000, subCalls: [] }, t, 'en')).toBeNull()
expect(detailsCardModel({ phase: 'start' as const, args: PartialArguments.fromText('{}'), callId: 'c1', name: 'create_goal', argsRaw: '{}', turn: 1, step: 1, time: 1000, subCalls: [] }, t, 'en')).toBeNull()
expect(detailsCardModel({ ...block, parentCallId: 'parent' }, t, 'en')?.items[0]?.title).toBe(goal.objective)
})
})
@@ -14,6 +14,7 @@ import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/tool/models/di
import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
import { FileMutationRow, fileMutationToolview } from '../src/client/tool/toolviews/file-mutation-row.tsx'
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
afterEach(cleanup)
@@ -27,18 +28,25 @@ const ARGS = '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"h
const DIFFS = [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }]
const running = (over?: Partial<StartedToolCall>): StartedToolCall => ({
phase: 'start' as const, callId: 'c1', name: 'edit', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
})
const running = (over?: Partial<StartedToolCall>): StartedToolCall => {
const argsRaw = over?.argsRaw ?? ARGS
return {
phase: 'start' as const, args: PartialArguments.fromText(argsRaw), callId: 'c1', name: 'edit', argsRaw,
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
}
}
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'edit', argsRaw: ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'The file notes/demo.txt has been updated successfully.' }], isError: false,
meta: { diffs: DIFFS }, subCalls: [], ...over,
})
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => {
const call = over?.call === undefined ? { name: 'edit', argsRaw: ARGS } : over.call
return {
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
name: call?.name ?? '', args: call === null ? PartialArguments.EMPTY : PartialArguments.fromText(call.argsRaw),
call,
callTime: 1_000,
content: [{ type: 'text', text: 'The file notes/demo.txt has been updated successfully.' }], isError: false,
meta: { diffs: DIFFS }, subCalls: [], ...over,
}
}
describe('diffCardModel', () => {
it('derives a running card from raw edit arguments', () => {
@@ -206,7 +214,6 @@ describe('FileMutationRow diff card', () => {
})
const rowProps = (block: StartedToolCall | ToolResultNode, toolName = 'edit'): FileMutationRowProps => ({
useToolCallArgumentsPartial: () => { throw new Error('dispatched calls must not subscribe to preparing arguments') },
useDisclosure, callId: 'c1', toolName, ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), cwd: '/w/app',
sessionId: SID, useSessions: bindSnapshotSelector(list()),
t,
@@ -24,6 +24,8 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import type { MessageImageLoader } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
import { AttachmentId, type ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { ToolImagesOwnerProps, ToolTreeProps } from '../src/client/contract/slots.ts'
import { imageCardModel } from '../src/client/tool/models/image-card-model.ts'
import { ReadImageRow, readImageToolview } from '../src/client/tool/toolviews/read-image-row.tsx'
@@ -38,8 +40,8 @@ const ARGS = '{"file_path":"shots/card.png"}'
const ENVELOPE = '<path>shots/card.png</path>\n<type>image</type>\n<content>\nimage/png image, 1496x260 px, 24588 bytes\n</content>'
/** The durable reference read_image persists through presentationMeta. */
const sampleImage = {
attachmentId: 'sha256:fe6d588c8d5a8e93c743d80524b9376634ca1cc262db9e1d21c9e4c18fc856cc',
const sampleImage: ImageAttachmentRef = {
attachmentId: AttachmentId('sha256:fe6d588c8d5a8e93c743d80524b9376634ca1cc262db9e1d21c9e4c18fc856cc'),
mediaType: 'image/png',
bytes: 24_588,
width: 1496,
@@ -56,27 +58,34 @@ const withImage = (attachment: unknown) => [
{ type: 'image', attachment },
]
const running = (over?: Partial<StartedToolCall>): StartedToolCall => ({
phase: 'start' as const, callId: 'c1', name: 'read_image', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
})
const running = (over?: Partial<StartedToolCall>): StartedToolCall => {
const argsRaw = over?.argsRaw ?? ARGS
return {
phase: 'start' as const, args: PartialArguments.fromText(argsRaw), callId: 'c1', name: 'read_image', argsRaw,
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
}
}
/**
* A settled read_image node carrying the REAL content shape: [text envelope, image
* block]. A text-only fixture would hide that the row must not flatten the image
* block into JSON under the picture.
*/
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'read_image', argsRaw: ARGS },
callTime: 1_000,
content: [
{ type: 'text', text: ENVELOPE },
{ type: 'image', attachment: sampleImage },
],
isError: false,
meta: imageMeta(), subCalls: [], ...over,
} as unknown as ToolResultNode)
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => {
const call = over?.call === undefined ? { name: 'read_image', argsRaw: ARGS } : over.call
return {
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
name: call?.name ?? '', args: call === null ? PartialArguments.EMPTY : PartialArguments.fromText(call.argsRaw),
call,
callTime: 1_000,
content: [
{ type: 'text', text: ENVELOPE },
{ type: 'image', attachment: sampleImage },
],
isError: false,
meta: imageMeta(), subCalls: [], ...over,
}
}
/**
* A renderSlot stub standing in for the attachment presentation plugin's
@@ -6,6 +6,7 @@ import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { en as common } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx'
import { FileMutationRow } from '../src/client/tool/toolviews/file-mutation-row.tsx'
import { ReadRow } from '../src/client/tool/toolviews/read-row.tsx'
@@ -23,17 +24,24 @@ afterEach(cleanup)
type Props = Parameters<typeof TodoRow>[0] & Parameters<typeof ReadImageRow>[0] & Parameters<typeof AskQuestionRow>[0]
function preparation(name: string): Props {
function preparation(name: string, args = new PartialArguments()): Extract<Props, { phase: 'preparing' }> {
return {
phase: 'preparing', callId: 'call', toolName: name,
block: { phase: 'preparing', callId: 'call', name, turn: 1, step: 1, time: 1, subCalls: [] },
t: makeTranslate(en, common), useDisclosure, useToolCallArgumentsPartial: vi.fn(() => ''), openFile: vi.fn(), loadImage: vi.fn(),
block: { phase: 'preparing', args, callId: 'call', name, turn: 1, step: 1, time: 1, subCalls: [] },
t: makeTranslate(en, common), useDisclosure, openFile: vi.fn(), loadImage: vi.fn(),
useTodoHistory: vi.fn(), useSession: vi.fn(() => false), renderSlot: vi.fn(() => null),
useProjection: vi.fn(() => undefined), revealPanel: vi.fn(() => false), reviewPanel: vi.fn(() => false),
} as Props
} as Extract<Props, { phase: 'preparing' }>
}
describe('argument-free tool preparation', () => {
describe('tool preparation', () => {
it.each(['read', 'write', 'edit'])('%s does not expose a malformed completed path as a file link', (name) => {
const args = PartialArguments.fromText(String.raw`{"file_path":"unsafe.txt\q"}`)
const props = preparation(name, args)
expect(toolRowModel(name, props.block).filePath).toBeUndefined()
expect(args.invalid).toBe(true)
})
it.each([
['read', ReadRow], ['read_image', ReadImageRow], ['write', FileMutationRow], ['edit', FileMutationRow],
['grep', SearchRow], ['glob', SearchRow], ['web_search', WebRow], ['web_fetch', WebRow],
@@ -54,29 +62,76 @@ describe('argument-free tool preparation', () => {
expect(view.container.querySelector('[aria-expanded="true"]')).toBeNull()
})
it('replaces preparation with the dispatched row and retains that row through the result', () => {
const props = preparation('write')
it.each([['write', 'content'], ['edit', 'new_string']])('%s hides content progress without a path', (name, field) => {
const args = new PartialArguments()
args.append(`{"${field}":"hello`)
const view = render(<FileMutationRow {...preparation(name, args)} />)
expect(view.queryByText('1KB')).toBeNull()
expect(view.queryByRole('button')).toBeNull()
})
it('retains the file row through streamed arguments, dispatch, and result', () => {
const args = new PartialArguments()
const props = preparation('write', args)
const view = render(<FileMutationRow {...props} />)
const preparingRow = view.container.querySelector('[data-tool="write"]')
vi.mocked(props.useToolCallArgumentsPartial).mockClear()
args.append('{"file_path":"hello')
view.rerender(<FileMutationRow {...props} block={{ ...props.block }} />)
expect(view.queryByRole('button')).toBeNull()
args.append('.txt"')
view.rerender(<FileMutationRow {...props} block={{ ...props.block }} />)
expect(view.getByText('hello.txt')).toBeTruthy()
expect(view.queryByText('1KB')).toBeNull()
args.append(',"content":"hello')
view.rerender(<FileMutationRow {...props} block={{ ...props.block }} />)
expect(view.getByText('hello.txt')).toBeTruthy()
expect(view.getByText('1KB')).toBeTruthy()
args.append('"}')
view.rerender(<FileMutationRow {...props} block={{ ...props.block }} />)
expect(view.getByText('1KB')).toBeTruthy()
fireEvent.click(view.getByText('hello.txt'))
expect(props.openFile).toHaveBeenCalledWith('hello.txt')
const started: StartedToolCall = {
phase: 'start', callId: 'call', name: 'write', turn: 1, step: 1, time: 2, subCalls: [],
phase: 'start', args: PartialArguments.fromText('{"file_path":"hello.txt","content":"hello"}'), callId: 'call', name: 'write', turn: 1, step: 1, time: 2, subCalls: [],
argsRaw: '{"file_path":"hello.txt","content":"hello"}',
}
view.rerender(<FileMutationRow {...props} phase="start" block={started} />)
const row = view.container.querySelector('[data-tool="write"]')
expect(row).not.toBe(preparingRow)
expect(props.useToolCallArgumentsPartial).not.toHaveBeenCalled()
expect(row).toBe(preparingRow)
expect(view.getByText('hello.txt')).toBeTruthy()
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(view.getByText('1KB')).toBeTruthy()
expect(row?.textContent).toMatch(/1KB.*\+1 -0/)
const result: ToolResultNode = {
kind: 'tool-result', seq: 3, time: 3, callId: 'call', callTime: 2,
name: 'write', args: PartialArguments.fromText(started.argsRaw),
call: { name: 'write', argsRaw: started.argsRaw }, content: [], isError: false, subCalls: [],
}
view.rerender(<FileMutationRow {...props} phase="result" block={result} />)
expect(view.container.querySelector('[data-tool="write"]')).toBe(row)
expect(props.useToolCallArgumentsPartial).not.toHaveBeenCalled()
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
expect(view.getByText('1KB')).toBeTruthy()
expect(row?.textContent).toMatch(/1KB.*\+1 -0/)
})
it('keeps the combined edit size before diff totals in every stage', () => {
const argsRaw = JSON.stringify({ file_path: 'hello.txt', old_string: 'a'.repeat(600), new_string: 'b'.repeat(425) })
const args = PartialArguments.fromText(argsRaw)
const props = preparation('edit', args)
const view = render(<FileMutationRow {...props} />)
const row = view.container.querySelector('[data-tool="edit"]')
expect(view.getByText('2KB')).toBeTruthy()
const started: StartedToolCall = { ...props.block, phase: 'start', argsRaw }
view.rerender(<FileMutationRow {...props} phase="start" block={started} />)
expect(row?.textContent).toMatch(/2KB.*\+1 -1/)
const result: ToolResultNode = {
kind: 'tool-result', seq: 3, time: 3, callId: 'call', callTime: 2,
name: 'edit', args, call: { name: 'edit', argsRaw }, content: [], isError: false, subCalls: [],
meta: { diffs: [{ path: 'hello.txt', oldText: 'before', newText: 'after' }] },
}
view.rerender(<FileMutationRow {...props} phase="result" block={result} />)
expect(view.container.querySelector('[data-tool="edit"]')).toBe(row)
expect(row?.textContent).toMatch(/2KB.*\+1 -1/)
})
it.each([
@@ -90,7 +145,7 @@ describe('argument-free tool preparation', () => {
expect(view.queryByText(name, { exact: true }) !== null).toBe(name === 'custom_tool')
expect(view.queryByRole('button')).toBeNull()
const started: StartedToolCall = {
phase: 'start', callId: 'call', name, turn: 1, step: 1, time: 2, subCalls: [],
phase: 'start', args: PartialArguments.fromText('{"prompt":"Inspect this file"}'), callId: 'call', name, turn: 1, step: 1, time: 2, subCalls: [],
argsRaw: '{"prompt":"Inspect this file"}',
}
view.rerender(<Component {...props} phase="start" block={started} />)
@@ -99,6 +154,7 @@ describe('argument-free tool preparation', () => {
expect(view.getByRole('button', { expanded: false })).toBeTruthy()
const result: ToolResultNode = {
kind: 'tool-result', seq: 3, time: 3, callId: 'call', callTime: 2,
name, args: PartialArguments.fromText(started.argsRaw),
call: { name, argsRaw: started.argsRaw }, content: [], isError: false, subCalls: [],
}
view.rerender(<Component {...props} phase="result" block={result} />)
@@ -106,11 +162,26 @@ describe('argument-free tool preparation', () => {
expect(view.getByText(summary, { exact: true })).toBeTruthy()
})
it('keeps specialized Bash session hooks outside its preparation branch', () => {
const useSessions = vi.fn(() => { throw new Error('Bash call details are unavailable during preparation') })
const view = render(<BashRow {...preparation('bash')} useSessions={useSessions} />)
it('updates edit progress when the combined strings cross a kilobyte', () => {
const args = new PartialArguments()
args.append(`{"file_path":"hello.txt","old_string":"${'a'.repeat(600)}","new_string":"`)
const props = preparation('edit', args)
const view = render(<FileMutationRow {...props} />)
expect(view.getByText('1KB')).toBeTruthy()
args.append('b'.repeat(424))
expect(args.refresh()).toBe(false)
args.append('b')
expect(args.refresh()).toBe(true)
view.rerender(<FileMutationRow {...props} block={{ ...props.block }} />)
expect(view.getByText('2KB')).toBeTruthy()
})
it('shows a streamed Bash description without enabling expansion', () => {
const args = new PartialArguments()
args.append('{"description":"List files')
const view = render(<BashRow {...preparation('bash', args)} useSessions={vi.fn()} />)
expect(view.container.querySelector('[data-state="preparing"]')).not.toBeNull()
expect(useSessions).not.toHaveBeenCalled()
expect(view.getByText('List files')).toBeTruthy()
expect(view.queryByRole('button')).toBeNull()
})
})
@@ -13,6 +13,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { CHAT_READ_MAX_LINES, readCallLine, readCardModel } from '../src/client/tool/models/read-card-model.ts'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
import { ReadRow, readToolview } from '../src/client/tool/toolviews/read-row.tsx'
afterEach(cleanup)
@@ -54,18 +55,25 @@ const readMeta = (over?: Partial<ReadMetaFixture>): ReadMetaFixture => ({
const readContent = (body = 'export const a = 1'): string => `<path>src/a.ts</path>\n<type>file</type>\n<content>\n${body}\n</content>`
const running = (over?: Partial<StartedToolCall>): StartedToolCall => ({
phase: 'start' as const, callId: 'c1', name: 'read', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
})
const running = (over?: Partial<StartedToolCall>): StartedToolCall => {
const argsRaw = over?.argsRaw ?? ARGS
return {
phase: 'start' as const, args: PartialArguments.fromText(argsRaw), callId: 'c1', name: 'read', argsRaw,
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
}
}
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'read', argsRaw: ARGS },
callTime: 1_000,
content: [{ type: 'text', text: readContent() }], isError: false,
meta: readMeta(), subCalls: [], ...over,
})
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => {
const call = over?.call === undefined ? { name: 'read', argsRaw: ARGS } : over.call
return {
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
name: call?.name ?? '', args: call === null ? PartialArguments.EMPTY : PartialArguments.fromText(call.argsRaw),
call,
callTime: 1_000,
content: [{ type: 'text', text: readContent() }], isError: false,
meta: readMeta(), subCalls: [], ...over,
}
}
describe('readCardModel', () => {
it('derives the card from settled read metadata and its raw envelope', () => {
@@ -9,6 +9,7 @@ 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'
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
import { SearchRow, searchToolview } from '../src/client/tool/toolviews/search-row.tsx'
@@ -62,26 +63,37 @@ const pathsMeta = (over?: Partial<PathsMeta>): PathsMeta => ({
shape: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over,
})
const runningGrep = (over?: Partial<StartedToolCall>): StartedToolCall => ({
phase: 'start' as const, callId: 'c1', name: 'grep', argsRaw: GREP_ARGS,
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
})
const runningGrep = (over?: Partial<StartedToolCall>): StartedToolCall => {
const argsRaw = over?.argsRaw ?? GREP_ARGS
return {
phase: 'start' as const, args: PartialArguments.fromText(argsRaw), callId: 'c1', name: 'grep', argsRaw,
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
}
}
const settledGrep = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'grep', argsRaw: GREP_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'a.ts\n Line 12: const foo = 1' }], isError: false,
meta: matchesMeta(), subCalls: [], ...over,
})
const settledGrep = (over?: Partial<ToolResultNode>): ToolResultNode => {
const call = over?.call === undefined ? { name: 'grep', argsRaw: GREP_ARGS } : over.call
return {
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
name: call?.name ?? '', args: call === null ? PartialArguments.EMPTY : PartialArguments.fromText(call.argsRaw),
call,
callTime: 1_000,
content: [{ type: 'text', text: 'a.ts\n Line 12: const foo = 1' }], isError: false,
meta: matchesMeta(), subCalls: [], ...over,
}
}
const settledGlob = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 11, time: 2_000, callId: 'c2',
call: { name: 'glob', argsRaw: GLOB_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'src/a.ts\nsrc/b.ts' }], isError: false,
meta: pathsMeta(), subCalls: [], ...over,
})
const settledGlob = (over?: Partial<ToolResultNode>): ToolResultNode => {
const call = over?.call === undefined ? { name: 'glob', argsRaw: GLOB_ARGS } : over.call
return {
kind: 'tool-result', seq: 11, time: 2_000, callId: 'c2',
name: call?.name ?? '', args: call === null ? PartialArguments.EMPTY : PartialArguments.fromText(call.argsRaw),
call,
callTime: 1_000,
content: [{ type: 'text', text: 'src/a.ts\nsrc/b.ts' }], isError: false,
meta: pathsMeta(), subCalls: [], ...over,
}
}
describe('searchCardModel', () => {
it('derives a matches card from grep result metadata', () => {
@@ -12,6 +12,7 @@ import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy'
import { formatSpillNotice } from '@deepseek-ai/dsh-spill-policy/notice'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
import { describe, expect, it } from 'vitest'
import { isSpilledShellCall, terminalCardModel } from '../src/client/tool/models/terminal-card-model.ts'
@@ -95,6 +96,7 @@ async function executeShell(text: string, nested: boolean, name = 'bash', maxInl
block = {
kind: 'tool-result', seq: event.seq, time: event.time, callTime: null,
callId: event.data.subCallId, parentCallId: event.data.parentCallId,
name: event.data.name, args: PartialArguments.fromText(JSON.stringify(event.data.arguments)),
call: { name: event.data.name, argsRaw: JSON.stringify(event.data.arguments) },
content: event.data.content, isError: event.data.isError, subCalls: [],
}
@@ -102,6 +104,7 @@ async function executeShell(text: string, nested: boolean, name = 'bash', maxInl
expect(result.value).toEqual([{ type: 'text', text }])
block = {
kind: 'tool-result', seq: 1, time: 1, callTime: null, callId,
name, args: PartialArguments.fromText(JSON.stringify(shellArgs)),
call: { name, argsRaw: JSON.stringify(shellArgs) },
content: result.content, isError: result.isError, subCalls: [],
}
@@ -17,6 +17,7 @@ import {
import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx'
import { en, zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
type BashRowProps = Parameters<typeof BashRow>[0]
@@ -45,18 +46,25 @@ const shellArgs = (over: Record<string, unknown> = {}): string => JSON.stringify
command: 'ls -la', description: 'List files', ...over,
})
const running = (over?: Partial<StartedToolCall>): StartedToolCall => ({
phase: 'start' as const, callId: 'c1', name: 'bash', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
})
const running = (over?: Partial<StartedToolCall>): StartedToolCall => {
const argsRaw = over?.argsRaw ?? ARGS
return {
phase: 'start' as const, args: PartialArguments.fromText(argsRaw), callId: 'c1', name: 'bash', argsRaw,
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
}
}
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'bash', argsRaw: ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'a.ts b.ts\nc.ts d.ts\n' }], isError: false,
subCalls: [], ...over,
})
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => {
const call = over?.call === undefined ? { name: 'bash', argsRaw: ARGS } : over.call
return {
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
name: call?.name ?? '', args: call === null ? PartialArguments.EMPTY : PartialArguments.fromText(call.argsRaw),
call,
callTime: 1_000,
content: [{ type: 'text', text: 'a.ts b.ts\nc.ts d.ts\n' }], isError: false,
subCalls: [], ...over,
}
}
describe('terminalCardModel', () => {
it('derives a running standard-shell card from raw arguments', () => {
@@ -9,6 +9,7 @@ import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts
import { todoCallDefinition, todoHistoryView, todoWriteDefinition, type TodoHistory } from '../src/client/tool/models/todo-history.ts'
import { todoDiffModel } from '../src/client/tool/models/todo-diff-model.ts'
import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
const t = makeTranslate(en, commonEn)
const first = [{ content: 'Build', status: 'in_progress' }, { content: 'Review', status: 'pending' }, { content: 'Old task', status: 'pending' }] as const
@@ -19,7 +20,7 @@ function entry(seq: number, type: string, data: unknown): SessionLiveEventEntry
}
function call(todos: unknown): ToolResultNode {
return { kind: 'tool-result', seq: 20, time: 2000, callTime: 1000, callId: 'second', call: { name: 'todo_write', argsRaw: JSON.stringify({ todos }) }, content: [], isError: false, subCalls: [] }
return { kind: 'tool-result', seq: 20, time: 2000, callTime: 1000, callId: 'second', name: 'todo_write', args: PartialArguments.fromText(JSON.stringify({ todos })), call: { name: 'todo_write', argsRaw: JSON.stringify({ todos }) }, content: [], isError: false, subCalls: [] }
}
function runtime() {
@@ -11,6 +11,7 @@ import { TodoRow, todoToolview } from '../src/client/tool/toolviews/todo-row.tsx
import { planSummary } from '../src/client/tool/toolviews/plan-summary.ts'
import { CONVERSATION_NS as NS } from '../src/client/locale.ts'
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
type TodoRowProps = Parameters<typeof TodoRow>[0]
@@ -60,11 +61,15 @@ describe('planSummary', () => {
})
})
const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
call: { name: 'todo_write', argsRaw },
content: [], isError: false, subCalls: [], ...over,
})
const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResultNode => {
const call = over?.call === undefined ? { name: 'todo_write', argsRaw } : over.call
return {
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
name: call?.name ?? '', args: call === null ? PartialArguments.EMPTY : PartialArguments.fromText(call.argsRaw),
call,
content: [], isError: false, subCalls: [], ...over,
}
}
function rowProps(block: TodoRowProps['block']): TodoRowProps {
return {
@@ -97,7 +102,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(<TodoRow {...rowProps({ phase: 'start' as const, callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, subCalls: [] })} />)
render(<TodoRow {...rowProps({ phase: 'start' as const, args: PartialArguments.fromText(args), callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, subCalls: [] })} />)
expect(screen.getByText('1/1 已完成')).toBeTruthy()
})
@@ -110,7 +115,7 @@ describe('TodoRow', () => {
it('keeps non-ok execution states visible through the shared row states', () => {
const args = JSON.stringify({ todos: LIST })
const running = render(<TodoRow {...rowProps({ phase: 'start' as const, callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, subCalls: [] })} />)
const running = render(<TodoRow {...rowProps({ phase: 'start' as const, args: PartialArguments.fromText(args), callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, subCalls: [] })} />)
expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull()
running.unmount()
@@ -1,122 +0,0 @@
// @vitest-environment jsdom
/** Call-scoped preparation subscriptions and file-mutation progress. */
import { useMemo } from 'react'
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { AssistantChatData, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en, zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import type { ToolCallHookContext } from '../src/client/contract/slots.ts'
import { bindToolCallArgumentsPartial } from '../src/client/tool/tool-call-arguments-partial.ts'
import { FileMutationRow } from '../src/client/tool/toolviews/file-mutation-row.tsx'
afterEach(cleanup)
const standard = {} as Parameters<typeof bindToolCallArgumentsPartial>[0]
const data = (first: string, second = ''): AssistantChatData => ({
status: 'running', turn: 1, step: 1, time: 1,
blocks: [
{ kind: 'text', text: 'Preparing changes' },
{ kind: 'tool-call', callId: 'first', name: 'write', argsRaw: first },
{ kind: 'tool-call', callId: 'second', name: 'edit', argsRaw: second },
],
})
function source(initial: AssistantChatData | undefined) {
const store = createSnapshotStore<AssistantChatData | undefined>(initial)
const listeners = new Set<() => void>()
return {
set: (value: AssistantChatData | undefined) => { store.set(value) },
listeners,
getSnapshot: () => store.getSnapshot(),
subscribe: (listener: () => void) => {
listeners.add(listener)
const dispose = store.subscribe(listener)
return () => { listeners.delete(listener); dispose() }
},
}
}
function Probe({ context, label, rendered }: { context: ToolCallHookContext; label: string; rendered: () => void }) {
const usePartial = useMemo(() => bindToolCallArgumentsPartial(standard, context), [context])
const raw = usePartial()
rendered()
return <output aria-label={label}>{raw}</output>
}
describe('tool argument prefix Hook', () => {
it('does not subscribe until used and refreshes only the call whose prefix changed', () => {
const assistant = source(data('{', 'second prefix'))
const first = bindToolCallArgumentsPartial(standard, { assistant, callId: 'first' })
expect(assistant.listeners.size).toBe(0)
const firstRendered = vi.fn()
const secondRendered = vi.fn()
const firstContext = { assistant, callId: 'first' }
const secondContext = { assistant, callId: 'second' }
const view = render(<>
<Probe context={firstContext} label="first" rendered={firstRendered} />
<Probe context={secondContext} label="second" rendered={secondRendered} />
</>)
expect(first).toBeTypeOf('function')
expect(assistant.listeners.size).toBe(2)
firstRendered.mockClear()
secondRendered.mockClear()
act(() => { assistant.set(data('{"file_path":', 'second prefix')) })
expect(view.getByLabelText('first').textContent).toBe('{"file_path":')
expect(firstRendered).toHaveBeenCalled()
expect(secondRendered).not.toHaveBeenCalled()
firstRendered.mockClear()
act(() => { assistant.set(data('{"file_path":', 'more second prefix')) })
expect(firstRendered).not.toHaveBeenCalled()
expect(view.getByLabelText('second').textContent).toBe('more second prefix')
view.unmount()
expect(assistant.listeners.size).toBe(0)
})
it('handles missing sources and calls and detaches a replaced Step source', () => {
const old = source(undefined)
const next = source(data('new step'))
const rendered = vi.fn()
const view = render(<Probe context={{ assistant: old, callId: 'missing' }} label="raw" rendered={rendered} />)
expect(view.getByLabelText('raw').textContent).toBe('')
act(() => { old.set(data('unrelated')) })
expect(view.getByLabelText('raw').textContent).toBe('')
view.rerender(<Probe context={{ assistant: next, callId: 'first' }} label="raw" rendered={rendered} />)
expect(old.listeners.size).toBe(0)
expect(next.listeners.size).toBe(1)
expect(view.getByLabelText('raw').textContent).toBe('new step')
view.rerender(<Probe context={{ assistant: undefined, callId: 'first' }} label="raw" rendered={rendered} />)
expect(next.listeners.size).toBe(0)
expect(view.getByLabelText('raw').textContent).toBe('')
view.unmount()
})
it.each([
['write', en, 'Preparing content'], ['edit', zh, '正在准备内容'],
] as const)('%s subscribes only during preparation and rounds raw length upward', (toolName, dictionary, label) => {
const assistant = source(data(''))
const usePartial = bindToolCallArgumentsPartial(standard, { assistant, callId: 'first' })
const props = {
phase: 'preparing', toolName, callId: 'first', useToolCallArgumentsPartial: usePartial,
block: { phase: 'preparing', callId: 'first', name: toolName, turn: 1, step: 1, time: 1, subCalls: [] },
useDisclosure, openFile: vi.fn(), loadImage: vi.fn(), t: makeTranslate(dictionary),
} as Parameters<typeof FileMutationRow>[0]
const view = render(<FileMutationRow {...props} />)
for (const [length, kilobytes] of [[0, 0], [1, 1], [1024, 1], [1025, 2], [12 * 1024, 12]]) {
act(() => { assistant.set(data('中'.repeat(length!))) })
expect(view.getByText(`${label} ${kilobytes}KB`)).toBeTruthy()
expect(view.queryByRole('button')).toBeNull()
}
expect(assistant.listeners.size).toBe(1)
const started = { phase: 'start' as const, callId: 'first', name: toolName, turn: 1, step: 1, time: 2, subCalls: [], argsRaw: '{"file_path":"file.txt","content":"hello"}' }
view.rerender(<FileMutationRow {...props} phase="start" block={started} />)
expect(assistant.listeners.size).toBe(0)
expect(view.getByText('file.txt')).toBeTruthy()
const result: ToolResultNode = { kind: 'tool-result', callId: 'first', seq: 3, time: 3, callTime: 2, call: { name: toolName, argsRaw: started.argsRaw }, content: [], isError: false, subCalls: [] }
view.rerender(<FileMutationRow {...props} phase="result" block={result} />)
expect(assistant.listeners.size).toBe(0)
expect(view.getByText('file.txt')).toBeTruthy()
})
})
@@ -11,6 +11,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.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'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
afterEach(cleanup)
@@ -18,6 +19,7 @@ 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,
name: call?.name ?? '', args: call === null ? PartialArguments.EMPTY : PartialArguments.fromText(call.argsRaw),
content: [], isError: false, subCalls: [],
})
@@ -78,7 +80,7 @@ describe('ToolCallTree', () => {
it('dispatches preparation through the existing keyed toolview with no argument fields', () => {
const owners: ToolCallOwnerProps[] = []
const view = render(<ToolCallTree {...props({
phase: 'preparing', callId: 'preparing', name: 'write', turn: 1, step: 1, time: 1, subCalls: [],
phase: 'preparing', args: PartialArguments.EMPTY, callId: 'preparing', name: 'write', turn: 1, step: 1, time: 1, subCalls: [],
}, undefined, owners)} />)
expect(owners[0]).toMatchObject({ phase: 'preparing', callId: 'preparing', toolName: 'write' })
expect(owners[0]?.block).not.toHaveProperty('argsRaw')
@@ -164,7 +166,7 @@ describe('ToolCallTree', () => {
it('dispatches a running call by its wire name and forwards inspect', () => {
const owners: ToolCallOwnerProps[] = []
const block: ToolCallBlock = {
phase: 'start' as const, callId: 'running', name: 'bash', argsRaw: '{"command":"pwd"}',
phase: 'start' as const, args: PartialArguments.fromText('{"command":"pwd"}'), callId: 'running', name: 'bash', argsRaw: '{"command":"pwd"}',
turn: 1, step: 0, time: 1_000, subCalls: [],
}
const treeProps = props(block, undefined, owners)
@@ -13,6 +13,7 @@ import {
import { ToolRow } from '../src/client/tool/components/ToolRow.tsx'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
afterEach(() => {
cleanup()
@@ -21,17 +22,24 @@ afterEach(() => {
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
const running = (over?: Partial<StartedToolCall>): StartedToolCall => ({
phase: 'start' as const, callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
})
const running = (over?: Partial<StartedToolCall>): StartedToolCall => {
const argsRaw = over?.argsRaw ?? '{"command":"ls -la","description":"List files"}'
return {
phase: 'start' as const, args: PartialArguments.fromText(argsRaw), callId: 'c1', name: 'bash', argsRaw,
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
}
}
const result = (over?: Partial<ToolResultNode>): 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, subCalls: [], ...over,
})
const result = (over?: Partial<ToolResultNode>): ToolResultNode => {
const call = over?.call === undefined ? { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' } : over.call
return {
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
name: call?.name ?? '', args: call === null ? PartialArguments.EMPTY : PartialArguments.fromText(call.argsRaw),
call,
callTime: 1_000,
content: [], isError: false, subCalls: [], ...over,
}
}
describe('tool-call-model', () => {
it('classifies known tools and falls back to others', () => {
@@ -102,6 +110,23 @@ describe('tool-call-model', () => {
expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' })).summary).toBe('pwd')
})
it.each([
{ name: 'web_search', variant: 'search', prefix: '' },
{ name: 'custom-tool', variant: 'others', prefix: 'custom-tool · ' },
])('prefers description for $variant summaries and retains the raw-argument fallback', ({ name, variant, prefix }) => {
for (const withDescription of [true, false]) {
const argsRaw = JSON.stringify({
query: 'First argument',
...withDescription ? { description: 'Preferred description\nSecond line' } : {},
})
for (const block of [running({ name, argsRaw }), result({ call: { name, argsRaw } })]) {
expect(toolRowModel(name, block)).toMatchObject({
variant, summary: prefix + (withDescription ? 'Preferred description' : 'First argument'),
})
}
}
})
it('keeps summaries single-line and falls back for opaque args', () => {
expect(toolRowModel('bash', running({ argsRaw: '{"command":"a\\nb"}' })).summary).toBe('a')
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/tmp/x.ts"}' })).summary).toBe('/tmp/x.ts')
@@ -440,6 +465,18 @@ describe('ToolRow', () => {
expect(failed.queryByText('+2')).toBeNull()
})
it('places the summary suffix before diff totals without replacing them', () => {
const diff = { card: { diffs: [{ path: 'out.txt', oldText: null, newText: 'one\ntwo\n' }] } }
const view = render(<ToolRow {...rowProps} variant="write" summary="out.txt" summarySuffix="2KB" diff={diff} />)
expect(view.container.querySelector('[data-disclosure-row]')?.textContent).toMatch(/2KB.*\+2 -0/)
for (const state of ['error', 'stopped'] as const) {
view.rerender(<ToolRow {...rowProps} state={state} summarySuffix="2KB" diff={diff} />)
expect(view.queryByText('2KB')).toBeNull()
expect(view.queryByText('+2')).toBeNull()
expect(view.queryByText('-0')).toBeNull()
}
})
it('an error file row drops the open-file link (the summary is failure prose, not the path)', () => {
const open = vi.fn()
const view = render(
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more