diff --git a/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.i18n.yaml index 48da0f768d..c1523d3b70 100644 --- a/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.md b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.md index f43eaf1a68..a99545b58b 100644 --- a/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.md +++ b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.zh.md b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.zh.md index accbd2e966..9bea117dd3 100644 --- a/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.zh.md @@ -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 保持组件身份;自定义外壳可按阶段切换内部组件。 +- 部分参数的读取与更新策略遵循懒计算参数视图,不需要独立的原始前缀钩子。 - 组装测试分别覆盖实时准备态、撤回、活跃流重建、持久历史与分页收敛;组件测试检查无参数展示和阶段切换,录制会话覆盖浏览器中的准备态与重载结果。 diff --git a/.agents/notes/implemented/architecture/2026-09-24-preparing-tool-arguments.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-preparing-tool-arguments.i18n.yaml new file mode 100644 index 0000000000..fcc4c5d557 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-24-preparing-tool-arguments.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-09-24-preparing-tool-arguments.md b/.agents/notes/implemented/architecture/2026-09-24-preparing-tool-arguments.md new file mode 100644 index 0000000000..60a9bc6a15 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-24-preparing-tool-arguments.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-09-24-preparing-tool-arguments.zh.md b/.agents/notes/implemented/architecture/2026-09-24-preparing-tool-arguments.zh.md new file mode 100644 index 0000000000..653b517aed --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-24-preparing-tool-arguments.zh.md @@ -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 事件或持久化格式。 +- 名字晚于参数到达的流放弃扫描,该调用的准备行退回只显示标题。 diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml index 891ae724e9..4dc12c0baf 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md index 7a24a649db..36cd7458b3 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md @@ -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. diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md index 7afd4c1c95..8a6bbaff8e 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md @@ -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 仍是完成证据。 diff --git a/apps/web/package.json b/apps/web/package.json index fe4de9e9e1..7bc21e7178 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index 78c7e87a40..e319880bf5 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -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(), held: false, + contentField: name === 'write' ? 'content' : 'old_string', + contentReady: Promise.withResolvers(), contentRelease: Promise.withResolvers(), + contentHeld: false, })) const names = new Map() 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 diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md index 88f7cec6b8..851f8c5f2e 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -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" diff --git a/benchmarks/active-stream-reconnect/README.i18n.yaml b/benchmarks/active-stream-reconnect/README.i18n.yaml index 25d326e31e..06b98b7145 100644 --- a/benchmarks/active-stream-reconnect/README.i18n.yaml +++ b/benchmarks/active-stream-reconnect/README.i18n.yaml @@ -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 diff --git a/benchmarks/active-stream-reconnect/README.md b/benchmarks/active-stream-reconnect/README.md index e75a41eba3..b9c7a64249 100644 --- a/benchmarks/active-stream-reconnect/README.md +++ b/benchmarks/active-stream-reconnect/README.md @@ -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. diff --git a/benchmarks/active-stream-reconnect/README.zh.md b/benchmarks/active-stream-reconnect/README.zh.md index 3d83120814..8e2f330e83 100644 --- a/benchmarks/active-stream-reconnect/README.zh.md +++ b/benchmarks/active-stream-reconnect/README.zh.md @@ -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)记录校准与排除项。 diff --git a/benchmarks/active-stream-reconnect/reconnect.bench.client.ts b/benchmarks/active-stream-reconnect/reconnect.bench.client.ts index 3388f9e3b9..0d25974013 100644 --- a/benchmarks/active-stream-reconnect/reconnect.bench.client.ts +++ b/benchmarks/active-stream-reconnect/reconnect.bench.client.ts @@ -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) }) diff --git a/benchmarks/conversation-fold/conversation-fold.bench.client.ts b/benchmarks/conversation-fold/conversation-fold.bench.client.ts index cf067aea2f..bee31c5b30 100644 --- a/benchmarks/conversation-fold/conversation-fold.bench.client.ts +++ b/benchmarks/conversation-fold/conversation-fold.bench.client.ts @@ -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 { +function requireReport( + run: BuiltBenchmarkWorkerRun, +): 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({ + 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) + }) +}) diff --git a/benchmarks/conversation-fold/conversation-fold.worker.client.ts b/benchmarks/conversation-fold/conversation-fold.worker.client.ts index 707051a4dc..77297a131e 100644 --- a/benchmarks/conversation-fold/conversation-fold.worker.client.ts +++ b/benchmarks/conversation-fold/conversation-fold.worker.client.ts @@ -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`) diff --git a/benchmarks/package.json b/benchmarks/package.json index e496a148b2..0fa57f90e5 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -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:*", diff --git a/docs/subsystems/conversation.i18n.yaml b/docs/subsystems/conversation.i18n.yaml index 9a57ab6f2a..b8f7380bbf 100644 --- a/docs/subsystems/conversation.i18n.yaml +++ b/docs/subsystems/conversation.i18n.yaml @@ -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 diff --git a/docs/subsystems/conversation.md b/docs/subsystems/conversation.md index dca3d51339..ba4eb88102 100644 --- a/docs/subsystems/conversation.md +++ b/docs/subsystems/conversation.md @@ -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`. diff --git a/docs/subsystems/conversation.zh.md b/docs/subsystems/conversation.zh.md index 18d2a2b282..7115bb90b8 100644 --- a/docs/subsystems/conversation.zh.md +++ b/docs/subsystems/conversation.zh.md @@ -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` 撤回它。 diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 7f9264f1b0..33fbabedbd 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -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`) → 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. diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 548db3905d..869a54318b 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index f09b12c461..0740f79bb1 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -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. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 599c4cb0cf..98815bdc66 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -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。 diff --git a/packages/client/ui-chat/src/client/contract/snapshot.ts b/packages/client/ui-chat/src/client/contract/snapshot.ts index 80d19ba50a..5a9b03dea0 100644 --- a/packages/client/ui-chat/src/client/contract/snapshot.ts +++ b/packages/client/ui-chat/src/client/contract/snapshot.ts @@ -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' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml index 1330f4f627..87b6c2526a 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md index b23d1d47f5..3621a86e32 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -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. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md index ef61f0dad3..e2d4c62742 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md @@ -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 个字素簇。已结束组发布的摘要清空实时类别和详情,保留类别次数。 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts index 53b8057c58..b7658a1bf5 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts @@ -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 = { * @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, + }, + }) } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/partial.ts b/packages/client/ui-chat/src/client/conversation-nodes/partial.ts deleted file mode 100644 index ea73fae9f6..0000000000 --- a/packages/client/ui-chat/src/client/conversation-nodes/partial.ts +++ /dev/null @@ -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 - } -} diff --git a/packages/client/ui-chat/src/client/conversation-nodes/process-activity.ts b/packages/client/ui-chat/src/client/conversation-nodes/process-activity.ts index b072b29d7e..3b82dc2ecb 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/process-activity.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/process-activity.ts @@ -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) diff --git a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts index ffbc94de5a..6d4e5c28ea 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts @@ -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 readonly parents: ReadonlyMap } @@ -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 = { 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 = { } 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 = { 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 = { * @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, + }, + }) } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts index 69f8baa42c..f151676c6b 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts @@ -305,5 +305,19 @@ export const turnProcessDefinition: ConversationNodeDefinition * @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, + }, + }) } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts index 5f5766b149..c1bfdd0c4d 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts @@ -144,5 +144,21 @@ export const turnTailDefinition: ConversationNodeDefinition = { * @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, + }, + }) } diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 6b361f10f7..7dc1146431 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -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 { diff --git a/packages/client/ui-chat/src/client/model/tool-call-tree.ts b/packages/client/ui-chat/src/client/model/tool-call-tree.ts index 1f29e3c121..2be2121414 100644 --- a/packages/client/ui-chat/src/client/model/tool-call-tree.ts +++ b/packages/client/ui-chat/src/client/model/tool-call-tree.ts @@ -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, diff --git a/packages/client/ui-chat/tests/approval-command.client.spec.tsx b/packages/client/ui-chat/tests/approval-command.client.spec.tsx index 6b18ea146a..95ff1963c0 100644 --- a/packages/client/ui-chat/tests/approval-command.client.spec.tsx +++ b/packages/client/ui-chat/tests/approval-command.client.spec.tsx @@ -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() expect(screen.getByText('pnpm test')).toBeTruthy() @@ -41,8 +42,8 @@ describe('ApprovalCommand', () => { const { container, rerender } = render( { expect(container.textContent).toBe('') rerender() expect(container.textContent).toBe('') }) diff --git a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts index 37eb7faf63..38322b1772 100644 --- a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts +++ b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts @@ -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 { diff --git a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx index fdf4556bce..c24e93bd5c 100644 --- a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx @@ -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]) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 78b0da0cbe..7205405e49 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -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 => ({ kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'], diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index f948d14450..310228f678 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -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() diff --git a/packages/client/ui-chat/tests/partial.client.spec.ts b/packages/client/ui-chat/tests/partial.client.spec.ts deleted file mode 100644 index 535f9d2f7e..0000000000 --- a/packages/client/ui-chat/tests/partial.client.spec.ts +++ /dev/null @@ -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): 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) - }) -}) diff --git a/packages/client/ui-chat/tests/process-groups.client.spec.ts b/packages/client/ui-chat/tests/process-groups.client.spec.ts index 9f652681a9..947932fc00 100644 --- a/packages/client/ui-chat/tests/process-groups.client.spec.ts +++ b/packages/client/ui-chat/tests/process-groups.client.spec.ts @@ -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() diff --git a/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts b/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts index 51d4aa9501..d2b2fa1dc2 100644 --- a/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts +++ b/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts @@ -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: [], }) diff --git a/packages/client/ui-chat/tests/tool-preparation.client.spec.ts b/packages/client/ui-chat/tests/tool-preparation.client.spec.ts index 286eca7eed..606a4a33d9 100644 --- a/packages/client/ui-chat/tests/tool-preparation.client.spec.ts +++ b/packages/client/ui-chat/tests/tool-preparation.client.spec.ts @@ -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((...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((...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)) diff --git a/packages/client/ui-chat/tests/turn-process-tail.client.spec.ts b/packages/client/ui-chat/tests/turn-process-tail.client.spec.ts new file mode 100644 index 0000000000..fd301201a1 --- /dev/null +++ b/packages/client/ui-chat/tests/turn-process-tail.client.spec.ts @@ -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]) + }) +}) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 055bc835d2..4bf9b4e7cf 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index f5e21c22ed..5f6c8a44da 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -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. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 243a2f79b5..0a1c1aa7cd 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -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)定义输入有效期、类型化注册、原子更新及渲染器职责。 diff --git a/packages/client/ui-conversation/src/client/contract/conversation.ts b/packages/client/ui-conversation/src/client/contract/conversation.ts index 64fa04ecee..8230bda46e 100644 --- a/packages/client/ui-conversation/src/client/contract/conversation.ts +++ b/packages/client/ui-conversation/src/client/contract/conversation.ts @@ -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 { readonly kind: string @@ -255,6 +262,12 @@ export interface ConversationNodeDefinition { buildViewNode?(context: ConversationNodeContext): ConversationViewNode | null } +/** Registration accepts a function or an immutable table of own event-type handlers. */ +export type ConversationNodeDefinitionInput = + Omit, 'match'> & { + readonly match: ConversationMatchHandler | Readonly> + } + /** Reference-stable Turn/Step facts published beside view Nodes. */ export interface ConversationTimelineSnapshot { readonly turnOrder: readonly number[] diff --git a/packages/client/ui-conversation/src/client/contract/records.ts b/packages/client/ui-conversation/src/client/contract/records.ts index f9e0c002c6..0cbb172f8c 100644 --- a/packages/client/ui-conversation/src/client/contract/records.ts +++ b/packages/client/ui-conversation/src/client/contract/records.ts @@ -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' } diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index 6466208dde..6ead68c793 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -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> { /** @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() private readonly contextsByKind = new Map() - private readonly contextsBySeq = new Map>() + private readonly contextsBySeq = new Map() private readonly contextsByTarget = new Map>() private readonly inputs = new Map() 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, ): 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() + 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 | 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() - 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() - 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() + let contexts = this.dirtyByTarget.get(target) + if (contexts === undefined) { + contexts = new Set() + this.dirtyByTarget.set(target, contexts) + } contexts.add(context) - this.dirtyByTarget.set(target, contexts) } private replaceDependencies(context: InternalContext, dependencies: Map): 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() 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 } diff --git a/packages/client/ui-conversation/src/client/conversation/event-registry.ts b/packages/client/ui-conversation/src/client/conversation/event-registry.ts index 197cf4420a..1638c07e30 100644 --- a/packages/client/ui-conversation/src/client/conversation/event-registry.ts +++ b/packages/client/ui-conversation/src/client/conversation/event-registry.ts @@ -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 { private fallback: ConversationNodeDefinition | undefined + private routes = new Map>() + private unrestricted: ReadonlySet = new Set() + private readonly tables = new WeakMap>() /** * 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 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 { + 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>() + const unrestricted = new Set() + 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`, diff --git a/packages/client/ui-conversation/src/client/conversation/location-index.ts b/packages/client/ui-conversation/src/client/conversation/location-index.ts index 839beedeaf..1a4e9b3092 100644 --- a/packages/client/ui-conversation/src/client/conversation/location-index.ts +++ b/packages/client/ui-conversation/src/client/conversation/location-index.ts @@ -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(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() - private locations = new Map() private seqsByTurn = new Map>() + private readonly stepsByTurn = new Map>() private timeline: ConversationTimelineSnapshot = { turnOrder: [], turns: new Map() } private readonly turnDataStores = new Map() private readonly stepDataStores = new Map() @@ -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 { - const previousLocations = this.locations + const previousCoordinates = this.coordinates const turns = new Map() const coordinates = new Map() 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() 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() + 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() 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() + let current = this.seqsByTurn.get(turn) + if (current === undefined) { + current = new Set() + this.seqsByTurn.set(turn, current) + } current.add(seq) - this.seqsByTurn.set(turn, current) } private turnData(turn: number): ConversationLocationDataStore { @@ -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 } } } diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index b97aa3ed44..2b9e30d332 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -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 { diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 78b26f7ab6..5f978ed0c8 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -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', diff --git a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts index c9cd56ea7a..a069733cbc 100644 --- a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts @@ -172,6 +172,145 @@ function fallbackDefinition(start: () => string): ConversationNodeDefinition { + 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, 'set') + const dirty = vi.spyOn(Reflect.get(assembler, 'dirty') as Set, 'add') + const targets = vi.spyOn(Reflect.get(assembler, 'dirtyByTarget') as Map, '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, '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 = { + 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[] = 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(( diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index 603e32b3f5..e3d8a814da 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -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([]) }) diff --git a/packages/client/ui-conversation/tests/conversation-routing.client.spec.ts b/packages/client/ui-conversation/tests/conversation-routing.client.spec.ts new file mode 100644 index 0000000000..a10ee52327 --- /dev/null +++ b/packages/client/ui-conversation/tests/conversation-routing.client.spec.ts @@ -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 { + 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(event => event.type === 'turn/start' && event.data.turn === 2 + ? { id: 'second', role: 'start' } : null) + const unrelated = vi.fn(() => null) + const unrestricted = vi.fn(() => null) + let handlerReads = 0 + const table: Record = { + 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(() => null) + const legacy = vi.fn(() => 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(() => 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(event => event.type === 'turn/start' && event.data.turn === 2 + ? { id: 'second', role: 'start' } : null) + const fallback = vi.fn(() => ({ 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(() => ({ 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) + }) +}) diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index f3c6f6f811..4e732fb9c2 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -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:*", diff --git a/packages/client/ui-deliverables/tests/present-row.client.spec.tsx b/packages/client/ui-deliverables/tests/present-row.client.spec.tsx index ee01d9b093..3cb53beb7a 100644 --- a/packages/client/ui-deliverables/tests/present-row.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/present-row.client.spec.tsx @@ -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[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() expect(view.container.querySelector('[data-state="preparing"] svg')).not.toBeNull() expect(view.queryByRole('button')).toBeNull() diff --git a/packages/client/ui-schedule/src/client/schedule-turn.ts b/packages/client/ui-schedule/src/client/schedule-turn.ts index 97dfeadf0a..f6e85d2640 100644 --- a/packages/client/ui-schedule/src/client/schedule-turn.ts +++ b/packages/client/ui-schedule/src/client/schedule-turn.ts @@ -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, diff --git a/packages/client/ui-schedule/tests/schedule-create-card.client.spec.tsx b/packages/client/ui-schedule/tests/schedule-create-card.client.spec.tsx index 2b762fd414..9a295266ba 100644 --- a/packages/client/ui-schedule/tests/schedule-create-card.client.spec.tsx +++ b/packages/client/ui-schedule/tests/schedule-create-card.client.spec.tsx @@ -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 }) }) diff --git a/packages/client/ui-schedule/tests/schedule-turn-card.client.spec.tsx b/packages/client/ui-schedule/tests/schedule-turn-card.client.spec.tsx index b65a8343ba..04fbd89222 100644 --- a/packages/client/ui-schedule/tests/schedule-turn-card.client.spec.tsx +++ b/packages/client/ui-schedule/tests/schedule-turn-card.client.spec.tsx @@ -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 = {}): 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 { 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() expect(view.getByText('准备加载技能')).toBeTruthy() expect(view.container.querySelector('svg')).not.toBeNull() diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index a58dc17c71..2d30ff848a 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index c54b9c8da5..fa5a502631 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -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 · `. 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 · `. 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 diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 64dcb2823a..59d02f335a 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -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`。 ### 卡片 diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 88f24b4dd5..072fd46015 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -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", diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index 36e5f7f691..e6a866d1b2 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -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)) diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index f02dfe3edf..6ad2204aaa 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -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: ''`; 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 | 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. */ diff --git a/packages/client/ui-tool/src/client/index.ts b/packages/client/ui-tool/src/client/index.ts index 5a7fc10a9d..1b0f2be27e 100644 --- a/packages/client/ui-tool/src/client/index.ts +++ b/packages/client/ui-tool/src/client/index.ts @@ -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' diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index 050de5e63a..d6419aa205 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -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 & { callId: string toolName: string call: ToolCallPhaseProps - assistant: ToolCallHookContext['assistant'] home?: string | undefined children?: ReactNode }) { - const preparing = call.phase === 'preparing' - const hookContext = useMemo(() => ({ - callId, assistant: preparing ? assistant : undefined, - }), [assistant, callId, preparing]) const owner: ToolCallOwnerProps = useMemo(() => ({ callId, toolName, @@ -56,7 +51,6 @@ const ToolCall = memo(function ToolCall({ ? : renderSlot('tool.call.toolview', owner, { entryKey: toolName, - hookContext, fallback: , })} {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 & { 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 ( & { - 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 -} diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index d91a72d87f..5bc7af6835 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -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) => { event.stopPropagation() @@ -243,12 +242,15 @@ export const ToolRow = memo(function ToolRow({ )} {suffix !== null && ( - - {typeof suffix === 'string' ? suffix : <>{`+${suffix.added}`}{' '}{`-${suffix.removed}`}} + {suffix} + )} + {totals !== null && ( + + {`+${totals.added}`}{' '}{`-${totals.removed}`} )} - ), [summaryLinkKeyDown, linkHref, openFile, state, suffix, summaryText]) + ), [summaryLinkKeyDown, linkHref, openFile, state, suffix, totals, summaryText]) const expandedContent = useMemo(() => open ? (
{askQuestionBody !== null diff --git a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts index de0e2a8c21..06f93669f4 100644 --- a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts @@ -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 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 = 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, diff --git a/packages/client/ui-tool/src/client/tool/tool-call-arguments-partial.ts b/packages/client/ui-tool/src/client/tool/tool-call-arguments-partial.ts deleted file mode 100644 index bc55412b4d..0000000000 --- a/packages/client/ui-tool/src/client/tool/tool-call-arguments-partial.ts +++ /dev/null @@ -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) - } -} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx index d8fd8032d7..c029ef86aa 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx @@ -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 = /** 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 - return -}) - -const StartedBashRow = memo(function StartedBashRow({ toolName, block, sessionId, useSessions, inspect, useDisclosure, t }: Exclude) { +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) => { if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return event.preventDefault() diff --git a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx index 5462f90f8a..d2071fbc62 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx @@ -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 = +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' - ? - : -} - -function PreparingFileMutationRow({ toolName, useDisclosure, useToolCallArgumentsPartial, t }: Extract) { - const raw = useToolCallArgumentsPartial() - return -} - -function StartedFileMutationRow({ toolName, block, cwd, home, openFile, inspect, useDisclosure, t }: Exclude) { +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 ( ): 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 => { + 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 '', openFile: vi.fn(), loadImage: vi.fn(async () => ''), sessionId: 's1' as SessionId, diff --git a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx index bf21f5ccc5..91245c1b51 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx @@ -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 => ({ - 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 => { + 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'> diff --git a/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx index 857a8e9858..9a69938fff 100644 --- a/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx @@ -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: [], diff --git a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx index 6e64b9e55a..99a97e039b 100644 --- a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx @@ -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[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: [], diff --git a/packages/client/ui-tool/tests/details-models.client.spec.ts b/packages/client/ui-tool/tests/details-models.client.spec.ts index 631772f168..9642bd55f7 100644 --- a/packages/client/ui-tool/tests/details-models.client.spec.ts +++ b/packages/client/ui-tool/tests/details-models.client.spec.ts @@ -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 = {}): 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: [], } } diff --git a/packages/client/ui-tool/tests/details-row.client.spec.tsx b/packages/client/ui-tool/tests/details-row.client.spec.tsx index 944a68b1c4..ec054fc78c 100644 --- a/packages/client/ui-tool/tests/details-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/details-row.client.spec.tsx @@ -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) }) }) diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index 7bfbcfa185..08590fbcb5 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -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 => ({ - phase: 'start' as const, callId: 'c1', name: 'edit', argsRaw: ARGS, - turn: 1, step: 1, time: 1_000, subCalls: [], ...over, -}) +const running = (over?: Partial): 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 => ({ - 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 => { + 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, diff --git a/packages/client/ui-tool/tests/image-card.client.spec.tsx b/packages/client/ui-tool/tests/image-card.client.spec.tsx index 5cb4229f49..8b0d642012 100644 --- a/packages/client/ui-tool/tests/image-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/image-card.client.spec.tsx @@ -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 = 'shots/card.png\nimage\n\nimage/png image, 1496x260 px, 24588 bytes\n' /** 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 => ({ - 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 => { + 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 => ({ - 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 => { + 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 diff --git a/packages/client/ui-tool/tests/preparing-rows.client.spec.tsx b/packages/client/ui-tool/tests/preparing-rows.client.spec.tsx index dd070b3aea..64e7a56449 100644 --- a/packages/client/ui-tool/tests/preparing-rows.client.spec.tsx +++ b/packages/client/ui-tool/tests/preparing-rows.client.spec.tsx @@ -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[0] & Parameters[0] & Parameters[0] -function preparation(name: string): Props { +function preparation(name: string, args = new PartialArguments()): Extract { 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 } -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() + 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() const preparingRow = view.container.querySelector('[data-tool="write"]') - vi.mocked(props.useToolCallArgumentsPartial).mockClear() + args.append('{"file_path":"hello') + view.rerender() + expect(view.queryByRole('button')).toBeNull() + args.append('.txt"') + view.rerender() + expect(view.getByText('hello.txt')).toBeTruthy() + expect(view.queryByText('1KB')).toBeNull() + args.append(',"content":"hello') + view.rerender() + expect(view.getByText('hello.txt')).toBeTruthy() + expect(view.getByText('1KB')).toBeTruthy() + args.append('"}') + view.rerender() + 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() 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() 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() + const row = view.container.querySelector('[data-tool="edit"]') + expect(view.getByText('2KB')).toBeTruthy() + const started: StartedToolCall = { ...props.block, phase: 'start', argsRaw } + view.rerender() + 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() + 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() @@ -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() @@ -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() + 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() + 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() + 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() expect(view.container.querySelector('[data-state="preparing"]')).not.toBeNull() - expect(useSessions).not.toHaveBeenCalled() + expect(view.getByText('List files')).toBeTruthy() expect(view.queryByRole('button')).toBeNull() }) }) diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index a5a9ffc0d4..1963336e73 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -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 => ({ const readContent = (body = 'export const a = 1'): string => `src/a.ts\nfile\n\n${body}\n` -const running = (over?: Partial): 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 => { + 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 => ({ - 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 => { + 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', () => { diff --git a/packages/client/ui-tool/tests/search-card.client.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx index bbff0bae31..9ba00cd18d 100644 --- a/packages/client/ui-tool/tests/search-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx @@ -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 => ({ shape: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over, }) -const runningGrep = (over?: Partial): 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 => { + 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 => ({ - 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 => { + 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 => ({ - 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 => { + 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', () => { diff --git a/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts b/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts index 26c1b6edb4..149bbfc4ab 100644 --- a/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts +++ b/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts @@ -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: [], } diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx index 239e59e0eb..477a87a5a3 100644 --- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -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[0] @@ -45,18 +46,25 @@ const shellArgs = (over: Record = {}): string => JSON.stringify command: 'ls -la', description: 'List files', ...over, }) -const running = (over?: Partial): 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 => { + 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 => ({ - 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 => { + 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', () => { diff --git a/packages/client/ui-tool/tests/todo-history.client.spec.ts b/packages/client/ui-tool/tests/todo-history.client.spec.ts index 2875f3c3bf..5a6480cfa7 100644 --- a/packages/client/ui-tool/tests/todo-history.client.spec.ts +++ b/packages/client/ui-tool/tests/todo-history.client.spec.ts @@ -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() { diff --git a/packages/client/ui-tool/tests/todo-row.client.spec.tsx b/packages/client/ui-tool/tests/todo-row.client.spec.tsx index a2bb96fcc3..d2a608d8ad 100644 --- a/packages/client/ui-tool/tests/todo-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/todo-row.client.spec.tsx @@ -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[0] @@ -60,11 +61,15 @@ describe('planSummary', () => { }) }) -const resultNode = (argsRaw: string, over?: Partial): ToolResultNode => ({ - kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1', - call: { name: 'todo_write', argsRaw }, - content: [], isError: false, subCalls: [], ...over, -}) +const resultNode = (argsRaw: string, over?: Partial): 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() + render() 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() + const running = render() expect(running.container.querySelector('[data-state="running"]')).not.toBeNull() expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull() running.unmount() diff --git a/packages/client/ui-tool/tests/tool-call-arguments-partial.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-arguments-partial.client.spec.tsx deleted file mode 100644 index f3e30f81b0..0000000000 --- a/packages/client/ui-tool/tests/tool-call-arguments-partial.client.spec.tsx +++ /dev/null @@ -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[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(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 {raw} -} - -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(<> - - - ) - 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() - expect(view.getByLabelText('raw').textContent).toBe('') - act(() => { old.set(data('unrelated')) }) - expect(view.getByLabelText('raw').textContent).toBe('') - view.rerender() - expect(old.listeners.size).toBe(0) - expect(next.listeners.size).toBe(1) - expect(view.getByLabelText('raw').textContent).toBe('new step') - view.rerender() - 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[0] - const view = render() - 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() - 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() - expect(assistant.listeners.size).toBe(0) - expect(view.getByText('file.txt')).toBeTruthy() - }) -}) diff --git a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx index cdcced8391..946ec2462c 100644 --- a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx @@ -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() 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) diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index 954083d8a6..57e89b1766 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -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 => ({ - 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 => { + 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 => ({ - 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 => { + 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() + expect(view.container.querySelector('[data-disclosure-row]')?.textContent).toMatch(/2KB.*\+2 -0/) + for (const state of ['error', 'stopped'] as const) { + view.rerender() + 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( diff --git a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index 6262cf31e4..e7c5b67bdc 100644 --- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -15,6 +15,7 @@ import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '@deepseek-ai/dsh-client-ui-tool/client' import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' +import { PartialArguments } from '@deepseek-ai/dsh-util-values' import { toolSessionEvents } from './tool-fixtures.client.ts' const SID = 's1' as SessionId @@ -47,12 +48,16 @@ const toolResult = ( name: string, args = '{"command":"make build","description":"Build"}', over: Partial = {}, -): ToolResultNode => ({ - kind: 'tool-result', seq, time: seq * 1_000, callId, - call: { name, argsRaw: args }, - callTime: seq * 1_000 - 500, - content: [], isError: false, subCalls: [], ...over, -}) +): ToolResultNode => { + const call = over.call === undefined ? { name, argsRaw: args } : 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: [], isError: false, subCalls: [], ...over, + } +} /** Test-owned AppFrame role: declares and renders the resident conversation area. */ type AppRootProps = PropsRenderSlots<'main'> diff --git a/packages/client/ui-tool/tests/web-card.client.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx index 5988f669cf..19fada291b 100644 --- a/packages/client/ui-tool/tests/web-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx @@ -12,6 +12,7 @@ import { WebRow, webToolview } from '../src/client/tool/toolviews/web-row.tsx' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { PartialArguments } from '@deepseek-ai/dsh-util-values' afterEach(cleanup) @@ -49,26 +50,37 @@ const fetchMeta = (over?: Partial): FetchMeta => ({ url: 'https://example.com/page', statusCode: 200, truncated: false, ...over, }) -const runningSearch = (over?: Partial): StartedToolCall => ({ - phase: 'start' as const, callId: 'c1', name: 'web_search', argsRaw: SEARCH_ARGS, - turn: 1, step: 1, time: 1_000, subCalls: [], ...over, -}) +const runningSearch = (over?: Partial): StartedToolCall => { + const argsRaw = over?.argsRaw ?? SEARCH_ARGS + return { + phase: 'start' as const, args: PartialArguments.fromText(argsRaw), callId: 'c1', name: 'web_search', argsRaw, + turn: 1, step: 1, time: 1_000, subCalls: [], ...over, + } +} -const settledSearch = (over?: Partial): ToolResultNode => ({ - kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1', - call: { name: 'web_search', argsRaw: SEARCH_ARGS }, - callTime: 1_000, - content: [{ type: 'text', text: 'search text' }], isError: false, - meta: searchMeta(), subCalls: [], ...over, -}) +const settledSearch = (over?: Partial): ToolResultNode => { + const call = over?.call === undefined ? { name: 'web_search', argsRaw: SEARCH_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: 'search text' }], isError: false, + meta: searchMeta(), subCalls: [], ...over, + } +} -const settledFetch = (over?: Partial): ToolResultNode => ({ - kind: 'tool-result', seq: 11, time: 2_000, callId: 'c2', - call: { name: 'web_fetch', argsRaw: FETCH_ARGS }, - callTime: 1_000, - content: [{ type: 'text', text: 'fetch body' }], isError: false, - meta: fetchMeta(), subCalls: [], ...over, -}) +const settledFetch = (over?: Partial): ToolResultNode => { + const call = over?.call === undefined ? { name: 'web_fetch', argsRaw: FETCH_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: 'fetch body' }], isError: false, + meta: fetchMeta(), subCalls: [], ...over, + } +} describe('webCardModel', () => { it('derives a search card from result metadata, projecting every source field', () => { diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index c87413b47f..0c4d5e5b3e 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -24,8 +24,8 @@ en: 0331f90cbd904862 zh: bdc2d4ed1c3c1f5e /deepseek-ai-dsh-client-ui-trajectory/understand-the-implementation: - en: 925abc8e4b092cb3 - zh: ebe85c5acdbea3b1 + en: 02ef008ff74a38df + zh: fa54d85ab89ed103 /deepseek-ai-dsh-client-ui-trajectory/understand-the-implementation/virtual-rows: en: c85e342bc7eee4e6 zh: 9feab66ef1755b94 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 3ccc7cbeba..a65c01e16b 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -55,7 +55,7 @@ The view is a pure projection: Trajectory-owned Definitions assemble business re Native and nested PTC Tool results retain their raw structured error details. Failed records show the error code in the ledger and the error name and code in the inspector. -Tool records begin at durable tool/call events and use complete arguments. Chat's transient preparing stage does not create Trajectory tool rows or alter historical tool timing. +Tool records begin at durable tool/call events and retain complete argument text with a lazy `args` view, reused by paired results. Chat's transient preparing stage does not create Trajectory tool rows or alter historical tool timing. A complete appended prompt without a loaded request header appears as a standalone system row; only its known text is available, with no inferred request options or tool catalog. Prepending its request history replaces that standalone presentation without duplicating the prompt. In-history system prompt changes compare against the most recent request state, including earlier prompt updates without a new request header. Each request retains the prompt and change that applied at its own position. Surface replacements, including compaction, restore the last nonempty surviving system prompt even without a new system event; an unloaded prompt remains unavailable until its page arrives. diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 133d6c41c7..25e5206224 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -55,7 +55,7 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间 原生与嵌套 PTC 工具结果保留原始结构化错误详情。失败记录在记录表中显示错误代码,在检查器中显示错误名称和代码。 -工具记录从持久 tool/call 事件开始,使用完整参数。Chat 的瞬态 preparing 阶段不创建 Trajectory 工具行,也不改变历史工具计时。 +工具记录从持久 tool/call 事件开始,保留完整参数文本和由配对结果复用的懒计算 `args` 视图。Chat 的瞬态 preparing 阶段不创建 Trajectory 工具行,也不改变历史工具计时。 完整的追加提示词在请求头未加载时显示为独立系统行;仅提供已知文本,不推断请求选项或工具目录。补入其请求历史后,该独立展示被替代而不重复提示词。历史中的系统提示词变更与最近的请求状态比较,包括没有新请求头的先前提示词更新。每个请求保留其所在位置生效的提示词与变更。包括压缩在内的 surface 替换会恢复最后一个非空的存活系统提示词,即使没有新的系统事件;未加载的提示词在对应分页到达前仍不可用。 diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index abbf4bb5d0..eea23e27b8 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -51,6 +51,7 @@ "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-compaction": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", diff --git a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts index 901ba7b0ca..f157648dae 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts @@ -4,6 +4,7 @@ import type { ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-tools/types' +import { PartialArguments } from '@deepseek-ai/dsh-util-values' import { trajectoryNode } from './trajectory-definition-common.ts' /* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event @@ -39,6 +40,7 @@ function rootCall(match: ConversationMatch): StartedToolCall { callId: String(match.event.data.callId), name: match.event.data.name, argsRaw: match.event.data.arguments, + args: PartialArguments.fromText(match.event.data.arguments), turn: match.event.data.turn, step: match.event.data.step, time: match.event.time, @@ -57,6 +59,8 @@ function rootResult( 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, @@ -84,6 +88,7 @@ function childCall(match: ConversationMatch, data: DispatchData): StartedToolCal parentCallId: data.parentCallId, name: data.name, argsRaw: JSON.stringify(data.arguments), + args: PartialArguments.fromObject(data.arguments), turn: locationTurn(match), step: locationStep(match), time: match.event.time, @@ -102,6 +107,8 @@ function childResult( time: match.event.time, callId: data.subCallId, parentCallId: data.parentCallId, + name: data.name, + args: previous?.args ?? PartialArguments.fromObject(data.arguments), call: { name: data.name, argsRaw: JSON.stringify(data.arguments) }, callTime: previous === undefined || 'kind' in previous ? null : previous.time, content: data.content ?? [], @@ -193,6 +200,8 @@ function projectCall( 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: [], diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx index a221f46c05..44fe560dd6 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -8,6 +8,7 @@ import { cleanup, render, screen } from '@testing-library/react' import type { ConversationLocation, ConversationNode, RequestView, } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { PartialArguments } from '@deepseek-ai/dsh-util-values' import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx' import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx' import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx' @@ -115,10 +116,11 @@ describe('deriveTrajectoryLayout', () => { }, { kind: 'tool-result', seq: 3, time: 7_500, callId: 'c1', + name: 'bash', args: PartialArguments.fromText('{"command":"ls"}'), call: { name: 'bash', argsRaw: '{"command":"ls"}' }, callTime: 6_200, content: [{ type: 'text', text: 'a.txt' }], isError: false, }, - ] as unknown as LegacyConversationSlice['nodes'] + ] as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) expect(turns).toHaveLength(1) expect(turns[0]?.turn).toBe(1) @@ -141,7 +143,7 @@ describe('deriveTrajectoryLayout', () => { nodes: [], partial: null, runningCalls: [{ - phase: 'start' as const, callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}', + phase: 'start' as const, args: PartialArguments.fromText('{"command":"pwd"}'), callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}', turn: 1, step: 2, time: 9_000, subCalls: [], }], }) @@ -205,7 +207,7 @@ describe('deriveTrajectoryLayout', () => { nodes: [], partial: { ...partial, blocks: [] }, runningCalls: [{ - phase: 'start' as const, callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}', + phase: 'start' as const, args: PartialArguments.fromText('{"command":"pwd"}'), callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}', turn: 1, step: 1, time: 9_000, subCalls: [], }], }) @@ -246,15 +248,17 @@ describe('deriveTrajectoryLayout', () => { }, { kind: 'tool-result', seq: 2, time: 2_500, callId: 'a', + name: 'bash', args: PartialArguments.fromText('{}'), call: { name: 'bash', argsRaw: '{}' }, callTime: 1_100, content: [], isError: false, }, { kind: 'tool-result', seq: 3, time: 4_000, callId: 'b', + name: 'bash', args: PartialArguments.fromText('{}'), call: { name: 'bash', argsRaw: '{}' }, callTime: 2_600, content: [], isError: false, }, - ] as unknown as LegacyConversationSlice['nodes'] + ] as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) expect(turns[0]?.groups[0]?.description).toBe('3,000 ms bash×2') }) @@ -475,6 +479,7 @@ describe('deriveTrajectoryLayout', () => { }, { kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', + name: 'bash', args: PartialArguments.fromText('{}'), call: { name: 'bash', argsRaw: '{}' }, callTime: 2_100, content: [], isError: false, }, @@ -492,7 +497,7 @@ describe('deriveTrajectoryLayout', () => { kind: 'assistant', seq: 6, time: 10_000, turn: 1, step: 0, blocks: [{ kind: 'text', text: 'done' }], }, - ] as unknown as LegacyConversationSlice['nodes'] + ] as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) const cells = turns[0]?.groups.flatMap(g => g.cells) ?? [] const message = cells.find(c => c.kind === 'message' && c.previewMarkdown === 'done') @@ -530,15 +535,17 @@ describe('run_code sub-dispatch cells', () => { }, { kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1', + name: 'run_code', args: PartialArguments.fromText('{"code":"…","description":"批量读取"}'), call: { name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' }, callTime: 6_200, content: [{ type: 'text', text: 'done' }], isError: false, subCalls: [], }, - ] as unknown as LegacyConversationSlice['nodes'] + ] as LegacyConversationSlice['nodes'] const settledSub = (n: number, name: string, start: number, end: number) => ({ kind: 'tool-result' as const, seq: 100 + n, time: end, callId: `p1:code:${n}`, + name, args: PartialArguments.fromText('{"x":1}'), call: { name, argsRaw: '{"x":1}' }, callTime: start, content: [{ type: 'text' as const, text: 'ok' }], isError: false, subCalls: [], @@ -566,7 +573,7 @@ describe('run_code sub-dispatch cells', () => { it('a running (unsettled) sub-call renders a subtool cell with blank time', () => { const running = { - phase: 'start' as const, callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}', + phase: 'start' as const, args: PartialArguments.fromText('{"pattern":"x"}'), callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}', turn: 0, step: 0, time: 6_400, subCalls: [], } const turns = deriveTrajectoryLayout({ nodes: withSubCalls([running]), partial: null, runningCalls: [] }) @@ -671,10 +678,11 @@ describe('durable image attachments', () => { }, { kind: 'tool-result', seq: 2, time: 2_000, callId: 'c1', + name: 'read_image', args: PartialArguments.fromText('{}'), call: { name: 'read_image', argsRaw: '{}' }, callTime: 1_200, content: [{ type: 'image', attachment }], isError: false, }, - ] as unknown as LegacyConversationSlice['nodes'] + ] as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool') expect(tool?.result).toBe('Images ×1') diff --git a/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts b/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts index 93c8228ef6..f68cf7c55e 100644 --- a/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts +++ b/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import type { RequestView } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { PartialArguments } from '@deepseek-ai/dsh-util-values' import type { TrajectoryContribution, TrajectoryConversationViewNode, TrajectoryRequestHeaderState, } from '../src/client/trajectory-contract.ts' @@ -216,7 +217,7 @@ describe('TrajectorySnapshotBuilder', () => { contribution('tool', 7, { kind: 'tool', root: { - phase: 'start' as const, callId: 'call-edit', + phase: 'start' as const, args: PartialArguments.fromText('{}'), callId: 'call-edit', name: 'edit', argsRaw: '{}', turn: 1, diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index e3158ee3e3..3d5f46716c 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -42,6 +42,7 @@ import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/sr import * as localePlugin from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' +import { PartialArguments } from '@deepseek-ai/dsh-util-values' import type { TrajectoryTurnModel } from '../src/client/layout.ts' import { TrajectoryTimeline as LocalizedTrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx' import { @@ -91,6 +92,7 @@ const NODES: LegacyConversationSlice['nodes'] = [ }, { kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: 2_200, + name: '', args: PartialArguments.EMPTY, content: [], isError: false, subCalls: [], }, { @@ -1372,6 +1374,7 @@ describe('TrajectoryView state', () => { blocks: [{ kind: 'tool-call', callId: 'boundary-call', name: 'bash', argsRaw: '{}' }], }, { kind: 'tool-result', seq: 3, time: 3, callId: 'boundary-call', + name: 'bash', args: PartialArguments.fromText('{}'), call: { name: 'bash', argsRaw: '{}' }, callTime: 2, content: [], isError: false, subCalls: [], }, ...Array.from({ length: 39 }, (_, index) => ({ @@ -1426,12 +1429,13 @@ describe('TrajectoryView state', () => { seq: 3, time: 3, callId: 'hidden-root', + name: 'run_code', args: PartialArguments.fromText('{}'), call: { name: 'run_code', argsRaw: '{}' }, callTime: 2, content: [], isError: false, subCalls: [{ - phase: 'start' as const, callId: 'hidden-child', parentCallId: 'hidden-root', name: 'bash', argsRaw: '{}', + phase: 'start' as const, args: PartialArguments.fromText('{}'), callId: 'hidden-child', parentCallId: 'hidden-root', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 3, subCalls: [], }], }, 'hidden-child'], @@ -1440,6 +1444,7 @@ describe('TrajectoryView state', () => { { kind: 'user', seq: 1, time: 1, content: [], source: null }, { kind: 'tool-result', seq: 2, time: 2, callId: 'unrelated', call: null, callTime: null, + name: '', args: PartialArguments.EMPTY, content: [], isError: false, subCalls: [], }, target, diff --git a/packages/client/ui-trajectory/tsconfig.json b/packages/client/ui-trajectory/tsconfig.json index c4938ad255..744091c364 100644 --- a/packages/client/ui-trajectory/tsconfig.json +++ b/packages/client/ui-trajectory/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../ui-conversation/tsconfig.client.json" }, + { + "path": "../../util/values" + }, { "path": "../ui-renderer" }, diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 34a751030e..8d71aa5010 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -4080,14 +4080,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'tool.call.images\', () => ctx.slots.register(\n { name: \'tool.call.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-tool/src/client/contract/slots.ts:46', + source: 'packages/client/ui-tool/src/client/contract/slots.ts:44', }, { key: 'tool.call.toolview', kind: 'keyed', scope: 'session', summary: 'Keyed Tool call view dispatched by wire Tool name.', - doc: 'Keyed Tool call view dispatched by wire Tool name. Any name is allowed,\nincluding tools registered by your package. Register with\n`key: \'\'`; a typo never renders.\n\nRegistering an occupied key replaces its view; unclaimed keys use the\ngeneric row. The owner supplies the call identity and frozen running\nor settled node through explicit phase props. Preparing blocks have no dispatched\narguments; useToolCallArgumentsPartial optionally subscribes to their raw prefix.', + doc: 'Keyed Tool call view dispatched by wire Tool name. Any name is allowed,\nincluding tools registered by your package. Register with\n`key: \'\'`; a typo never renders.\n\nRegistering an occupied key replaces its view; unclaimed keys use the\ngeneric row. The owner supplies the call identity and running\nor settled node through explicit phase props. Every stage supplies\n`name` and a lazy `args` view; preparing arguments may be incomplete.', registerOptions: [ { name: 'key', @@ -4127,8 +4127,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: ask_user_question, bash, cordis_define, cordis_inspect_list, cordis_inspect_query, cordis_inspect_self, cordis_run, cordis_stop, cordis_undefine, create_goal, edit, get_goal, glob, grep, interrupt_agent, job_kill, job_list, job_output, list_agents, list_subagent_models, lsp, present, ralph, read, read_image, schedule_create, schedule_delete, schedule_list, schedule_update, send_message, session_event_read, session_event_search, session_event_trace, session_search, session_trace, skill, spawn_teammate, subagent, team_task_create, team_task_get, team_task_list, team_task_update, terminal_close, terminal_list, terminal_open, terminal_read, terminal_signal, todo_write, update_goal, wait_agent, web_fetch, web_search, workflow, write', - hookContext: 'ToolCallHookContext', - slotInject: 'ToolCallInjected', + hookContext: '', + slotInject: '', declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-tool), so it exists while that entry is mounted', occupants: [ 'client-ui-deliverables PresentRow key \'present\'', diff --git a/packages/extensions/ui-cordis/package.json b/packages/extensions/ui-cordis/package.json index 5c91ba3b26..1c203eec39 100644 --- a/packages/extensions/ui-cordis/package.json +++ b/packages/extensions/ui-cordis/package.json @@ -60,6 +60,7 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-client-ui-tool": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0" diff --git a/packages/extensions/ui-cordis/tests/card-model.client.spec.ts b/packages/extensions/ui-cordis/tests/card-model.client.spec.ts index a0f0859d56..14a81de6b6 100644 --- a/packages/extensions/ui-cordis/tests/card-model.client.spec.ts +++ b/packages/extensions/ui-cordis/tests/card-model.client.spec.ts @@ -3,21 +3,25 @@ import { describe, expect, it } from 'vitest' import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import { PartialArguments } from '@deepseek-ai/dsh-util-values' import { cordisActionCard, cordisDefineCard } from '../src/client/card-model.ts' const ARGS = '{"name":"clock","purpose":"顶栏时钟","code":{"client":"return {}","host":"harness.handle(\'now\', () => Date.now())"}}' function running(over: Partial = {}): StartedToolCall { + const argsRaw = over.argsRaw ?? ARGS return { - phase: 'start' as const, callId: 'call-1', name: 'cordis_define', argsRaw: ARGS, turn: 1, step: 1, time: 1_000, + phase: 'start' as const, args: PartialArguments.fromText(argsRaw), callId: 'call-1', name: 'cordis_define', argsRaw, turn: 1, step: 1, time: 1_000, subCalls: [], ...over, } } function settled(over: Partial = {}): ToolResultNode { + const call = over.call === undefined ? { name: 'cordis_define', argsRaw: ARGS } : over.call return { kind: 'tool-result', seq: 2, time: 2_000, callId: 'call-1', - call: { name: 'cordis_define', argsRaw: ARGS }, callTime: 1_000, + name: call?.name ?? '', args: call === null ? PartialArguments.EMPTY : PartialArguments.fromText(call.argsRaw), + call, callTime: 1_000, content: [{ type: 'text', text: 'defined dyn-1' }], isError: false, meta: { pluginId: 'dyn-1', packageId: 'pkg-1' }, subCalls: [], ...over, } diff --git a/packages/extensions/ui-cordis/tests/status-icons.client.spec.tsx b/packages/extensions/ui-cordis/tests/status-icons.client.spec.tsx index ff5db2e814..741df7fc73 100644 --- a/packages/extensions/ui-cordis/tests/status-icons.client.spec.tsx +++ b/packages/extensions/ui-cordis/tests/status-icons.client.spec.tsx @@ -4,6 +4,7 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { PartialArguments } from '@deepseek-ai/dsh-util-values' import { CordisActionRow } from '../src/client/CordisActionRow.tsx' import { CordisDefineRow } from '../src/client/CordisDefineRow.tsx' import { CordisRunRow } from '../src/client/CordisRunRow.tsx' @@ -16,6 +17,7 @@ const t = ((key: keyof typeof zh) => zh[key]) as Parameters { const props = { ...callbacks, phase: 'preparing', toolName, callId: 'call', t, - block: { phase: 'preparing', name: toolName, callId: 'call', turn: 1, step: 1, time: 1, subCalls: [] }, + block: { phase: 'preparing', args: PartialArguments.EMPTY, name: toolName, callId: 'call', turn: 1, step: 1, time: 1, subCalls: [] }, useInventory: unused, useLoaded: unused, useRunCards: unused, useActiveRuns: unused, useDisclosure: unused, } as Parameters[0] & Parameters[0] const view = render() @@ -101,6 +103,7 @@ describe('Cordis tool failure icons', () => { const pluginRunId = 'run-1' as CordisDynamicPluginRunId const block: ToolResultNode = { kind: 'tool-result', seq: 3, time: 3, callId: 'call-cordis_run', callTime: 2, + name: 'cordis_run', args: PartialArguments.fromText(JSON.stringify({ pluginId, packageId, mode: 'run' })), call: { name: 'cordis_run', argsRaw: JSON.stringify({ pluginId, packageId, mode: 'run' }) }, content: [], isError: false, subCalls: [], meta: { pluginId, packageId, pluginRunId }, } diff --git a/packages/extensions/ui-cordis/tests/versioning.client.spec.ts b/packages/extensions/ui-cordis/tests/versioning.client.spec.ts index 74350ef1ca..f5fe288379 100644 --- a/packages/extensions/ui-cordis/tests/versioning.client.spec.ts +++ b/packages/extensions/ui-cordis/tests/versioning.client.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { DynamicCordisLivePackage } from '@deepseek-ai/dsh-cordis-client-runner/client' +import { PartialArguments } from '@deepseek-ai/dsh-util-values' import type { CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, DynamicCordisInventoryRow, @@ -28,15 +29,16 @@ const row = (client: boolean): DynamicCordisInventoryRow => ({ describe('versioned Cordis card models', () => { it('reads symmetric Host and Client source fields from cordis_define', () => { + const argsRaw = JSON.stringify({ + plugin: { kind: 'new', idPrefix: 'clock' }, + name: 'Clock', + purpose: 'show time', + code: { host: 'HOST_CODE', client: 'CLIENT_CODE' }, + }) const card = cordisDefineCard({ - phase: 'start' as const, callId: 'call-1', + phase: 'start' as const, args: PartialArguments.fromText(argsRaw), callId: 'call-1', name: 'cordis_define', - argsRaw: JSON.stringify({ - plugin: { kind: 'new', idPrefix: 'clock' }, - name: 'Clock', - purpose: 'show time', - code: { host: 'HOST_CODE', client: 'CLIENT_CODE' }, - }), + argsRaw, turn: 1, step: 1, time: 1, @@ -58,6 +60,7 @@ describe('versioned Cordis card models', () => { seq: 9, time: 2, callId: 'call-2', + name: 'cordis_run', args: PartialArguments.fromText(JSON.stringify({ pluginId: PLUGIN, packageId: PACKAGE, mode: 'run' })), call: { name: 'cordis_run', argsRaw: JSON.stringify({ pluginId: PLUGIN, packageId: PACKAGE, mode: 'run' }) }, callTime: 1, content: [{ type: 'text', text: 'running' }], @@ -78,7 +81,7 @@ describe('versioned Cordis card models', () => { it('keeps the target identities while cordis_run waits for approval', () => { const card = cordisRunCard({ - phase: 'start' as const, callId: 'call-3', + phase: 'start' as const, args: PartialArguments.fromText(JSON.stringify({ pluginId: PLUGIN, packageId: PACKAGE, mode: 'update' })), callId: 'call-3', name: 'cordis_run', argsRaw: JSON.stringify({ pluginId: PLUGIN, packageId: PACKAGE, mode: 'update' }), turn: 1, diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 1907e652aa..39e2dbe882 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -11,6 +11,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' +import type { FsVersion } from '@deepseek-ai/dsh-fs' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' @@ -462,19 +463,38 @@ describe('signal, concurrency, and the fs/observed contract', () => { it('two concurrent edits of the same file, same session: one wins, one FS_STALE_VERSION', async () => { await writeFile(join(dir, 'a.txt'), 'base value here') - // One read establishes the observed version both edits guard against; then - // race two edits so both carry the SAME observed version (the barrier). expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false) - const [one, two] = await Promise.all([ - callOwned('edit', { file_path: 'a.txt', old_string: 'base', new_string: 'ONE', replaceAll: false }), - callOwned('edit', { file_path: 'a.txt', old_string: 'value', new_string: 'TWO', replaceAll: false }), - ]) - const errors = [one, two].filter(r => r.isError) - expect(errors).toHaveLength(1) - expect(errors[0]?.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) - // The world is consistent: exactly one edit landed. - const onDisk = await readFile(join(dir, 'a.txt'), 'utf8') - expect(onDisk === 'ONE value here' || onDisk === 'base TWO here').toBe(true) + const captured = Promise.withResolvers() + const versions: (FsVersion | undefined)[] = [] + // Both guards must be captured before a successful edit refreshes the session's observed version. + const dispose = ctx.on('fs/edit-intent', async (_target, _actor, next) => { + const intent = await next() + versions.push(intent?.version) + if (versions.length === 2) captured.resolve(undefined) + await captured.promise + return intent + }, { prepend: true }) + // An early tool failure must release any peer already waiting at the barrier. + const pending = [ + callOwned('edit', { file_path: 'a.txt', old_string: 'base', new_string: 'ONE', replace_all: false }), + callOwned('edit', { file_path: 'a.txt', old_string: 'value', new_string: 'TWO', replace_all: false }), + ].map(pendingCall => pendingCall.finally(() => { captured.resolve(undefined) })) + try { + const results = await Promise.all(pending) + expect(versions).toHaveLength(2) + expect(versions[0]).toBeDefined() + expect(versions[1]).toBe(versions[0]) + const errors = results.filter(r => r.isError) + expect(errors).toHaveLength(1) + expect(errors[0]?.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + // The world is consistent: exactly one edit landed. + const onDisk = await readFile(join(dir, 'a.txt'), 'utf8') + expect(onDisk === 'ONE value here' || onDisk === 'base TWO here').toBe(true) + } finally { + captured.resolve(undefined) + await Promise.allSettled(pending) + dispose() + } }) it('a stale observed version from an older read fails closed at edit CAS', async () => { diff --git a/packages/util/values/README.i18n.yaml b/packages/util/values/README.i18n.yaml index 71a78967d6..5b73359bdf 100644 --- a/packages/util/values/README.i18n.yaml +++ b/packages/util/values/README.i18n.yaml @@ -9,8 +9,8 @@ en: d4e5c3dd11f4c1f4 zh: ac01621284cc2f99 /deepseek-ai-dsh-util-values/summary: - en: 0900fbf667e6bb37 - zh: 2ea81387d169a116 + en: 7c7e9304b1266973 + zh: 68dd911e2a80d7a8 /deepseek-ai-dsh-util-values/table-of-contents: en: 80b31a934611cb4a zh: 05f3f7f9cb2fd881 @@ -20,6 +20,9 @@ /deepseek-ai-dsh-util-values/use-this-package/validate-or-snapshot-json-data: en: dc6de9f27cc98557 zh: 9c2bac179e536147 +/deepseek-ai-dsh-util-values/use-this-package/read-streamed-arguments: + en: c53d8e94245c2378 + zh: de0308a325b17dad /deepseek-ai-dsh-util-values/use-this-package/publish-compare-or-retain-keyed-values: en: 13628af2abe3eea6 zh: e284bb8d3ebe38ea @@ -30,8 +33,8 @@ en: 36866610a9a7f898 zh: aa2f0e4c3cc12335 /deepseek-ai-dsh-util-values/understand-the-implementation/source-map: - en: a3e80436a6006043 - zh: 1de25c1bf436e494 + en: 089278d3216a7eba + zh: b69c9ddb0e730a06 /deepseek-ai-dsh-util-values/further-exploration: en: 3be59be736dc8e9a zh: 4f419cb5db38fd12 diff --git a/packages/util/values/README.md b/packages/util/values/README.md index e969e806ec..d3a4fcd62d 100644 --- a/packages/util/values/README.md +++ b/packages/util/values/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-util-values` gives runtime packages one implementation for lossless JSON values, immutable object graphs, structural JSON equality, and exhaustive closed-union failures. Callers can validate untrusted values, detach a JSON snapshot, freeze a published value, compare JSON-compatible data, or terminate an unreachable branch without importing a capability package. The helpers hold no shared registry, constructor identity, or mutable module state. +Callers can validate lossless JSON, read streamed arguments, detach a JSON snapshot, freeze a published value, compare JSON-compatible data, or terminate an unreachable branch without importing a capability package. Each streamed call uses its own `PartialArguments` reader; `PartialArguments.EMPTY` is a shared sealed view with no fields. ## Table of Contents @@ -37,6 +37,12 @@ if (!isJsonValue(input)) throw new TypeError('expected lossless JSON') const snapshot = snapshotJsonValue(input) as JsonValue ``` +### Read streamed arguments + +`PartialArguments` reads a JSON object's top-level fields lazily. `append()` retains separate fragments without scanning or concatenating them. Readers index new key/value ranges, skipping unrequested contents; only requested strings are decoded or counted, and only requested complete non-string values are parsed. `complete()` reports a closing delimiter, not validated contents; `invalid` reports errors already discovered by indexing or content reads. It is not a substitute for tool-input validation. + +`refresh()` reports changes to previously observed answers at publication time; intervening reads do not acknowledge pending changes. It evaluates content before comparing completion, so decoding errors cannot suppress a completion update. String readers provide decoded text, bounded prefixes, and exact UTF-16 length, with optional step and offset for change detection. `settle(finalText)` compares fragments directly against the complete text: equal input seals the same view and retains its caches; missing or conflicting deltas produce a new sealed view. `fromText()` and `fromObject()` also create sealed views that reject appends. `isSealed` reads that append restriction without scanning or observing content; `closed()` also covers a closed outer object or failed indexing. See [the readers](src/partial-json.ts) for return distinctions. + ### Publish, compare, or retain keyed values `deepFreeze(value)` freezes an object graph in place and returns the same value. It walks enumerable string-keyed children and deliberately leaves live `AbortSignal` objects mutable. `deepEqualJson(a, b)` compares JSON-compatible arrays and records structurally; callers must validate hostile or unconstrained values before comparison. @@ -62,6 +68,7 @@ The JSON validator uses an explicit work stack and tracks only the active ancest | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | JSON value type, validation and snapshot traversal, structural equality, deep freezing, weak-key/strong-value associations, and exhaustive-union failure | +| [`src/partial-json.ts`](src/partial-json.ts) | Lazy top-level argument scanning and observed-answer change detection | | — | No runtime invariant companion is published because these value operations have no shared runtime state; unit tests cover their algebra. | diff --git a/packages/util/values/README.zh.md b/packages/util/values/README.zh.md index f723a9b48f..83451fe4fc 100644 --- a/packages/util/values/README.zh.md +++ b/packages/util/values/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -`dsh-util-values` 为运行时包提供统一的无损 JSON 值、不可变对象图、JSON 结构相等和封闭联合类型穷尽失败实现。调用方可以校验不受信任的值、创建分离的 JSON 快照、冻结待发布值、比较 JSON 兼容数据,或终止不可达分支,而无需导入某个能力包。这些 helper 不持有共享注册表、constructor identity 或可变模块状态。 +调用方可以校验无损 JSON、读取流式参数、创建分离的 JSON 快照、冻结待发布值、比较 JSON 兼容数据,或终止不可达分支,而无需导入某个能力包。每次流式调用使用独立的 `PartialArguments` 读器;`PartialArguments.EMPTY` 是不含字段的共用封存视图。 ## 目录 @@ -37,6 +37,12 @@ if (!isJsonValue(input)) throw new TypeError('expected lossless JSON') const snapshot = snapshotJsonValue(input) as JsonValue ``` +### 读取流式参数 + +`PartialArguments` 懒读取 JSON 对象的顶层字段。`append()` 分别保留分片,不扫描也不拼接。读器为新的键值范围建立索引,跳过未请求的内容;仅在请求时解码或计数字符串,仅在请求时解析已完整的非字符串值。`complete()` 表示结束分隔符已到达,不代表内容已校验;`invalid` 报告索引或内容读取已经发现的错误。它不能替代工具入参校验。 + +`refresh()` 在发布点报告已观察答案的变化;期间的读取不会确认待发布的变化。它先读取内容再比较完成状态,避免解码错误吞掉完成状态更新。字符串读器提供解码文本、有界前缀和精确 UTF-16 长度,并可为变化判定指定步长和偏移量。`settle(finalText)` 直接将分片与完整文本比较:输入相同时封存原视图并保留缓存,分片缺失或冲突时创建新的封存视图。`fromText()` 和 `fromObject()` 也创建拒绝追加的封存视图。`isSealed` 读取这一追加限制,不扫描或观察内容;`closed()` 还包含外层对象闭合或索引失败的情况。返回值区别见[读器定义](src/partial-json.ts)。 + ### 发布、比较或保留键控值 `deepFreeze(value)` 原地冻结对象图并返回同一个值。它遍历可枚举字符串键的子项,并刻意让活跃 `AbortSignal` 对象保持可变。`deepEqualJson(a, b)` 按结构比较 JSON 兼容数组与记录;调用方必须先校验恶意或不受约束的值,再进行比较。 @@ -62,6 +68,7 @@ JSON 校验器使用显式工作栈,并只跟踪当前祖先链,因此深层 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | JSON 值类型、校验与快照遍历、结构相等、深度冻结、弱键/强值关联和穷尽联合类型失败 | +| [`src/partial-json.ts`](src/partial-json.ts) | 懒扫描顶层参数,并检测已读取答案的变化 | | — | 不发布运行时不变量伴生入口;这些值操作没有共享运行时状态,其代数行为由单元测试覆盖。 | diff --git a/packages/util/values/src/index.ts b/packages/util/values/src/index.ts index 2e4b81cc86..8664da7e34 100644 --- a/packages/util/values/src/index.ts +++ b/packages/util/values/src/index.ts @@ -304,3 +304,5 @@ export class WeakMapWithValues { this.valueSet.clear() } } + +export { PartialArguments, type LengthReadOptions } from './partial-json.ts' diff --git a/packages/util/values/src/partial-json.ts b/packages/util/values/src/partial-json.ts new file mode 100644 index 0000000000..2fd6f7867e --- /dev/null +++ b/packages/util/values/src/partial-json.ts @@ -0,0 +1,666 @@ +/** + * Lazily scanned view of one JSON object's top-level fields, built from text + * that may still be streaming or from an already parsed object. Nothing is + * scanned until a reader asks; the view remembers every question it answered + * and reports changed answers when the owner refreshes for publication. + * Used for model tool-call arguments: a row reads the fields it + * cares about at whatever granularity it displays, at every stage of the call. + * @module @deepseek-ai/dsh-util-values/src/partial-json + */ +import { assertNever, type JsonValue } from './index.ts' + +/** Granularity of a length read; a change is reported only when the rounded-up step moves. */ +export interface LengthReadOptions { + /** Characters per step; defaults to 1 (every character counts). */ + readonly step?: number + /** Completed characters included in the displayed total; affects change detection only. */ + readonly offset?: number +} + +/** Scanner position inside the object text. */ +type Mode = + | 'root' | 'key-or-end' | 'key-only' | 'key' | 'colon' | 'value' + | 'string' | 'scalar' | 'nested' | 'comma-or-end' | 'closed' | 'invalid' + +/** A content reader advances independently of the boundary scanner. */ +interface StringRead { + at: number + length: number + text: string +} + +/** Raw ranges exclude string quotes and include a non-string value's own brackets. */ +type Entry = + | { + readonly kind: 'string' + readonly start: number + end: number + needsDecoding: boolean + invalidAt: number | undefined + length: StringRead | undefined + text: StringRead | undefined + prefixes: Map | undefined + } + | { readonly kind: 'value'; readonly start: number; end: number; parsed: JsonValue | undefined; invalid: boolean } + +type ReadKind = 'closed' | 'keys' | 'has' | 'complete' | 'text' | 'value' + | `length:${number}:${number}` | `prefix:${number}` | `exceeds:${number}` + +/** One answered question, kept to detect whether later text changes the answer. */ +interface Read { + readonly completion: boolean + readonly answer: () => unknown + last: unknown +} + +const SIMPLE_ESCAPES: Readonly> = { + '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t', +} + +const CONTENT_ESCAPE = /[\\\u0000-\u001f]/u + +function isWhitespace(c: string): boolean { + return c === ' ' || c === '\n' || c === '\r' || c === '\t' +} + +function isHex(c: string): boolean { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') +} + +/** + * The view. A streaming instance grows through {@link PartialArguments.append}; + * {@link PartialArguments.fromText} and {@link PartialArguments.fromObject} build + * sealed instances over a finished call. Every reader is total: an absent or + * differently typed field answers `undefined` (or `false`), never throws. + */ +export class PartialArguments { + /** The view of a call with no arguments available. */ + static readonly EMPTY: PartialArguments = PartialArguments.fromObject({}) + + /** + * View finished argument text without scanning it until a reader asks. + * @param text - the complete argument JSON text. + * @returns a sealed view. + */ + static fromText(text: string): PartialArguments { + const view = new PartialArguments() + view.append(text) + view.sealed = true + return view + } + + /** + * View an already parsed argument payload, such as a PTC dispatch object. + * @param value - the parsed argument value. + * @returns a sealed view; a non-object payload has no fields. + */ + static fromObject(value: unknown): PartialArguments { + const view = new PartialArguments() + view.object = typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Readonly> + : {} + view.sealed = true + return view + } + + /** + * The source: text so far or a parsed object, plus whether it can still grow. + * These are the only enumerable fields, so two views over the same source + * compare equal structurally however far each has been read. + */ + private chunks: string[] = [] + private object: Readonly> | undefined + private sealed = false + // Scan progress, located fields, and remembered reads are caches over the source. + #ends: number[] = [] + #size = 0 + #consumed = 0 + #mode: Mode = 'root' + #escape = false + #keyStart = 0 + #keyEscaped = false + #key = '' + #current: Entry | null = null + #nestedEnds: string[] = [] + #nestedInString = false + #invalidAt: number | undefined + #invalidValue = false + readonly #entries = new Map() + readonly #order: string[] = [] + readonly #reads = new Map() + + /** Whether this view rejects further appends; does not scan text or register reads. */ + get isSealed(): boolean { + return this.sealed + } + + /** Whether indexing or a content read found invalid JSON; unread value contents are not validated. */ + get invalid(): boolean { + this.scan() + return this.#mode === 'invalid' || this.#invalidValue + } + + /** + * Retain streamed argument text without scanning or comparing observed answers. + * @param fragment - the text following every fragment appended before. + */ + append(fragment: string): void { + if (this.sealed) throw new Error('PartialArguments: cannot append to a sealed view') + if (fragment.length === 0) return + this.chunks.push(fragment) + this.#size += fragment.length + this.#ends.push(this.#size) + } + + /** + * Reconcile a streamed prefix with authoritative complete text without joining the fragments. + * @param text - the final argument text, which replaces missing or conflicting deltas. + * @returns this view sealed with its caches retained when every character matches; otherwise a new sealed view. + */ + settle(text: string): PartialArguments { + if (this.object !== undefined || text.length !== this.#size) return PartialArguments.fromText(text) + let offset = 0 + for (const chunk of this.chunks) { + if (!text.startsWith(chunk, offset)) return PartialArguments.fromText(text) + offset += chunk.length + } + this.chunks = text.length === 0 ? [] : [text] + this.#ends = text.length === 0 ? [] : [text.length] + this.sealed = true + return this + } + + /** + * Compare observed answers and advance their publication baseline. Unread views remain unscanned. + * @returns whether any observed answer changed since its first read or the preceding refresh. + */ + refresh(): boolean { + if (this.#reads.size === 0) return false + this.scan() + let changed = false + let completions = false + for (const read of this.#reads.values()) { + if (read.completion) { + completions = true + continue + } + changed = this.refreshRead(read) || changed + } + // Content reads can discover errors that change a field's completion answer. + if (completions) { + for (const read of this.#reads.values()) if (read.completion) changed = this.refreshRead(read) || changed + } + if (this.sealed) this.#reads.clear() + return changed + } + + private refreshRead(read: Read): boolean { + const now = read.answer() + if (Object.is(now, read.last)) return false + read.last = now + return true + } + + /** + * Check whether no further fields can arrive. + * @returns whether the outer object closed, indexing failed, or the view is sealed; unread values are not validated. + */ + closed(): boolean { + return this.remember('closed', '', () => this.closedNow()) + } + + /** + * List discovered fields in first-appearance order. + * @returns top-level keys seen so far, in first-appearance order. + */ + keys(): readonly string[] { + return this.remember('keys', '', () => this.keysNow(), keys => keys.length) + } + + /** + * Check whether a top-level field has appeared. + * @param key - argument name. + * @returns whether the field has appeared (a string opened or another value began). + */ + has(key: string): boolean { + return this.remember('has', key, () => this.hasNow(key)) + } + + /** + * Check whether a field's closing delimiter has arrived, without validating its contents. + * @param key - argument name. + * @returns whether its delimiter arrived and no content reader has reported an error for this value. + */ + complete(key: string): boolean { + return this.remember('complete', key, () => this.completeNow(key)) + } + + /** + * Read string length without materializing its text. + * @param key - argument name. + * @param options - change granularity for a streaming string. + * @returns decoded UTF-16 length of the string field so far; undefined when absent or not a string. + */ + stringLength(key: string, options?: LengthReadOptions): number | undefined { + const step = Math.max(1, Math.floor(options?.step ?? 1)) + const offset = options?.offset ?? 0 + return this.remember(`length:${step}:${offset}`, key, () => this.lengthNow(key), + length => length === undefined ? undefined : Math.ceil((length + offset) / step)) + } + + /** + * Check a string against a decoded UTF-16 length limit without materializing it. + * @param key - argument name. + * @param maxLength - decoded UTF-16 limit, floored to at least zero. + * @returns whether the string is longer than the limit; false when absent or not a string. + */ + stringExceeds(key: string, maxLength: number): boolean { + const limit = Math.max(0, Math.floor(maxLength)) + return this.remember(`exceeds:${limit}`, key, + () => (this.lengthNow(key, limit + 1) ?? 0) > limit) + } + + /** + * Read a decoded string, including a streaming prefix. + * @param key - argument name. + * @returns the string field's decoded text so far; undefined when absent or not a string. + */ + text(key: string): string | undefined { + return this.remember('text', key, () => this.textNow(key)) + } + + /** + * Read at most the first decoded UTF-16 units of a string. + * @param key - argument name. + * @param maxLength - maximum decoded UTF-16 length, floored to at least one. + * @returns the bounded string prefix; undefined when absent or not a string. + */ + textPrefix(key: string, maxLength: number): string | undefined { + const limit = Math.max(1, Math.floor(maxLength)) + return this.remember(`prefix:${limit}`, key, + () => this.textPrefixNow(key, limit)) + } + + /** + * Read a completed non-string argument. + * @param key - argument name. + * @returns the parsed non-string value once it closed; undefined while open, absent, or a string. + */ + value(key: string): JsonValue | undefined { + return this.remember('value', key, () => this.valueNow(key)) + } + + /** Answer a question and, on a streaming view, remember it for change detection. */ + private remember( + kind: ReadKind, + key: string, + read: () => T, + comparison?: (value: T) => unknown, + ): T { + this.scan() + const result = read() + if (!this.sealed) { + const id = `${kind}/${key}` + if (!this.#reads.has(id)) { + this.#reads.set(id, { + completion: kind === 'complete', + answer: comparison === undefined ? read : () => comparison(read()), + last: comparison === undefined ? result : comparison(result), + }) + } + } + return result + } + + private closedNow(): boolean { + return this.sealed || this.#mode === 'closed' || this.#mode === 'invalid' + } + + private keysNow(): readonly string[] { + return this.object === undefined ? this.#order : Object.keys(this.object) + } + + private hasNow(key: string): boolean { + return this.object === undefined ? this.#entries.has(key) : Object.hasOwn(this.object, key) + } + + private completeNow(key: string): boolean { + if (this.object !== undefined) return Object.hasOwn(this.object, key) + const entry = this.#entries.get(key) + return entry !== undefined && entry.end >= 0 + && (entry.kind === 'string' ? entry.invalidAt === undefined : !entry.invalid) + } + + private lengthNow(key: string, limit = Number.POSITIVE_INFINITY): number | undefined { + if (this.object !== undefined) { + const field = Object.hasOwn(this.object, key) ? this.object[key] : undefined + return typeof field === 'string' ? field.length : undefined + } + const entry = this.#entries.get(key) + if (entry?.kind !== 'string') return undefined + if (entry.text !== undefined && entry.text.at === entry.end) return entry.text.length + const read = entry.length ??= { at: entry.start, length: 0, text: '' } + this.readString(entry, read, limit, false) + return read.length + } + + private textNow(key: string): string | undefined { + if (this.object !== undefined) { + const field = Object.hasOwn(this.object, key) ? this.object[key] : undefined + return typeof field === 'string' ? field : undefined + } + const entry = this.#entries.get(key) + if (entry?.kind !== 'string') return undefined + if (entry.text === undefined && entry.end >= 0 && entry.needsDecoding && entry.invalidAt === undefined) { + let text: string | undefined + try { + text = JSON.parse(`"${this.slice(entry.start, entry.end)}"`) as string + } catch (_error) { + // The incremental reader retains the valid prefix when complete text contains an invalid escape. + } + if (text !== undefined) entry.text = { at: entry.end, length: text.length, text } + } + const read = entry.text ??= { at: entry.start, length: 0, text: '' } + this.readString(entry, read, Number.POSITIVE_INFINITY, true) + return read.text + } + + private textPrefixNow(key: string, maxLength: number): string | undefined { + if (this.object !== undefined) { + const field = Object.hasOwn(this.object, key) ? this.object[key] : undefined + return typeof field === 'string' ? field.slice(0, maxLength) : undefined + } + const entry = this.#entries.get(key) + if (entry?.kind !== 'string') return undefined + const prefixes = entry.prefixes ??= new Map() + let read = prefixes.get(maxLength) + if (read === undefined) { + read = { at: entry.start, length: 0, text: '' } + prefixes.set(maxLength, read) + } + this.readString(entry, read, maxLength, true) + return read.text + } + + private valueNow(key: string): JsonValue | undefined { + if (this.object !== undefined) { + if (!Object.hasOwn(this.object, key)) return undefined + const field = this.object[key] + return typeof field === 'string' ? undefined : field + } + const entry = this.#entries.get(key) + if (entry?.kind !== 'value' || entry.end < 0 || entry.invalid) return undefined + if (entry.parsed === undefined) { + try { + entry.parsed = JSON.parse(this.slice(entry.start, entry.end)) as JsonValue + } catch (_error) { + // Unread values retain only their ranges; a requested malformed value has no parsed result. + entry.invalid = true + this.#invalidValue = true + } + } + return entry.parsed + } + + private chunkAt(at: number): number { + let low = 0 + let high = this.#ends.length + while (low < high) { + const mid = (low + high) >>> 1 + if ((this.#ends[mid] as number) <= at) low = mid + 1 + else high = mid + } + return low + } + + /** Materialize only a requested range, never the cumulative source. */ + private slice(start: number, end: number): string { + if (start >= end) return '' + const first = this.chunkAt(start) + const last = this.chunkAt(end - 1) + const base = first === 0 ? 0 : this.#ends[first - 1] as number + if (first === last) return (this.chunks[first] as string).slice(start - base, end - base) + const parts = [(this.chunks[first] as string).slice(start - base)] + for (let i = first + 1; i < last; i++) parts.push(this.chunks[i] as string) + parts.push((this.chunks[last] as string).slice(0, end - (this.#ends[last - 1] as number))) + return parts.join('') + } + + private readString( + entry: Extract, + read: StringRead, + limit: number, + materialize: boolean, + ): void { + const end = Math.min(entry.end < 0 ? this.#consumed : entry.end, + entry.invalidAt ?? Number.POSITIVE_INFINITY, this.#invalidAt ?? Number.POSITIVE_INFINITY) + if (!entry.needsDecoding) { + const length = Math.min(end - read.at, limit - read.length) + if (length <= 0) return + if (materialize) read.text += this.slice(read.at, read.at + length) + read.at += length + read.length += length + return + } + let chunkIndex = this.chunkAt(read.at) + while (read.at < end && read.length < limit) { + const base = chunkIndex === 0 ? 0 : this.#ends[chunkIndex - 1] as number + const chunk = this.chunks[chunkIndex] as string + const remaining = chunk.slice(read.at - base, Math.min(chunk.length, end - base)) + const boundary = remaining.search(CONTENT_ESCAPE) + const length = Math.min(boundary < 0 ? remaining.length : boundary, limit - read.length) + if (length > 0) { + if (materialize) read.text += remaining.slice(0, length) + read.at += length + read.length += length + if (read.at === base + chunk.length) chunkIndex++ + continue + } + const type = remaining.length > 1 ? remaining[1] + : read.at + 1 < end ? (this.chunks[chunkIndex + 1] as string)[0] : undefined + let decoded: string | undefined + let width = 2 + if (remaining[0] === '\\' && type === undefined && entry.end < 0) return + if (remaining[0] === '\\' && type === 'u') { + const hex = this.slice(read.at + 2, Math.min(end, read.at + 6)) + let valid = true + for (let i = 0; i < hex.length; i++) if (!isHex(hex[i] as string)) valid = false + if (valid) { + if (hex.length < 4 && entry.end < 0) return + if (hex.length === 4) decoded = String.fromCharCode(Number.parseInt(hex, 16)) + } + width = 6 + } else if (remaining[0] === '\\' && type !== undefined) { + decoded = SIMPLE_ESCAPES[type] + } + if (decoded === undefined) { + entry.invalidAt = read.at + this.#invalidValue = true + return + } + if (materialize) read.text += decoded + read.length++ + read.at += width + while (chunkIndex < this.chunks.length && read.at >= (this.#ends[chunkIndex] as number)) chunkIndex++ + } + } + + /** Locate new field ranges without decoding or parsing their contents. */ + private scan(): void { + if (this.object !== undefined || this.#consumed === this.#size) return + for (let i = this.chunkAt(this.#consumed); i < this.chunks.length && this.#invalidAt === undefined; i++) { + const pending = this.chunks[i] as string + const base = i === 0 ? 0 : this.#ends[i - 1] as number + for (let index = this.#consumed - base; index < pending.length && this.#mode !== 'invalid'; index++) { + if (this.#mode === 'string' || (this.#mode === 'nested' && this.#nestedInString)) { + const end = this.stringBoundary(pending, index) + this.#consumed += end - index + index = end + if (index === pending.length) break + } + this.step(pending[index] as string, this.#consumed) + this.#consumed++ + } + } + } + + /** Only raw quotes and their preceding backslash runs can terminate a string. */ + private stringBoundary(fragment: string, start: number): number { + let at = start + while (true) { + const quote = fragment.indexOf('"', at) + const end = quote < 0 ? fragment.length : quote + if (this.#mode === 'string') { + const entry = this.#current as Extract + if (!entry.needsDecoding && CONTENT_ESCAPE.test(fragment.slice(at, end))) entry.needsDecoding = true + } + let slashStart = end + while (slashStart > at && fragment[slashStart - 1] === '\\') slashStart-- + const escaped = ((end - slashStart) % 2 === 1) !== (slashStart === at && this.#escape) + this.#escape = quote < 0 && escaped + if (quote < 0 || !escaped) return end + at = quote + 1 + } + } + + private step(c: string, at: number): void { + switch (this.#mode) { + case 'root': + if (isWhitespace(c)) return + if (c === '{') { this.#mode = 'key-or-end'; return } + this.fail(); return + case 'key-or-end': + if (isWhitespace(c)) return + if (c === '}') { this.#mode = 'closed'; return } + if (c === '"') { this.beginKey(at); return } + this.fail(); return + case 'key-only': + if (isWhitespace(c)) return + if (c === '"') { this.beginKey(at); return } + this.fail(); return + case 'key': + this.stepKey(c, at); return + case 'colon': + if (isWhitespace(c)) return + if (c === ':') { this.#mode = 'value'; return } + this.fail(); return + case 'value': + this.beginValue(c, at); return + case 'string': { + const entry = this.#current as Extract + entry.end = at + this.#current = null + this.#mode = 'comma-or-end' + return + } + case 'scalar': + this.stepScalar(c, at); return + case 'nested': + this.stepNested(c, at); return + case 'comma-or-end': + if (isWhitespace(c)) return + if (c === ',') { this.#mode = 'key-only'; return } + if (c === '}') { this.#mode = 'closed'; return } + this.fail(); return + case 'closed': + if (isWhitespace(c)) return + this.fail(); return + /* v8 ignore next 2 -- scan() stops stepping once the view is invalid. */ + case 'invalid': + return + /* v8 ignore next 2 -- Every scanner mode has a handler above. */ + default: + assertNever(this.#mode) + } + } + + private fail(): void { + this.#invalidAt = this.#consumed + this.#mode = 'invalid' + this.#current = null + } + + private beginKey(at: number): void { + this.#mode = 'key' + this.#keyStart = at + 1 + this.#keyEscaped = false + this.#escape = false + } + + private stepKey(c: string, at: number): void { + if (c < ' ') { this.fail(); return } + if (this.#escape) { this.#escape = false; return } + if (c === '\\') { this.#escape = true; this.#keyEscaped = true; return } + if (c !== '"') return + const raw = this.slice(this.#keyStart, at) + if (this.#keyEscaped) { + try { + this.#key = JSON.parse(`"${raw}"`) as string + } catch (_error) { + // Invalid key escapes prevent identifying subsequent fields. + this.fail() + return + } + } else { + this.#key = raw + } + this.#mode = 'colon' + } + + private open(entry: Entry): void { + if (!this.#entries.has(this.#key)) this.#order.push(this.#key) + this.#entries.set(this.#key, entry) + this.#current = entry + } + + private beginValue(c: string, at: number): void { + if (isWhitespace(c)) return + if (c === '"') { + this.open({ + kind: 'string', start: at + 1, end: -1, needsDecoding: false, invalidAt: undefined, + length: undefined, text: undefined, prefixes: undefined, + }) + this.#escape = false + this.#mode = 'string' + return + } + if (c === '}' || c === ',' || c === ':' || c === ']') { this.fail(); return } + this.open({ kind: 'value', start: at, end: -1, parsed: undefined, invalid: false }) + if (c === '{' || c === '[') { + this.#mode = 'nested' + this.#nestedEnds = [c === '{' ? '}' : ']'] + this.#nestedInString = false + this.#escape = false + return + } + this.#mode = 'scalar' + } + + private stepScalar(c: string, at: number): void { + if (c !== ',' && c !== '}' && !isWhitespace(c)) return + this.closeValue(at) + this.#mode = c === ',' ? 'key-only' : c === '}' ? 'closed' : 'comma-or-end' + } + + private stepNested(c: string, at: number): void { + if (this.#nestedInString) { + this.#nestedInString = false + return + } + if (c === '"') { this.#nestedInString = true; return } + if (c === '{' || c === '[') { this.#nestedEnds.push(c === '{' ? '}' : ']'); return } + if (c === '}' || c === ']') { + if (this.#nestedEnds.pop() !== c) { this.fail(); return } + if (this.#nestedEnds.length === 0) { + this.closeValue(at + 1) + this.#mode = 'comma-or-end' + } + } + } + + private closeValue(end: number): void { + const entry = this.#current as Extract + entry.end = end + this.#current = null + } +} diff --git a/packages/util/values/tests/partial-json.spec.ts b/packages/util/values/tests/partial-json.spec.ts new file mode 100644 index 0000000000..94116b4c89 --- /dev/null +++ b/packages/util/values/tests/partial-json.spec.ts @@ -0,0 +1,1260 @@ +import { describe, expect, it, vi } from 'vitest' +import { type JsonValue, PartialArguments } from '../src/index.ts' + +/** Complete, valid objects exercising strings, escapes, scalars, nested values, and whitespace. */ +const CLOSED_TEXTS = [ + String.raw`{"file_path":"src/a.ts","content":"line 1\nline \"2\"\t\u4e2d\ud83d\ude00","n":-1.5e3,"t":true}`, + String.raw` { "skip" : "a\"}{b\\" , "todos" : [ { "content" : "x}" } , [ [ ] ] ] , "file_path" : "y" , "content" : "\u00E9" } `, + String.raw`{"n":1 ,"content":"","m":null}`, + String.raw`{"o":{"k":[1,2,{"s":"\u00e9]}"}]},"n":-0.5e1}`, + String.raw`{"file\u005fpath":"x","content":"a\\b\/c"}`, + String.raw`{"__proto__":{"polluted":true},"constructor":"c","a":1}`, + '{}', +] + +/** Invalid structure or value contents, inspected by the full-reader oracle. */ +const INVALID_TEXTS = [ + String.raw`{"file_path":"ab\q"}`, + String.raw`{"file_path":"ab\u12G4"}`, + '{"file_path":"ab\nc"}', + '{"file_path":"ab\tc"}', + String.raw`{"a":[1,{"b":tru}]}`, + String.raw`{"n":1.}`, + '{"a":"b" x', +] + +/** Cut `text` into consecutive slices whose sizes come from `nextSize`. */ +function slices(text: string, nextSize: () => number): string[] { + const out: string[] = [] + for (let index = 0; index < text.length;) { + const size = nextSize() + out.push(text.slice(index, index + size)) + index += size + } + return out +} + +/** Deterministic slice sizes in 1..3 (mulberry32), so a failing split is reproducible from its seed. */ +function seededSizes(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state + 0x6d2b79f5) >>> 0 + let t = state + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) % 3 + 1 + } +} + +interface FieldView { + readonly has: boolean + readonly complete: boolean + readonly length: number | undefined + readonly prefix: string | undefined + readonly exceeds: boolean + readonly text: string | undefined + readonly value: JsonValue | undefined +} + +interface ViewSnapshot { + readonly invalid: boolean + readonly closed: boolean + readonly keys: readonly string[] + readonly fields: readonly (readonly [string, FieldView])[] +} + +/** Read every answer for `keys` (default: the view's own keys); on a streaming view this also registers the reads. */ +function snapshotOf(view: PartialArguments, keys: readonly string[] = view.keys()): ViewSnapshot { + const fields = keys.map((key): readonly [string, FieldView] => { + const length = view.stringLength(key) + const prefix = view.textPrefix(key, 4) + const exceeds = view.stringExceeds(key, 4) + const text = view.text(key) + const value = view.value(key) + return [key, { has: view.has(key), complete: view.complete(key), length, prefix, exceeds, text, value }] + }) + return { + invalid: view.invalid, + closed: view.closed(), + keys: [...view.keys()], + fields, + } +} + +function streamed(chunks: readonly string[]): PartialArguments { + const view = new PartialArguments() + for (const chunk of chunks) view.append(chunk) + return view +} + +function appendAndRefresh(view: PartialArguments, fragment: string): boolean { + view.append(fragment) + return view.refresh() +} + +describe('PartialArguments', () => { + describe('sealed views', () => { + it('reports sealing independently of object closure and preserves append rejection', () => { + const view = streamed(['{"a":"value"}']) + expect(view.closed()).toBe(true) + expect(view.isSealed).toBe(false) + view.append(' ') + expect(view.settle('{"a":"value"} ')).toBe(view) + expect(view.isSealed).toBe(true) + expect(() => { view.append('') }).toThrow('cannot append to a sealed view') + expect(PartialArguments.EMPTY.isSealed).toBe(true) + expect(PartialArguments.fromText('{"a":"unfinished').isSealed).toBe(true) + const partial = streamed(['{"a":"old']) + expect(partial.settle('{"a":"new"}').isSealed).toBe(true) + expect(partial.isSealed).toBe(false) + }) + + it('reads sealing without scanning or observing streamed text', () => { + const view = streamed(['{"a":"value']) + const search = vi.spyOn(String.prototype, 'indexOf') + let sealed: boolean + let searches: number + try { + sealed = view.isSealed + searches = search.mock.calls.length + } finally { + search.mockRestore() + } + expect(sealed).toBe(false) + expect(searches).toBe(0) + view.append(' grows"}') + expect(view.refresh()).toBe(false) + }) + + it('EMPTY answers every reader with absence and refuses appends', () => { + const empty = PartialArguments.EMPTY + expect(empty.keys()).toEqual([]) + expect(empty.has('x')).toBe(false) + expect(empty.complete('x')).toBe(false) + expect(empty.stringLength('x')).toBeUndefined() + expect(empty.text('x')).toBeUndefined() + expect(empty.value('x')).toBeUndefined() + expect(empty.invalid).toBe(false) + expect(() => { empty.append('{') }).toThrow('cannot append to a sealed view') + }) + + it('reports every sealed view as closed, whatever its text says', () => { + expect(PartialArguments.EMPTY.closed()).toBe(true) + expect(PartialArguments.fromObject({ a: 1 }).closed()).toBe(true) + expect(PartialArguments.fromObject(null).closed()).toBe(true) + for (const text of [...CLOSED_TEXTS, ...INVALID_TEXTS, '', '{"a":1']) { + expect(PartialArguments.fromText(text).closed()).toBe(true) + } + }) + + it('never answers with inherited Object.prototype members', () => { + for (const view of [PartialArguments.EMPTY, PartialArguments.fromObject({ a: 1 })]) { + for (const key of ['constructor', '__proto__', 'toString', 'hasOwnProperty']) { + expect(view.has(key)).toBe(false) + expect(view.complete(key)).toBe(false) + expect(view.value(key)).toBeUndefined() + expect(view.text(key)).toBeUndefined() + expect(view.stringLength(key)).toBeUndefined() + } + } + }) + + it('fromObject reads strings and other values straight from the parsed object', () => { + const view = PartialArguments.fromObject({ file_path: 'src/a.ts', empty: '', n: 12.5, z: null, o: { k: [1] }, a: [1, 'x'] }) + expect(view.keys()).toEqual(['file_path', 'empty', 'n', 'z', 'o', 'a']) + expect(view.stringLength('file_path')).toBe(8) + expect(view.text('file_path')).toBe('src/a.ts') + expect(view.value('file_path')).toBeUndefined() + expect(view.stringLength('empty')).toBe(0) + expect(view.text('empty')).toBe('') + expect(view.value('n')).toBe(12.5) + expect(view.value('z')).toBeNull() + expect(view.value('o')).toStrictEqual({ k: [1] }) + expect(view.value('a')).toStrictEqual([1, 'x']) + expect(view.stringLength('n')).toBeUndefined() + expect(view.text('n')).toBeUndefined() + for (const key of view.keys()) { + expect(view.has(key)).toBe(true) + expect(view.complete(key)).toBe(true) + } + expect(view.has('missing')).toBe(false) + expect(view.complete('missing')).toBe(false) + expect(view.value('missing')).toBeUndefined() + expect(view.invalid).toBe(false) + expect(() => { view.append('{') }).toThrow('cannot append to a sealed view') + }) + + it('fromObject treats anything but an object as a call without fields', () => { + for (const input of [undefined, null, 'str', 42, true, [1, 2]]) { + const view = PartialArguments.fromObject(input) + expect(view.keys()).toEqual([]) + expect(view.has('0')).toBe(false) + expect(view.value('0')).toBeUndefined() + } + }) + + it('fromText answers exactly like fromObject over the parsed text, and refuses appends', () => { + for (const text of CLOSED_TEXTS) { + const fromText = PartialArguments.fromText(text) + expect(snapshotOf(fromText)).toStrictEqual(snapshotOf(PartialArguments.fromObject(JSON.parse(text)))) + expect(() => { fromText.append(' ') }).toThrow('cannot append to a sealed view') + } + expect('polluted' in {}).toBe(false) + }) + + it('fromText reports invalid contents when read and retains independent fields', () => { + for (const text of INVALID_TEXTS) expect(snapshotOf(PartialArguments.fromText(text)).invalid).toBe(true) + const view = PartialArguments.fromText('{"n":1,"b":tru}') + expect(view.invalid).toBe(false) + expect(view.value('n')).toBe(1) + expect(view.has('b')).toBe(true) + expect(view.complete('b')).toBe(true) + expect(view.value('b')).toBeUndefined() + expect(view.invalid).toBe(true) + expect(view.complete('b')).toBe(false) + }) + + it('scans nothing until a reader asks', () => { + const parse = vi.spyOn(JSON, 'parse') + try { + const sealed = PartialArguments.fromText('{"todos":[1],"n":2}') + const streaming = new PartialArguments() + expect(appendAndRefresh(streaming, '{"todos":[1],')).toBe(false) + expect(appendAndRefresh(streaming, '"n":2}')).toBe(false) + expect(parse).not.toHaveBeenCalled() + + expect(sealed.keys()).toEqual(['todos', 'n']) + expect(sealed.has('todos')).toBe(true) + expect(sealed.complete('todos')).toBe(true) + expect(sealed.invalid).toBe(false) + expect(streaming.keys()).toEqual(['todos', 'n']) + expect(streaming.has('todos')).toBe(true) + expect(streaming.complete('todos')).toBe(true) + expect(parse).not.toHaveBeenCalled() + + expect(streaming.value('todos')).toStrictEqual([1]) + expect(parse).toHaveBeenCalledExactlyOnceWith('[1]') + const todos = streaming.value('todos') + expect(streaming.value('todos')).toBe(todos) + expect(parse).toHaveBeenCalledTimes(1) + expect(streaming.value('n')).toBe(2) + expect(parse).toHaveBeenCalledTimes(2) + expect(parse).toHaveBeenLastCalledWith('2') + } finally { + parse.mockRestore() + } + }) + }) + + describe('settlement', () => { + it('seals matching chunks without joining and retains previously read values', () => { + const view = new PartialArguments() + view.append(String.raw`{"command":"a\u4e`) + expect(view.text('command')).toBe('a') + expect(view.stringLength('command')).toBe(1) + expect(view.textPrefix('command', 2)).toBe('a') + expect(view.complete('command')).toBe(false) + view.append('2d","todos":[1]}') + const todos = view.value('todos') + expect(todos).toEqual([1]) + const finalText = String.raw`{"command":"a\u4e2d","todos":[1]}` + const join = vi.spyOn(Array.prototype, 'join') + let settled: PartialArguments + let joins: number + try { + settled = view.settle(finalText) + joins = join.mock.calls.length + } finally { + join.mockRestore() + } + expect(joins).toBe(0) + expect(settled).toBe(view) + expect(view).toEqual(PartialArguments.fromText(finalText)) + expect(view.text('command')).toBe('a中') + expect(view.stringLength('command')).toBe(2) + expect(view.textPrefix('command', 2)).toBe('a中') + expect(view.complete('command')).toBe(true) + expect(view.value('todos')).toBe(todos) + expect(view.closed()).toBe(true) + expect(view.invalid).toBe(false) + expect(view.refresh()).toBe(true) + expect(view.refresh()).toBe(false) + expect(view.settle(finalText)).toBe(view) + expect(() => { view.append(' ') }).toThrow('cannot append to a sealed view') + }) + + it('leaves matching unread containers unparsed until requested after settlement', () => { + const view = streamed(['{"items":', '[1,2]}']) + const parse = vi.spyOn(JSON, 'parse') + try { + expect(view.settle('{"items":[1,2]}')).toBe(view) + expect(view.keys()).toEqual(['items']) + expect(view.has('items')).toBe(true) + expect(view.complete('items')).toBe(true) + expect(parse).not.toHaveBeenCalled() + expect(view.value('items')).toEqual([1, 2]) + expect(parse).toHaveBeenCalledExactlyOnceWith('[1,2]') + } finally { + parse.mockRestore() + } + }) + + it.each<[string, readonly string[], string, string, string]>([ + ['same-length conflicting text', ['x"}'], '{"a":"y"}', 'x', 'y'], + ['missing deltas', ['x'], '{"a":"xy"}', 'x', 'xy'], + ['duplicate deltas', ['x', 'x"}'], '{"a":"x"}', 'xx', 'x'], + ])('uses final text after %s without changing the streaming view', (_label, tail, finalText, before, after) => { + const view = new PartialArguments() + view.append('{"a":"') + expect(view.text('a')).toBe('') + expect(view.textPrefix('a', 1)).toBe('') + for (const chunk of tail) view.append(chunk) + expect(view.text('a')).toBe(before) + const original = streamed(['{"a":"', ...tail]) + const settled = view.settle(finalText) + expect(settled).not.toBe(view) + expect(settled).toEqual(PartialArguments.fromText(finalText)) + expect(settled.text('a')).toBe(after) + expect(settled.stringLength('a')).toBe(after.length) + expect(settled.closed()).toBe(true) + expect(settled.refresh()).toBe(false) + expect(view).toEqual(original) + expect(view.text('a')).toBe(before) + expect(view.refresh()).toBe(true) + expect(view.refresh()).toBe(false) + expect(() => { view.append(' ') }).not.toThrow() + expect(() => { settled.append(' ') }).toThrow('cannot append to a sealed view') + }) + + it('does not carry an invalid reader cache into corrected authoritative text', () => { + const view = streamed([String.raw`{"a":"ab\q"}`]) + expect(view.text('a')).toBe('ab') + expect(view.invalid).toBe(true) + const settled = view.settle(String.raw`{"a":"ab\n"}`) + expect(settled).not.toBe(view) + expect(settled.text('a')).toBe('ab\n') + expect(settled.complete('a')).toBe(true) + expect(settled.invalid).toBe(false) + expect(view.text('a')).toBe('ab') + expect(view.invalid).toBe(true) + }) + + it('seals empty text and uses authoritative text for an object-backed view', () => { + const empty = streamed(['', '']) + expect(empty.settle('')).toBe(empty) + expect(empty.closed()).toBe(true) + expect(empty.keys()).toEqual([]) + expect(empty).toEqual(PartialArguments.fromText('')) + expect(() => { empty.append('{}') }).toThrow('cannot append to a sealed view') + const object = PartialArguments.fromObject({ a: 'old' }) + const settled = object.settle('{"a":"new"}') + expect(settled).not.toBe(object) + expect(settled.text('a')).toBe('new') + expect(object.text('a')).toBe('old') + }) + }) + + describe('structural equality', () => { + it('compares two views by their source alone, however far each has been read', () => { + for (const text of [...CLOSED_TEXTS, ...INVALID_TEXTS]) { + const read = PartialArguments.fromText(text) + snapshotOf(read) + const fresh = PartialArguments.fromText(text) + expect(read).toEqual(fresh) + expect(fresh).toEqual(read) + + const eager = new PartialArguments() + snapshotOf(eager, fresh.keys()) + eager.append(text) + const untouched = new PartialArguments() + untouched.append(text) + expect(eager).toEqual(untouched) + } + const object = { file_path: 'src/a.ts', n: 1 } + const readObject = PartialArguments.fromObject(object) + snapshotOf(readObject) + expect(readObject).toEqual(PartialArguments.fromObject(object)) + }) + + it('distinguishes a streaming view from a sealed view of the same text even though every reader agrees', () => { + const text = String.raw`{"file_path":"src/a.ts","content":"a\nb","n":1,"todos":[{"content":"x"}]}` + const sealed = PartialArguments.fromText(text) + const streaming = streamed(slices(text, seededSizes(3))) + expect(streaming).not.toEqual(sealed) + const answers = snapshotOf(streaming) + expect({ ...answers, closed: sealed.closed() }).toStrictEqual(snapshotOf(sealed)) + expect(answers.closed).toBe(true) + }) + + it('distinguishes views of different sources', () => { + expect(PartialArguments.fromText('{"a":1}')).not.toEqual(PartialArguments.fromText('{"a":2}')) + expect(PartialArguments.fromText('{"a":1}')).not.toEqual(PartialArguments.fromText('{"a":1} ')) + expect(PartialArguments.fromObject({ a: 1 })).not.toEqual(PartialArguments.fromObject({ a: 2 })) + expect(PartialArguments.fromText('{}')).not.toEqual(PartialArguments.EMPTY) + }) + }) + + describe('streaming readers', () => { + it('distinguishes an open string, a closed string, a pending value, a closed value, and an absent key', () => { + const view = new PartialArguments() + view.append('{"file_path":"ab') + expect(view.keys()).toEqual(['file_path']) + expect(view.has('file_path')).toBe(true) + expect(view.complete('file_path')).toBe(false) + expect(view.stringLength('file_path')).toBe(2) + expect(view.text('file_path')).toBe('ab') + expect(view.value('file_path')).toBeUndefined() + + view.append('","content":"xy') + expect(view.complete('file_path')).toBe(true) + expect(view.text('file_path')).toBe('ab') + expect(view.complete('content')).toBe(false) + expect(view.stringLength('content')).toBe(2) + expect(view.text('content')).toBe('xy') + + view.append('","n":12') + expect(view.keys()).toEqual(['file_path', 'content', 'n']) + expect(view.has('n')).toBe(true) + expect(view.complete('n')).toBe(false) + expect(view.value('n')).toBeUndefined() + expect(view.stringLength('n')).toBeUndefined() + expect(view.text('n')).toBeUndefined() + + view.append('}') + expect(view.complete('n')).toBe(true) + expect(view.value('n')).toBe(12) + expect(view.has('missing')).toBe(false) + expect(view.complete('missing')).toBe(false) + expect(view.stringLength('missing')).toBeUndefined() + expect(view.text('missing')).toBeUndefined() + expect(view.value('missing')).toBeUndefined() + expect(view.invalid).toBe(false) + }) + + it('reports every top-level field of a finished object', () => { + const view = streamed(['{"file_path":"src/a.ts","content":"hello","n":1,"o":{"file_path":"inner"},"a":[1,2]}']) + expect(view.keys()).toEqual(['file_path', 'content', 'n', 'o', 'a']) + expect(view.text('file_path')).toBe('src/a.ts') + expect(view.stringLength('file_path')).toBe(8) + expect(view.stringLength('content')).toBe(5) + expect(view.value('n')).toBe(1) + expect(view.value('o')).toStrictEqual({ file_path: 'inner' }) + expect(view.value('a')).toStrictEqual([1, 2]) + expect(view.text('o')).toBeUndefined() + expect(view.value('file_path')).toBeUndefined() + for (const key of view.keys()) expect(view.complete(key)).toBe(true) + expect(view.invalid).toBe(false) + }) + + it('treats "__proto__" and Object.prototype member names as ordinary keys', () => { + const flat = streamed(['{"__proto__":"x","constructor":"c","a":1}']) + expect(flat.keys()).toEqual(['__proto__', 'constructor', 'a']) + expect(flat.has('__proto__')).toBe(true) + expect(flat.text('__proto__')).toBe('x') + expect(flat.text('constructor')).toBe('c') + expect(flat.value('a')).toBe(1) + + const nested = streamed(['{"__proto__":{"polluted":true}}']) + expect(nested.keys()).toEqual(['__proto__']) + expect(nested.value('__proto__')).toStrictEqual({ polluted: true }) + const parsed = PartialArguments.fromObject(JSON.parse('{"__proto__":{"polluted":true}}')) + expect(parsed.keys()).toEqual(['__proto__']) + expect(parsed.value('__proto__')).toStrictEqual({ polluted: true }) + expect('polluted' in {}).toBe(false) + }) + + it('reports numbers and literals once a comma, whitespace, or the closing brace ends them', () => { + const byComma = streamed(['{"n":12.5,"b":true,"z":null,"file_path":"p"}']) + expect(byComma.value('n')).toBe(12.5) + expect(byComma.value('b')).toBe(true) + expect(byComma.value('z')).toBeNull() + expect(byComma.has('z')).toBe(true) + expect(byComma.complete('z')).toBe(true) + expect(byComma.text('file_path')).toBe('p') + + const byBrace = streamed(['{"file_path":"p","n":-1e3}']) + expect(byBrace.value('n')).toBe(-1000) + expect(byBrace.invalid).toBe(false) + + const byWhitespace = streamed(['{"n":1 ,"file_path":"p","m":false\n}']) + expect(byWhitespace.value('n')).toBe(1) + expect(byWhitespace.value('m')).toBe(false) + expect(byWhitespace.invalid).toBe(false) + expect(streamed(['{"n":1 }']).value('n')).toBe(1) + }) + + it('holds a number or literal open until its delimiter arrives', () => { + const view = new PartialArguments() + view.append('{"n":12') + expect(view.has('n')).toBe(true) + expect(view.complete('n')).toBe(false) + expect(view.value('n')).toBeUndefined() + view.append('.5') + expect(view.value('n')).toBeUndefined() + view.append(',') + expect(view.complete('n')).toBe(true) + expect(view.value('n')).toBe(12.5) + view.append('"t":tru') + expect(view.value('t')).toBeUndefined() + view.append('e}') + expect(view.value('t')).toBe(true) + expect(view.invalid).toBe(false) + }) + + it('parses a nested value when its matching bracket closes, ignoring brackets and escapes inside nested strings', () => { + const todos = [{ content: 'x}' }, { content: ']"\\' }, [[]], { deep: { a: [1, { b: '}' }] } }] + const view = new PartialArguments() + view.append(String.raw`{"todos":[{"content":"x}"},{"content":"]\"\\"},[[]],{"deep":{"a":[1,{"b":"}"}]}}`) + expect(view.has('todos')).toBe(true) + expect(view.complete('todos')).toBe(false) + expect(view.value('todos')).toBeUndefined() + expect(view.has('content')).toBe(false) + view.append(']') + expect(view.complete('todos')).toBe(true) + expect(view.value('todos')).toStrictEqual(todos) + view.append(',"file_path":"y"}') + expect(view.keys()).toEqual(['todos', 'file_path']) + expect(view.text('file_path')).toBe('y') + expect(view.invalid).toBe(false) + }) + + it('reports a string whose quotes, backslashes, and braces are escaped by decoded length and text', () => { + const view = streamed([String.raw`{"skip":"a\"}{b\\","file_path":"x"}`]) + expect(view.stringLength('skip')).toBe(6) + expect(view.text('skip')).toBe('a"}{b\\') + expect(view.text('file_path')).toBe('x') + expect(view.invalid).toBe(false) + }) + }) + + describe('escapes', () => { + it.each<[string, string, string, string]>([ + ['odd backslash run', String.raw`{"command":"a\\`, String.raw`\"b","later":"ok"}`, 'a\\"b'], + ['even backslash run', String.raw`{"command":"a\\`, String.raw`\\","later":"ok"}`, 'a\\\\'], + ['Unicode quote', String.raw`{"command":"a\u00`, '22b","later":"ok"}', 'a"b'], + ])('keeps a split %s inside its string field', (_label, head, tail, expected) => { + const view = new PartialArguments() + view.append(head) + expect(view.keys()).toEqual(['command']) + expect(view.complete('command')).toBe(false) + expect(view.has('later')).toBe(false) + view.text('command') + view.stringLength('command') + view.append(tail) + expect(view.keys()).toEqual(['command', 'later']) + expect(view.complete('command')).toBe(true) + expect(view.text('command')).toBe(expected) + expect(view.stringLength('command')).toBe(expected.length) + expect(view.text('later')).toBe('ok') + expect(view.invalid).toBe(false) + }) + + it('keeps escaped nested quotes inside an unread container across one-character chunks', () => { + const text = String.raw`{"items":["a\\\"b","a\\\\","\u0022"],"later":"ok"}` + const view = new PartialArguments() + for (const chunk of text) { + view.append(chunk) + view.keys() + } + expect(view.keys()).toEqual(['items', 'later']) + expect(view.complete('items')).toBe(true) + expect(view.text('later')).toBe('ok') + expect(view.value('items')).toEqual(['a\\"b', 'a\\\\', '"']) + expect(view.invalid).toBe(false) + }) + + it('decodes every simple escape', () => { + const view = streamed([String.raw`{"file_path":"\"\\\/\b\f\n\r\t"}`]) + expect(view.stringLength('file_path')).toBe(8) + expect(view.text('file_path')).toBe('"\\/\b\f\n\r\t') + }) + + it('decodes \\uXXXX escapes in either hex case', () => { + const view = streamed([String.raw`{"file_path":"\u4E2D\u00e9"}`]) + expect(view.stringLength('file_path')).toBe(2) + expect(view.text('file_path')).toBe('中é') + }) + + it('decodes a \\uXXXX escape split across appends, whether the text was read before or only after', () => { + const chunkings = [[String.raw`{"file_path":"\u4e`, '2d"}'], ['{"file_path":"\\', 'u', '4', 'e2', 'd"}']] + for (const chunks of chunkings) { + const lazy = streamed(chunks) + expect(lazy.text('file_path')).toBe('中') + expect(lazy.stringLength('file_path')).toBe(1) + + const eager = new PartialArguments() + expect(eager.text('file_path')).toBeUndefined() + for (const chunk of chunks) appendAndRefresh(eager, chunk) + expect(eager.text('file_path')).toBe('中') + expect(eager.stringLength('file_path')).toBe(1) + } + }) + + it('decodes a surrogate pair of two \\u escapes into one code point counted as two UTF-16 units', () => { + const view = new PartialArguments() + view.append(String.raw`{"file_path":"\ud83d`) + expect(view.text('file_path')).toBe('\ud83d') + expect(view.stringLength('file_path')).toBe(1) + view.append(String.raw`\ude00"}`) + expect(view.text('file_path')).toBe('😀') + expect(view.stringLength('file_path')).toBe(2) + expect(PartialArguments.fromText(String.raw`{"content":"\u00e9\ud83d\ude00"}`).stringLength('content')).toBe(3) + }) + }) + + describe('lazy text reads', () => { + it('decodes the streamed prefix on first read, including complete escapes, then keeps it current', () => { + const view = new PartialArguments() + view.append(String.raw`{"file_path":"a\n\u4e2d\"x`) + expect(view.text('file_path')).toBe('a\n中"x') + expect(view.stringLength('file_path')).toBe(5) + view.append('y"}') + expect(view.text('file_path')).toBe('a\n中"xy') + expect(view.complete('file_path')).toBe(true) + }) + + it.each<[string, string, string]>([ + // Plain strings: a raw template cannot end in a backslash without escaping its closing backtick. + ['{"file_path":"a\\', 'n"}', 'a\n'], + ['{"file_path":"a\\u', '4e2d"}', 'a中'], + [String.raw`{"file_path":"a\u4e`, '2d"}', 'a中'], + ])('drops an escape still in flight from the first read of %j and completes it from %j', (head, tail, decoded) => { + const view = new PartialArguments() + view.append(head) + expect(view.text('file_path')).toBe(decoded.slice(0, 1)) + expect(view.stringLength('file_path')).toBe(1) + view.append(tail) + expect(view.text('file_path')).toBe(decoded) + expect(view.stringLength('file_path')).toBe(decoded.length) + expect(view.complete('file_path')).toBe(true) + }) + + it('decodes a closed string in one step on first read', () => { + const view = PartialArguments.fromText(String.raw`{"file_path":"a\n\u4e2d\ud83d\ude00"}`) + expect(view.text('file_path')).toBe('a\n中😀') + expect(view.text('file_path')).toBe('a\n中😀') + expect(view.stringLength('file_path')).toBe(5) + }) + + it('returns the prefix before an invalid simple escape, whether the text is first read before, at, or after the failure', () => { + const text = String.raw`{"file_path":"ab\q"}` + const lazy = PartialArguments.fromText(text) + expect(lazy.text('file_path')).toBe('ab') + expect(lazy.stringLength('file_path')).toBe(2) + expect(lazy.invalid).toBe(true) + expect(lazy.complete('file_path')).toBe(false) + + const readBeforeKey = new PartialArguments() + expect(readBeforeKey.text('file_path')).toBeUndefined() + expect(appendAndRefresh(readBeforeKey, text)).toBe(true) + expect(readBeforeKey.text('file_path')).toBe('ab') + expect(readBeforeKey.stringLength('file_path')).toBe(2) + + const readMidString = new PartialArguments() + readMidString.append('{"file_path":"a') + expect(readMidString.text('file_path')).toBe('a') + expect(appendAndRefresh(readMidString, String.raw`b\q"}`)).toBe(true) + expect(readMidString.text('file_path')).toBe('ab') + expect(readMidString.stringLength('file_path')).toBe(2) + expect(readMidString.invalid).toBe(true) + }) + + it('returns the prefix before an invalid unicode escape, matching a read made before the failure', () => { + const lazy = PartialArguments.fromText(String.raw`{"file_path":"ab\u12G4"}`) + expect(lazy.invalid).toBe(false) + expect(lazy.text('file_path')).toBe('ab') + expect(lazy.invalid).toBe(true) + expect(lazy.stringLength('file_path')).toBe(2) + expect(lazy.complete('file_path')).toBe(false) + + const eager = new PartialArguments() + expect(eager.text('file_path')).toBeUndefined() + eager.append(String.raw`{"file_path":"ab\u12G4"}`) + expect(eager.invalid).toBe(false) + expect(eager.text('file_path')).toBe('ab') + expect(eager.invalid).toBe(true) + expect(eager.stringLength('file_path')).toBe(2) + }) + }) + + describe('keys and whitespace', () => { + it('matches an escaped key against its decoded name', () => { + const view = streamed([String.raw`{"file\u005fpath":"x","a\"b":"v"}`]) + expect(view.keys()).toEqual(['file_path', 'a"b']) + expect(view.text('file_path')).toBe('x') + expect(view.text('a"b')).toBe('v') + }) + + it('accepts JSON whitespace around every structural token', () => { + const view = streamed([' \n{\t"file_path"\r:\n"x"\t,\r"content" : "y" ,\n"n" : 1 ,\t"o" : { "k" : [ 1 ] } \n}\r\n']) + expect(view.keys()).toEqual(['file_path', 'content', 'n', 'o']) + expect(view.text('file_path')).toBe('x') + expect(view.text('content')).toBe('y') + expect(view.value('n')).toBe(1) + expect(view.value('o')).toStrictEqual({ k: [1] }) + expect(view.invalid).toBe(false) + }) + + it('accepts an empty object and trailing whitespace after the close, but nothing else after it', () => { + for (const text of ['{}', '{ \n}', '{"a":1} \n\t\r', '']) { + const view = PartialArguments.fromText(text) + expect(view.invalid).toBe(false) + expect(view.has('b')).toBe(false) + } + expect(PartialArguments.fromText('{} x').invalid).toBe(true) + expect(PartialArguments.fromText('{"file_path":"x"}}').invalid).toBe(true) + expect(PartialArguments.fromText('{"file_path":"x"}}').text('file_path')).toBe('x') + }) + + it('reports a streaming object closed when its outer closing brace arrives', () => { + const view = new PartialArguments() + expect(view.closed()).toBe(false) + view.append('{"a":1,"b":"x') + expect(view.closed()).toBe(false) + view.append('"}') + expect(view.closed()).toBe(true) + view.append(' \n') + expect(view.closed()).toBe(true) + expect(view.invalid).toBe(false) + expect(streamed(['{}']).closed()).toBe(true) + + const broken = new PartialArguments() + broken.append('{"a":tru') + expect(broken.closed()).toBe(false) + broken.append('}') + expect(broken.closed()).toBe(true) + expect(broken.invalid).toBe(false) + expect(broken.value('a')).toBeUndefined() + expect(broken.invalid).toBe(true) + }) + + it('lets a repeated key replace the earlier entry without repeating the key', () => { + const view = new PartialArguments() + view.append('{"file_path":"a",') + expect(view.text('file_path')).toBe('a') + view.append('"file_path":"') + expect(view.keys()).toEqual(['file_path']) + expect(view.text('file_path')).toBe('') + expect(view.complete('file_path')).toBe(false) + view.append('bc",') + expect(view.text('file_path')).toBe('bc') + + view.append('"file_path":[1],') + expect(view.value('file_path')).toStrictEqual([1]) + expect(view.text('file_path')).toBeUndefined() + expect(view.stringLength('file_path')).toBeUndefined() + view.append('"file_path":"d"}') + expect(view.text('file_path')).toBe('d') + expect(view.value('file_path')).toBeUndefined() + expect(view.keys()).toEqual(['file_path']) + expect(view.invalid).toBe(false) + }) + }) + + describe('fragmentation', () => { + const expectedByText = new Map([...CLOSED_TEXTS, ...INVALID_TEXTS].map(text => [text, snapshotOf(PartialArguments.fromText(text))])) + + it('answers the same whether scanned eagerly per character, per seeded 1-3 character slice, or lazily as a whole', () => { + for (const [text, expected] of expectedByText) { + const chunkings = [text.split('')] + for (let seed = 1; seed <= 8; seed++) chunkings.push(slices(text, seededSizes(seed))) + for (const chunks of chunkings) { + const eager = new PartialArguments() + snapshotOf(eager, expected.keys) + for (const chunk of chunks) appendAndRefresh(eager, chunk) + expect(snapshotOf(eager, expected.keys)).toStrictEqual(expected) + expect(snapshotOf(streamed(chunks))).toStrictEqual(expected) + } + } + }) + + it('keeps text and length consistent when the first read lands at any character boundary', () => { + for (const [text, expected] of expectedByText) { + for (let cut = 0; cut <= text.length; cut++) { + const view = new PartialArguments() + view.append(text.slice(0, cut)) + const midway = snapshotOf(view, expected.keys) + for (const [, field] of midway.fields) { + if (field.text !== undefined) { + expect(field.text.length).toBe(field.length) + expect(field.prefix).toBe(field.text.slice(0, 4)) + expect(field.exceeds).toBe(field.text.length > 4) + } + } + view.append(text.slice(cut)) + expect(snapshotOf(view, expected.keys)).toStrictEqual(expected) + } + } + }) + }) + + describe('change detection', () => { + it('publishes a completion error discovered by a later content read during sealed refresh', () => { + const view = streamed(['{"a":"ab"']) + expect(view.complete('a')).toBe(true) + expect(view.text('a')).toBe('ab') + view.append(String.raw`,"a":"ab\q"}`) + view.settle(String.raw`{"a":"ab","a":"ab\q"}`) + expect(view.refresh()).toBe(true) + expect(view.complete('a')).toBe(false) + expect(view.text('a')).toBe('ab') + expect(view.refresh()).toBe(false) + }) + + it('reports no change while nothing has been read, then answers fully on the first read', () => { + const view = new PartialArguments() + expect(appendAndRefresh(view, '{"file_path":"src/a.ts","n":1,')).toBe(false) + expect(appendAndRefresh(view, '"todos":[1]}')).toBe(false) + expect(view.keys()).toEqual(['file_path', 'n', 'todos']) + expect(view.text('file_path')).toBe('src/a.ts') + expect(view.value('todos')).toStrictEqual([1]) + }) + + it('reports a length read only when its rounded-up step moves', () => { + const view = new PartialArguments() + view.append('{"content":"') + expect(view.stringLength('content', { step: 1024 })).toBe(0) + expect(appendAndRefresh(view, 'a')).toBe(true) + expect(appendAndRefresh(view, 'a'.repeat(1022))).toBe(false) + expect(appendAndRefresh(view, 'a')).toBe(false) + expect(view.stringLength('content', { step: 1024 })).toBe(1024) + expect(appendAndRefresh(view, 'a')).toBe(true) + expect(appendAndRefresh(view, 'a'.repeat(1023))).toBe(false) + expect(appendAndRefresh(view, 'a')).toBe(true) + expect(view.stringLength('content', { step: 1024 })).toBe(2049) + expect(appendAndRefresh(view, '"}')).toBe(false) + }) + + it('treats a missing, zero, or fractional step as its floor of at least one', () => { + const perCharacter = new PartialArguments() + perCharacter.append('{"c":"') + perCharacter.stringLength('c') + perCharacter.stringLength('c', {}) + perCharacter.stringLength('c', { step: 0 }) + for (const c of 'abc') expect(appendAndRefresh(perCharacter, c)).toBe(true) + + const byTwo = new PartialArguments() + byTwo.append('{"c":"') + byTwo.stringLength('c', { step: 2.9 }) + expect(appendAndRefresh(byTwo, 'a')).toBe(true) + expect(appendAndRefresh(byTwo, 'b')).toBe(false) + expect(appendAndRefresh(byTwo, 'c')).toBe(true) + expect(appendAndRefresh(byTwo, 'd')).toBe(false) + }) + + it('reports a text read on every decoded character and nothing else', () => { + const view = new PartialArguments() + view.append('{"description":"') + expect(view.text('description')).toBe('') + for (const c of 'hello') expect(appendAndRefresh(view, c)).toBe(true) + expect(appendAndRefresh(view, '\\')).toBe(false) + expect(appendAndRefresh(view, 'n')).toBe(true) + expect(appendAndRefresh(view, '"')).toBe(false) + expect(appendAndRefresh(view, ',"n":1}')).toBe(false) + expect(view.text('description')).toBe('hello\n') + }) + + it('stops reporting a bounded text prefix after its decoded limit', () => { + const view = new PartialArguments() + view.append('{"command":"') + expect(view.textPrefix('command', 4)).toBe('') + expect(appendAndRefresh(view, 'ab')).toBe(true) + expect(appendAndRefresh(view, 'cd')).toBe(true) + expect(view.textPrefix('command', 4)).toBe('abcd') + expect(appendAndRefresh(view, 'ef')).toBe(false) + expect(view.textPrefix('command', 4)).toBe('abcd') + expect(view.stringExceeds('command', 4)).toBe(true) + expect(view.text('command')).toBe('abcdef') + }) + + it('evaluates deferred fragments together at the publication point', () => { + const view = new PartialArguments() + view.append('{"content":"') + expect(view.stringLength('content', { step: 4 })).toBe(0) + view.append('ab') + view.append('cd') + expect(view.refresh()).toBe(true) + expect(view.stringLength('content', { step: 4 })).toBe(4) + expect(view.refresh()).toBe(false) + view.append('ef') + expect(view.refresh()).toBe(true) + view.append('gh') + expect(view.refresh()).toBe(false) + expect(view.stringLength('content', { step: 4 })).toBe(8) + }) + + it('bounds decoded prefixes across JSON escapes and parsed objects', () => { + expect(PartialArguments.fromText('{"command":"\\u4e2d tail"}').textPrefix('command', 1)).toBe('中') + expect(PartialArguments.EMPTY.textPrefix('missing', 4)).toBeUndefined() + expect(PartialArguments.fromText(String.raw`{"command":"a\n\u4e2d😀z"}`).textPrefix('command', 4)).toBe('a\n中\ud83d') + expect(PartialArguments.fromObject({ command: 'abcdef' }).textPrefix('command', 3)).toBe('abc') + expect(PartialArguments.fromObject({ command: 'abcdef' }).stringExceeds('command', 3)).toBe(true) + expect(PartialArguments.fromObject({ command: 'abcdef' }).stringExceeds('command', 6)).toBe(false) + expect(PartialArguments.fromObject({ command: 1 }).textPrefix('command', 3)).toBeUndefined() + expect(PartialArguments.fromObject({ command: 1 }).stringExceeds('command', 3)).toBe(false) + }) + + it('tracks bounded reads through split Unicode escapes and repeated fields', () => { + const view = new PartialArguments() + const observe = () => ({ prefix: view.textPrefix('command', 1), exceeds: view.stringExceeds('command', 1) }) + expect(observe()).toEqual({ prefix: undefined, exceeds: false }) + for (const [fragment, prefix, exceeds, changed] of [ + [String.raw`{"command":"\ud83`, '', false, true], + ['d', '\ud83d', false, true], + [String.raw`\ude`, '\ud83d', false, false], + ['00', '\ud83d', true, true], + ['x"', '\ud83d', true, false], + [',"command":"', '', false, true], + [String.raw`\u4e`, '', false, false], + ['2d', '中', false, true], + ['x"', '中', true, true], + [',"command":0}', undefined, false, true], + ] as const) { + view.append(fragment) + expect(observe()).toEqual({ prefix, exceeds }) + expect(view.refresh()).toBe(changed) + expect(view.refresh()).toBe(false) + } + }) + + it('reports a has read when the field begins: at a string quote or the first character of another value', () => { + const view = new PartialArguments() + expect(view.has('x')).toBe(false) + expect(view.has('y')).toBe(false) + expect(appendAndRefresh(view, '{"a":1,"x"')).toBe(false) + expect(appendAndRefresh(view, ':')).toBe(false) + expect(appendAndRefresh(view, '"')).toBe(true) + expect(appendAndRefresh(view, 'v","y":')).toBe(false) + expect(appendAndRefresh(view, '1')).toBe(true) + expect(appendAndRefresh(view, '}')).toBe(false) + }) + + it('reports a complete read when the string closes or the value lands', () => { + const view = new PartialArguments() + view.append('{"file_path":"a') + expect(view.complete('file_path')).toBe(false) + expect(view.complete('todos')).toBe(false) + expect(appendAndRefresh(view, 'b')).toBe(false) + expect(appendAndRefresh(view, '"')).toBe(true) + expect(appendAndRefresh(view, ',"todos":[1,')).toBe(false) + expect(appendAndRefresh(view, '2]')).toBe(true) + expect(appendAndRefresh(view, '}')).toBe(false) + }) + + it('reports a value read only when the value lands, not while its nested text streams', () => { + const view = new PartialArguments() + view.append('{"todos":') + expect(view.value('todos')).toBeUndefined() + expect(appendAndRefresh(view, '[{"content":"x"')).toBe(false) + expect(appendAndRefresh(view, '}')).toBe(false) + expect(appendAndRefresh(view, ']')).toBe(true) + expect(view.value('todos')).toStrictEqual([{ content: 'x' }]) + expect(appendAndRefresh(view, '}')).toBe(false) + }) + + it('reports empty keys and new keys, not repeated keys', () => { + const view = new PartialArguments() + expect(view.keys()).toEqual([]) + expect(appendAndRefresh(view, '{"":"x"')).toBe(true) + expect(appendAndRefresh(view, ',"":"y"')).toBe(false) + expect(appendAndRefresh(view, ',"b"')).toBe(false) + expect(appendAndRefresh(view, ':1')).toBe(true) + expect(view.keys()).toEqual(['', 'b']) + }) + + it('reports a closed read when the outer object closes or indexing fails', () => { + const view = new PartialArguments() + expect(view.closed()).toBe(false) + expect(appendAndRefresh(view, '{"a":1,"b":"x"')).toBe(false) + expect(appendAndRefresh(view, '}')).toBe(true) + expect(appendAndRefresh(view, ' ')).toBe(false) + + const broken = new PartialArguments() + expect(broken.closed()).toBe(false) + expect(appendAndRefresh(broken, '{"a":')).toBe(false) + expect(appendAndRefresh(broken, ']')).toBe(true) + expect(broken.closed()).toBe(true) + expect(broken.invalid).toBe(true) + expect(appendAndRefresh(broken, '}')).toBe(false) + }) + + it('keeps an observed text change pending until refresh', () => { + const view = new PartialArguments() + view.append('{"a":"') + expect(view.text('a')).toBe('') + view.append('xy') + expect(view.text('a')).toBe('xy') + expect(view.refresh()).toBe(true) + expect(view.refresh()).toBe(false) + view.append('z') + expect(view.text('a')).toBe('xyz') + expect(view.refresh()).toBe(true) + expect(view.refresh()).toBe(false) + }) + + it('keeps changes pending while several readers inspect the same field', () => { + const view = new PartialArguments() + view.append('{"command":"ab') + const observe = () => ({ + text: view.text('command'), + prefix: view.textPrefix('command', 3), + exceeds: view.stringExceeds('command', 3), + }) + expect(observe()).toEqual({ text: 'ab', prefix: 'ab', exceeds: false }) + view.append('cd') + expect(observe()).toEqual({ text: 'abcd', prefix: 'abc', exceeds: true }) + expect(view.stringLength('command', { step: 4 })).toBe(4) + expect(view.refresh()).toBe(true) + expect(view.refresh()).toBe(false) + view.append('e') + expect(observe()).toEqual({ text: 'abcde', prefix: 'abc', exceeds: true }) + expect(view.stringLength('command', { step: 4 })).toBe(5) + expect(view.refresh()).toBe(true) + expect(view.refresh()).toBe(false) + }) + + it('keeps length observations independent by step and offset until refresh', () => { + const view = new PartialArguments() + view.append('{"content":"a') + const observe = () => [ + view.stringLength('content', { step: 4 }), + view.stringLength('content', { step: 4, offset: 2 }), + view.stringLength('content', { step: 8 }), + ] + expect(observe()).toEqual([1, 1, 1]) + for (const [fragment, length, changed] of [ + ['b', 2, false], + ['c', 3, true], + ['d', 4, false], + ['e', 5, true], + ['fgh', 8, true], + ['i', 9, true], + ] as const) { + view.append(fragment) + expect(observe()).toEqual([length, length, length]) + expect(view.refresh()).toBe(changed) + expect(view.refresh()).toBe(false) + } + }) + + it('ignores text after structural failure and retains an invalid string prefix', () => { + const view = new PartialArguments() + expect(view.has('file_path')).toBe(false) + expect(appendAndRefresh(view, '{"n":1,"b":}')).toBe(false) + expect(view.invalid).toBe(true) + expect(view.value('n')).toBe(1) + expect(appendAndRefresh(view, ',"file_path":"x"}')).toBe(false) + expect(view.has('file_path')).toBe(false) + expect(view.invalid).toBe(true) + + const midString = new PartialArguments() + midString.append('{"file_path":"ab') + expect(midString.text('file_path')).toBe('ab') + expect(appendAndRefresh(midString, String.raw`\q"}`)).toBe(false) + expect(midString.invalid).toBe(true) + expect(midString.text('file_path')).toBe('ab') + expect(midString.stringLength('file_path')).toBe(2) + expect(midString.complete('file_path')).toBe(false) + }) + + it('tracks a write call streamed in 3-character slices exactly as an independent oracle predicts', () => { + const content = [ + 'import { a } from "./a.ts"', + '', + 'export const x = `${a}\\n`\t// "quoted" and C:\\path', + 'bell \u0007 中文 😀', + '', + ].join('\n') + const text = JSON.stringify({ file_path: 'src/a.ts', content, overwrite: true }) + // JSON.stringify writes the control character as a \uXXXX escape, so the stream exercises one. + expect(text).toContain('\\u0007') + + const view = new PartialArguments() + const observe = () => { + const length = view.stringLength('content', { step: 64 }) + return { + path: view.text('file_path'), + pathDone: view.complete('file_path'), + length, + steps: length === undefined ? undefined : Math.ceil(length / 64), + overwrite: view.value('overwrite'), + } + } + let before = observe() + expect(before).toStrictEqual({ path: undefined, pathDone: false, length: undefined, steps: undefined, overwrite: undefined }) + + const chunks = slices(text, () => 3) + let reported = 0 + let previousLength = 0 + for (const chunk of chunks) { + view.append(chunk) + const changed = view.refresh() + const after = observe() + const expectChanged = after.path !== before.path || after.pathDone !== before.pathDone + || after.steps !== before.steps || after.overwrite !== before.overwrite + expect(changed).toBe(expectChanged) + if (changed) reported++ + if (after.length !== undefined) { + expect(after.pathDone).toBe(true) + expect(after.length).toBeGreaterThanOrEqual(previousLength) + previousLength = after.length + } + before = after + } + expect(reported).toBeGreaterThan(0) + expect(reported).toBeLessThan(chunks.length / 2) + expect(before).toStrictEqual({ path: 'src/a.ts', pathDone: true, length: content.length, steps: Math.ceil(content.length / 64), overwrite: true }) + expect(view.complete('content')).toBe(true) + expect(view.keys()).toEqual(['file_path', 'content', 'overwrite']) + expect(view.invalid).toBe(false) + }) + }) + + describe('invalid text', () => { + it.each<[string, string]>([ + ['x', 'a first character other than {'], + ['{1', 'a key that is not a string'], + ['{"a":1,}', 'a trailing comma before the closing brace'], + ['{"a":1, 2', 'a key that is not a string after a comma'], + [String.raw`{"a\q":1}`, 'a key with an invalid escape'], + ['{"a" 1', 'a missing colon'], + ['{"a":}', 'a value starting with }'], + ['{"a":,', 'a value starting with ,'], + ['{"a"::', 'a value starting with :'], + ['{"a":]', 'a value starting with ]'], + ['{"a":"b" x', 'text other than , or } after a value'], + ['{"file_path":"x"}}', 'a second closing brace'], + ['{"a":[}', 'mismatched nested brackets'], + ['{"a":{"b":[]]', 'mismatched nested object brackets'], + [String.raw`{"a\u12G4":1}`, 'a non-hex digit in a key escape'], + ['{"a\nb":1}', 'an unescaped control character in a key'], + ])('freezes as invalid on %j (%s) and ignores later text', (text) => { + expect(PartialArguments.fromText(text).invalid).toBe(true) + + const view = new PartialArguments() + expect(view.has('later')).toBe(false) + view.append(text) + expect(view.invalid).toBe(true) + expect(view.closed()).toBe(true) + expect(appendAndRefresh(view, ',"later":"x"}')).toBe(false) + expect(view.has('later')).toBe(false) + }) + + it.each<[string, string, string | undefined]>([ + [String.raw`{"file_path":"ab\q","later":"x"}`, 'file_path', 'ab'], + [String.raw`{"file_path":"ab\u12G4","later":"x"}`, 'file_path', 'ab'], + ['{"file_path":"ab\nc","later":"x"}', 'file_path', 'ab'], + ['{"a":tru,"later":"x"}', 'a', undefined], + ['{"a":1.,"later":"x"}', 'a', undefined], + ['{"a":1. ,"later":"x"}', 'a', undefined], + ['{"a":[tru],"later":"x"}', 'a', undefined], + [String.raw`{"a":{"b":"\q"},"later":"x"}`, 'a', undefined], + ])('indexes later keys before validating the malformed value in %j', (text, key, prefix) => { + const view = PartialArguments.fromText(text) + expect(view.keys()).toEqual([key, 'later']) + expect(view.has(key)).toBe(true) + expect(view.complete(key)).toBe(true) + expect(view.has('later')).toBe(true) + expect(view.invalid).toBe(false) + expect(view.text('later')).toBe('x') + expect(view.invalid).toBe(false) + if (prefix === undefined) { + expect(view.value(key)).toBeUndefined() + } else { + expect(view.text(key)).toBe(prefix) + expect(view.stringLength(key)).toBe(prefix.length) + } + expect(view.invalid).toBe(true) + expect(view.complete(key)).toBe(false) + expect(view.text('later')).toBe('x') + }) + + it.each<[string, string | undefined]>([ + [String.raw`{"bad":"ab\q",`, 'ab'], + ['{"bad":[tru],', undefined], + ])('keeps the outer object open after reading invalid contents in %j', (head, prefix) => { + const view = streamed([head]) + expect(view.closed()).toBe(false) + expect(view.has('later')).toBe(false) + expect(view.invalid).toBe(false) + if (prefix === undefined) expect(view.value('bad')).toBeUndefined() + else expect(view.text('bad')).toBe(prefix) + expect(view.invalid).toBe(true) + expect(view.closed()).toBe(false) + expect(view.refresh()).toBe(false) + + view.append('"later":"ok"') + expect(view.refresh()).toBe(true) + expect(view.keys()).toEqual(['bad', 'later']) + expect(view.text('later')).toBe('ok') + expect(view.invalid).toBe(true) + expect(view.closed()).toBe(false) + view.append('}') + expect(view.refresh()).toBe(true) + expect(view.closed()).toBe(true) + expect(view.refresh()).toBe(false) + }) + + it('rejects an incomplete Unicode escape when its closed string is first read', () => { + const view = PartialArguments.fromText(String.raw`{"a":"\u12"}`) + expect(view.complete('a')).toBe(true) + expect(view.invalid).toBe(false) + expect(view.text('a')).toBe('') + expect(view.invalid).toBe(true) + expect(view.complete('a')).toBe(false) + }) + + it('indexes past a nested unescaped newline and reports it when the container is read', () => { + const view = PartialArguments.fromText('{"a":{"b":"x\ny"},"later":1}') + expect(view.keys()).toEqual(['a', 'later']) + expect(view.complete('a')).toBe(true) + expect(view.invalid).toBe(false) + expect(view.value('a')).toBeUndefined() + expect(view.invalid).toBe(true) + expect(view.complete('a')).toBe(false) + expect(view.value('later')).toBe(1) + }) + + it('discovers malformed escapes from a length-only read', () => { + const view = PartialArguments.fromText(String.raw`{"content":"ab\q","later":1}`) + expect(view.complete('content')).toBe(true) + expect(view.invalid).toBe(false) + expect(view.stringLength('content')).toBe(2) + expect(view.invalid).toBe(true) + expect(view.complete('content')).toBe(false) + expect(view.value('later')).toBe(1) + }) + + it('caches a malformed container result without parsing unrelated containers', () => { + const view = PartialArguments.fromText('{"bad":[tru],"good":[1]}') + const parse = vi.spyOn(JSON, 'parse') + try { + expect(view.keys()).toEqual(['bad', 'good']) + expect(view.has('bad')).toBe(true) + expect(view.complete('bad')).toBe(true) + expect(view.complete('good')).toBe(true) + expect(view.invalid).toBe(false) + expect(parse).not.toHaveBeenCalled() + expect(view.value('bad')).toBeUndefined() + expect(parse).toHaveBeenCalledExactlyOnceWith('[tru]') + expect(view.value('bad')).toBeUndefined() + expect(view.complete('bad')).toBe(false) + expect(view.invalid).toBe(true) + expect(parse).toHaveBeenCalledTimes(1) + const good = view.value('good') + expect(good).toEqual([1]) + expect(view.value('good')).toBe(good) + expect(parse).toHaveBeenCalledTimes(2) + } finally { + parse.mockRestore() + } + }) + + it('stops at the first invalid character of a fragment and ignores the rest of it', () => { + const view = PartialArguments.fromText('{"a" 1,"file_path":"x"}') + expect(view.invalid).toBe(true) + expect(view.keys()).toEqual([]) + expect(view.has('file_path')).toBe(false) + }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b5c2234db..05aa854d44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -814,6 +814,9 @@ importers: '@deepseek-ai/dsh-subprocess-local': specifier: workspace:* version: link:../../packages/subprocess/subprocess-local + '@deepseek-ai/dsh-util-values': + specifier: workspace:* + version: link:../../packages/util/values '@deepseek-ai/dsh-workspace-changes': specifier: workspace:* version: link:../../packages/deliverables/workspace-changes @@ -880,6 +883,9 @@ importers: '@deepseek-ai/dsh-api-session-controller': specifier: workspace:* version: link:../packages/api/session-controller + '@deepseek-ai/dsh-brand': + specifier: workspace:* + version: link:../packages/util/brand '@deepseek-ai/dsh-client-store': specifier: workspace:* version: link:../packages/client/store @@ -3699,6 +3705,9 @@ importers: '@deepseek-ai/dsh-tool-present': specifier: workspace:* version: link:../../deliverables/tool-present + '@deepseek-ai/dsh-util-values': + specifier: workspace:* + version: link:../../util/values '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:* version: link:../../util/workspace-path @@ -5763,6 +5772,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session + '@deepseek-ai/dsh-util-values': + specifier: workspace:* + version: link:../../util/values '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:* version: link:../../util/workspace-path @@ -5992,6 +6004,9 @@ importers: '@deepseek-ai/dsh-user-questions': specifier: workspace:* version: link:../../interaction/user-questions + '@deepseek-ai/dsh-util-values': + specifier: workspace:* + version: link:../../util/values '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:* version: link:../../util/workspace-path @@ -6064,6 +6079,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools + '@deepseek-ai/dsh-util-values': + specifier: workspace:* + version: link:../../util/values '@tanstack/react-virtual': specifier: ^3.14.9 version: 3.14.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -8374,6 +8392,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session + '@deepseek-ai/dsh-util-values': + specifier: workspace:* + version: link:../../util/values '@types/react': specifier: ~18.3.1 version: 18.3.31 diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index d43d1be214..e1f0fab8af 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -751,7 +751,6 @@ }, "packages/client/ui-tool/tests/image-card.client.spec.tsx": { "1a5ae4c2528dae3d43a3decbe30410647b6e60104341e070fd4de2c6a668a607": 1, - "1d34efc5aadced56f018a7f1b453e5b2117ab13ab6e414a7bf32584a9b168af4": 1, "7266b2c682605ac318dad266d9371b6061a73746beac20f2fd94e9391eca5d4a": 1, "a4968fcd795d3444b0896dcd7e9688e84335d4fcc72e138c46f951d157a421c7": 1 }, @@ -784,20 +783,15 @@ "189b21b2a9360e0384602b7a81c935ca9a419f9d97d526fb1f6684343486c4fe": 1, "24334994394c1d4336d192b2f38986beadcd0f6b235108370c3a736c34eebe98": 1, "33e462c9f8d9ef55f1b54a408f047caaa9467facc4fc88887fa5979fdce6c09f": 1, - "3e385a12e8e6c592fbcc51a8e897d2ad4f21ab7a0c8ef00727e1b37b52af2c16": 1, "455072902745ebd120bdeaaebf1b00b2d747a300409066715375263bcc4e8a63": 1, "4df26af834cb4f8d55bb6908736ddeb6971b4c4c1cbd5a9ace2f9cfe9c0b94e4": 1, "6078d7e1390a0c3c94892edd74526a76e6cbbda8360656bf9f97d367371c0c4c": 1, "71b9c7a18658556ab36c9af394f29dac5885fab402304b0b6f4d5127d6acdeff": 1, - "8bd3bc4fed398e1420c3ff656c7f9847a9b5aba1cf8b653623907922004ff038": 1, "9117562f15005ef8ec18dbad9c29cdc723c4af2c6a9c217af3cd7d8380de999b": 1, "98169406d3c872458f70f3f8f70abdd6eeaca296816ce91cdcf8392cfa6a77bf": 1, "afecf8092cd8de095ffbe00a1df00816897e8205854fd33a9d2beeaee88eca95": 1, "b32e3f8c876615b2607309ee288de85817f0dd0480c4e16dfb3695fb84aa2674": 1, - "b389d62887b2c8991189e944ab6e6da49be6e907e3d85417926cd578cc212f73": 1, - "b8af8a60d1b9ee562bc77cc2f504fac357dd8b8ea37aea670d768d6a5b4ae285": 1, "bc448993c30c4cc92c43cc077a2b00d033ef01a64427c609a8d89b0ba257dfcd": 1, - "c0d6774c76d86ae3c7881f2292fc41f3fb30e74ebd41a8130a3000c0466bc2b5": 1, "e888d93b9171a1022992aecebca9c67f8b564ae541bbf0f9129eda3ea423b95c": 1, "efed1d00d0f288a0c2716c88ee57ba53ace8f627abba4bd2c20de596755b5bad": 1, "f37cbef85628f8df72f3d455868ac0715105f463a9bd858f10e2cb5f984baefb": 1 diff --git a/snapshots/web/changed-files-turn/preparing-edit-content.expected.md b/snapshots/web/changed-files-turn/preparing-edit-content.expected.md new file mode 100644 index 0000000000..8dae454fce --- /dev/null +++ b/snapshots/web/changed-files-turn/preparing-edit-content.expected.md @@ -0,0 +1,3 @@ +- text: 正在准备调用 编辑 +- button "intro.md" +- text: 1KB diff --git a/snapshots/web/changed-files-turn/preparing-edit.expected.md b/snapshots/web/changed-files-turn/preparing-edit.expected.md index c563131f17..bea3a89d65 100644 --- a/snapshots/web/changed-files-turn/preparing-edit.expected.md +++ b/snapshots/web/changed-files-turn/preparing-edit.expected.md @@ -1 +1,2 @@ -- text: 正在准备调用 编辑 正在准备内容 1KB +- text: 正在准备调用 编辑 +- button "intro.md" diff --git a/snapshots/web/changed-files-turn/preparing-write-content.expected.md b/snapshots/web/changed-files-turn/preparing-write-content.expected.md new file mode 100644 index 0000000000..c07d41cdf5 --- /dev/null +++ b/snapshots/web/changed-files-turn/preparing-write-content.expected.md @@ -0,0 +1,3 @@ +- text: 正在准备调用 写入 +- button "src/util.ts" +- text: 1KB diff --git a/snapshots/web/changed-files-turn/preparing-write.expected.md b/snapshots/web/changed-files-turn/preparing-write.expected.md index b639c72c3f..e9b73bc233 100644 --- a/snapshots/web/changed-files-turn/preparing-write.expected.md +++ b/snapshots/web/changed-files-turn/preparing-write.expected.md @@ -1 +1,2 @@ -- text: 正在准备调用 写入 正在准备内容 1KB +- text: 正在准备调用 写入 +- button "src/util.ts" diff --git a/snapshots/web/changed-files-turn/ui.expected.md b/snapshots/web/changed-files-turn/ui.expected.md index 0c2bcb603b..b60d855316 100644 --- a/snapshots/web/changed-files-turn/ui.expected.md +++ b/snapshots/web/changed-files-turn/ui.expected.md @@ -9,18 +9,18 @@ - button "intro.md" - button "运行命令 List files and show notes.txt" - button "思考 Now do all four actions. Edit intro.md, write src/util.ts, write app.local, bash append." -- button "编辑 intro.md +1 -1": +- button "编辑 intro.md 1KB +1 -1": - text: 编辑 - button "intro.md" - - text: +1 -1 -- button "写入 src/util.ts +3 -0": + - text: 1KB +1 -1 +- button "写入 src/util.ts 1KB +3 -0": - text: 写入 - button "src/util.ts" - - text: +3 -0 -- button "写入 app.local +1 -0": + - text: 1KB +3 -0 +- button "写入 app.local 1KB +1 -0": - text: 写入 - button "app.local" - - text: +1 -0 + - text: 1KB +1 -0 - button "运行命令 Append done to notes.txt" - 'button "思考 All four done. Present the files? The user asked to create files. I should call present for the deliverables. Files: intro.md, src/util.ts, app.local, notes.txt. All are outputs. Let me present them."' - button "交付文件 已交付 intro.md, src/util.ts, app.local, notes.txt" diff --git a/snapshots/web/diff-bounded/ui.expected.md b/snapshots/web/diff-bounded/ui.expected.md index 09cf4e6d4d..bd86d0e4fe 100644 --- a/snapshots/web/diff-bounded/ui.expected.md +++ b/snapshots/web/diff-bounded/ui.expected.md @@ -1,7 +1,7 @@ -- button "Edit large.txt +130 -130" [expanded]: +- button "Edit large.txt 4KB +130 -130" [expanded]: - text: Edit - button "large.txt" - - text: +130 -130 + - text: 4KB +130 -130 - text: Code block - button "Wrap lines" - button "Copy" diff --git a/snapshots/web/diff-context/ui.expected.md b/snapshots/web/diff-context/ui.expected.md index 72bb5f0d61..28f8fcb675 100644 --- a/snapshots/web/diff-context/ui.expected.md +++ b/snapshots/web/diff-context/ui.expected.md @@ -1,7 +1,7 @@ -- button "Edit config.txt +1 -1" [expanded]: +- button "Edit config.txt 1KB +1 -1" [expanded]: - text: Edit - button "config.txt" - - text: +1 -1 + - text: 1KB +1 -1 - text: Code block - button "Wrap lines" - button "Copy" diff --git a/snapshots/web/present-svg/ui.expected.md b/snapshots/web/present-svg/ui.expected.md index 304d41f506..65674a871b 100644 --- a/snapshots/web/present-svg/ui.expected.md +++ b/snapshots/web/present-svg/ui.expected.md @@ -6,10 +6,10 @@ - button "思考 The user wants a simple SVG diagram of the von Neumann architecture, saved as von-neumann.svg. Let me create a clean SVG showing CPU (control unit, ALU), memory, input, and output with bidirectional data/control buses." - paragraph: 我来创建一个冯诺依曼架构的 SVG 示意图。 - button "已写入文件并调用工具" [expanded] -- button "写入 von-neumann.svg +83 -0": +- button "写入 von-neumann.svg 6KB +83 -0": - text: 写入 - button "von-neumann.svg" - - text: +83 -0 + - text: 6KB +83 -0 - button "思考 The file is created. Let me present it as a deliverable." - button "交付文件 已交付 von-neumann.svg" - paragraph: