perf(web): finish incremental tool argument optimizations

This commit is contained in:
imccyu authored and GitHub committed 2026-10-01 00:19:52 +08:00
1 parent a33bd1a80b
commit 91818992b0
41 files changed
+2065 -634

No files matched your search

@@ -12,20 +12,20 @@
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: ef2471c7851f24ba
zh: f4c656241ebae8c5
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: 63b022a5ec7ec17e
zh: 82b38d3909585672
en: 518206f94d82993d
zh: c6cab3b2f71dd18b
/agent-note-incremental-tool-argument-scanning-and-argument-order-during-preparation/decision/argument-declaration-order:
en: a092b8af61631d7c
zh: 32ff2293afc92d9a
en: 0fd7c9f0b8cf6a28
zh: 7d410403283b9996
/agent-note-incremental-tool-argument-scanning-and-argument-order-during-preparation/alternatives-considered:
en: 30a65af3e8632fc0
zh: 46ee0da17f357c47
en: 7a033f81c557c58f
zh: 2ec66b0db6cf5d9e
/agent-note-incremental-tool-argument-scanning-and-argument-order-during-preparation/verification:
en: 5b9437432e22be6f
zh: 5321d498fd3aa493
en: c1e09085a137be1c
zh: ec79806f84f59dec
/agent-note-incremental-tool-argument-scanning-and-argument-order-during-preparation/consequences:
en: d74f1c2348788c41
zh: 5aea9a6f2c2f1ffe
en: f1c44f9d58d36920
zh: bb297337882ec407
@@ -23,25 +23,33 @@ In these samples, reordering `required` changes argument order where reordering
### A lazily computed argument view lives in `dsh-util-values`
`PartialArguments` is a lazily computed class that knows no tool. It holds the raw text (the accumulated deltas while preparing; the event's string after `tool/call`; the event's object for a PTC child) and a level-one index, and any reader (`has`, `complete`, `stringLength`, `text`, `value`, `keys`) resumes scanning from the last consumed position only when called; with no reader, not one character is scanned. Text is decoded on demand from the raw slice, so no specification of "which fields keep text" and no registry is needed.
`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.
Scanning indexes only unread fragments. Indexing the cumulative source after every append would repeatedly flatten V8 concatenation strings even with a forward cursor; the cumulative source is retained for field slices, not for the scan loop.
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.
The view judges change itself: it remembers every question it answered, and `append(delta)` recomputes only those, returning true only when an answer differs; with nothing read yet it is always false. The business states its granularity as it reads: `stringLength('content', { step: 1024 })` makes only a kilobyte crossing count, `text('description')` makes every character count. An `offset` includes completed strings when edit progress combines old and new text.
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)`, and the root reference is replaced only when the view reports a change. Publication keeps the `animation-frame` cadence. 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.
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. Dispatched roots use `PartialArguments.fromText(argsRaw)`, PTC children use `fromObject(payload)` without stringifying, and results reuse the started view. An unpaired root result has an empty name and shared empty view. Card models continue to read `argsRaw`.
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` without an instruction. `file_path` is already first for write/edit/read. Other tools retain their argument 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
@@ -51,7 +59,7 @@ Third-party tools receive the same view without registration or a separate subsc
**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.
**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.
@@ -60,20 +68,21 @@ Third-party tools receive the same view without registration or a separate subsc
## 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 a 161-cluster traversal limit without changing whitespace or Unicode handling.
- 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, and Chat groups 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. On Linux x64, AMD EPYC 7763, Node 24.18.0:
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.
| Workload | Optimized samples (ms) | Reintroduced cumulative indexing and full segmentation (ms) | Time / retained-heap budget |
|---|---|---|---|
| 512 KiB write content | 295.3, 282.9, 277.7 | 4035.5, 4035.5, 3984.9 | 750 ms / 37.5 MiB |
| 128 KiB command before description | 144.0, 141.9, 145.3 | 1494.9, 1486.0, 1471.2 | 375 ms / 10 MiB |
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.
Reference expectations are 300/150 ms and 30/8 MiB. The shared 2× CI time scale and 1.25× headroom apply to time; only headroom applies to heap. Both negative controls exceed their time budget. These are local Node measurements, not CI-runner, model, network, or browser-paint latency; whitespace normalization still scans the whole field.
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.
- Model ordering is probabilistic: the reorder and the description sentence raise description-first to about 100% but are not a contract; the row logic shows whichever field closes first, so a reversed order only loses the benefit.
- 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.
@@ -23,25 +23,33 @@ Status: implemented
### 懒计算的参数视图住在 `dsh-util-values`
`PartialArguments` 是一个不认识任何工具的懒计算类。它持有原始文本(preparing 期是累积的 delta;`tool/call` 后直接包着事件里的字符串;PTC 子调用包着事件里的对象)和一份一级索引,任何读器(`has`、`complete`、`stringLength`、`text`、`value`、`keys`)被调用时才从上次消费的位置继续扫到末尾;没人读就一个字符都不扫。原文按需从原始文本切出解码,所以不需要"哪些字段保留文本"的规格,也不需要注册表。
`PartialArguments` 是一个与工具无关的懒读器。它分别保留参数分片和顶层键值范围索引,或借用已解析的 PTC 参数。读取只索引新收到的边界;未请求的值不解码、不计数、不解析。请求字段时仅从该字段范围生成值,不需要每工具字段规格或注册表。
扫描只索引尚未读取的片段。即使游标单向前进,每次追加后索引累计原文仍会反复扁平化 V8 的拼接字符串;累计原文保留用于字段取片,不用于扫描循环。
边界扫描器跨分片跟踪引号、反斜杠奇偶和嵌套括号配对。键名为查找而解码,值内容延后处理。`complete()` 表示结束分隔符已到达,不代表内容已校验;`invalid` 报告索引或内容读取已经发现的错误。内容错误不阻止后续字段建立索引。正式工具入参校验仍位于此展示读器之外。
变化由视图自己判定:它记住每个被问过的问题及其答案,`append(delta)` 只重算这些问题,某个答案变了才返回 true;没人读过就永远 false。业务在读的时候说明粒度——`stringLength('content', { step: 1024 })` 让跨 KB 才算变化,`text('description')` 让每个字符都算变化。edit 进度合计新旧文本时,`offset` 计入已完成的字符串。
被请求的无转义字符串长度直接由偏移量计算。转义字符串仅为已请求的长度、文本或有界前缀维护内容游标;上限查询在答案确定后停止。完整字符串首次被请求全文时使用原生解码。支持任意晚到的读取,需要保留原分片,直到权威完整文本替换它们。
变化由视图自己判定:`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`。参数在名字之前到达的流不扫描:迟开的视图看到非对象前缀即 invalid、零字段。
[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` 仍可使用。已派发根调用使用 `PartialArguments.fromText(argsRaw)`,PTC 子调用使用 `fromObject(payload)` 而不 stringify,结果复用已派发视图。未配对的根结果使用空名称和共享空视图。卡片模型继续读取 `argsRaw`。
三个阶段的块都提供 `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` 前,不加指令。write/edit/read 的 `file_path` 已在首位。其他工具保留原有参数顺序。
`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`;其他工具保留原有参数顺序。
## 考虑过的替代方案
@@ -51,7 +59,7 @@ Status: implemented
**第三方 `partial-json`。** 每帧重解析累计输入,且不独立于解码值暴露字符串完成状态。
**提前解析所有字段。** `content` 等大字段通常只需要长度;在消费方请求前构造全文,会增加解码和内存成本。
**提前解析所有字段。** `content` 等大字段通常只需要长度;在消费方请求前构造全文,会增加解码和内存成本。观察整个键列表还会在无关参数出现时重新发布块,因此详情保持逐字段观察。
**从规范化字段切出截断详情。** V8 的子字符串可能保留整个字段。拼接有界数量的字素簇,使展示前缀不再依赖该原文。
@@ -60,20 +68,21 @@ Status: implemented
## 验证
- 解析器测试覆盖懒扫描、已读取答案变化、空键与重复键、跨片转义、非字符串值、非法输入、封存视图,以及不受读取历史影响的结构相等。
- 工具行和分组测试覆盖流式路径与描述、内容长度、共用行身份、参数不再增长后才回退工具名,以及保留空白和 Unicode 处理语义的 161 字素簇遍历上限。
- 工具行和分组测试覆盖流式路径与描述、内容长度、共用行身份、参数不再增长后才回退工具名,以及不改变空白或 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 分组,以 16 字符分片输入,每 64 片 flush。三个独立的已编译 Node worker 报告全部样本及中位数;准备工作和显式 GC 不计时,保留 Assembler 与参数视图后测量驻留堆。Linux x64、AMD EPYC 7763、Node 24.18.0 上的结果:
必跑的 [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。
| 负载 | 优化后样本(ms) | 恢复累计索引与全文分段(ms) | 时间 / 驻留堆预算 |
|---|---|---|---|
| 512 KiB write 内容 | 295.3, 282.9, 277.7 | 4035.5, 4035.5, 3984.9 | 750 ms / 37.5 MiB |
| description 前的 128 KiB command | 144.0, 141.9, 145.3 | 1494.9, 1486.0, 1471.2 | 375 ms / 10 MiB |
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 延迟。
参考预期为 300/150 ms 和 30/8 MiB。时间使用共享的 2× CI 倍率和 1.25× 余量;堆预算只使用余量。两条负控均超出时间预算。这些是本地 Node 测量,不代表 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,均超过未调整的预算。
## 影响
- 已读取参数变化时替换准备态块,无人读取的调用保留块引用。视图保留源文本,分组标题可以独立于工具行请求字段。已派发和已结算视图在首次读取时扫描,不发布更新。
- 模型顺序是概率行为:调序与描述指令把 description 先到的比例提到约 100%,但不是契约;行的显示逻辑按「哪个字段先闭合先显示哪个」设计,顺序反转时只是收益消失。
- schema 顺序和描述指令引导模型先生成展示标签,但不保证生成顺序。工具行在字段可用时显示它;路径晚到会推迟路径及其后的内容进度展示。
- 展示仍由 Client 派生;参数扫描不新增 Session 事件或持久化格式。
- 名字晚于参数到达的流放弃扫描,该调用的准备行退回只显示标题。
@@ -96,6 +96,10 @@ describe('preparing tool arguments', () => {
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)
}
@@ -51,6 +51,10 @@ export interface PreparingToolWorkerReport {
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
}
@@ -217,9 +221,25 @@ function preparingTool(tool: 'write' | 'bash', characters: number): PreparingToo
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 (node === undefined) throw new Error('preparing benchmark did not create its Tool node')
if (snapshot === undefined || node === undefined) throw new Error('preparing benchmark did not create its Tool node')
const args = node.data.root.args
if (tool === 'write') args.stringLength('content', { step: 1024 })
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()
@@ -229,13 +249,15 @@ function preparingTool(tool: 'write' | 'bash', characters: number): PreparingToo
}
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,
elapsedMs, retainedMb, maxRssMb: process.resourceUsage().maxRSS / 1024,
rowReads, progressKb, filePath,
detail: group === undefined ? undefined : groups?.groupSource(group.key).getSnapshot()?.data.summary.runningDetail,
}
}
@@ -54,5 +54,5 @@
en: e7796e7ba825c3d6
zh: c377d9eab0a8869f
/chat-conversation-node-rules/activity-summaries/live-activity-and-detail:
en: e37ddd41501b4b77
zh: 2985cc2d8ae4354e
en: e0ca09c305690be7
zh: fb35d0238b1a827b
@@ -278,6 +278,10 @@ Exact-name rules also mean that a recorded name such as `functions.read`, `mcp.r
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.
Live selection uses call start `time`, not the latest output/progress time. A call is a running candidate while its projected tool value has no result; pending approval or waiting for a result does not receive a separate category.
@@ -311,4 +315,4 @@ The priority list is shared by every tool, not specialized per category. For exa
| Any tool with `{"description":" ","command":42,"path":"src/app.ts"}` | `src/app.ts`; whitespace-only strings and numbers are skipped. |
| 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. Truncation reads at most 161 clusters; whitespace normalization still covers the full field. 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.
@@ -278,6 +278,10 @@ Subagent 工具遵循普通分组边界,不会仅因委派任务就单独成
准备中的调用使用首个具名 delta 的时间,已派发调用使用 tool/call 的时间。两者都从块的懒计算 `args` 视图读取详情。
仅 Tool Definition 保留流式参数分片。Assistant 块保留工具身份和时间,不累加参数 delta;完整文本由 `block-end` 和持久消息提供。Tool 读器将分片直接与 `block-end` 和 `tool/call` 的参数比较,仅在完全一致时保留字段索引。最终文本替换缺失或冲突的分片。
TurnProcess 和 TurnTail 保留工具参数 Match,作为 Turn 起始事件在已加载窗口之外时的 Location 证据,即使这些片段不改变其 State。
运行中的调用以最大 `time` 决定实时类别和详情;时间相同时取后遍历的调用。没有运行调用时,类别为空,详情取成员顺序中最近一个含有非空推理段落的运行中 Assistant 的最后一个非空段落。推理详情移除 `**` 标记,不要求首行以换行结束;单个推理行的预览另有规则。
实时选择依据调用开始的 `time`,不是最近一次输出或进度的时间。投影后的工具值尚无结果时就参与运行中候选;等待批准或等待结果不会产生另一个类别。
@@ -311,4 +315,4 @@ Subagent 工具遵循普通分组边界,不会仅因委派任务就单独成
| 任意工具,`{"description":" ","command":42,"path":"src/app.ts"}` | `src/app.ts`,跳过纯空白字符串和数值。 |
| 暂无可用字段 | 参数仍可增长时留空,否则使用工具名。 |
所有实时详情都合并空白、去除首尾空白,并限制为 160 个字素簇;截断时末尾的 `…` 计入上限。截断最多读取 161 个字素簇;空白规范化仍处理整个字段。已结束组发布的摘要清空实时类别和详情,保留类别次数。
所有实时详情都合并空白、去除首尾空白,并限制为 160 个字素簇;截断时末尾的 `…` 计入上限。字符串参数起初最多提供 512 个已解码 UTF-16 单元;只有空白或包含多个单元的字素簇使文本不足以判定截断时,规范化才扩大该前缀。每次截断遍历最多读取 161 个字素簇。已结束组发布的摘要清空实时类别和详情,保留类别次数。
@@ -92,6 +92,12 @@ function updateChunk(
seq: number,
time: number,
): AssistantState {
if (chunk.type === 'tool-call-delta') {
const previous = state.blocks[chunk.index]
if (previous?.kind === 'tool-call' && previous.callId !== ''
&& (chunk.name === undefined || chunk.name === previous.name)
&& (state.firstTokenTime !== undefined || !isTokenDelta(chunk))) return state
}
const blocks = [...state.blocks]
let changedIndex = -1
let previousVisible = false
@@ -119,14 +125,12 @@ function updateChunk(
const previous = blocks[chunk.index]
changedIndex = chunk.index
previousVisible = blockIsVisible(previous)
const base = previous?.kind === 'tool-call'
? previous
: { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' }
// Tool Nodes own streamed arguments; Assistant protocol blocks retain identity until full settlement.
blocks[chunk.index] = {
kind: 'tool-call',
callId: base.callId || String(chunk.id),
name: chunk.name ?? base.name,
argsRaw: base.argsRaw + chunk.argumentsDelta,
callId: (previous?.kind === 'tool-call' ? previous.callId : '') || String(chunk.id),
name: chunk.name ?? (previous?.kind === 'tool-call' ? previous.name : ''),
argsRaw: previous?.kind === 'tool-call' ? previous.argsRaw : '',
}
break
}
@@ -356,5 +360,14 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
* @param ctx - owning UI Conversation context.
*/
export function registerAssistantConversationNode(ctx: Context): void {
ctx.uiConversation.events.register(assistantDefinition)
const match = assistantDefinition.match.bind(assistantDefinition)
ctx.uiConversation.events.register({
...assistantDefinition,
match: {
'step/start': match,
'assistant/live-chunk': match,
'assistant/message': match,
'llm/retry': match,
},
})
}
@@ -1,101 +0,0 @@
import type { StreamChunk } from '@deepseek-ai/dsh-llm/types'
import type {
AssistantBlock, PartialAssistant,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { emptyAssistantBlock, toAssistantBlock } from './event-projection.ts'
/**
* Whether a stream chunk changes the partial assistant projection shown by the UI.
* @param type - Stream chunk discriminant.
* @returns Whether publishing the accumulated partial can change the visible snapshot.
*/
export function isVisibleAssistantChunk(type: string): boolean {
return type === 'block-start'
|| type === 'text-delta'
|| type === 'reasoning-delta'
|| type === 'tool-call-delta'
|| type === 'block-end'
}
/** Live Assistant-frame accumulator: folds StreamChunks into AssistantBlock[] with block-level immutability. */
export class PartialAccumulator {
// Sparse on purpose: block-start may arrive out of order, leaving holes until compaction.
private blocks: (AssistantBlock | undefined)[] = []
private changed = true
private snapshot: PartialAssistant
/**
* @param turn - Owning agent turn.
* @param step - Owning model step.
* @param initialBlocks - Materialized prefix when accumulation begins after history replay.
*/
constructor(
readonly turn: number,
readonly step: number,
initialBlocks: readonly AssistantBlock[] = [],
) {
this.blocks = [...initialBlocks]
this.snapshot = { turn, step, blocks: initialBlocks }
}
/**
* Fold one chunk.
* @param chunk - the stream chunk.
* @returns whether it caused a visible change (usage/finish return false, skipping notification).
*/
push(chunk: StreamChunk): boolean {
switch (chunk.type) {
case 'block-start': {
this.blocks[chunk.index] = emptyAssistantBlock(chunk.blockType)
this.changed = true
return true
}
case 'text-delta': {
const prev = this.blocks[chunk.index]
this.blocks[chunk.index] = { kind: 'text', text: (prev?.kind === 'text' ? prev.text : '') + chunk.text }
this.changed = true
return true
}
case 'reasoning-delta': {
const prev = this.blocks[chunk.index]
this.blocks[chunk.index] = { kind: 'reasoning', text: (prev?.kind === 'reasoning' ? prev.text : '') + chunk.text }
this.changed = true
return true
}
case 'tool-call-delta': {
const prev = this.blocks[chunk.index]
const base = prev?.kind === 'tool-call' ? prev : { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' }
this.blocks[chunk.index] = {
kind: 'tool-call',
callId: base.callId || String(chunk.id),
name: chunk.name ?? base.name,
argsRaw: base.argsRaw + chunk.argumentsDelta,
}
this.changed = true
return true
}
case 'block-end': {
this.blocks[chunk.index] = toAssistantBlock(chunk.block)
this.changed = true
return true
}
default:
// usage / finish / merge-extensible unknown variants: no visible block change
// (finish is immediately followed by the assistant/message that supersedes the partial).
return false
}
}
/**
* Current partial projection.
* @returns the cached snapshot (the blocks array reference only changes after a mutation).
*/
toPartial(): PartialAssistant {
if (this.changed) {
// Compact sparse indexes (out-of-order block-start) into render order.
this.snapshot = { turn: this.turn, step: this.step, blocks: this.blocks.filter((b): b is AssistantBlock => b !== undefined) }
this.changed = false
}
return this.snapshot
}
}
@@ -22,27 +22,52 @@ 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: string[] = []
for (const { segment } of LIVE_TOOL_DETAIL_SEGMENTER.segment(normalized)) {
if (chars.length === LIVE_TOOL_DETAIL_MAX_CHARS) {
return `${chars.slice(0, LIVE_TOOL_DETAIL_MAX_CHARS - 1).join('').trimEnd()}…`
}
chars.push(segment)
return normalizeLiveToolText(text).text
}
function argumentTextDetail(args: ToolArgs, key: string): string | undefined {
let limit = LIVE_TOOL_DETAIL_PREFIX_CHARS
while (true) {
const prefix = args.textPrefix(key, limit)
if (prefix === undefined) return undefined
const detail = normalizeLiveToolText(prefix)
if (detail.truncated || !args.stringExceeds(key, limit)) return detail.text
limit *= 2
}
return normalized
}
function questionDetail(value: unknown): string {
@@ -81,7 +106,9 @@ function liveReasoningDetail(nodes: readonly ChatNode[]): string {
function liveToolDetail(name: string, args: ToolArgs): string {
for (const key of LIVE_TOOL_DETAIL_KEYS) {
if (!args.has(key)) continue
const detail = key === 'questions' ? questionDetail(args.value(key)) : normalizeLiveToolDetail(args.text(key) ?? args.value(key))
const detail = key === 'questions'
? questionDetail(args.value(key))
: argumentTextDetail(args, key) ?? normalizeLiveToolDetail(args.value(key))
if (detail !== '') return detail
}
return args.closed() ? normalizeLiveToolDetail(name) : ''
@@ -1,6 +1,6 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, StartedToolCall,
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, PreparingToolCall, StartedToolCall,
ToolCallBlock, ToolResultNode,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
@@ -38,15 +38,16 @@ function jsonArguments(value: unknown): string {
return JSON.stringify(value)
}
function rootCall(match: ConversationMatch): StartedToolCall {
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,
// Viewed lazily: the text is scanned only when a row reads a field.
args: PartialArguments.fromText(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,
@@ -55,16 +56,19 @@ function rootCall(match: ConversationMatch): StartedToolCall {
}
/**
* Fold one streamed argument delta. The named delta creates the preparing root
* with a streaming argument view; every delta feeds that view, and the root is
* replaced only when the view reports that a field somebody read has changed.
* 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
if (chunk.type !== 'tool-call-delta') return state
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
@@ -82,7 +86,15 @@ function applyDelta(state: ToolState, match: ConversationMatch): ToolState {
}
}
if ('kind' in root || root.phase !== 'preparing') return state
return root.args.append(chunk.argumentsDelta) ? { ...state, root: { ...root } } : 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 {
@@ -282,6 +294,9 @@ export const toolDefinition: ConversationNodeDefinition<ToolState> = {
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
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' }
@@ -302,7 +317,7 @@ export const toolDefinition: ConversationNodeDefinition<ToolState> = {
},
update: (context, 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) }
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 = root !== undefined && !('kind' in root) && root.phase === 'start' ? root : undefined
@@ -319,7 +334,10 @@ export const toolDefinition: ConversationNodeDefinition<ToolState> = {
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'
@@ -337,5 +355,15 @@ export const toolDefinition: ConversationNodeDefinition<ToolState> = {
* @param ctx - owning UI Conversation context.
*/
export function registerToolConversationNode(ctx: Context): void {
ctx.uiConversation.events.register(toolDefinition)
const match = toolDefinition.match.bind(toolDefinition)
ctx.uiConversation.events.register({
...toolDefinition,
match: {
'assistant/live-chunk': match,
'tool/call': match,
'tool/result': match,
'tool/ptc-dispatch-start': match,
'tool/ptc-dispatch': match,
},
})
}
@@ -305,5 +305,19 @@ export const turnProcessDefinition: ConversationNodeDefinition<TurnProcessState>
* @param ctx - owning UI Conversation context.
*/
export function registerTurnProcess(ctx: Context): void {
ctx.uiConversation.events.register(turnProcessDefinition)
const match = turnProcessDefinition.match.bind(turnProcessDefinition)
ctx.uiConversation.events.register({
...turnProcessDefinition,
match: {
'turn/start': match,
'assistant/live-chunk': match,
'assistant/message': match,
'tool/call': match,
'tool/result': match,
'llm/retry': match,
'step/start': match,
'step/end': match,
'turn/end': match,
},
})
}
@@ -144,5 +144,21 @@ export const turnTailDefinition: ConversationNodeDefinition<TurnTailState> = {
* @param ctx - owning UI Conversation context.
*/
export function registerTurnTailConversationNode(ctx: Context): void {
ctx.uiConversation.events.register(turnTailDefinition)
const match = turnTailDefinition.match.bind(turnTailDefinition)
ctx.uiConversation.events.register({
...turnTailDefinition,
match: {
'turn/start': match,
'turn/end': match,
'tool/call': match,
'tool/result': match,
'assistant/message': match,
'assistant/attempt': match,
'assistant/live-chunk': match,
'step/start': match,
'step/end': match,
'llm/retry': match,
'llm/retry-started': match,
},
})
}
@@ -1276,7 +1276,7 @@ describe('built-in conversation node Definitions', () => {
blocks: [
{ kind: 'text', text: ' \tanswer' },
{ kind: 'reasoning', text: 'thinking' },
{ kind: 'tool-call', callId: 'call-1', name: '', argsRaw: '{"x":1}' },
{ kind: 'tool-call', callId: 'call-1', name: '', argsRaw: '' },
],
})
expect(snapshot(packed).legacy.partial).toBeNull()
@@ -1,97 +0,0 @@
/**
* PartialAccumulator: six-variant chunk folding, sparse-index compaction, and
* the block/snapshot reference discipline (a delta swaps only that block).
*/
import { describe, expect, it } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-api-remotes/client'
import { PartialAccumulator } from '../src/client/conversation-nodes/partial.ts'
const chunk = (c: Record<string, unknown>): StreamChunk => c as StreamChunk
describe('PartialAccumulator', () => {
it('builds empty blocks per block-start type, unknown type falls to other', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'block-start', index: 0, blockType: 'text' }))
acc.push(chunk({ type: 'block-start', index: 1, blockType: 'reasoning' }))
acc.push(chunk({ type: 'block-start', index: 2, blockType: 'tool-call' }))
acc.push(chunk({ type: 'block-start', index: 3, blockType: 'no-such' }))
expect(acc.toPartial().blocks).toEqual([
{ kind: 'text', text: '' },
{ kind: 'reasoning', text: '' },
{ kind: 'tool-call', callId: '', name: '', argsRaw: '' },
{ kind: 'other', block: null },
])
})
it('accumulates text deltas, starting from empty when prev is missing or another kind', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'text-delta', index: 0, text: '无 start ' })) // prev missing
acc.push(chunk({ type: 'text-delta', index: 0, text: '也累积' }))
expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '无 start 也累积' }])
acc.push(chunk({ type: 'reasoning-delta', index: 0, text: '换型重起' })) // prev is text → restart
expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '换型重起' }])
})
it('accumulates reasoning deltas on the reasoning lane', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'block-start', index: 0, blockType: 'reasoning' }))
acc.push(chunk({ type: 'reasoning-delta', index: 0, text: '思' }))
acc.push(chunk({ type: 'reasoning-delta', index: 0, text: '考' }))
expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '思考' }])
})
it('continues from a materialized history prefix', () => {
const acc = new PartialAccumulator(1, 0, [{ kind: 'text', text: '已有' }])
acc.push(chunk({ type: 'text-delta', index: 0, text: '增量' }))
expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '已有增量' }])
})
it('folds tool-call deltas: first id pins callId, late name overrides, argsRaw concatenates', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c1', argumentsDelta: '{"a"' }))
acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c2-late', name: 'echo', argumentsDelta: ':1}' }))
expect(acc.toPartial().blocks).toEqual([
{ kind: 'tool-call', callId: 'c1', name: 'echo', argsRaw: '{"a":1}' },
])
})
it('replaces the accumulated block wholesale on block-end', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'text-delta', index: 0, text: '中间态' }))
acc.push(chunk({ type: 'block-end', index: 0, block: { type: 'text', text: '定稿全文' } }))
expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '定稿全文' }])
})
it('returns false (no notification) for usage/finish/unknown variants and keeps blocks', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'text-delta', index: 0, text: 'x' }))
const before = acc.toPartial()
expect(acc.push(chunk({ type: 'usage', usage: {} }))).toBe(false)
expect(acc.push(chunk({ type: 'finish', reason: 'stop' }))).toBe(false)
expect(acc.push(chunk({ type: 'future-variant' }))).toBe(false)
expect(acc.toPartial()).toBe(before) // unchanged: same snapshot reference
})
it('compacts sparse indexes into a dense render-order array', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'block-start', index: 2, blockType: 'text' }))
acc.push(chunk({ type: 'text-delta', index: 2, text: '先到的高位' }))
acc.push(chunk({ type: 'block-start', index: 0, blockType: 'reasoning' }))
const { blocks } = acc.toPartial()
expect(blocks).toHaveLength(2) // no undefined holes
expect(blocks[0]).toEqual({ kind: 'reasoning', text: '' })
expect(blocks[1]).toEqual({ kind: 'text', text: '先到的高位' })
})
it('keeps the snapshot reference stable without changes and swaps it once per mutation', () => {
const acc = new PartialAccumulator(3, 1)
const first = acc.toPartial()
expect(first).toMatchObject({ turn: 3, step: 1, blocks: [] })
expect(acc.toPartial()).toBe(first)
acc.push(chunk({ type: 'text-delta', index: 0, text: 'a' }))
const second = acc.toPartial()
expect(second).not.toBe(first)
expect(acc.toPartial()).toBe(second)
})
})
@@ -122,8 +122,13 @@ describe('Definition-owned Chat process groups', () => {
try {
expect(processActivity([node]).runningDetail).toBe(`${'x'.repeat(159)}…`)
expect(visited).toBeLessThanOrEqual(161)
expect(args.append('","description":"Run focused tests"}')).toBe(true)
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()
}
@@ -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,69 @@ function harness(entries: readonly SessionEventLikeEntry[] = opening, hasMore =
}
describe('Tool preparation and durable replay', () => {
it('keeps Assistant state unchanged for parameter-only deltas while the Tool reader advances', () => {
const update = vi.fn<typeof assistantDefinition.update>((...values) => assistantDefinition.update(...values))
const h = harness(opening, false, toolDefinition, { ...assistantDefinition, update })
h.assembler.append(delta(2.1, first, '{"file_path":"hello.txt","content":"he'))
const before: unknown = update.mock.results.at(-1)!.value
const view = h.tools()[0]!.data.root.args
expect(view.text('content')).toBe('he')
h.assembler.append(delta(2.2, first, 'llo"}', null))
expect(update.mock.results.at(-1)!.value).toBe(before)
expect(view.text('content')).toBe('hello')
expect(before).toMatchObject({ blocks: [{ kind: 'tool-call', name: 'write', argsRaw: '' }], firstTokenTime: 2.1 })
h.assembler.append(blockEnd(2.3, args))
expect(update.mock.results.at(-1)!.value).toMatchObject({ blocks: [{ argsRaw: args }], firstTokenTime: 2.1 })
})
it('accepts a complete tool block before tool/call when no deltas were observed', () => {
const h = harness()
h.assembler.append(blockEnd(2.1, args))
expect(() => h.assembler.append(call(6))).not.toThrow()
expect(h.phases()).toEqual([[first, 'start']])
expect(h.tools()[0]!.data.root.args.text('content')).toBe('hello')
})
it('retains the argument reader across matching block-end, call, and result', () => {
const h = harness()
h.assembler.append(delta(2.1, first, '{"file_path":"hello.txt","content":"he'))
const before = h.tools()[0]!
const view = before.data.root.args
expect(view.text('content')).toBe('he')
expect(view.complete('content')).toBe(false)
h.assembler.append(delta(2.2, first, 'llo"}', null))
h.assembler.append(blockEnd(2.3, args))
const ended = h.tools()[0]!
expect(ended).not.toBe(before)
expect(ended.data.root.args).toBe(view)
expect(view.text('content')).toBe('hello')
expect(view.complete('content')).toBe(true)
h.assembler.append(call(6))
expect(h.tools()[0]!.data.root.args).toBe(view)
h.assembler.append(result(7))
expect(h.tools()[0]!.data.root.args).toBe(view)
})
it.each([
'{"file_path":"other.txt","content":"hello"}',
'{"file_path":"hello.txt","content":"he',
args + args,
])('uses authoritative block-end text after conflicting deltas: %s', (streamed) => {
const h = harness()
h.assembler.append(delta(2.1, first, streamed))
const before = h.tools()[0]!.data.root.args
before.text('file_path')
h.assembler.append(blockEnd(2.2, args))
const ended = h.tools()[0]!.data.root.args
expect(ended).not.toBe(before)
expect(ended.text('file_path')).toBe('hello.txt')
expect(ended.text('content')).toBe('hello')
const dispatched = '{"file_path":"final.txt","content":"done"}'
h.assembler.append(call(6, first, 'write', dispatched))
expect(h.tools()[0]!.data.root.args.text('file_path')).toBe('final.txt')
expect(h.tools()[0]!.data.root).toMatchObject({ argsRaw: dispatched })
})
it('batches repeated named deltas and retains the unchanged Tool node', () => {
const start = vi.fn<typeof toolDefinition.start>((...args) => toolDefinition.start(...args))
const h = harness(opening, false, { ...toolDefinition, start })
@@ -104,6 +181,37 @@ describe('Tool preparation and durable replay', () => {
expect(h.phases()).toEqual([[first, 'result']])
})
it('publishes a reread argument change and retains the new node through unchanged details', () => {
const h = harness()
h.assembler.append(delta(2.1, first, '{"command":"a', 'bash'))
const original = h.tools()[0]!
const groups = h.assembler.grouped('chat')!
const group = groups.entries.find(entry => entry.kind === 'group')!
if (group.kind !== 'group') throw new Error('expected a process group')
const source = groups.groupSource(group.key)
const before = source.getSnapshot()
expect(before?.data.summary.runningDetail).toBe('a')
const argumentsView = original.data.root.args
expect(argumentsView.textPrefix('command', 512)).toBe('a')
h.assembler.append(delta(2.2, first, 'b', null))
expect(argumentsView.textPrefix('command', 512)).toBe('ab')
expect(source.getSnapshot()).toBe(before)
const updated = h.tools()[0]!
expect(updated).not.toBe(original)
expect(updated.data.root).not.toBe(original.data.root)
expect(updated.data.root.args).toBe(argumentsView)
expect(updated.key).toBe(original.key)
const after = source.getSnapshot()
expect(after?.data.summary.runningDetail).toBe('ab')
for (const [index, fragment] of ['', '","unread":"x', 'y"}'].entries()) {
h.assembler.append(delta(2.3 + index / 1000, first, fragment, null))
expect(h.tools()[0]).toBe(updated)
expect(source.getSnapshot()).toBe(after)
}
})
it('creates two independent live preparations and updates each call under the same key', () => {
const h = harness()
h.assembler.append(delta(2.1, first, '', null))
@@ -0,0 +1,136 @@
/** Turn-node Locations and Assistant timing across partial windows and transient retirement. */
import { describe, expect, it } from 'vitest'
import { createAssistantMessage, createToolResultMessage, LlmAttemptId, ToolCallId } from '@deepseek-ai/dsh-llm'
import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream'
import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
SessionAssistantSettlementEntry, SessionEventLikeEntry, SessionLiveEventEntry, SessionTransientEventEntry,
} from '@deepseek-ai/dsh-api-session-controller/client'
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client'
import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts'
import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
import { turnProcessDefinition } from '../src/client/conversation-nodes/turn-process.ts'
import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts'
const attemptId = LlmAttemptId('turn-projection-test')
const callId = ToolCallId('turn-projection-call')
const toolBlock = { type: 'tool-call' as const, id: callId, name: 'write', arguments: '{"content":"hello"}' }
function entry(event: SessionEvent): SessionLiveEventEntry {
return { type: 'event', event }
}
const opening = [
entry({ type: 'turn/start', seq: SessionSeq(1), time: 1_000, data: { turn: 1 } }),
entry({ type: 'step/start', seq: SessionSeq(2), time: 2_000, data: { turn: 1, step: 1 } }),
]
const deltas: SessionTransientEventEntry[] = ['', toolBlock.arguments].map((argumentsDelta, index) => ({
type: 'transient', event: {
type: 'assistant/live-chunk', seq: 2.1 + index / 10, time: 2_100 + index * 100,
data: { turn: 1, step: 1, attemptId, chunk: {
type: 'tool-call-delta', index: 0, id: callId, argumentsDelta,
...(index === 0 ? {} : { name: 'write' }),
} },
},
}))
function stream() {
const accumulator = new AssistantStreamAccumulator()
for (const { event } of deltas) accumulator.push({ time: event.time, chunk: event.data.chunk })
return [...accumulator.snapshot()]
}
function harness(entries: readonly SessionEventLikeEntry[], hasMore = true) {
const assembler = new ConversationNodeAssembler(
{ entries: () => [assistantDefinition, turnProcessDefinition, turnTailDefinition], fallbackEntry: () => undefined },
{ entries: () => [chatViewDefinition] },
)
assembler.replaceWindow(entries, hasMore)
assembler.activateTarget('chat')
const read = () => {
assembler.flush()
return assembler.snapshot('chat') as ChatSnapshot
}
return { assembler, read }
}
describe('Turn nodes in partial windows', () => {
it('retains Step Locations and first-token timing through message settlement, replay, and prepend', () => {
const value = harness(deltas)
const committed: SessionAssistantSettlementEntry = { type: 'event', event: {
type: 'assistant/message', seq: SessionSeq(3), time: 3_000, surfaceOp: 'append', data: {
turn: 1, step: 1, stream: stream(),
message: createAssistantMessage({ source: { provider: 'test', model: 'test' }, content: [toolBlock] }),
},
} }
value.assembler.append(committed)
expect(value.read().timeline.turns.get(1)?.steps[0]?.data.get('assistant-step')?.finalNode?.timing)
.toMatchObject({ stepStartTime: null, firstTokenTime: 2_200, completedTime: 3_000 })
const closing = [
entry({ type: 'tool/call', seq: SessionSeq(4), time: 4_000,
data: { turn: 1, step: 1, callId, name: 'write', arguments: toolBlock.arguments } }),
entry({ type: 'tool/result', seq: SessionSeq(5), time: 5_000, surfaceOp: 'append', data: {
turn: 1, step: 1, message: createToolResultMessage({ callId, content: [{ type: 'text', text: 'Written' }], isError: false }),
} }),
entry({ type: 'step/end', seq: SessionSeq(6), time: 6_000, data: { turn: 1, step: 1 } }),
entry({ type: 'step/start', seq: SessionSeq(7), time: 7_000, data: { turn: 1, step: 2 } }),
entry({ type: 'assistant/message', seq: SessionSeq(8), time: 8_000, surfaceOp: 'append', data: {
turn: 1, step: 2, stream: [],
message: createAssistantMessage({ source: { provider: 'test', model: 'test' }, content: [{ type: 'text', text: 'Done' }] }),
} }),
entry({ type: 'step/end', seq: SessionSeq(9), time: 9_000, data: { turn: 1, step: 2 } }),
entry({ type: 'turn/end', seq: SessionSeq(10), time: 10_000, data: { turn: 1, reason: { kind: 'completed' } } }),
]
for (const event of closing) value.assembler.append(event)
const before = value.read()
for (const kind of ['turn-process', 'turn-tail']) {
expect(before.nodes.values().find(node => node.kind === kind)?.location)
.toMatchObject({ kind: 'step', turn: { turn: 1 }, step: { step: 1 } })
}
value.assembler.settleAssistant(attemptId, committed)
const replay = harness([committed, ...closing]).read()
const settled = value.read()
expect(settled.order).toEqual(replay.order)
for (const kind of ['turn-process', 'turn-tail']) {
const node = settled.nodes.values().find(node => node.kind === kind)
expect(node?.location).toMatchObject({ kind: 'step', turn: { turn: 1 }, step: { step: 1 } })
expect(node?.data).toEqual(replay.nodes.values().find(node => node.kind === kind)?.data)
}
expect(settled.timeline.turns.get(1)?.steps[0]?.data.get('assistant-step')?.finalNode?.timing)
.toMatchObject({ firstTokenTime: null })
value.assembler.prepend(opening, false)
const complete = value.read()
for (const kind of ['turn-process', 'turn-tail']) {
expect(complete.nodes.values().find(node => node.kind === kind)?.location)
.toMatchObject({ kind: 'turn', turn: { turn: 1 } })
}
expect(complete.timeline.turns.get(1)?.steps[0]?.data.get('assistant-step')?.finalNode?.timing)
.toEqual({ stepStartTime: 2_000, firstTokenTime: null, completedTime: 3_000 })
})
it.each(['attempt', 'uncommitted'] as const)('retains a missing-start footer after %s stream retirement', (kind) => {
const value = harness(deltas)
// The Turn end can arrive before the Assistant end frame that retires its live chunks.
value.assembler.append(entry({ type: 'turn/end', seq: SessionSeq(4), time: 4_000,
data: { turn: 1, reason: { kind: 'completed' } } }))
const before = value.read()
const tail = before.nodes.values().find(node => node.kind === 'turn-tail')!
expect(tail.location).toMatchObject({ kind: 'step', turn: { turn: 1 }, step: { step: 1 } })
expect(before.timeline.turns.get(1)?.steps[0]?.data.get('assistant-step'))
.toMatchObject({ status: 'interrupted', blocks: [{ kind: 'tool-call', callId, name: 'write' }] })
const committed: SessionAssistantSettlementEntry | undefined = kind === 'attempt' ? { type: 'event', event: {
type: 'assistant/attempt', seq: SessionSeq(3), time: 3_000, data: { turn: 1, step: 1, stream: stream() },
} } : undefined
value.assembler.settleAssistant(attemptId, committed)
const after = value.read()
const current = after.nodes.get(tail.key)!
expect(current.location).toMatchObject(kind === 'attempt'
? { kind: 'step', turn: { turn: 1 }, step: { step: 1 } }
: { kind: 'turn', turn: { turn: 1 } })
expect(current.data).toMatchObject({ turn: 1, seq: 4, closing: null, branchUnavailable: true })
expect(after.timeline.turns.get(1)?.steps[0]?.data.get('assistant-step')).toBeUndefined()
expect(after.nodes.values().find(node => node.kind === 'assistant-step')?.visibility).toBe('hidden')
expect(after.order).toEqual([tail.key])
})
})
@@ -15,8 +15,8 @@
en: 9c1c4905952fea5e
zh: d1506dd6fc1018d1
/deepseek-ai-dsh-client-ui-conversation/conversation-assembly:
en: 13ae68865fa315d7
zh: c152f020abf20f35
en: 423d68eff03d9bb8
zh: c9c6108fb27d30b2
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
en: 8fddd7e838da57d8
zh: 98748142be2e61d4
@@ -33,6 +33,14 @@ Desktop product events use the optional [product analytics service](../product-a
The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes durable events from Client-only transient events, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Replacement windows may include both entry variants, while historical prepends carry durable entries and live appends may carry either. Definitions use the same match/start/update interface for durable and transient events. The earliest loaded start initializes State; every later Match, including another start for that identity, updates it. Definitions that do not consume Assistant deltas return `null` for `assistant/live-chunk`. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append, prepend, and Assistant-settlement revisions use incremental assembly. Settlement removes only the named attempt's transient matches, applies its optional durable entry, and recomputes the affected Contexts from their remaining earliest start, refreshing predecessor indexes and dependents without replacing unrelated target nodes. A Context with no remaining start has no State. Its key and previously published nodes remain available to later evidence until a full window rebuild; the owning Definition may hide those nodes. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`.
Definitions may share the same read-only Match and Location for one input and lifecycle role. Each Definition keeps its own Context, State, and ordered Match list; Match objects are not reused across assembler instances.
Registration accepts the existing callable `match` or a read-only event-type-to-handler table. The Registry copies table entries and precomputes ordered candidate Sets on registration changes. Function-form Definitions keep their callable API and receive every event; table handlers receive only their declared types. Changing a table requires unregistering and registering again. Legacy assembler adapters with only `entries()` and `fallbackEntry()` retain their original dispatch path.
All accepted Matches update State in order. Publication indexes record a dirty Context only once between flushes; each input's owner list is indexed once and collects every matching Context. Append reuses an indexed Context key only when both kind and id match, and may reuse private validated coordinates; Definition-facing Context snapshots remain distinct objects. Inputs claiming at most two targets require no temporary target Set; fallback selection still checks every claimed target after ordinary Definitions finish.
Location lookup indexes Steps by Turn and Step number, so a non-boundary append does not scan the Turn's Step array. Each input's numeric coordinates and resolved Location share one private index record; published Location values remain immutable. Boundary updates, replacement, and prepend keep the indexes aligned with the current timeline.
A target becomes active when shell selection resolves it or when its source receives a first subscriber. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it and unsubscription does not deactivate it.
`UiConversation.groups` registers one optional business Group Definition per existing target. It consumes projected Node changes, changed Turns, and indexed target positions after Node materialization, including first activation, and owns all segmentation and group data. Indexed Turn reads preserve interruptions from neighbouring Nodes, allowing business updates to stay within affected Turns and groups. The assembler validates and installs root references and keyed group snapshots before publishing Node, Group, and Location data sources. Ungrouped targets retain their original path. [Grouping](../../../docs/subsystems/conversation.md#group-definitions) defines input lifetime, typed registration, atomic updates, and renderer responsibilities.
@@ -33,6 +33,14 @@ kind: "package-reference"
适配器把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分持久事件与 Client-only transient event,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。replacement window 可以包含两种 entry,历史 prepend 携带持久 entry,实时 append 则可以携带任一种。持久与瞬态事件使用同一组 match/start/update 接口。当前最早的 start 初始化 State;后续所有 Match,包括同一身份的其他 start,都用于更新。不消费 Assistant delta 的 Definition 对 `assistant/live-chunk` 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append、prepend 与 Assistant settlement 使用增量组装。settlement 只删除具名 attempt 的 transient match,应用可选持久 entry,并从剩余最早的 start 重算受影响的 Context,刷新前序索引及 dependent,不替换无关 target node。没有剩余 start 的 Context 不保留 State,但其 key 和已发布节点仍保留,供后续证据复用,直到完整窗口重建;所属 Definition 可以隐藏这些节点。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。
多个 Definition 可以为同一输入和生命周期角色共享只读 Match 与 Location。各 Definition 仍持有独立的 Context、State 和有序 Match 列表;Match 对象不会跨 assembler 实例复用。
注册入口接受原有可调用的 `match`,或只读的事件类型到处理函数表。Registry 在注册关系变化时复制表条目并预计算有序候选 Set。函数形式的 Definition 保持原有可调用 API,接收全部事件;表中的处理函数只接收已声明类型。修改表需要注销后重新注册。只有 `entries()` 与 `fallbackEntry()` 的旧 assembler 适配器保留原有分发路径。
所有接受的 Match 按顺序更新 State。两次 flush 之间,发布索引只登记一次脏 Context;每个输入的 owner 列表只建立一次索引,并收集所有匹配的 Context。append 仅在 kind 和 id 都相同时复用已索引 Context 的 key,也可复用私有的已校验坐标;传给 Definition 的 Context 快照仍为独立对象。最多认领两个目标的输入不需要临时目标 Set;普通 Definition 处理结束后,fallback 选择仍检查全部已认领的目标。
Location 查询按 Turn 和 Step 编号索引 Step,因此非边界 append 不扫描 Turn 的 Step 数组。每个输入的数值坐标与解析出的 Location 共用一条私有索引记录;已发布的 Location 值保持不可变。边界更新、完整替换和向前补页使这些索引与当前时间线保持一致。
shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。
`UiConversation.groups` 为每个已注册目标注册一个可选的业务 Group Definition。它在节点物化后消费投影后的节点变化、变化轮次及已索引的目标位置,覆盖首次激活,并拥有全部分段规则与组数据。按索引读取 Turn 时保留相邻 Node 造成的分隔,使业务更新可以限制在受影响轮次和组内。assembler 先校验并安装根引用和按键索引的组快照,再发布 Node、Group 和 Location 数据来源。未分组目标保持原有路径。[分组](../../../docs/subsystems/conversation.zh.md#group-definitions)定义输入有效期、类型化注册、原子更新及渲染器职责。
@@ -192,6 +192,13 @@ export type ConversationPublication = 'none' | 'animation-frame' | 'immediate'
/** Engine-owned Location data publication phase. */
export type ConversationLocationDataScope = 'step' | 'turn'
/**
* Extract a stable business identity using only the current event.
* @param event - durable or transient Client event.
* @returns identity and lifecycle role, or null when unrelated.
*/
export type ConversationMatchHandler = (event: SessionEventLike) => ConversationMatchResult | null
/** One independently registered business Event-to-Node state machine. */
export interface ConversationNodeDefinition<State = unknown> {
readonly kind: string
@@ -255,6 +262,12 @@ export interface ConversationNodeDefinition<State = unknown> {
buildViewNode?(context: ConversationNodeContext<State>): ConversationViewNode | null
}
/** Registration accepts a function or an immutable table of own event-type handlers. */
export type ConversationNodeDefinitionInput<State = unknown> =
Omit<ConversationNodeDefinition<State>, 'match'> & {
readonly match: ConversationMatchHandler | Readonly<Record<string, ConversationMatchHandler>>
}
/** Reference-stable Turn/Step facts published beside view Nodes. */
export interface ConversationTimelineSnapshot {
readonly turnOrder: readonly number[]
@@ -15,6 +15,7 @@ import type {
ConversationGroupData, ConversationGroupDefinition, ConversationGroupedView,
} from '../contract/groups.ts'
import { ConversationGroupStore } from './group-store.ts'
import type { ConversationEventRegistry } from './event-registry.ts'
import {
ConversationLocationIndex, type ConversationLocationDataChange,
} from './location-index.ts'
@@ -143,7 +144,7 @@ function conversationMatch(
}
/** Event Registry subset consumed by a Session-owned Assembler. */
export interface ConversationEventDefinitions {
export interface ConversationEventDefinitions extends Partial<Pick<ConversationEventRegistry, 'forEvent'>> {
/** @returns ordinary Definitions in registration order. */
entries(): readonly ConversationNodeDefinition[]
/** @returns unmatched-event fallback, when registered. */
@@ -173,7 +174,7 @@ const NO_GROUPS: ConversationGroupDefinitions = { entries: () => [], forTarget:
export class ConversationNodeAssembler implements ConversationViewSnapshotStore {
private readonly contexts = new Map<string, InternalContext>()
private readonly contextsByKind = new Map<string, InternalContext[]>()
private readonly contextsBySeq = new Map<number, Set<InternalContext>>()
private readonly contextsBySeq = new Map<number, InternalContext[]>()
private readonly contextsByTarget = new Map<string, Set<InternalContext>>()
private readonly inputs = new Map<number, SessionEventLikeEntry>()
private readonly locationIndex = new ConversationLocationIndex()
@@ -250,7 +251,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
append(record: SessionEventLikeEntry): ConversationPublication {
const event = record.event
if (this.inputs.has(event.seq)) return 'none'
this.revised.clear()
if (this.revised.size > 0) this.revised.clear()
this.inputs.set(event.seq, record)
let publication: ConversationPublication = 'none'
if (event.type !== 'assistant/live-chunk' && isLocationBoundary(event.type)) {
@@ -267,7 +268,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
}
publication = maximumPublication(publication, this.matchInput(record))
if (this.replayRevisedDependents()) publication = 'immediate'
this.revised.clear()
if (this.revised.size > 0) this.revised.clear()
return publication
}
@@ -456,21 +457,16 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
}
private matchInput(input: SessionEventLikeEntry): ConversationPublication {
return this.dispatchInput(input, (definition, id, role) =>
this.acceptMatch(definition, id, role, input))
// oxlint-disable-next-line typescript/unbound-method -- dispatchInput supplies the assembler receiver
return this.dispatchInput(input, this.acceptMatch)
}
private collectInput(
input: SessionEventLikeEntry,
pending: Map<string, PendingMatch[]>,
): ConversationPublication {
return this.dispatchInput(input, (definition, id, role) => {
return this.dispatchInput(input, (definition, id, match) => {
const key = conversationContextKey(definition.kind, id)
const match = conversationMatch(
input,
role,
this.locationIndex.locationOf(input.event),
)
const matches = pending.get(key) ?? []
matches.push({ definition, id, match })
pending.set(key, matches)
@@ -481,26 +477,57 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
private dispatchInput(
input: SessionEventLikeEntry,
accept: (
this: ConversationNodeAssembler,
definition: ConversationNodeDefinition,
id: string,
role: ConversationMatch['role'],
match: ConversationMatch,
) => ConversationPublication,
): ConversationPublication {
const event = input.event
const matchedTargets = new Set<string>()
let startMatch: ConversationMatch | undefined
let updateMatch: ConversationMatch | undefined
let location: ConversationMatch['location'] | undefined
const matchFor = (role: ConversationMatch['role']): ConversationMatch => {
location ??= this.locationIndex.locationOf(event)
return role === 'start'
? startMatch ??= conversationMatch(input, role, location)
: updateMatch ??= conversationMatch(input, role, location)
}
let firstTarget: string | undefined
let secondTarget: string | undefined
let otherTargets: Set<string> | undefined
let publication: ConversationPublication = 'none'
for (const definition of this.eventDefinitions.entries()) {
const result = definition.match(event)
if (result === null) continue
if (definition.target !== undefined) matchedTargets.add(definition.target)
publication = maximumPublication(publication, accept(definition, result.id, result.role))
const routes = this.eventDefinitions.forEvent?.(event.type)
if (routes === undefined) {
for (const definition of this.eventDefinitions.entries()) {
const result = definition.match(event)
if (result === null) continue
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)))
}
} 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 +560,32 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
private acceptMatch(
definition: ConversationNodeDefinition,
id: string,
role: ConversationMatch['role'],
input: SessionEventLikeEntry,
match: ConversationMatch,
): ConversationPublication {
const key = conversationContextKey(definition.kind, id)
const latest = this.contextsByKind.get(definition.kind)?.at(-1)
const key = latest?.id === id ? latest.key : conversationContextKey(definition.kind, id)
let context = this.contexts.get(key)
context ??= this.createContext(definition, id, key)
const starting = role === 'start' && context.start === undefined
const match = conversationMatch(
input,
role,
this.locationIndex.locationOf(input.event),
)
const starting = match.role === 'start' && context.start === undefined
const previous = context.matches.at(-1)
if (previous !== undefined && previous.event.seq >= input.event.seq) {
throw new Error(`conversation Context ${key} received non-appended Match ${input.event.seq}`)
if (previous !== undefined && previous.event.seq >= match.event.seq) {
throw new Error(`conversation Context ${key} received non-appended Match ${match.event.seq}`)
}
if (starting && context.matches.length > 0) {
throw new Error(`conversation Context ${key} received an update before its start Match`)
}
context.matches.push(match)
if (starting && match.role === 'start') {
context.startSeq = input.event.seq
if (starting) {
context.startSeq = match.event.seq
context.start = match
this.indexStartedContext(context)
}
const owners = this.contextsBySeq.get(input.event.seq) ?? new Set<InternalContext>()
owners.add(context)
this.contextsBySeq.set(input.event.seq, owners)
let owners = this.contextsBySeq.get(match.event.seq)
if (owners === undefined) {
owners = []
this.contextsBySeq.set(match.event.seq, owners)
}
owners.push(context)
if (starting) {
this.replayContext(context)
@@ -588,9 +613,12 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
if (entry.definition !== context.definition || entry.id !== context.id) {
throw new Error(`conversation Context ${key} received inconsistent Definition identity`)
}
const owners = this.contextsBySeq.get(entry.match.event.seq) ?? new Set<InternalContext>()
owners.add(context)
this.contextsBySeq.set(entry.match.event.seq, owners)
let owners = this.contextsBySeq.get(entry.match.event.seq)
if (owners === undefined) {
owners = []
this.contextsBySeq.set(entry.match.event.seq, owners)
}
owners.push(context)
return entry.match
})
.sort((left, right) => left.event.seq - right.event.seq)
@@ -679,12 +707,16 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
}
private markDirty(context: InternalContext): void {
if (this.dirty.has(context)) return
this.dirty.add(context)
const target = context.definition.target
if (target === undefined || !this.activeTargets.has(target)) return
const contexts = this.dirtyByTarget.get(target) ?? new Set<InternalContext>()
let contexts = this.dirtyByTarget.get(target)
if (contexts === undefined) {
contexts = new Set<InternalContext>()
this.dirtyByTarget.set(target, contexts)
}
contexts.add(context)
this.dirtyByTarget.set(target, contexts)
}
private replaceDependencies(context: InternalContext, dependencies: Map<string, Dependency>): void {
@@ -704,6 +736,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
}
private replayRevisedDependents(): boolean {
if (this.dependents.size === 0) return false
const pending = [...this.revised]
const affected = new Set<InternalContext>()
for (let index = 0; index < pending.length; index++) {
@@ -715,7 +748,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
pending.push(dependent)
}
}
this.replayContexts(affected)
if (affected.size > 0) this.replayContexts(affected)
return affected.size > 0
}
@@ -1,20 +1,31 @@
import type { ConversationNodeDefinition } from '../contract/conversation.ts'
import type {
ConversationMatchHandler, ConversationNodeDefinition, ConversationNodeDefinitionInput,
} from '../contract/conversation.ts'
import { ConversationDefinitionRegistry } from './definition-registry.ts'
/** A registered Definition and its event-type-selected handler. */
export interface ConversationEventRoute {
readonly definition: ConversationNodeDefinition
readonly match: ConversationMatchHandler
}
/** Runtime registry of independently owned Conversation business Definitions. */
export class ConversationEventRegistry extends ConversationDefinitionRegistry<ConversationNodeDefinition> {
private fallback: ConversationNodeDefinition | undefined
private routes = new Map<string, ReadonlySet<ConversationEventRoute>>()
private unrestricted: ReadonlySet<ConversationEventRoute> = new Set()
private readonly tables = new WeakMap<ConversationNodeDefinition, ReadonlyMap<string, ConversationMatchHandler>>()
/**
* Register a uniquely named business Definition for the caller's lifetime.
* @param definition - Definition contribution.
* @returns idempotent disposer.
*/
register(definition: ConversationNodeDefinition): () => void {
register(definition: ConversationNodeDefinitionInput): () => void {
assertDefinitionTarget(definition)
return this.registerDefinition(
definition.kind,
definition,
this.resolve(definition),
`conversation Definition "${definition.kind}" is already registered`,
`uiConversation.events.register(${JSON.stringify(definition.kind)})`,
)
@@ -22,14 +33,15 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry<Co
/**
* Register the sole fallback used only when no ordinary Definition matches.
* @param definition - fallback Definition.
* @param input - fallback Definition.
* @returns idempotent disposer.
*/
registerFallback(definition: ConversationNodeDefinition): () => void {
assertDefinitionTarget(definition)
const target = definition.target
registerFallback(input: ConversationNodeDefinitionInput): () => void {
assertDefinitionTarget(input)
const target = input.target
if (target === undefined) throw new Error('conversation fallback Definition must declare a target')
if (this.fallback !== undefined) throw new Error('conversation fallback Definition is already registered')
const definition = this.resolve(input)
const dispose = this.ctx.effect(() => {
this.fallback = definition
this.refresh()
@@ -49,9 +61,54 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry<Co
fallbackEntry(): ConversationNodeDefinition | undefined {
return this.fallback
}
/**
* Read precomputed candidates in registration order; the returned Set is borrowed read-only.
* @param type - current event type.
* @returns table handlers for this type together with all function-form handlers.
*/
forEvent(type: string): ReadonlySet<ConversationEventRoute> {
return this.routes.get(type) ?? this.unrestricted
}
private resolve(input: ConversationNodeDefinitionInput): ConversationNodeDefinition {
if (typeof input.match === 'function') return input as ConversationNodeDefinition
const table = new Map(Object.entries(input.match))
const definition: ConversationNodeDefinition = {
...input,
match: event => table.get(event.type)?.call(definition, event) ?? null,
}
this.tables.set(definition, table)
return definition
}
protected override refresh(): void {
const routes = new Map<string, Set<ConversationEventRoute>>()
const unrestricted = new Set<ConversationEventRoute>()
for (const definition of this.definitions.values()) {
const table = this.tables.get(definition)
if (table === undefined) {
const route = { definition, match: definition.match.bind(definition) }
unrestricted.add(route)
for (const candidates of routes.values()) candidates.add(route)
} else {
for (const [type, match] of table) {
let candidates = routes.get(type)
if (candidates === undefined) {
candidates = new Set(unrestricted)
routes.set(type, candidates)
}
candidates.add({ definition, match: match.bind(definition) })
}
}
}
this.routes = routes
this.unrestricted = unrestricted
super.refresh()
}
}
function assertDefinitionTarget(definition: ConversationNodeDefinition): void {
function assertDefinitionTarget(definition: ConversationNodeDefinitionInput): void {
if ((definition.target === undefined) !== (definition.buildViewNode === undefined)) {
throw new Error(
`conversation Definition "${definition.kind}" must declare target and buildViewNode together`,
@@ -115,6 +115,7 @@ interface Coordinates {
readonly turn?: number
readonly step?: number
readonly session?: true
location: ConversationLocation | undefined
}
interface StepDraft {
@@ -138,14 +139,19 @@ const UNRESOLVED_LOCATION = { kind: 'unresolved' } as const
function payloadCoordinates(event: SessionEventLike): Coordinates {
const data = event.data as unknown as { turn?: unknown; step?: unknown }
if (data.turn === null) return { session: true }
if (data.turn === null) return { session: true, location: undefined }
const turn = Number.isSafeInteger(data.turn) && (data.turn as number) >= 0
? data.turn as number
: undefined
const step = Number.isSafeInteger(data.step) && (data.step as number) >= 0
? data.step as number
: undefined
return { ...turn === undefined ? {} : { turn }, ...step === undefined ? {} : { step } }
if (turn === undefined) return step === undefined
? { location: undefined }
: { step, location: undefined }
return step === undefined
? { turn, location: undefined }
: { turn, step, location: undefined }
}
function sameReferences<T>(left: readonly T[], right: readonly T[]): boolean {
@@ -177,8 +183,8 @@ function sameLocation(left: ConversationLocation | undefined, right: Conversatio
/** Session-owned Turn/Step timeline and event-to-Location index. */
export class ConversationLocationIndex {
private coordinates = new Map<number, Coordinates>()
private locations = new Map<number, ConversationLocation>()
private seqsByTurn = new Map<number, Set<number>>()
private readonly stepsByTurn = new Map<number, Map<number, StepLocation>>()
private timeline: ConversationTimelineSnapshot = { turnOrder: [], turns: new Map() }
private readonly turnDataStores = new Map<number, MutableLocationDataStore>()
private readonly stepDataStores = new Map<string, MutableLocationDataStore>()
@@ -268,7 +274,7 @@ export class ConversationLocationIndex {
* @returns current Location, falling back to session when it has no Turn/Step affinity.
*/
locationOf(event: SessionEventLike): ConversationLocation {
return this.locations.get(event.seq) ?? SESSION_LOCATION
return this.coordinates.get(event.seq)?.location ?? SESSION_LOCATION
}
/**
@@ -277,7 +283,7 @@ export class ConversationLocationIndex {
* @returns seqs whose resolved Location changed.
*/
rebuild(entries: readonly SessionEventLikeEntry[]): ReadonlySet<number> {
const previousLocations = this.locations
const previousCoordinates = this.coordinates
const turns = new Map<number, TurnDraft>()
const coordinates = new Map<number, Coordinates>()
let currentTurn: number | undefined
@@ -327,6 +333,7 @@ export class ConversationLocationIndex {
coordinates.set(event.seq, {
...turn === undefined ? {} : { turn },
...turn === undefined || step === undefined ? {} : { step },
location: undefined,
})
if (turn !== undefined) turnDraft(turn, event.seq)
if (turn !== undefined && step !== undefined) stepDraft(turn, step, event.seq)
@@ -403,20 +410,26 @@ export class ConversationLocationIndex {
this.timeline = sameMap && turnOrder === this.timeline.turnOrder
? this.timeline
: { turnOrder, turns: nextTurns }
this.stepsByTurn.clear()
for (const [number, turn] of this.timeline.turns) {
this.stepsByTurn.set(number, new Map(turn.steps.map(step => [step.step, step])))
}
this.coordinates = coordinates
this.locations = new Map()
this.seqsByTurn = new Map()
for (const { event } of entries) {
const coordinates = this.coordinates.get(event.seq)
if (coordinates?.turn !== undefined) this.indexTurnSeq(coordinates.turn, event.seq)
this.locations.set(event.seq, this.resolve(event.seq))
const coordinates = this.coordinates.get(event.seq) as Coordinates
if (coordinates.turn !== undefined) this.indexTurnSeq(coordinates.turn, event.seq)
coordinates.location = this.resolve(event.seq)
}
this.currentTurn = currentTurn
this.currentStep = currentStep
const changed = new Set<number>()
for (const { event } of entries) {
if (!sameLocation(previousLocations.get(event.seq), this.locations.get(event.seq))) {
if (!sameLocation(
previousCoordinates.get(event.seq)?.location,
this.coordinates.get(event.seq)?.location,
)) {
changed.add(event.seq)
}
}
@@ -455,6 +468,7 @@ export class ConversationLocationIndex {
this.coordinates.set(event.seq, {
turn: turnNumber,
...stepNumber === undefined ? {} : { step: stepNumber },
location: this.coordinates.get(event.seq)?.location,
})
this.indexTurnSeq(turnNumber, event.seq)
@@ -462,7 +476,8 @@ export class ConversationLocationIndex {
let steps = previousTurn?.steps ?? []
if (event.type === 'step/start' || event.type === 'step/end') {
const number = event.data.step
const previousStep = steps.find(candidate => candidate.step === number)
const indexedSteps = this.stepsByTurn.get(turnNumber) ?? new Map<number, StepLocation>()
const previousStep = indexedSteps.get(number)
const candidate: StepLocation = {
turn: turnNumber,
step: number,
@@ -472,6 +487,8 @@ export class ConversationLocationIndex {
data: this.stepData(turnNumber, number),
}
const nextStep = sameStep(previousStep, candidate) ? previousStep as StepLocation : candidate
indexedSteps.set(number, nextStep)
this.stepsByTurn.set(turnNumber, indexedSteps)
const index = steps.findIndex(step => step.step === number)
steps = index < 0
? [...steps, nextStep]
@@ -498,9 +515,10 @@ export class ConversationLocationIndex {
const changed = new Set<number>()
for (const seq of this.seqsByTurn.get(turnNumber) ?? []) {
const previous = this.locations.get(seq)
const coordinates = this.coordinates.get(seq) as Coordinates
const previous = coordinates.location
const next = this.resolve(seq)
this.locations.set(seq, next)
coordinates.location = next
if (!sameLocation(previous, next)) changed.add(seq)
}
@@ -515,14 +533,13 @@ export class ConversationLocationIndex {
}
/**
* Index one non-boundary tail event without rescanning the window.
* Index one non-boundary tail event without scanning the window or the Turn's Steps.
* @param event - contiguous appended event.
*/
appendNonBoundary(event: SessionEventLike): void {
const explicit = payloadCoordinates(event)
if (explicit.session === true) {
this.coordinates.set(event.seq, {})
this.locations.set(event.seq, SESSION_LOCATION)
this.coordinates.set(event.seq, { location: SESSION_LOCATION })
return
}
if (explicit.turn !== undefined) {
@@ -532,12 +549,14 @@ export class ConversationLocationIndex {
}
const turn = explicit.turn ?? this.currentTurn
const step = explicit.step ?? (turn === this.currentTurn ? this.currentStep : undefined)
this.coordinates.set(event.seq, {
const coordinates: Coordinates = explicit.turn !== undefined && explicit.step !== undefined ? explicit : {
...turn === undefined ? {} : { turn },
...turn === undefined || step === undefined ? {} : { step },
})
location: undefined,
}
this.coordinates.set(event.seq, coordinates)
if (turn !== undefined) this.indexTurnSeq(turn, event.seq)
this.locations.set(event.seq, this.resolve(event.seq))
coordinates.location = this.resolve(event.seq)
}
/**
@@ -553,7 +572,6 @@ export class ConversationLocationIndex {
if (seqs?.size === 0) this.seqsByTurn.delete(turn)
}
this.coordinates.delete(event.seq)
this.locations.delete(event.seq)
}
}
@@ -564,18 +582,23 @@ export class ConversationLocationIndex {
insertAssistantSettlement(
event: SessionEvent<'assistant/message'> | SessionEvent<'assistant/attempt'>,
): void {
this.coordinates.set(event.seq, {
const coordinates: Coordinates = {
turn: event.data.turn,
step: event.data.step,
})
location: undefined,
}
this.coordinates.set(event.seq, coordinates)
this.indexTurnSeq(event.data.turn, event.seq)
this.locations.set(event.seq, this.resolve(event.seq))
coordinates.location = this.resolve(event.seq)
}
private indexTurnSeq(turn: number, seq: number): void {
const current = this.seqsByTurn.get(turn) ?? new Set<number>()
let current = this.seqsByTurn.get(turn)
if (current === undefined) {
current = new Set<number>()
this.seqsByTurn.set(turn, current)
}
current.add(seq)
this.seqsByTurn.set(turn, current)
}
private turnData(turn: number): ConversationLocationDataStore<ConversationTurnDataMap> {
@@ -617,7 +640,7 @@ export class ConversationLocationIndex {
const turn = this.timeline.turns.get(coordinates.turn)
if (turn === undefined) return UNRESOLVED_LOCATION
if (coordinates.step === undefined) return { kind: 'turn', turn }
const step = turn.steps.find(candidate => candidate.step === coordinates.step)
const step = this.stepsByTurn.get(coordinates.turn)?.get(coordinates.step)
return step === undefined ? { kind: 'turn', turn } : { kind: 'step', turn, step }
}
}
@@ -16,8 +16,8 @@ export type {
ConversationContextReader, ConversationLocation,
ConversationLocationData, ConversationLocationDataScope, ConversationLocationDataSource,
ConversationLocationDataStore,
ConversationMatch, ConversationMatchResult, ConversationNodeContext,
ConversationNodeDefinition, ConversationPreviousContext, ConversationPublication,
ConversationMatch, ConversationMatchHandler, ConversationMatchResult, ConversationNodeContext,
ConversationNodeDefinition, ConversationNodeDefinitionInput, ConversationPreviousContext, ConversationPublication,
ConversationStartMatch,
ConversationStepDataMap, ConversationTimelineSnapshot, ConversationTurnDataMap,
ConversationViewBuilder, ConversationViewDefinition, ConversationViewNode,
@@ -172,6 +172,145 @@ function fallbackDefinition(start: () => string): ConversationNodeDefinition<str
}
describe('ConversationNodeAssembler', () => {
it('indexes each appended input once and schedules each Context once per flush', () => {
const updates = vi.fn()
const definitions: ConversationNodeDefinition<{ count: number }>[] = ['copy', 'mutable'].map(kind => ({
kind,
target: 'test',
match: event => ({ id: 'one', role: event.seq === 1 ? 'start' : 'update' }),
start: () => ({ count: 0 }),
update: (context) => {
updates()
if (kind === 'copy') return { count: context.state.count + 1 }
context.state.count++
return context.state
},
buildViewNode: context => node(context, context.state?.count),
}))
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions(definitions), new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(SessionSeq(1), 'count/start', {}))], false)
assembler.flush()
// Count index writes separately from the required per-event State updates.
const owners = vi.spyOn(Reflect.get(assembler, 'contextsBySeq') as Map<number, unknown>, 'set')
const dirty = vi.spyOn(Reflect.get(assembler, 'dirty') as Set<unknown>, 'add')
const targets = vi.spyOn(Reflect.get(assembler, 'dirtyByTarget') as Map<string, unknown>, 'set')
try {
for (const batch of [0, 1]) {
for (let offset = 0; offset < 64; offset++) {
assembler.append(input(at(SessionSeq(2 + batch * 64 + offset), 'count/update', {})))
}
expect(owners).toHaveBeenCalledTimes(64)
expect(dirty).toHaveBeenCalledTimes(2)
expect(targets).toHaveBeenCalledTimes(1)
expect(updates).toHaveBeenCalledTimes((batch + 1) * 128)
assembler.flush()
expect([...testSnapshot(assembler)!.nodes.values()].map(value => value.data))
.toEqual([(batch + 1) * 64, (batch + 1) * 64])
owners.mockClear()
dirty.mockClear()
targets.mockClear()
}
} finally {
owners.mockRestore()
dirty.mockRestore()
targets.mockRestore()
}
})
it('resolves nonconsecutive Steps without searching the Turn timeline on each delta', () => {
const index = new ConversationLocationIndex()
const turnStart = at(SessionSeq(1), 'turn/start', { turn: 4 })
const firstStep = at(SessionSeq(2), 'step/start', { turn: 4, step: 7 })
const lastStep = at(SessionSeq(3), 'step/start', { turn: 4, step: 200 })
index.rebuild([input(turnStart), input(firstStep), input(lastStep)])
const turn = index.snapshot().turns.get(4)!
const find = vi.spyOn(turn.steps, 'find')
const turnIndex = vi.spyOn(Reflect.get(index, 'seqsByTurn') as Map<number, unknown>, 'set')
try {
for (const [offset, step] of [7, 200, 7].entries()) {
const { event } = transientChunk(3 + (offset + 1) / 10, 4, step, { type: 'text-delta', index: 0, text: 'a' })
index.appendNonBoundary(event)
expect(index.locationOf(event)).toEqual({
kind: 'step', turn, step: turn.steps[step === 7 ? 0 : 1],
})
}
expect(find).not.toHaveBeenCalled()
expect(turnIndex).not.toHaveBeenCalled()
} finally {
find.mockRestore()
turnIndex.mockRestore()
}
})
it('resolves current Step references after closure, prepend and window replacement', () => {
const index = new ConversationLocationIndex()
const turnStart = at(SessionSeq(1), 'turn/start', { turn: 1 })
const stepStart = at(SessionSeq(2), 'step/start', { turn: 1, step: 9 })
const stepEnd = at(SessionSeq(3), 'step/end', { turn: 1, step: 9 })
const { event } = transientChunk(2.5, 1, 9, { type: 'text-delta', index: 0, text: 'a' })
index.rebuild([input(turnStart), input(stepStart)])
index.appendNonBoundary(event)
const open = index.locationOf(event)
index.appendBoundary(stepEnd)
const closed = index.locationOf(event)
expect(closed).toMatchObject({ kind: 'step', step: { status: 'closed', end: stepEnd } })
expect(closed).not.toEqual(open)
index.rebuild([input(stepEnd)])
index.appendNonBoundary(event)
expect(index.locationOf(event)).toMatchObject({ kind: 'step', step: { start: undefined, end: stepEnd } })
index.rebuild([input(turnStart), input(stepStart), input(stepEnd)])
index.appendNonBoundary(event)
expect(index.locationOf(event)).toMatchObject({ kind: 'step', step: { start: stepStart, end: stepEnd } })
index.rebuild([input(turnStart)])
index.appendNonBoundary(event)
expect(index.locationOf(event)).toEqual({ kind: 'turn', turn: index.snapshot().turns.get(1) })
index.rebuild([])
index.appendNonBoundary(event)
expect(index.locationOf(event)).toEqual({ kind: 'unresolved' })
index.appendBoundary(turnStart)
index.appendBoundary(stepStart)
expect(index.locationOf(event)).toMatchObject({ kind: 'step', step: { start: stepStart, end: undefined } })
})
it('reports a repaired Location while preserving the previously returned value', () => {
const index = new ConversationLocationIndex()
const turnStart = input(at(SessionSeq(1), 'turn/start', { turn: 1 }))
const stepStart = input(at(SessionSeq(2), 'step/start', { turn: 1, step: 9 }))
const delta = transientChunk(2.5, 1, 9, { type: 'text-delta', index: 0, text: 'a' })
index.rebuild([turnStart])
index.appendNonBoundary(delta.event)
const previous = index.locationOf(delta.event)
expect(previous.kind).toBe('turn')
const changed = index.rebuild([turnStart, stepStart, delta])
expect(changed.has(delta.event.seq)).toBe(true)
expect(index.locationOf(delta.event)).toMatchObject({ kind: 'step', step: { step: 9 } })
expect(previous.kind).toBe('turn')
expect(previous).not.toBe(index.locationOf(delta.event))
})
it('keeps equally numbered Steps in different Turns independent', () => {
const index = new ConversationLocationIndex()
index.rebuild([
input(at(SessionSeq(1), 'turn/start', { turn: 1 })),
input(at(SessionSeq(2), 'step/start', { turn: 1, step: 3 })),
input(at(SessionSeq(3), 'turn/start', { turn: 2 })),
input(at(SessionSeq(4), 'step/start', { turn: 2, step: 3 })),
])
for (const turn of [1, 2]) {
const { event } = transientChunk(4 + turn / 10, turn, 3, { type: 'text-delta', index: 0, text: 'a' })
index.appendNonBoundary(event)
const location = index.locationOf(event)
expect(location).toMatchObject({ kind: 'step', step: { turn, step: 3 } })
if (location.kind !== 'step') throw new Error('expected Step location')
expect(location.step).toBe(index.snapshot().turns.get(turn)?.steps[0])
}
})
it('reports boundary changes only when the owning Turn location changes', () => {
const index = new ConversationLocationIndex()
const boundaries = [
@@ -184,7 +323,7 @@ describe('ConversationNodeAssembler', () => {
index.appendBoundary(boundary)
expect(index.takeChangedTurns()).toEqual([1])
const turn = index.snapshot().turns.get(1)
index.appendBoundary(boundary)
expect(index.appendBoundary(boundary)).toEqual(new Set())
expect(index.snapshot().turns.get(1)).toBe(turn)
expect(index.takeChangedTurns()).toEqual([])
}
@@ -371,6 +510,103 @@ describe('ConversationNodeAssembler', () => {
{ callSeq: 1, results: 1 },
{ callSeq: 2, results: 0 },
])
const keys = snapshot?.order
for (const [index, callId] of ['b', 'a', 'b'].entries()) {
assembler.append(input(at(SessionSeq(4 + index), 'tool/result', {
turn: 1, step: 1,
message: { source: { type: 'tool-result', callId }, content: [], isError: false },
})))
}
assembler.flush()
expect(starts).not.toHaveBeenCalled()
expect(updates).toHaveBeenCalledTimes(4)
expect(testSnapshot(assembler)?.order).toEqual(keys)
expect([...testSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)).toEqual([
{ callSeq: 1, results: 2 },
{ callSeq: 2, results: 2 },
])
})
it('shares input Matches by role without sharing Definition State or Session locations', () => {
type State = { readonly seqs: readonly number[] }
const opening = [
input(at(SessionSeq(1), 'turn/start', { turn: 1 })),
input(at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })),
input(at(SessionSeq(3), 'shared/seed', { turn: 1, step: 1 })),
]
const createSession = () => {
const probes = (['start', 'start', 'update', 'update'] as const).map((role, index) => {
const matches: ConversationMatch[] = []
const definition: ConversationNodeDefinition<State> = {
kind: `shared-${index}`,
target: 'test',
match: (event) => {
const type: string = event.type
if (type === 'shared/seed' && role === 'update') return { id: 'one', role: 'start' }
if (type === 'shared/input') return { id: 'one', role }
if (type === 'shared/first-only' && index === 0) return { id: 'one', role: 'update' }
return null
},
start: (_context, match) => {
matches.push(match)
return { seqs: [match.event.seq] }
},
update: (context, match) => {
matches.push(match)
return { seqs: [...context.state.seqs, match.event.seq] }
},
buildViewNode: context => node(context, context.state),
}
return { definition, matches }
})
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions(probes.map(probe => probe.definition)),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow(opening, false)
assembler.flush()
const states = () => {
const nodes = [...testSnapshot(assembler)?.nodes.values() ?? []]
return probes.map(probe => nodes.find(value => value.kind === probe.definition.kind)?.data)
}
return { assembler, probes, states }
}
const first = createSession()
const second = createSession()
const shared = input(at(SessionSeq(4), 'shared/input', { turn: 1, step: 1 }))
const expectedStates = [{ seqs: [4] }, { seqs: [4] }, { seqs: [3, 4] }, { seqs: [3, 4] }]
for (const session of [first, second]) {
session.assembler.append(shared)
session.assembler.flush()
const start = session.probes[0]!.matches.at(-1)!
const update = session.probes[2]!.matches.at(-1)!
expect(start).toBe(session.probes[1]!.matches.at(-1))
expect(update).toBe(session.probes[3]!.matches.at(-1))
expect(start).not.toBe(update)
expect(start.role).toBe('start')
expect(update.role).toBe('update')
expect(start.event).toBe(shared.event)
expect(update.event).toBe(shared.event)
expect(start.location.kind).toBe('step')
expect(start.location).toBe(update.location)
expect(session.states()).toEqual(expectedStates)
}
expect(new Set([...first.states(), ...second.states()]).size).toBe(8)
for (const index of [0, 2]) {
const left = first.probes[index]!.matches.at(-1)!
const right = second.probes[index]!.matches.at(-1)!
expect(left).not.toBe(right)
expect(left.location).not.toBe(right.location)
}
const secondSnapshot = testSnapshot(second.assembler)
first.assembler.append(input(at(SessionSeq(5), 'shared/first-only', { turn: 1, step: 1 })))
first.assembler.flush()
expect(first.probes[0]!.matches.at(-1)).not.toBe(first.probes[2]!.matches.at(-1))
expect(first.probes[2]!.matches.at(-1)!.event).toBe(shared.event)
expect(first.states()).toEqual([{ seqs: [4, 5] }, ...expectedStates.slice(1)])
expect(testSnapshot(second.assembler)).toBe(secondSnapshot)
expect(second.states()).toEqual(expectedStates)
})
it('keeps one Match collection while a long Context appends without replay', () => {
@@ -599,6 +835,64 @@ describe('ConversationNodeAssembler', () => {
.toEqual(['assistant/message', 'llm/retry'])
})
it.each(['update', 'publication'] as const)('retires every accepted transient owner after a %s failure', (failure) => {
const definitions: ConversationNodeDefinition<number>[] = Array.from({ length: 6 }, (_, index) => ({
kind: index === 5 ? 'unrelated' : `owner-${index}`,
target: 'test',
match: event => event.type === 'step/start'
? { id: 'one', role: 'start' }
: index < 5 && event.type === 'assistant/live-chunk' ? { id: 'one', role: 'update' } : null,
start: () => 0,
update: (context) => {
if (index === 4 && failure === 'update') throw new Error('owner failure')
return context.state + 1
},
publication: (match) => {
if (index === 4 && failure === 'publication' && match.event.type === 'assistant/live-chunk') {
throw new Error('owner failure')
}
return 'immediate'
},
buildViewNode: context => node(context, {
count: context.state, seqs: context.matches.map(match => match.event.seq),
}),
}))
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions(definitions), new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(SessionSeq(1), 'step/start', { turn: 1, step: 1 }))], false)
assembler.flush()
const before = testSnapshot(assembler)!
expect(before.order).toHaveLength(6)
const unrelated = before.nodes.get(before.order[5]!)
const delta = transientChunk(1.5, 1, 1, {
type: 'tool-call-delta', index: 0, id: ToolCallId('shared'), name: 'write', argumentsDelta: '{}',
})
expect(() => assembler.append(delta)).toThrow('owner failure')
assembler.flush()
const partial = testSnapshot(assembler)!
for (const [index, key] of before.order.entries()) {
const changed = index < 4 || (index === 4 && failure === 'publication')
expect(partial.nodes.get(key)?.data).toEqual({ count: changed ? 1 : 0, seqs: changed ? [1, 1.5] : [1] })
}
expect(assembler.append(delta)).toBe('none')
assembler.flush()
expect(testSnapshot(assembler)).toBe(partial)
expect(assembler.settleAssistant(LlmAttemptId('test-attempt'))).toBe('immediate')
assembler.flush()
const settled = testSnapshot(assembler)!
for (const key of before.order.slice(0, 5)) {
expect(settled.nodes.get(key)?.data).toEqual({ count: 0, seqs: [1] })
expect(settled.nodes.get(key)).not.toBe(partial.nodes.get(key))
}
expect(settled.nodes.get(before.order[5]!)).toBe(unrelated)
assembler.rebuildRegistry()
assembler.flush()
const rebuilt = testSnapshot(assembler)!
expect(rebuilt.order).toEqual(before.order)
expect([...rebuilt.nodes.values()].map(value => value.data)).toEqual([...before.nodes.values()].map(value => value.data))
})
it('replays one pending transient Match after prepend supplies its durable start', () => {
const starts = vi.fn(() => ({ batches: 0, status: 'unresolved' }))
const updates = vi.fn((
@@ -419,11 +419,13 @@ describe('Conversation registries', () => {
expect(events.entries()).toHaveLength(1)
expect(events.fallbackEntry()).toBeDefined()
expect(events.forEvent('turn/start').size).toBe(1)
expect(views.entries()).toHaveLength(1)
await feature.dispose()
expect(events.entries()).toEqual([])
expect(events.fallbackEntry()).toBeUndefined()
expect(events.forEvent('turn/start').size).toBe(0)
expect(views.entries()).toEqual([])
})
@@ -0,0 +1,182 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import type { SessionEventLike } from '@deepseek-ai/dsh-api-session-controller/client'
import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types'
import {
ConversationEventRegistry, ConversationNodeAssembler,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ConversationMatchHandler, ConversationNodeDefinition, ConversationNodeDefinitionInput,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
function registry(): ConversationEventRegistry {
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose() })
return new ConversationEventRegistry(ctx)
}
function definition(kind: string, match: ConversationNodeDefinitionInput['match'], target?: string): ConversationNodeDefinitionInput<null> {
return {
kind, match, start: () => null, update: context => context.state,
...target === undefined ? {} : { target, buildViewNode: () => null },
}
}
function turn(seq: number, number = 1): SessionEvent<'turn/start'> {
return { type: 'turn/start', seq: SessionSeq(seq), time: seq, data: { turn: number } }
}
describe('Conversation event routing', () => {
it('precomputes mixed function and table routes in registration order', () => {
const events = registry()
const handler = () => null
const a = definition('a', handler)
const b = definition('b', { 'turn/start': handler })
const c = definition('c', handler)
const d = definition('d', { 'step/start': handler, 'turn/start': handler })
events.register(a)
const removeB = events.register(b)
events.register(c)
events.register(d)
const selected = events.forEvent('turn/start')
expect([...selected].map(route => route.definition.kind)).toEqual(['a', 'b', 'c', 'd'])
expect([...events.forEvent('step/start')].map(route => route.definition.kind)).toEqual(['a', 'c', 'd'])
expect([...events.forEvent('unknown')].map(route => route.definition.kind)).toEqual(['a', 'c'])
expect(events.forEvent('unknown')).toBe(events.forEvent('another-unknown'))
expect(events.forEvent('turn/start')).toBe(selected)
removeB()
removeB()
expect([...events.forEvent('turn/start')].map(route => route.definition.kind)).toEqual(['a', 'c', 'd'])
expect([...selected].map(route => route.definition.kind)).toEqual(['a', 'b', 'c', 'd'])
events.register(b)
expect([...events.forEvent('turn/start')].map(route => route.definition.kind)).toEqual(['a', 'c', 'd', 'b'])
})
it('reads table handlers at registration and invokes only candidates for each payload', () => {
const events = registry()
const selected = vi.fn<ConversationMatchHandler>(event => event.type === 'turn/start' && event.data.turn === 2
? { id: 'second', role: 'start' } : null)
const unrelated = vi.fn<ConversationMatchHandler>(() => null)
const unrestricted = vi.fn<ConversationMatchHandler>(() => null)
let handlerReads = 0
const table: Record<string, ConversationMatchHandler> = {
get 'turn/start'() { handlerReads++; return selected },
'step/start': unrelated,
}
events.register(definition('table', table))
events.register(definition('function', unrestricted))
const readsAfterRegistration = handlerReads
const readAllDefinitions = vi.spyOn(events, 'entries')
const assembler = new ConversationNodeAssembler(events, { entries: () => [] })
assembler.replaceWindow([], false)
for (let seq = 1; seq <= 100; seq++) assembler.append({ type: 'event', event: turn(seq, seq) })
expect(handlerReads).toBe(readsAfterRegistration)
expect(readAllDefinitions).not.toHaveBeenCalled()
expect(selected).toHaveBeenCalledTimes(100)
expect(unrestricted).toHaveBeenCalledTimes(100)
expect(unrelated).not.toHaveBeenCalled()
expect(selected.mock.results[0]?.value).toBeNull()
expect(selected.mock.results[1]?.value).toEqual({ id: 'second', role: 'start' })
})
it('keeps callable entries usable by an assembler adapter without indexed routing', () => {
const events = registry()
const table = vi.fn<ConversationMatchHandler>(() => null)
const legacy = vi.fn<ConversationMatchHandler>(() => null)
events.register(definition('table', { 'turn/start': table }))
events.register(definition('legacy', legacy))
expect(events.entries().every(entry => typeof entry.match === 'function')).toBe(true)
const assembler = new ConversationNodeAssembler({
entries: () => events.entries(), fallbackEntry: () => events.fallbackEntry(),
}, { entries: () => [] })
assembler.append({ type: 'event', event: turn(1) })
assembler.append({ type: 'event', event: {
type: 'step/start', seq: SessionSeq(2), time: 2, data: { turn: 1, step: 1 },
} })
expect(table).toHaveBeenCalledOnce()
expect(legacy).toHaveBeenCalledTimes(2)
})
it('preserves the Definition receiver of a function-form matcher', () => {
const events = registry()
const receivers: ConversationNodeDefinition[] = []
const match = function (this: ConversationNodeDefinition, _event: SessionEventLike) {
receivers.push(this)
return null
}
const entry = definition('receiver', match)
events.register(entry)
for (const route of events.forEvent('turn/start')) route.match(turn(1))
expect(receivers).toEqual([entry])
})
it('indexes only own table keys, including names inherited by ordinary objects', () => {
const events = registry()
const entry = definition('own-keys', { ['__proto__']: () => null, constructor: () => null })
events.register(entry)
expect([...events.forEvent('__proto__')].map(route => route.definition.kind)).toEqual(['own-keys'])
expect([...events.forEvent('constructor')].map(route => route.definition.kind)).toEqual(['own-keys'])
expect(events.forEvent('toString').size).toBe(0)
events.register(definition('empty', {}))
expect(events.forEvent('unlisted').size).toBe(0)
})
it('keeps fallback functions callable and normalizes table fallbacks', () => {
const events = registry()
const match = vi.fn<ConversationMatchHandler>(() => null)
const unrestricted = definition('generic-fallback', match, 'chat')
const dispose = events.registerFallback(unrestricted)
expect(events.fallbackEntry()).toBe(unrestricted)
dispose()
dispose()
expect(events.fallbackEntry()).toBeUndefined()
const table = definition('table-fallback', { 'turn/start': match, ['__proto__']: match }, 'chat')
const removeTable = events.registerFallback(table)
const resolved = events.fallbackEntry()!
expect(typeof resolved.match).toBe('function')
resolved.match(turn(1))
expect(match).toHaveBeenCalledOnce()
resolved.match({ type: 'step/start', seq: SessionSeq(2), time: 2, data: { turn: 1, step: 1 } })
expect(match).toHaveBeenCalledOnce()
removeTable()
expect(events.fallbackEntry()).toBeUndefined()
})
it.each(['chat', 'other', undefined] as const)('suppresses fallback only after a real match for its target (%s)', (target) => {
const events = registry()
const match = vi.fn<ConversationMatchHandler>(event => event.type === 'turn/start' && event.data.turn === 2
? { id: 'second', role: 'start' } : null)
const fallback = vi.fn<ConversationMatchHandler>(() => ({ id: 'fallback', role: 'start' }))
events.register(definition('ordinary', { 'turn/start': match }, target))
events.registerFallback(definition('fallback', { 'turn/start': fallback }, 'chat'))
const assembler = new ConversationNodeAssembler(events, { entries: () => [] })
assembler.replaceWindow([], false)
assembler.append({ type: 'event', event: turn(1) })
assembler.append({ type: 'event', event: turn(2, 2) })
expect(match).toHaveBeenCalledTimes(2)
expect(fallback).toHaveBeenCalledTimes(target === 'chat' ? 1 : 2)
})
it.each(['indexed', 'legacy'].flatMap(adapter => (
['first', 'second', 'third', 'unmatched'].map(target => ({ adapter, target }))
)))('checks fallback against every matched target ($adapter, $target)', ({ adapter, target }) => {
const events = registry()
const targets = ['first', 'first', 'second', 'third', 'second']
const matched: string[] = []
for (const [index, ordinaryTarget] of targets.entries()) {
events.register(definition(`ordinary-${index}`, { 'turn/start': () => {
matched.push(ordinaryTarget)
return { id: 'one', role: 'start' }
} }, ordinaryTarget))
}
const fallback = vi.fn<ConversationMatchHandler>(() => ({ id: 'fallback', role: 'start' }))
events.registerFallback(definition('fallback', fallback, target))
const definitions = adapter === 'indexed' ? events : {
entries: () => events.entries(), fallbackEntry: () => events.fallbackEntry(),
}
const assembler = new ConversationNodeAssembler(definitions, { entries: () => [] })
assembler.append({ type: 'event', event: turn(1) })
expect(matched).toEqual(targets)
expect(fallback).toHaveBeenCalledTimes(target === 'unmatched' ? 1 : 0)
})
})
+4 -4
View File
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-tool/use-this-package:
en: 83b01ee6e554504b
zh: 3d33b3a110e4dfdc
en: b99e542d5173027c
zh: 0bc200adf2289ba0
/deepseek-ai-dsh-client-ui-tool/use-this-package/registering-a-business-tool-view:
en: f19e077425b8f312
zh: 24d88794be439302
@@ -27,8 +27,8 @@
en: 00bc7b0c84edc603
zh: 1cfcf5b435248a91
/deepseek-ai-dsh-client-ui-tool/understand-the-implementation/rendering-contract:
en: fc6bc5aeb693692d
zh: ca3df7cff8126785
en: 672e63b2f92e689b
zh: ba950ecb480325a6
/deepseek-ai-dsh-client-ui-tool/understand-the-implementation/cards:
en: d202f30c846543d2
zh: a84656956c66a574
+2 -2
View File
@@ -29,7 +29,7 @@ Tool calls appear in the conversation as cards: a root call tree with its nested
Shared Tool rows and Bash rows retain error and warning colors for failed and stopped summaries, including on hover. Hover darkens only summaries without those states.
Before dispatch, a named model call appears as one non-expandable row with its tool-owned icon and title. A generic row shows `Tool call · <tool name>`. Read/write/edit show an openable path once `file_path` closes; write/edit also show `Preparing content NKB` while content streams. N rounds the decoded content's UTF-16 length up in units of 1024, not file bytes. Command rows show the streamed description. `tool/call` enables the existing call presentation; completing an argument block alone does not start execution.
Before dispatch, a named model call appears as one non-expandable row with its tool-owned icon and title. A generic row shows `Tool call · <tool name>`. Read/write/edit show an openable path once `file_path` closes and decodes without errors; write/edit also show `Preparing content NKB` after that path while content streams, never as a standalone summary. N rounds the decoded content's UTF-16 length up in units of 1024, not file bytes. 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
@@ -72,7 +72,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.
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. A streamed delta replaces the block only when an observed answer changes; publication remains frame-batched. Card models that need dispatched arguments continue to read `argsRaw`.
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
+2 -2
View File
@@ -29,7 +29,7 @@ kind: "package-reference"
共享工具行和 Bash 行的失败、停止摘要在悬停时仍保留错误色和警告色;只有不处于这两种状态的摘要会在悬停时加深。
派发前,模型已给出名称的调用显示为不可展开的一行,使用工具自己的图标与标题。通用行显示为 `工具调用 · <工具名>`。read/write/edit 在 `file_path` 闭合后显示可打开的路径;write/edit 在内容流入时还显示「正在准备内容 NKB」。N 按解码后内容的 UTF-16 长度除以 1024 向上取整,不是文件字节数。命令行显示流入中的描述。`tool/call` 才启用既有调用展示;参数块结束本身不代表开始执行。
派发前,模型已给出名称的调用显示为不可展开的一行,使用工具自己的图标与标题。通用行显示为 `工具调用 · <工具名>`。read/write/edit 在 `file_path` 闭合且解码无误后显示可打开的路径;write/edit 在内容流入时还在该路径后显示「正在准备内容 NKB」,不将它单独作为摘要。N 按解码后内容的 UTF-16 长度除以 1024 向上取整,不是文件字节数。命令行显示流入中的描述。`tool/call` 才启用既有调用展示;参数块结束本身不代表开始执行。
### 注册业务工具视图
@@ -72,7 +72,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、`phase` 判别字
Tool 所有者属性将 Chat 注入的稳定 `useDisclosure` 钩子传给根调用及嵌套调用。工具行在拥有展开正文的位置调用它,中间 renderer 不订阅。每次调用拥有独立展开状态,外层轮次收起时重置该状态,不替换 React 身份;展示模式切换保留该状态。
Tool Definition 拥有[懒计算参数视图](../../util/values/README.zh.md)。工具行通过读器选择字段和更新粒度,不另设订阅或注册。流式 delta 只在已读取的答案变化时替换块引用,发布仍按帧合批。需要完整派发参数的卡片模型继续读取 `argsRaw`。
Tool Definition 拥有[懒计算参数视图](../../util/values/README.zh.md)。工具行通过读器选择字段和更新粒度,不另设订阅或注册。delta 追加时不扫描;按帧合批的发布会刷新已观察答案,仅在答案变化时替换块引用。需要完整派发参数的卡片模型继续读取 `argsRaw`。
### 卡片
@@ -256,7 +256,7 @@ function argumentSummary(
): { summary: string; filePath: string | undefined } {
if (FILE_PATH_VARIANTS.has(variant)) {
const path = args.complete('file_path') ? args.text('file_path') : undefined
const filePath = path !== undefined && path !== '' ? firstLine(path) : undefined
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')
@@ -26,14 +26,13 @@ function pendingContentKilobytes(block: FileMutationRowProps['block']): number |
/**
* Lets users open the path as soon as its string closes, watch the content
* size while it streams, and expand the applied diff once the call settles.
* size alongside it while it streams, and expand the applied diff once the call settles.
*/
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 = pendingContentKilobytes(block)
const size = kilobytes === null ? null : t('tool.preparing.content', { kilobytes })
// The size stands alone until the path closes, then trails the path.
return (
<ToolRow
useDisclosure={useDisclosure}
@@ -42,7 +41,7 @@ export function FileMutationRow({ toolName, block, cwd, home, openFile, inspect,
toolName={toolName}
icon={FILE_MUTATION_ICON}
title={t(model.titleKey)}
summary={size !== null && model.summary === '' ? size : model.summary}
summary={model.summary}
summarySuffix={size !== null && model.summary !== '' ? size : undefined}
output={model.output}
errorSummary={model.errorSummary}
@@ -35,6 +35,13 @@ function preparation(name: string, args = new PartialArguments()): Extract<Props
}
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],
@@ -55,12 +62,27 @@ describe('tool preparation', () => {
expect(view.container.querySelector('[aria-expanded="true"]')).toBeNull()
})
it.each([['write', 'content'], ['edit', 'new_string']])('%s hides content progress without a path', (name, field) => {
const args = new PartialArguments()
args.append(`{"${field}":"hello`)
const view = render(<FileMutationRow {...preparation(name, args)} />)
expect(view.queryByText('Preparing content 1KB')).toBeNull()
expect(view.queryByRole('button')).toBeNull()
})
it('retains the file row through streamed arguments, dispatch, and result', () => {
const args = new PartialArguments()
const props = preparation('write', args)
const view = render(<FileMutationRow {...props} />)
const preparingRow = view.container.querySelector('[data-tool="write"]')
args.append('{"file_path":"hello.txt","content":"hello')
args.append('{"file_path":"hello')
view.rerender(<FileMutationRow {...props} block={{ ...props.block }} />)
expect(view.queryByRole('button')).toBeNull()
args.append('.txt"')
view.rerender(<FileMutationRow {...props} block={{ ...props.block }} />)
expect(view.getByText('hello.txt')).toBeTruthy()
expect(view.queryByText('Preparing content 1KB')).toBeNull()
args.append(',"content":"hello')
view.rerender(<FileMutationRow {...props} block={{ ...props.block }} />)
expect(view.getByText('hello.txt')).toBeTruthy()
expect(view.getByText('Preparing content 1KB')).toBeTruthy()
@@ -120,8 +142,10 @@ describe('tool preparation', () => {
const props = preparation('edit', args)
const view = render(<FileMutationRow {...props} />)
expect(view.getByText('Preparing content 1KB')).toBeTruthy()
expect(args.append('b'.repeat(424))).toBe(false)
expect(args.append('b')).toBe(true)
args.append('b'.repeat(424))
expect(args.refresh()).toBe(false)
args.append('b')
expect(args.refresh()).toBe(true)
view.rerender(<FileMutationRow {...props} block={{ ...props.block }} />)
expect(view.getByText('Preparing content 2KB')).toBeTruthy()
})
+2 -2
View File
@@ -21,8 +21,8 @@
en: dc6de9f27cc98557
zh: 9c2bac179e536147
/deepseek-ai-dsh-util-values/use-this-package/read-streamed-arguments:
en: 0106fe950f8ade21
zh: a7bf33e77c2d042c
en: f1bc062e66cf3978
zh: 6154fe02e1a4b2ce
/deepseek-ai-dsh-util-values/use-this-package/publish-compare-or-retain-keyed-values:
en: 13628af2abe3eea6
zh: e284bb8d3ebe38ea
+3 -1
View File
@@ -39,7 +39,9 @@ const snapshot = snapshotJsonValue(input) as JsonValue
### Read streamed arguments
`PartialArguments` reads a JSON object's top-level fields lazily. It scans only unread fragments and retains the source for later field reads. Append fragments to a new instance; `append()` reports changes only to answers already read. String readers expose decoded text, completion, and UTF-16 length, with an optional step and completed-prefix offset for change detection. Non-string values become available when complete. `fromText()` and `fromObject()` create sealed views that reject appends; `closed()` also covers invalid input, where scanning stops. See [the readers](src/partial-json.ts) for return distinctions.
`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. `closed()` covers a closed outer object, failed indexing, or sealing. See [the readers](src/partial-json.ts) for return distinctions.
### Publish, compare, or retain keyed values
+3 -1
View File
@@ -39,7 +39,9 @@ const snapshot = snapshotJsonValue(input) as JsonValue
### 读取流式参数
`PartialArguments` 懒读取 JSON 对象的顶层字段。它只扫描尚未读取的片段,并保留原文供后续字段读取。向新实例追加分片,`append()` 只报告已读取答案的变化。字符串读器提供解码文本、完成状态和 UTF-16 长度,并可为变化判定指定步长和已完成前缀的偏移量。非字符串值在完整后可用。`fromText()` 和 `fromObject()` 创建拒绝追加的封存视图;`closed()` 也包含停止扫描的非法输入。返回值区别见[读器定义](src/partial-json.ts)。
`PartialArguments` 懒读取 JSON 对象的顶层字段。`append()` 分别保留分片,不扫描也不拼接。读器为新的键值范围建立索引,跳过未请求的内容;仅在请求时解码或计数字符串,仅在请求时解析已完整的非字符串值。`complete()` 表示结束分隔符已到达,不代表内容已校验;`invalid` 报告索引或内容读取已经发现的错误。它不能替代工具入参校验。
`refresh()` 在发布点报告已观察答案的变化;期间的读取不会确认待发布的变化。它先读取内容再比较完成状态,避免解码错误吞掉完成状态更新。字符串读器提供解码文本、有界前缀和精确 UTF-16 长度,并可为变化判定指定步长和偏移量。`settle(finalText)` 直接将分片与完整文本比较:输入相同时封存原视图并保留缓存,分片缺失或冲突时创建新的封存视图。`fromText()` 和 `fromObject()` 也创建拒绝追加的封存视图。`closed()` 包含外层对象闭合、索引失败或已封存的情况。返回值区别见[读器定义](src/partial-json.ts)。
### 发布、比较或保留键控值
+340 -163
View File
@@ -2,8 +2,8 @@
* 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, when more text arrives, reports a change only if one of those answers
* would differ. Used for model tool-call arguments: a row reads the fields it
* 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
*/
@@ -22,16 +22,34 @@ type Mode =
| 'root' | 'key-or-end' | 'key-only' | 'key' | 'colon' | 'value'
| 'string' | 'scalar' | 'nested' | 'comma-or-end' | 'closed' | 'invalid'
/** One top-level field located in the text: a string with its decoded progress, or another value. */
type Entry =
| { readonly kind: 'string'; readonly start: number; end: number; length: number; text: string | undefined }
| { readonly kind: 'value'; readonly start: number; end: number; parsed: JsonValue | undefined }
/** A content reader advances independently of the boundary scanner. */
interface StringRead {
at: number
length: number
text: string
}
type ReadKind = 'closed' | 'keys' | 'has' | 'complete' | 'text' | 'value' | `length:${number}:${number}`
/** 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<number, StringRead> | 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 answer: (view: PartialArguments) => unknown
readonly completion: boolean
readonly answer: () => unknown
last: unknown
}
@@ -39,6 +57,8 @@ const SIMPLE_ESCAPES: Readonly<Record<string, string>> = {
'"': '"', '\\': '\\', '/': '/', 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'
}
@@ -47,32 +67,6 @@ function isHex(c: string): boolean {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
}
/**
* Decode an already scanned JSON string body. The caller cuts the body before any
* escape still in flight, so a trailing `\\` or an unknown escape only appears
* after the scan failed there; decoding stops at that point.
*/
function decodeStringPrefix(body: string): string {
let out = ''
for (let i = 0; i < body.length; i++) {
const c = body[i] as string
if (c < ' ') break
if (c !== '\\') { out += c; continue }
const e = body[i + 1]
if (e === undefined) break
if (e === 'u') {
out += String.fromCharCode(Number.parseInt(body.slice(i + 2, i + 6), 16))
i += 5
continue
}
const decoded = SIMPLE_ESCAPES[e]
if (decoded === undefined) break
out += decoded
i += 1
}
return out
}
/**
* The view. A streaming instance grows through {@link PartialArguments.append};
* {@link PartialArguments.fromText} and {@link PartialArguments.fromObject} build
@@ -114,58 +108,100 @@ export class PartialArguments {
* These are the only enumerable fields, so two views over the same source
* compare equal structurally however far each has been read.
*/
private raw = ''
private chunks: string[] = []
private object: Readonly<Record<string, JsonValue>> | undefined
private sealed = false
// Scan progress, located fields, and remembered reads are caches over the source.
#pending = ''
#ends: number[] = []
#size = 0
#consumed = 0
#mode: Mode = 'root'
#escape = false
#unicode: string | null = null
#keyRaw = ''
#keyStart = 0
#keyEscaped = false
#key = ''
#current: Entry | null = null
#nestedDepth = 0
#nestedEnds: string[] = []
#nestedInString = false
#invalidAt: number | undefined
#invalidValue = false
readonly #entries = new Map<string, Entry>()
readonly #order: string[] = []
readonly #reads = new Map<string, Read>()
/** Whether the text stopped being a JSON object prefix; scanning stops there and answers freeze. */
/** Whether indexing or a content read found invalid JSON; unread value contents are not validated. */
get invalid(): boolean {
this.scan()
return this.#mode === 'invalid'
return this.#mode === 'invalid' || this.#invalidValue
}
/**
* Append streamed argument text.
* Retain streamed argument text without scanning or comparing observed answers.
* @param fragment - the text following every fragment appended before.
* @returns whether the answer to a question already asked would now differ; false when nothing was read yet.
*/
append(fragment: string): boolean {
append(fragment: string): void {
if (this.sealed) throw new Error('PartialArguments: cannot append to a sealed view')
this.raw += fragment
this.#pending += fragment
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()) {
const now = read.answer(this)
if (!Object.is(now, read.last)) {
read.last = now
changed = true
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 no further field can appear: the object closed, the text stopped being JSON, or the view is sealed.
* @returns whether the outer object closed, indexing failed, or the view is sealed; unread values are not validated.
*/
closed(): boolean {
return this.remember('closed', '', view => view.closedNow(), () => this.closedNow())
return this.remember('closed', '', () => this.closedNow())
}
/**
@@ -173,7 +209,7 @@ export class PartialArguments {
* @returns top-level keys seen so far, in first-appearance order.
*/
keys(): readonly string[] {
return this.remember('keys', '', view => view.keysNow().length, () => this.keysNow())
return this.remember('keys', '', () => this.keysNow(), keys => keys.length)
}
/**
@@ -182,16 +218,16 @@ export class PartialArguments {
* @returns whether the field has appeared (a string opened or another value began).
*/
has(key: string): boolean {
return this.remember('has', key, view => view.hasNow(key), () => this.hasNow(key))
return this.remember('has', key, () => this.hasNow(key))
}
/**
* Check whether a field's value is complete.
* Check whether a field's closing delimiter has arrived, without validating its contents.
* @param key - argument name.
* @returns whether the field's value is final: a closed string or a closed other value.
* @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, view => view.completeNow(key), () => this.completeNow(key))
return this.remember('complete', key, () => this.completeNow(key))
}
/**
@@ -203,10 +239,20 @@ export class PartialArguments {
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, (view) => {
const length = view.lengthNow(key)
return length === undefined ? undefined : Math.ceil((length + offset) / step)
}, () => this.lengthNow(key))
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)
}
/**
@@ -215,7 +261,19 @@ export class PartialArguments {
* @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, view => view.textNow(key), () => this.textNow(key))
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))
}
/**
@@ -224,18 +282,27 @@ export class PartialArguments {
* @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, view => view.valueNow(key), () => this.valueNow(key))
return this.remember('value', key, () => this.valueNow(key))
}
/** Answer a question and, on a streaming view, remember it for change detection. */
private remember<T>(kind: ReadKind, key: string, answer: (view: PartialArguments) => unknown, read: () => T): T {
private remember<T>(
kind: ReadKind,
key: string,
read: () => T,
comparison?: (value: T) => unknown,
): T {
this.scan()
const result = read()
if (!this.sealed) {
const id = `${kind}/${key}`
const existing = this.#reads.get(id)
if (existing === undefined) this.#reads.set(id, { answer, last: answer(this) })
else existing.last = answer(this)
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
}
@@ -256,15 +323,20 @@ export class PartialArguments {
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): number | undefined {
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)
return entry?.kind === 'string' ? entry.length : undefined
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 {
@@ -274,19 +346,35 @@ export class PartialArguments {
}
const entry = this.#entries.get(key)
if (entry?.kind !== 'string') return undefined
if (entry.text === undefined) {
// First text read: decode what streamed so far; scanning keeps it current from here on.
entry.text = entry.end >= 0
? JSON.parse(`"${this.raw.slice(entry.start, entry.end)}"`) as string
: decodeStringPrefix(this.raw.slice(entry.start, this.openStringEnd()))
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 }
}
return entry.text
const read = entry.text ??= { at: entry.start, length: 0, text: '' }
this.readString(entry, read, Number.POSITIVE_INFINITY, true)
return read.text
}
/** Where the decoded prefix of the string being read ends: before any escape still in flight. */
private openStringEnd(): number {
if (this.#unicode !== null) return this.#consumed - 2 - this.#unicode.length
return this.#escape ? this.#consumed - 1 : this.#consumed
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<number, StringRead>()
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 {
@@ -296,17 +384,137 @@ export class PartialArguments {
return typeof field === 'string' ? undefined : field
}
const entry = this.#entries.get(key)
return entry?.kind === 'value' && entry.end >= 0 ? entry.parsed : undefined
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
}
/** Index only unread text; indexing accumulated raw text repeatedly flattens its prefix. */
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<Entry, { kind: 'string' }>,
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) return
const pending = this.#pending
this.#pending = ''
for (let index = 0; index < pending.length && this.#mode !== 'invalid'; index++) {
this.step(pending[index] as string, this.#consumed)
this.#consumed++
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<Entry, { kind: 'string' }>
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
}
}
@@ -319,22 +527,27 @@ export class PartialArguments {
case 'key-or-end':
if (isWhitespace(c)) return
if (c === '}') { this.#mode = 'closed'; return }
if (c === '"') { this.beginKey(); return }
if (c === '"') { this.beginKey(at); return }
this.fail(); return
case 'key-only':
if (isWhitespace(c)) return
if (c === '"') { this.beginKey(); return }
if (c === '"') { this.beginKey(at); return }
this.fail(); return
case 'key':
this.stepKey(c); return
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':
this.stepString(c, at); return
case 'string': {
const entry = this.#current as Extract<Entry, { kind: 'string' }>
entry.end = at
this.#current = null
this.#mode = 'comma-or-end'
return
}
case 'scalar':
this.stepScalar(c, at); return
case 'nested':
@@ -357,29 +570,35 @@ export class PartialArguments {
}
private fail(): void {
this.#invalidAt = this.#consumed
this.#mode = 'invalid'
this.#current = null
}
private beginKey(): void {
private beginKey(at: number): void {
this.#mode = 'key'
this.#keyRaw = ''
this.#keyStart = at + 1
this.#keyEscaped = false
this.#escape = false
}
private stepKey(c: string): void {
if (this.#escape) { this.#escape = false; this.#keyRaw += c; return }
if (c === '\\') { this.#escape = true; this.#keyRaw += c; return }
if (c !== '"') { this.#keyRaw += c; return }
let key: unknown
try {
key = JSON.parse(`"${this.#keyRaw}"`)
} catch {
// The key text is not a valid JSON string: the object text is not a JSON prefix.
this.fail()
return
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.#key = key as string
this.#mode = 'colon'
}
@@ -392,17 +611,19 @@ export class PartialArguments {
private beginValue(c: string, at: number): void {
if (isWhitespace(c)) return
if (c === '"') {
this.open({ kind: 'string', start: at + 1, end: -1, length: 0, text: undefined })
this.open({
kind: 'string', start: at + 1, end: -1, needsDecoding: false, invalidAt: undefined,
length: undefined, text: undefined, prefixes: undefined,
})
this.#escape = false
this.#unicode = null
this.#mode = 'string'
return
}
if (c === '}' || c === ',' || c === ':' || c === ']') { this.fail(); return }
this.open({ kind: 'value', start: at, end: -1, parsed: undefined })
this.open({ kind: 'value', start: at, end: -1, parsed: undefined, invalid: false })
if (c === '{' || c === '[') {
this.#mode = 'nested'
this.#nestedDepth = 1
this.#nestedEnds = [c === '{' ? '}' : ']']
this.#nestedInString = false
this.#escape = false
return
@@ -410,75 +631,31 @@ export class PartialArguments {
this.#mode = 'scalar'
}
private stepString(c: string, at: number): void {
const entry = this.#current as Extract<Entry, { kind: 'string' }>
if (c < ' ') { this.fail(); return }
if (this.#unicode !== null) {
if (!isHex(c)) { this.fail(); return }
this.#unicode += c
if (this.#unicode.length === 4) {
this.grow(entry, String.fromCharCode(Number.parseInt(this.#unicode, 16)))
this.#unicode = null
}
return
}
if (this.#escape) {
this.#escape = false
if (c === 'u') { this.#unicode = ''; return }
const decoded = SIMPLE_ESCAPES[c]
if (decoded === undefined) { this.fail(); return }
this.grow(entry, decoded)
return
}
if (c === '\\') { this.#escape = true; return }
if (c === '"') {
entry.end = at
this.#current = null
this.#mode = 'comma-or-end'
return
}
this.grow(entry, c)
}
/** Count one decoded unit and keep the text current when a reader asked for it. */
private grow(entry: Extract<Entry, { kind: 'string' }>, decoded: string): void {
entry.length += decoded.length
if (entry.text !== undefined) entry.text += decoded
}
private stepScalar(c: string, at: number): void {
if (c !== ',' && c !== '}' && !isWhitespace(c)) return
if (!this.closeValue(at - 1)) return
this.closeValue(at)
this.#mode = c === ',' ? 'key-only' : c === '}' ? 'closed' : 'comma-or-end'
}
private stepNested(c: string, at: number): void {
if (this.#nestedInString) {
if (this.#escape) { this.#escape = false; return }
if (c === '\\') { this.#escape = true; return }
if (c === '"') this.#nestedInString = false
this.#nestedInString = false
return
}
if (c === '"') { this.#nestedInString = true; return }
if (c === '{' || c === '[') { this.#nestedDepth++; return }
if (c === '{' || c === '[') { this.#nestedEnds.push(c === '{' ? '}' : ']'); return }
if (c === '}' || c === ']') {
this.#nestedDepth--
if (this.#nestedDepth === 0 && this.closeValue(at)) this.#mode = 'comma-or-end'
if (this.#nestedEnds.pop() !== c) { this.fail(); return }
if (this.#nestedEnds.length === 0) {
this.closeValue(at + 1)
this.#mode = 'comma-or-end'
}
}
}
/** Close the non-string value ending at `end`; text that is not JSON freezes the view. */
private closeValue(end: number): boolean {
private closeValue(end: number): void {
const entry = this.#current as Extract<Entry, { kind: 'value' }>
try {
entry.parsed = JSON.parse(this.raw.slice(entry.start, end + 1)) as JsonValue
} catch {
// A number or literal that is not JSON (`tru`, `1.`): the object text is not a JSON prefix.
this.fail()
return false
}
entry.end = end
this.#current = null
return true
}
}
+495 -89
View File
@@ -12,7 +12,7 @@ const CLOSED_TEXTS = [
'{}',
]
/** Texts that stop being a JSON object prefix part-way through. */
/** 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"}`,
@@ -50,6 +50,8 @@ 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
}
@@ -63,17 +65,19 @@ interface ViewSnapshot {
/** 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: keys.map((key): readonly [string, FieldView] => [key, {
has: view.has(key),
complete: view.complete(key),
length: view.stringLength(key),
text: view.text(key),
value: view.value(key),
}]),
fields,
}
}
@@ -83,6 +87,11 @@ function streamed(chunks: readonly string[]): PartialArguments {
return view
}
function appendAndRefresh(view: PartialArguments, fragment: string): boolean {
view.append(fragment)
return view.refresh()
}
describe('PartialArguments', () => {
describe('sealed views', () => {
it('EMPTY answers every reader with absence and refuses appends', () => {
@@ -94,7 +103,7 @@ describe('PartialArguments', () => {
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')
expect(() => { empty.append('{') }).toThrow('cannot append to a sealed view')
})
it('reports every sealed view as closed, whatever its text says', () => {
@@ -140,7 +149,7 @@ describe('PartialArguments', () => {
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')
expect(() => { view.append('{') }).toThrow('cannot append to a sealed view')
})
it('fromObject treats anything but an object as a call without fields', () => {
@@ -156,19 +165,21 @@ describe('PartialArguments', () => {
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(() => { fromText.append(' ') }).toThrow('cannot append to a sealed view')
}
expect('polluted' in {}).toBe(false)
})
it('fromText reports invalid text and keeps the answers reached before the failure', () => {
for (const text of INVALID_TEXTS) expect(PartialArguments.fromText(text).invalid).toBe(true)
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(true)
expect(view.invalid).toBe(false)
expect(view.value('n')).toBe(1)
expect(view.has('b')).toBe(true)
expect(view.complete('b')).toBe(false)
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', () => {
@@ -176,22 +187,141 @@ describe('PartialArguments', () => {
try {
const sealed = PartialArguments.fromText('{"todos":[1],"n":2}')
const streaming = new PartialArguments()
expect(streaming.append('{"todos":[1],')).toBe(false)
expect(streaming.append('"n":2}')).toBe(false)
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(parse).toHaveBeenCalled()
parse.mockClear()
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).toHaveBeenCalled()
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]) {
@@ -366,6 +496,41 @@ describe('PartialArguments', () => {
})
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)
@@ -387,7 +552,7 @@ describe('PartialArguments', () => {
const eager = new PartialArguments()
expect(eager.text('file_path')).toBeUndefined()
for (const chunk of chunks) eager.append(chunk)
for (const chunk of chunks) appendAndRefresh(eager, chunk)
expect(eager.text('file_path')).toBe('中')
expect(eager.stringLength('file_path')).toBe(1)
}
@@ -449,14 +614,14 @@ describe('PartialArguments', () => {
const readBeforeKey = new PartialArguments()
expect(readBeforeKey.text('file_path')).toBeUndefined()
expect(readBeforeKey.append(text)).toBe(true)
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(readMidString.append(String.raw`b\q"}`)).toBe(true)
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)
@@ -464,16 +629,18 @@ describe('PartialArguments', () => {
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(true)
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(true)
expect(eager.invalid).toBe(false)
expect(eager.text('file_path')).toBe('ab')
expect(eager.invalid).toBe(true)
expect(eager.stringLength('file_path')).toBe(2)
})
})
@@ -507,7 +674,7 @@ describe('PartialArguments', () => {
expect(PartialArguments.fromText('{"file_path":"x"}}').text('file_path')).toBe('x')
})
it('reports a streaming view closed once the object closes or the text turns invalid, never while open', () => {
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')
@@ -524,6 +691,8 @@ describe('PartialArguments', () => {
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)
})
@@ -560,7 +729,7 @@ describe('PartialArguments', () => {
for (const chunks of chunkings) {
const eager = new PartialArguments()
snapshotOf(eager, expected.keys)
for (const chunk of chunks) eager.append(chunk)
for (const chunk of chunks) appendAndRefresh(eager, chunk)
expect(snapshotOf(eager, expected.keys)).toStrictEqual(expected)
expect(snapshotOf(streamed(chunks))).toStrictEqual(expected)
}
@@ -574,7 +743,11 @@ describe('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)
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)
@@ -584,10 +757,22 @@ describe('PartialArguments', () => {
})
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(view.append('{"file_path":"src/a.ts","n":1,')).toBe(false)
expect(view.append('"todos":[1]}')).toBe(false)
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])
@@ -597,15 +782,15 @@ describe('PartialArguments', () => {
const view = new PartialArguments()
view.append('{"content":"')
expect(view.stringLength('content', { step: 1024 })).toBe(0)
expect(view.append('a')).toBe(true)
expect(view.append('a'.repeat(1022))).toBe(false)
expect(view.append('a')).toBe(false)
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(view.append('a')).toBe(true)
expect(view.append('a'.repeat(1023))).toBe(false)
expect(view.append('a')).toBe(true)
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(view.append('"}')).toBe(false)
expect(appendAndRefresh(view, '"}')).toBe(false)
})
it('treats a missing, zero, or fractional step as its floor of at least one', () => {
@@ -614,39 +799,102 @@ describe('PartialArguments', () => {
perCharacter.stringLength('c')
perCharacter.stringLength('c', {})
perCharacter.stringLength('c', { step: 0 })
for (const c of 'abc') expect(perCharacter.append(c)).toBe(true)
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(byTwo.append('a')).toBe(true)
expect(byTwo.append('b')).toBe(false)
expect(byTwo.append('c')).toBe(true)
expect(byTwo.append('d')).toBe(false)
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(view.append(c)).toBe(true)
expect(view.append('\\')).toBe(false)
expect(view.append('n')).toBe(true)
expect(view.append('"')).toBe(false)
expect(view.append(',"n":1}')).toBe(false)
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(view.append('{"a":1,"x"')).toBe(false)
expect(view.append(':')).toBe(false)
expect(view.append('"')).toBe(true)
expect(view.append('v","y":')).toBe(false)
expect(view.append('1')).toBe(true)
expect(view.append('}')).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', () => {
@@ -654,73 +902,123 @@ describe('PartialArguments', () => {
view.append('{"file_path":"a')
expect(view.complete('file_path')).toBe(false)
expect(view.complete('todos')).toBe(false)
expect(view.append('b')).toBe(false)
expect(view.append('"')).toBe(true)
expect(view.append(',"todos":[1,')).toBe(false)
expect(view.append('2]')).toBe(true)
expect(view.append('}')).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(view.append('[{"content":"x"')).toBe(false)
expect(view.append('}')).toBe(false)
expect(view.append(']')).toBe(true)
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(view.append('}')).toBe(false)
expect(appendAndRefresh(view, '}')).toBe(false)
})
it('reports empty keys and new keys, not repeated keys', () => {
const view = new PartialArguments()
expect(view.keys()).toEqual([])
expect(view.append('{"":"x"')).toBe(true)
expect(view.append(',"":"y"')).toBe(false)
expect(view.append(',"b"')).toBe(false)
expect(view.append(':1')).toBe(true)
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 closing brace arrives or the text turns invalid', () => {
it('reports a closed read when the outer object closes or indexing fails', () => {
const view = new PartialArguments()
expect(view.closed()).toBe(false)
expect(view.append('{"a":1,"b":"x"')).toBe(false)
expect(view.append('}')).toBe(true)
expect(view.append(' ')).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(broken.append('{"a":tru')).toBe(false)
expect(broken.append('}')).toBe(true)
expect(appendAndRefresh(broken, '{"a":')).toBe(false)
expect(appendAndRefresh(broken, ']')).toBe(true)
expect(broken.closed()).toBe(true)
expect(broken.append('}')).toBe(false)
expect(broken.invalid).toBe(true)
expect(appendAndRefresh(broken, '}')).toBe(false)
})
it('absorbs a change once the reader has seen it', () => {
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.append('')).toBe(false)
expect(view.append('z')).toBe(true)
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('freezes every answer once the text is invalid', () => {
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(view.append('{"n":1,"b":tru}')).toBe(false)
expect(appendAndRefresh(view, '{"n":1,"b":}')).toBe(false)
expect(view.invalid).toBe(true)
expect(view.value('n')).toBe(1)
expect(view.append(',"file_path":"x"}')).toBe(false)
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(midString.append(String.raw`\q"}`)).toBe(false)
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)
@@ -757,7 +1055,8 @@ describe('PartialArguments', () => {
let reported = 0
let previousLength = 0
for (const chunk of chunks) {
const changed = view.append(chunk)
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
@@ -793,14 +1092,10 @@ describe('PartialArguments', () => {
['{"a":]', 'a value starting with ]'],
['{"a":"b" x', 'text other than , or } after a value'],
['{"file_path":"x"}}', 'a second closing brace'],
[String.raw`{"file_path":"\q"}`, 'an invalid escape in a string'],
[String.raw`{"file_path":"\u12G4"}`, 'a non-hex digit in a unicode escape'],
['{"a":tru}', 'a malformed literal ended by }'],
['{"a":1.,', 'a malformed number ended by ,'],
['{"a":1. ', 'a malformed number ended by whitespace'],
['{"a":[tru]}', 'a malformed literal inside a nested value'],
['{"a":[}', 'mismatched nested brackets'],
[String.raw`{"a":{"b":"\q"}}`, 'an invalid escape inside a nested string'],
['{"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)
@@ -808,10 +1103,121 @@ describe('PartialArguments', () => {
expect(view.has('later')).toBe(false)
view.append(text)
expect(view.invalid).toBe(true)
expect(view.append(',"later":"x"}')).toBe(false)
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)