Merge remote-tracking branch 'origin/master' into fix/landlock-runner-failure-classification
This commit is contained in:
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-19-zstandard-jsonl-session-logs.md: 74430624c771a265fb281e588e28733bc55d3eb6
|
||||
2026-07-19-zstandard-jsonl-session-logs.zh.md: b22275d1a7c54a743b11f4396318dd87e4f5b42a
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md
|
||||
2026-07-19-zstandard-jsonl-session-logs.md: 287ec94a91101850e9343d36ffd27870daf1333b
|
||||
2026-07-19-zstandard-jsonl-session-logs.zh.md: 4e578432640651de1eb1977229b7cdd462766c24
|
||||
@@ -28,11 +28,11 @@ First materialization compresses the two initial frames before opening the tempo
|
||||
|
||||
### Read, listing, and crash recovery
|
||||
|
||||
A frame-boundary scanner reads the standard magic, variable header fields, block headers and payload sizes, and optional checksum trailer. It does not interpret compressed blocks. Complete frames are decompressed independently and sequentially, which validates their checksums, and their plaintext is passed to the existing JSONL scanner. A checksum/decompression failure in any complete frame, a malformed complete-frame JSONL tail, or invalid frame structure is corruption and rejects.
|
||||
A frame-boundary scanner reads the standard magic, variable header fields, block headers and payload sizes, and optional checksum trailer. It does not interpret compressed blocks. Complete frames are decompressed independently and sequentially with Node's default `ZSTD_e_end`, which requires frame completion and validates their checksums, and their plaintext is passed to the existing JSONL scanner. A checksum/decompression failure in any complete frame, a malformed complete-frame JSONL tail, or invalid frame structure is corruption and rejects.
|
||||
|
||||
Listing reads in bounded chunks only until the first complete frame is available, validates and decompresses that header frame, and never reads an event frame. The dedicated header frame therefore preserves metadata-only listing even for very large session logs.
|
||||
|
||||
EOF inside the final frame is a recoverable torn tail. Node's decoder is given the available frame prefix; every complete newline-terminated event it emits is retained. Repair truncates from that frame's starting byte and appends one new checksummed frame containing the recovered complete events followed by the coordinator's synthetic tool, step, and turn closers. If the tear occurs before any complete event is decodable, repair drops the partial frame and retains all prior complete frames.
|
||||
EOF inside the final frame is a recoverable torn tail. After the scanner establishes that boundary, a dedicated prefix decoder uses `finishFlush: ZSTD_e_flush` so Node emits available plaintext without requiring frame or checksum completion; every complete newline-terminated event it emits is retained. Repair truncates from that frame's starting byte and appends one new checksummed frame containing the recovered complete events followed by the coordinator's synthetic tool, step, and turn closers. If the tear occurs before any complete event is decodable, repair drops the partial frame and retains all prior complete frames.
|
||||
|
||||
### Consumers and verification
|
||||
|
||||
|
||||
+2
-2
@@ -28,11 +28,11 @@ JSONL 持久化后端会逐字保留每个 `SessionEvent`,其中包括数量
|
||||
|
||||
### 读取、列举与崩溃恢复
|
||||
|
||||
帧边界扫描器会读取标准魔数、可变头字段、块头与负载长度,以及可选校验和尾部,但不会解释压缩块。后端独立且按顺序解压完整帧,由此验证各帧校验和,再把明文交给既有 JSONL 扫描器。任何完整帧的校验和或解压失败、完整帧中畸形的 JSONL 尾部,或者无效帧结构都属于损坏并拒绝加载。
|
||||
帧边界扫描器会读取标准魔数、可变头字段、块头与负载长度,以及可选校验和尾部,但不会解释压缩块。后端使用 Node 默认的 `ZSTD_e_end` 独立且按顺序解压完整帧;该模式要求帧完整并验证各帧校验和,再把明文交给既有 JSONL 扫描器。任何完整帧的校验和或解压失败、完整帧中畸形的 JSONL 尾部,或者无效帧结构都属于损坏并拒绝加载。
|
||||
|
||||
列举只按有界分片读取到第一个完整帧可用为止,验证并解压该头部帧,绝不读取事件帧。因此,即使会话日志很大,专用头部帧仍能维持仅元数据列举。
|
||||
|
||||
最终帧内部遇到 EOF 属于可恢复的撕裂尾部。后端把已有帧前缀交给 Node 解码器,并保留其产出的每个完整、以换行结束的事件。修复从该帧起始字节截断,再追加一个新的带校验和帧,其中依次包含恢复出的完整事件,以及协调器生成的工具、步骤与轮次闭合事件。如果撕裂位置尚不足以解码任何完整事件,修复会丢弃该不完整帧并保留此前全部完整帧。
|
||||
最终帧内部遇到 EOF 属于可恢复的撕裂尾部。扫描器确定该边界后,专用前缀解码器会使用 `finishFlush: ZSTD_e_flush`,使 Node 不必等到帧结束或读到完整校验和就能产出已有明文;其中每个完整且以换行结束的事件都会保留。修复从该帧起始字节截断,再追加一个新的带校验和帧,其中依次包含恢复出的完整事件,以及协调器生成的工具、步骤与轮次闭合事件。如果撕裂位置尚不足以解码任何完整事件,修复会丢弃该不完整帧并保留此前全部完整帧。
|
||||
|
||||
### 消费方与验证
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.md
|
||||
2026-08-03-tui-long-session-render-costs.md: c5b03960b6951cb2de2b847f03ec8eb2b92cc55c
|
||||
2026-08-03-tui-long-session-render-costs.zh.md: b41c5a8c546e296525645d82808117673fdeec6d
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: TUI long-session render costs — shared step-timing scan and card line caches
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-03-tui-long-session-render-costs.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
On a long resumed session (196k events, 2.2k steps, 1.8k tool cards) the TUI took ~12 s to render the transcript and ~800 ms to echo one keystroke. Profiling attributed both to the render path, not to session load (zstd + parse + surface seed is ~1.7 s):
|
||||
|
||||
- Every step's timing footer called `stepTimingAt`, which replayed the whole event log from index 0 per footer — O(steps × events) on the initial render, ~6 s of CPU.
|
||||
- pi-tui re-renders every component each frame and relies on per-component line caches (its own `Text`/`Markdown` cache by `(text, width)`). `ToolCardComponent.render()` and `ContextCardComponent.render()` built throwaway `new Text(...)`/`new Markdown(...)` instances inside `render(width)`, so every frame — every keystroke — re-wrapped every settled card's output.
|
||||
|
||||
## Decision
|
||||
|
||||
`packages/ui/tui/src/chat/timing.ts` replaces `stepTimingAt` with `StepTimingTracker`: one accumulator per chat mount, created in `createTuiChat` and threaded through `StreamingAssistantComponent` into each `StepTimingComponent`. A query advances a cursor over events appended since the previous query and keeps per-step bucket state in a map, so all footers together cost O(events). The open bucket is accumulated to the query clock at lookup, and a step is pinned at its `step/end`. The tracker requires the append-only session log (the `seq = log length` contract).
|
||||
|
||||
`ToolCardComponent` and `ContextCardComponent` cache their rendered rows keyed by width. The cache drops on every state mutator (`updateResult`, `setVisibility`, `setExpanded`) and on `invalidate()` (pi-tui's tree-wide cascade), so a state change always re-renders; everything else — including every keystroke frame — returns the cached rows. This restores upstream pi's own component convention (persistent child components plus explicit `cachedWidth`/`cachedLines` where rendering is custom, e.g. pi `coding-agent` `bash.ts`), which the imperative `render(width)` bodies here had silently defeated.
|
||||
|
||||
Measured on the 196k-event session (tmux, 200×50): resume prompt-ready 12.2 s → 7.2 s; per-keystroke echo 796 ms median → 17 ms (fresh-session parity).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Index `step/start` offsets, keep per-footer replay** — removes the `findIndex` but each footer still scans its step's span from a shared array; the tracker's single shared pass is the same complexity win with less bookkeeping.
|
||||
- **Restructure the cards into persistent pi-tui child components** (upstream pi's primary style) — equivalent steady-state cost, but a larger diff across card state handling for no additional win over the width-keyed cache.
|
||||
- **Cache inside pi-tui's `Container.render`** — wrong layer: the vendored patch surface would grow, and the contract (components own their caches) already exists upstream.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Typing latency no longer scales with total tool output; the residual per-frame cost is pi-tui's tree traversal and row concatenation, linear in rendered rows. Resume render cost is now dominated by pi-tui's one-time initial layout (~4 s at 196k events) plus load (~1.7 s), both linear.
|
||||
- The tracker consumes event times as logged and drops the removed implementation's mid-scan `time > at` cutoff, which per-footer `at` values make impossible in a shared scan; under a backward wall-clock step each bucket clamps at zero, which can differ from the old cutoff's totals.
|
||||
- Card `render()` is no longer a pure function of `(state, width)` per call — mutators must drop `linesCache`. A new mutator that forgets to do so shows stale rows; the cache tests in `packages/ui/tui/tests/transcript-card-cache.spec.ts` pin the contract for the existing mutators.
|
||||
- `StepTimingTracker` assumes step coordinates are not reused after `step/end`; a duplicate `step/start` for a closed step is ignored rather than restarting the step.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: TUI 长会话渲染开销:共享步骤耗时扫描与卡片行缓存
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-03-tui-long-session-render-costs.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
在一个恢复后的长会话(196k 条事件、2.2k 个步骤、1.8k 张工具卡片)中,TUI 渲染 transcript(文本记录)耗时约 12 秒,回显一次按键耗时约 800 毫秒。性能剖析表明,两项耗时都来自渲染路径,而非会话加载(zstd + 解析 + 表层播种约为 1.7 秒):
|
||||
|
||||
- 每个步骤的耗时页脚都会调用 `stepTimingAt`,而它会针对每个页脚从索引 0 起回放整个事件日志,因此初次渲染的复杂度为 O(步骤数 × 事件数),占用约 6 秒 CPU 时间。
|
||||
- pi-tui 每一帧都会重新渲染所有组件,并依赖各组件自己的行缓存(它的 `Text`/`Markdown` 会按 `(text, width)` 缓存)。`ToolCardComponent.render()` 和 `ContextCardComponent.render()` 构造用后即弃的 `new Text(...)`/`new Markdown(...)` 实例,且构造发生在 `render(width)` 内,因此每一帧,也就是每次按键,都会重新对每张已结算卡片的输出进行折行。
|
||||
|
||||
## 决策
|
||||
|
||||
`packages/ui/tui/src/chat/timing.ts` 不再使用 `stepTimingAt`,改用 `StepTimingTracker`:每次挂载聊天界面时在 `createTuiChat` 中创建一个累加器,再经 `StreamingAssistantComponent` 传入每个 `StepTimingComponent`。每次查询都会推进游标,扫描上次查询后追加的事件,并在一个映射表中保存各步骤的 bucket 状态,因此所有页脚合计只需 O(事件数)。查询时,系统把未闭合 bucket 累加到查询时刻;步骤在其 `step/end` 处固定。该跟踪器要求会话日志仅追加,即遵守 `seq = log length` 契约。
|
||||
|
||||
`ToolCardComponent` 和 `ContextCardComponent` 按宽度键控缓存渲染行。调用任一状态修改方法(`updateResult`、`setVisibility`、`setExpanded`)或 `invalidate()`(pi-tui 的全树级联)时会清空缓存,因此状态变化一定会重新渲染;其他情况,包括每一次按键帧,都会返回缓存行。这恢复了上游 pi 自身的组件惯例:使用常驻子组件;自定义渲染时显式使用 `cachedWidth`/`cachedLines`,例如 pi `coding-agent` 的 `bash.ts`。而这里命令式的 `render(width)` 函数体此前让这套惯例失效。
|
||||
|
||||
在该 196k 条事件的会话上测得(tmux,200×50):恢复后提示符就绪耗时从 12.2 秒降至 7.2 秒;每次按键的回显耗时中位数从 796 毫秒降至 17 毫秒(与新会话持平)。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **索引 `step/start` 偏移量,保留逐页脚回放**:这会消除 `findIndex`,但每个页脚仍要从共享数组扫描所属步骤的区间;跟踪器的一次共享遍历以更少的额外状态记录取得相同的复杂度改进。
|
||||
- **把卡片重构为常驻 pi-tui 子组件**(上游 pi 的主要风格):稳定状态下成本相同,但卡片状态处理所需改动更大,相较按宽度键控的缓存并无额外收益。
|
||||
- **在 pi-tui 的 `Container.render` 内缓存**:层级不对:对第三方内嵌代码的补丁范围会扩大,而上游已经约定由组件拥有各自的缓存。
|
||||
|
||||
## 后果
|
||||
|
||||
- 输入延迟不再随工具输出总量增长;剩余的每帧成本是 pi-tui 的树遍历与行拼接,与渲染行数呈线性关系。恢复时的渲染成本现由 pi-tui 的一次性初始布局(196k 条事件时约 4 秒)与加载(约 1.7 秒)主导,两者均为线性。
|
||||
- 该跟踪器直接采用日志记录的事件时间,不再像已移除的实现那样,在扫描中途遇到 `time > at` 时截断;由于每个页脚的 `at` 值不同,共享扫描无法采用这种截断;挂钟时间倒退时,每个 bucket 都以零为下限,所得总计值可能与旧截断下的总计值不同。
|
||||
- 卡片的 `render()` 不再是每次调用时 `(state, width)` 的纯函数,状态修改方法必须清空 `linesCache`。若新增状态修改方法时忘记清空,界面会显示陈旧行;`packages/ui/tui/tests/transcript-card-cache.spec.ts` 中的缓存测试固定了现有状态修改方法的契约。
|
||||
- `StepTimingTracker` 假定步骤坐标在 `step/end` 后不会复用;对已关闭步骤重复出现的 `step/start` 会被忽略,不会重新启动该步骤。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
|
||||
2026-07-30-web-result-card-frontend.md: d6f4785e83335ca2dd5295516baf47c845ebf5bd
|
||||
2026-07-30-web-result-card-frontend.zh.md: ed95cbe39f4f0bf77ba5da64d664705a0841863f
|
||||
2026-07-30-web-result-card-frontend.md: 7457f30f71e811960ecadeb49caedef276682505
|
||||
2026-07-30-web-result-card-frontend.zh.md: 5fa53c4ecbeda40bd18a3c4e59ec75bd4358662d
|
||||
@@ -16,9 +16,9 @@ One component draws both kinds, discriminated by `kind`. A `search` shows the an
|
||||
|
||||
**Links are safe by the http(s) subset of the allowlist MarkdownText applies to untrusted assistant-authored links** — MarkdownText also permits `mailto:`, deliberately excluded here since a retrieval URL is never a mail address. A source or fetch URL becomes a navigable anchor only when its protocol is `http:` or `https:`, with `target="_blank"` and `rel="noopener noreferrer"`; a `javascript:`/`data:`/`file:`/`mailto:` URL or an unparseable string renders as plain text with no href. The result content a web tool returns is model-authored and reaches this component unverified, so it is treated as untrusted exactly as assistant markdown is. The label falls back from title to hostname to the raw URL, so a source always reads as something even when both the title is absent and the URL does not parse.
|
||||
|
||||
**Geometry mirrors CodeBlock/TerminalBlock** (12px radius, code-block surface, 16px vertical margin) so a web card reads as one family with them. A long source list caps at `maxSources` with a head/tail collapse using TerminalBlock's exact split arithmetic (`ceil(max/2)` head lines plus the remaining tail), so a long body's slices agree between the two cards. A source list is prose rather than column-aligned output, so it wraps normally instead of scrolling horizontally the way a terminal card's output does — that is the one deliberate divergence from TerminalBlock.
|
||||
**Geometry mirrors CodeBlock/TerminalBlock** (12px radius, code-block surface, 16px vertical margin) so a web card reads as one family with them. The whole source list renders in one `<ol>` bounded by `max-height: 320px` and `overflow-y: auto`, so a list taller than that scrolls vertically in place rather than growing the card ([source scroll](2026-08-03-web-search-source-scroll.md)). A source list is prose rather than column-aligned output, so it wraps normally instead of scrolling horizontally the way a terminal card's output does — that is the one deliberate divergence from TerminalBlock.
|
||||
|
||||
The card is **resident** under the summary row in the chat rows, capped at `CHAT_WEB_MAX_SOURCES` (8) — half the primitive's own default of 16, which the details panel keeps — the same summary-surface-versus-reading-surface split `CHAT_TERMINAL_MAX_LINES` draws for the terminal card, and the same resident posture `BashRow` uses. The keyed rows register one `WebRow` component under both `web_search` and `web_fetch`; the row discriminates on the tool name only to pick its icon (search vs. browse) and its title (`Search`/`Fetch`). A web-declaring tool without its own keyed row lands on `GenericToolCard`, which grows the same resident card. The details panel renders the card at the primitive's full source allowance and, below it, the flattened model-visible result content: a `web_fetch` card carries only the URL and status, so its fetched body is readable only here.
|
||||
The card is **resident** under the summary row in the chat rows, the same resident posture `BashRow` uses. Both render sites show the same complete source list, bounded only by the card's own scroll height rather than by a row-versus-panel source cap. The keyed rows register one `WebRow` component under both `web_search` and `web_fetch`; the row discriminates on the tool name only to pick its icon (search vs. browse) and its title (`Search`/`Fetch`). A web-declaring tool without its own keyed row lands on `GenericToolCard`, which grows the same resident card. The details panel renders the card and, below it, the flattened model-visible result content: a `web_fetch` card carries only the URL and status, so its fetched body is readable only here.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -36,14 +36,15 @@ A separate later PR unifies the whole-row collapse/expand interaction and will f
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-primitives/tests/web-block.spec.tsx` pins the component per-file to the 100% gate: both kinds; the title-or-hostname-or-raw-URL label fallback; the safe-link attributes on both kinds (an http(s) URL becoming an external anchor with `target`/`rel`, a `javascript:`/`file:`/unparseable URL rendering as a plain span with no href); the snippet and date shown or omitted on present/empty/absent; the truncation indicator gated on the flag; and the source-list height cap with its head/tail slice and expand/collapse control including the default cap.
|
||||
`packages/client/ui-primitives/tests/web-block.spec.tsx` pins the component per-file to the 100% gate: both kinds; the title-or-hostname-or-raw-URL label fallback; the safe-link attributes on both kinds (an http(s) URL becoming an external anchor with `target`/`rel`, a `javascript:`/`file:`/unparseable URL rendering as a plain span with no href); the snippet and date shown or omitted on present/empty/absent; the truncation indicator gated on the flag; and the full source list rendering inside one scroll container with no expand control and `<li value>` numbering every source contiguously from 1.
|
||||
|
||||
`packages/client/ui-conversation/tests/web-card.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring seam: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag, unknown web `kind`); the keyed `WebRow`'s resident card for both kinds capped tighter than the panel, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds — including a `web_fetch`'s body flattened below its URL/status card — and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so a coverage run measures none of it.
|
||||
`packages/client/ui-conversation/tests/web-card.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring seam: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag, unknown web `kind`); the keyed `WebRow`'s resident card for both kinds, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds — including a `web_fetch`'s body flattened below its URL/status card — and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so a coverage run measures none of it.
|
||||
|
||||
The fixture (`packages/client/connection/src/client/fixture.ts`) adds turns 66 (`web_search`) and 67 (`web_fetch`), authored inline because the client-side fixture cannot import the web tool: turn 66's result view carries an answer and three sources exercising the citation list (a titled source with a snippet and date, a source with no title so its hostname labels the link, and a source with a date but no snippet) with the capped indicator on; turn 67's carries the fetched URL and a 200 status. Both keep a generic pending call view and add the `web` card only at result time, matching the contract's result-only web shape, and are named after the real tools so they hit the keyed `WebRow`. They are ordered before the todo turn (renumbered to 68) for the same reason the terminal turn is: the standing plan retires at the next `turn/start`, so a turn appended after it would empty the dock's plan strip. This drives the built-boot snapshot and a live `?fixture` server.
|
||||
|
||||
## Related
|
||||
|
||||
- [Web result card](2026-07-30-web-result-card.md) — the backend PR that added the `card: 'web'` result arm and made the two tools emit it; this is its deferred frontend consumer.
|
||||
- [Web search source card scrolls instead of collapsing](2026-08-03-web-search-source-scroll.md) — replaces this note's source-list head/tail collapse with a fixed-height scroll container and removes `CHAT_WEB_MAX_SOURCES` and the primitive's own source cap; every other decision here still holds.
|
||||
- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this mirrors: a `ui-primitives` block, a single card-model derivation, keyed and fallback chat rows, and a details-panel arm, for the `terminal` render intent.
|
||||
- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary; the Web client is now a full consumer of the `web` arm.
|
||||
@@ -16,9 +16,9 @@ Status: implemented
|
||||
|
||||
**链接的安全性沿用 MarkdownText 对不受信任的 assistant 链接所用 allowlist 的 http(s) 子集。** MarkdownText 还允许 `mailto:`,此处刻意排除,因为检索 URL 绝不会是邮件地址。一个 source 或 fetch URL 仅当其协议为 `http:` 或 `https:` 时才成为可导航锚点,带 `target="_blank"` 和 `rel="noopener noreferrer"`;`javascript:`/`data:`/`file:`/`mailto:` URL 或无法解析的字符串渲染为纯文本、无 href。web 工具返回的 result content 是模型创作的,未经验证抵达本组件,因此像 assistant markdown 一样被当作不受信任处理。标签从标题回退到主机名再回退到原始 URL,因此即便标题缺失且 URL 无法解析,source 也总能读作某个东西。
|
||||
|
||||
**几何镜像 CodeBlock/TerminalBlock**(12px 圆角、code-block 表面、16px 垂直外边距),使 web 卡片与它们读作一家。长 source 列表在 `maxSources` 处折叠,用 TerminalBlock 完全相同的分割算术做头/尾折叠(`ceil(max/2)` 头部行加剩余尾部),使长正文的切片在两张卡之间一致。source 列表是散文而非按列对齐的输出,所以它正常换行,而不像终端卡片的输出那样横向滚动 —— 这是与 TerminalBlock 唯一刻意的分歧。
|
||||
**几何镜像 CodeBlock/TerminalBlock**(12px 圆角、code-block 表面、16px 垂直外边距),使 web 卡片与它们读作一家。整份 source 列表渲染在单个 `<ol>` 里,由 `max-height: 320px` 与 `overflow-y: auto` 约束,因此高于该值的列表在原地纵向滚动,而不是把卡片撑高([来源滚动](2026-08-03-web-search-source-scroll.md))。source 列表是散文而非按列对齐的输出,所以它正常换行,而不像终端卡片的输出那样横向滚动 —— 这是与 TerminalBlock 唯一刻意的分歧。
|
||||
|
||||
卡片在 chat 行中**常驻**于摘要行之下,上限 `CHAT_WEB_MAX_SOURCES`(8)—— 原语自身默认 16 的一半,面板保留 16 —— 与 `CHAT_TERMINAL_MAX_LINES` 为终端卡片所画的摘要面对阅读面的同一划分,以及 `BashRow` 所用的同一常驻姿态。键控行把一个 `WebRow` 组件注册在 `web_search` 与 `web_fetch` 两个键下;行仅根据工具名判别以选取其图标(search 对 browse)与标题(`Search`/`Fetch`)。没有自己键控行的 web 声明工具落到 `GenericToolCard`,它长出同一张常驻卡片。详情面板以原语的完整 source 额度渲染卡片,并在其下方渲染摊平的模型可见结果内容:`web_fetch` 卡片只携带 URL 与状态,因此其抓取正文只在此处可读。
|
||||
卡片在 chat 行中**常驻**于摘要行之下,与 `BashRow` 所用的同一常驻姿态。两个渲染点展示同一份完整的 source 列表,仅由卡片自身的滚动高度约束,而没有行与面板两级的 source 上限。键控行把一个 `WebRow` 组件注册在 `web_search` 与 `web_fetch` 两个键下;行仅根据工具名判别以选取其图标(search 对 browse)与标题(`Search`/`Fetch`)。没有自己键控行的 web 声明工具落到 `GenericToolCard`,它长出同一张常驻卡片。详情面板渲染该卡片,并在其下方渲染摊平的模型可见结果内容:`web_fetch` 卡片只携带 URL 与状态,因此其抓取正文只在此处可读。
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -36,14 +36,15 @@ Status: implemented
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-primitives/tests/web-block.spec.tsx` 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 `target`/`rel` 的外链,`javascript:`/`file:`/无法解析的 URL 渲染为无 href 的纯 span);snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及 source 列表高度上限及其头/尾切片与展开/收起控件,含默认上限。
|
||||
`packages/client/ui-primitives/tests/web-block.spec.tsx` 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 `target`/`rel` 的外链,`javascript:`/`file:`/无法解析的 URL 渲染为无 href 的纯 span);snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及完整 source 列表渲染在单个滚动容器内、无展开控件、`<li value>` 从 1 起为每条 source 连续编号。
|
||||
|
||||
`packages/client/ui-conversation/tests/web-card.spec.tsx` 在每个接线接缝镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web `kind`);键控 `WebRow` 对两种 kind 的常驻卡片、比面板收得更紧、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-conversation/src/*`),因此覆盖率运行不度量它。
|
||||
`packages/client/ui-conversation/tests/web-card.spec.tsx` 在每个接线接缝镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web `kind`);键控 `WebRow` 对两种 kind 的常驻卡片、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-conversation/src/*`),因此覆盖率运行不度量它。
|
||||
|
||||
fixture(`packages/client/connection/src/client/fixture.ts`)添加 turn 66(`web_search`)与 67(`web_fetch`),内联撰写,因为客户端 fixture 无法 import web 工具:turn 66 的 result view 携带一个 answer 与三个 source,演练引用列表(一个带 snippet 与日期的有标题 source、一个无标题因而以主机名标注链接的 source、一个有日期无 snippet 的 source)并开启截断提示;turn 67 携带抓取的 URL 与一个 200 状态。两者都保留 generic pending call view,仅在 result 时添加 `web` 卡片,匹配契约的 result-only web 形状,且以真实工具命名,使其命中键控 `WebRow`。它们被排在 todo turn(重编号为 68)之前,理由与终端 turn 相同:待定计划在下一个 `turn/start` 退休,所以排在其后的 turn 会清空 dock 的 plan strip。这驱动 built-boot snapshot 与一个实时 `?fixture` 服务。
|
||||
|
||||
## Related
|
||||
|
||||
- [Web result card](2026-07-30-web-result-card.md) —— 添加 `card: 'web'` result 支路并让两个工具发出它的后端 PR;本条是它推迟的前端消费者。
|
||||
- [Web search 来源卡片改为滚动而非折叠](2026-08-03-web-search-source-scroll.md) —— 用定高滚动容器替换本笔记的 source 列表头/尾折叠,并移除 `CHAT_WEB_MAX_SOURCES` 与原语自身的 source 上限;本笔记的其余决策依然成立。
|
||||
- [Web terminal card](2026-07-28-web-terminal-card.md) —— 本条所镜像的先例:一个 `ui-primitives` block、一处 card-model 派生、键控与兜底 chat 行、以及一个详情面板支路,用于 `terminal` 渲染意图。
|
||||
- [工具调用呈现的标签化 render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— `card` 标签词汇;Web 客户端现在是 `web` 支路的完整消费者。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md
|
||||
2026-08-03-web-search-source-scroll.md: c11bb2317b6ae6cad8017a4b76cb0b9ccebd6fc0
|
||||
2026-08-03-web-search-source-scroll.zh.md: add012216589b33cf244d8a14053a5b6d60631a6
|
||||
@@ -0,0 +1,46 @@
|
||||
# Agent Note: Web search source card scrolls instead of collapsing
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-03-web-search-source-scroll.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The `web_search` result card (`WebBlock`, `packages/client/ui-primitives/src/WebBlock.tsx`) rendered its source list with a head/tail collapse: past a `maxSources` count (16 in the details panel, 8 in the chat row via `CHAT_WEB_MAX_SOURCES`) it drew the first `ceil(max/2)` sources, an `… 其余 N 条来源` expand button, then the last `max - ceil(max/2)`, mirroring `TerminalBlock`'s output cap. A user reading the card saw `来源列表已截断` and assumed the frontend had dropped sources it was holding.
|
||||
|
||||
It had not. The seam (`capSources`, `packages/web/web/src/index.ts`) cuts the provider's sources to the tool's `searchMaxResults` bound (default 8) and sets `truncated`, and that one capped list feeds both the model-facing render text and the card's `presentationMeta`. The card never holds more sources than that one cut produced. So the collapse was hiding sources the user was entitled to see in full — and, with the default bound at 8 and the panel cap at 16, it almost never even triggered, leaving only the `truncated` note with no way to reveal anything.
|
||||
|
||||
## Decision
|
||||
|
||||
`WebBlock`'s search arm renders every source it receives in one `<ol className={css.sources}>`, with no head/tail slicing, no expand button, and no `maxSources` prop. `.sources` (`WebBlock.module.css`) gets a fixed `max-height` and `overflow-y: auto`, so a list longer than the card height scrolls in place rather than growing the card or hiding rows. The height is a design constant of the card geometry, so it lives in CSS, not a plugin config field.
|
||||
|
||||
The model side is unchanged: the seam still caps sources at `searchMaxResults`, the model-facing render text is untouched, and the `truncated` flag and its `来源列表已截断` indicator stay. The card draws the list the seam produced, in full and scrollable, instead of collapsing its middle.
|
||||
|
||||
That list is the one the model reads as long as nothing downstream of the tool rewrites the result content alone. A deployment mounting `dsh-spill-policy` breaks that correspondence for an oversized result: `tools/post-execute` replaces the model-facing `content` with a preview plus a spill locator and leaves `presentationMeta` whole, so the card still draws every source while the model reads a bounded excerpt. The card's contract is therefore the view it receives, not the model's context.
|
||||
|
||||
`CHAT_WEB_MAX_SOURCES` and the primitive's `DEFAULT_WEB_MAX_SOURCES` are removed: with scroll, the chat row and the details panel show the same full list, differentiated only by their container height. `<li value={ordinal}>` still pins each source's 1-based citation index; without the collapse gap the ordinals are now simply contiguous.
|
||||
|
||||
Making the list a scroll container also makes its `padding-left` a correctness constraint, not spacing. A scroll container clips inline-start overflow and offers no way to scroll it back, and `::marker` is right-aligned to the content edge, so a marker wider than the padding silently loses its leading digits — at the list's 20px the two-digit markers rendered as `0.` and `1.` where `10.` and `11.` belonged. `searchMaxResults` is an unbounded positive integer, so the padding is sized in `em` against the list's own font — the one a marker inherits — to hold a three-digit marker (`999. ` measures 2.35em in the app font stack) and keeps the gap the one-digit case already had.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Raise `searchMaxResults` (or make it unbounded) so more sources reach both the model and the card.** Rejected by the user: it changes model-side behavior (more sources into every request's context, more tokens) and widens the gap between what the model reads and what the card draws. The instruction was explicit — keep the cap and the truncation, add a scrollbar.
|
||||
|
||||
**Keep the head/tail collapse and add scroll only to the expanded region.** Rejected: two overlapping mechanisms for one concern. Once the whole list is always rendered, the collapse arithmetic, the expand/collapse state, and the button are dead weight; scroll alone bounds the height.
|
||||
|
||||
**Make the scroll height a plugin config field.** Rejected: the height bounds the card's on-screen geometry, not a deployment policy, so it belongs in `WebBlock.module.css` alongside the radius, surface, and margin that [the web result card frontend note](2026-07-30-web-result-card-frontend.md) already fixes there as this card's geometry.
|
||||
|
||||
## Consequences
|
||||
|
||||
Every source the tool returned is always in the DOM, so no source the view carries is hidden behind an interaction. The card's height is bounded regardless of source count, and a list taller than the container scrolls in place. The cost is that the scroll affordance depends on the platform's scrollbar rendering: an overlay-scrollbar system (macOS default) shows no persistent bar when the pointer is away, so a capped list relies on the `来源列表已截断` note plus a clipped last row to signal there is more. `WebSearchBlockProps`/`WebFetchBlockProps` lose their `maxSources` prop and the primitive loses `DEFAULT_WEB_MAX_SOURCES`, so any future caller renders the full list by construction rather than by passing a large cap.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-primitives/tests/web-block.spec.tsx` drops the collapse cases (head/tail slice, expand-on-click, collapsed-tail numbering, expander-out-of-numbering, head-alone, default cap) and adds: a 30-source card renders all 30 `<li>` with no `[aria-expanded]` and no `<button>`, every `<ol>` child is a source `<li>`, and `<li value>` numbers 1..N contiguously. `packages/client/ui-conversation/tests/web-card.spec.tsx` drops the `CHAT_WEB_MAX_SOURCES` cap assertion; the WebRow expansion test still asserts the card shows every source field. The `packages/web/tool-web` tests are unchanged — the model side did not move.
|
||||
|
||||
jsdom resolves no CSS Modules layout, so it reports `scrollHeight === clientHeight` for every element and cannot witness the scroll at all. The geometry is pinned in the assembled browser instead, by `apps/web/tests/web-search-round.e2e.ts`: its deterministic search double returns 12 provider results, each with a title, a citation snippet, and a date. That first pins the seam's cap end to end in a real composition — the shipped `searchMaxResults` keeps 8, the model-visible render text carries the 8 kept titles and none of the 4 dropped URLs plus `(Showing the first 8 sources. Refine the query for more.)`, and `meta.truncated` is true. A case after the aria golden then expands the `web_search` row and asserts on the card's `<ol>`: 8 `<li>`, no `<button>` anywhere in the card, the `来源列表已截断` indicator visible, and computed `max-height: 320px` with `overflow-y: auto` over `scrollHeight` 574 against `clientHeight` 320. A further case measures a `999. ` marker in the list's own inherited font and requires the computed `padding-left` to be at least that wide, so the marker room the scroll container cannot clip back is pinned against the widest marker rather than against one fixture's source count. Neither the recorded stream nor the aria golden moved: replay is a positional cursor over the fixture's `assistant/chunk` entries and the search double is a separate local endpoint the provider reaches by `fetch`, while the card is collapsed at capture time so its `<ol>` is out of the DOM and the summary row carries no source count.
|
||||
|
||||
## Related
|
||||
|
||||
- [Web result card](2026-07-30-web-result-card.md) — the `card: 'web'` render-intent arm and `presentationMeta` route this card consumes; the source of the capped-once list.
|
||||
- [Web result card frontend](2026-07-30-web-result-card-frontend.md) — owns `WebBlock`, the single `web-card-model` derivation, and the render sites that draw the card; this note replaces the source-list collapse it specified, and its other decisions (one component for both kinds, the http(s) link allowlist, the single derivation, the resident posture) stand.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Agent Note: Web search source card scrolls instead of collapsing
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-03-web-search-source-scroll.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`web_search` 结果卡片(`WebBlock`,`packages/client/ui-primitives/src/WebBlock.tsx`)此前用首尾折叠渲染它的来源列表:超过 `maxSources` 数量(详情面板为 16,聊天行经由 `CHAT_WEB_MAX_SOURCES` 为 8)时,它画出前 `ceil(max/2)` 条来源、一个 `… 其余 N 条来源` 展开按钮,再画出末尾 `max - ceil(max/2)` 条,与 `TerminalBlock` 的输出上限一致。用户阅读该卡片时看到 `来源列表已截断`,会以为前端丢弃了它正持有的来源。
|
||||
|
||||
其实并没有。seam(`capSources`,`packages/web/web/src/index.ts`)把 provider 的来源裁剪到工具的 `searchMaxResults` 上限(默认 8)并置位 `truncated`,而这一份被裁剪过一次的列表同时喂给面向模型的 render 文本与卡片的 `presentationMeta`。卡片持有的来源绝不会多于这一次裁剪的产物。因此这个折叠隐藏的正是用户本有权完整查看的来源——并且在默认上限为 8、面板上限为 16 时,它几乎从不触发,只留下 `truncated` 提示,却无从展开任何内容。
|
||||
|
||||
## Decision
|
||||
|
||||
`WebBlock` 的 search 分支把它收到的每一条来源都渲染进单个 `<ol className={css.sources}>`,不做首尾切片、不设展开按钮、也不带 `maxSources` prop。`.sources`(`WebBlock.module.css`)获得一个固定的 `max-height` 与 `overflow-y: auto`,因此长于卡片高度的列表在原地滚动,而非撑大卡片或隐藏行。该高度是卡片几何形状的一个设计常量,因此放在 CSS 里,而非插件配置字段。
|
||||
|
||||
模型侧不变:seam 仍在 `searchMaxResults` 处封顶来源,面向模型的 render 文本未动,`truncated` 标志及其 `来源列表已截断` 指示保留。卡片完整且可滚动地画出 seam 产出的这份列表,而非折叠其中段。
|
||||
|
||||
只要工具下游没有单独改写结果 content,这份列表就是模型读到的那份。挂载了 `dsh-spill-policy` 的部署会对超限结果打破这一对应:`tools/post-execute` 把面向模型的 `content` 替换为预览加 spill 定位符,而 `presentationMeta` 原样保留,因此卡片仍画出全部来源,模型读到的却是一段有界摘录。所以卡片的契约是它收到的 view,不是模型的上下文。
|
||||
|
||||
`CHAT_WEB_MAX_SOURCES` 与该 primitive 的 `DEFAULT_WEB_MAX_SOURCES` 被移除:有了滚动,聊天行与详情面板展示同一份完整列表,仅以各自的容器高度区分。`<li value={ordinal}>` 仍钉住每条来源从 1 起算的引用序号;没有了折叠造成的间断,这些序号如今就是连续的。
|
||||
|
||||
把列表变成滚动容器,也把它的 `padding-left` 从间距变成了正确性约束。滚动容器裁掉 inline-start 方向的溢出且无从滚回,而 `::marker` 右对齐到内容边缘,因此宽于 padding 的序号会静默丢掉前导数字——在列表原本的 20px 下,两位数序号被画成 `0.` 与 `1.`,而本该是 `10.` 与 `11.`。`searchMaxResults` 是无上界的正整数,因此该 padding 以 `em` 计量——相对列表自身的字体,也就是序号所继承的那个——装得下三位数序号(`999. ` 在应用字体栈下量得 2.35em),并保留一位数情形原有的间隙。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**提高 `searchMaxResults`(或让它无上限),使更多来源同时抵达模型与卡片。** 被用户否决:它改变了模型侧行为(每个请求的上下文纳入更多来源、更多 token),并拉大模型读到的内容与卡片画出的内容之间的差距。指令很明确——保留上限与截断,加一个滚动条。
|
||||
|
||||
**保留首尾折叠,仅对展开区域加滚动。** 否决:一个关注点上两套重叠机制。一旦整份列表始终渲染,折叠的算术、展开/折叠状态与那个按钮都是死重;仅靠滚动即可约束高度。
|
||||
|
||||
**把滚动高度做成插件配置字段。** 否决:该高度约束的是卡片在屏幕上的几何形状,而非部署策略,因此它属于 `WebBlock.module.css`,与 [Web result 卡片前端笔记](2026-07-30-web-result-card-frontend.md) 已作为本卡片几何固定在那里的圆角、表面与外边距并列。
|
||||
|
||||
## Consequences
|
||||
|
||||
工具返回的每一条来源始终存在于 DOM 中,因此 view 携带的来源没有一条被藏在交互之后。无论来源数量多少,卡片高度都受限;高于容器的列表在原地滚动。代价是滚动提示依赖平台的滚动条渲染:overlay 滚动条系统(macOS 默认)在指针离开时不显示常驻滚动条,因此被裁剪的列表依靠 `来源列表已截断` 提示加上被裁切的最后一行来表明还有更多内容。`WebSearchBlockProps`/`WebFetchBlockProps` 失去 `maxSources` prop,primitive 失去 `DEFAULT_WEB_MAX_SOURCES`,因此未来任何调用方都从构造上渲染完整列表,而不是靠传入一个很大的上限值。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-primitives/tests/web-block.spec.tsx` 删去折叠相关用例(首尾切片、点击展开、折叠尾部编号、展开器不计入编号、仅首部、默认上限),并新增:一个 30 条来源的卡片渲染出全部 30 个 `<li>`,无 `[aria-expanded]`、无 `<button>`,每个 `<ol>` 子元素都是一条来源 `<li>`,且 `<li value>` 从 1 到 N 连续编号。`packages/client/ui-conversation/tests/web-card.spec.tsx` 删去 `CHAT_WEB_MAX_SOURCES` 上限断言;WebRow 展开测试仍断言卡片展示每一个来源字段。`packages/web/tool-web` 的测试不变——模型侧未曾移动。
|
||||
|
||||
jsdom 不解析 CSS Modules 布局,对任何元素都报 `scrollHeight === clientHeight`,因此它根本无从见证这次滚动。几何改由组装态浏览器钉住,位于 `apps/web/tests/web-search-round.e2e.ts`:其确定性 search double 返回 12 条 provider 结果,每条带标题、引用摘录与日期。这首先在真实组合里端到端钉住 seam 的裁剪——出厂 `searchMaxResults` 保留 8 条,面向模型的 render 文本含这 8 条标题、不含被丢弃的 4 条 URL,并含 `(Showing the first 8 sources. Refine the query for more.)`,`meta.truncated` 为 true。随后位于 aria golden 之后的一个用例展开 `web_search` 行,对卡片的 `<ol>` 断言:8 个 `<li>`、卡片内任何位置都没有 `<button>`、`来源列表已截断` 指示可见,以及计算样式 `max-height: 320px` 与 `overflow-y: auto`,`scrollHeight` 为 574、`clientHeight` 为 320。再后一个用例在列表自身继承的字体下量出 `999. ` 序号的宽度,要求计算后的 `padding-left` 不小于该宽度,从而把滚动容器无从滚回的那段序号空间钉在最宽序号上,而非钉在某一份 fixture 的来源条数上。录制的模型流与 aria golden 都未变动:replay 是对 fixture 中 `assistant/chunk` 条目的位置游标,而 search double 是 provider 经 `fetch` 抵达的另一个本地端点;捕获时卡片处于折叠状态,其 `<ol>` 不在 DOM 中,摘要行也不携带来源数量。
|
||||
|
||||
## Related
|
||||
|
||||
- [Web result card](2026-07-30-web-result-card.md) —— 本卡片消费的 `card: 'web'` 渲染意图分支与 `presentationMeta` 路由;那份裁剪过一次的列表的来源。
|
||||
- [Web result 卡片前端](2026-07-30-web-result-card-frontend.md) —— `WebBlock`、唯一的 `web-card-model` 派生,以及绘制该卡片的各渲染点由它拥有;本笔记替换掉它所规定的来源列表折叠,它的其余决策(一个组件绘制两种 kind、http(s) 链接 allowlist、单一派生、常驻姿态)依然成立。
|
||||
@@ -11,6 +11,7 @@ import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { WEB_SEARCH_MAX_RESULTS } from '@deepseek-ai/dsh-tool-web'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
@@ -25,7 +26,37 @@ const QUERY = 'DeepSeek Harness snapshot search'
|
||||
const PROMPT = `Use web_search to search exactly "${QUERY}". Then reply exactly SEARCH_DONE and stop.`
|
||||
const SEARCH_CREDENTIAL_REF = credentialRef('DSH_WEB_SEARCH_E2E_KEY')
|
||||
const SEARCH_CREDENTIAL = 'snapshot-search-key'
|
||||
const RESULT_URL = 'https://docs.example.test/search'
|
||||
|
||||
/**
|
||||
* Provider results the double returns, exceeding the shipped `searchMaxResults`
|
||||
* so the seam's cap and the card's scroll container are both exercised. Each row
|
||||
* carries a title, a snippet, and a date, so 8 kept rows exceed the `.sources`
|
||||
* 320px max-height.
|
||||
*/
|
||||
const PROVIDER_RESULT_COUNT = 12
|
||||
|
||||
/** One provider result's URL, by 1-based provider order. */
|
||||
function resultUrl(ordinal: number): string {
|
||||
return `https://docs.example.test/search/${ordinal}`
|
||||
}
|
||||
|
||||
/** One provider result's title, by 1-based provider order. */
|
||||
function resultTitle(ordinal: number): string {
|
||||
return `Snapshot Search Result ${ordinal}`
|
||||
}
|
||||
|
||||
/** One provider result's citation excerpt, by 1-based provider order. */
|
||||
function resultSnippet(ordinal: number): string {
|
||||
return `Snapshot search excerpt ${ordinal}: the harness replays this source list from a local endpoint.`
|
||||
}
|
||||
|
||||
/** One provider result's `page_age`, by 1-based provider order (July 2026 days 01..12). */
|
||||
function resultPageAge(ordinal: number): string {
|
||||
return `2026-07-${String(ordinal).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** The 1-based provider ordinals, in provider order. */
|
||||
const RESULT_ORDINALS = Array.from({ length: PROVIDER_RESULT_COUNT }, (_value, index) => index + 1)
|
||||
|
||||
interface CapturedSearchRequest {
|
||||
path: string
|
||||
@@ -50,21 +81,21 @@ async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ s
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Found one source.',
|
||||
citations: [{
|
||||
text: `Found ${PROVIDER_RESULT_COUNT} sources.`,
|
||||
citations: RESULT_ORDINALS.map(ordinal => ({
|
||||
type: 'web_search_result_location',
|
||||
url: RESULT_URL,
|
||||
cited_text: 'Snapshot search excerpt.',
|
||||
}],
|
||||
url: resultUrl(ordinal),
|
||||
cited_text: resultSnippet(ordinal),
|
||||
})),
|
||||
},
|
||||
{
|
||||
type: 'web_search_tool_result',
|
||||
content: [{
|
||||
content: RESULT_ORDINALS.map(ordinal => ({
|
||||
type: 'web_search_result',
|
||||
url: RESULT_URL,
|
||||
title: 'Snapshot Search Result',
|
||||
page_age: '2026-07-31',
|
||||
}],
|
||||
url: resultUrl(ordinal),
|
||||
title: resultTitle(ordinal),
|
||||
page_age: resultPageAge(ordinal),
|
||||
})),
|
||||
},
|
||||
],
|
||||
}))
|
||||
@@ -141,7 +172,7 @@ describe('web e2e: shipped default web search', () => {
|
||||
if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
}, 200_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('uses the real provider and persists the structured result', () => {
|
||||
it.skipIf(MODE === 'record')('uses the real provider and persists the capped structured result', () => {
|
||||
expect(searchRequests).toHaveLength(1)
|
||||
expect(searchRequests[0]).toMatchObject({
|
||||
path: '/messages',
|
||||
@@ -177,16 +208,27 @@ describe('web e2e: shipped default web search', () => {
|
||||
if (searchResult === undefined) throw new Error('web_search produced no durable result')
|
||||
const content = searchResult.data.message.content[0]
|
||||
expect(content.isError).toBe(false)
|
||||
expect(content.content.filter(block => block.type === 'text').map(block => block.text).join(''))
|
||||
.toContain(`[Snapshot Search Result](${RESULT_URL})`)
|
||||
const rendered = content.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
// The seam caps the provider's list at the shipped searchMaxResults before
|
||||
// the tool renders it, so the kept prefix is model-visible and the dropped
|
||||
// suffix is not.
|
||||
for (const ordinal of RESULT_ORDINALS.slice(0, WEB_SEARCH_MAX_RESULTS)) {
|
||||
expect(rendered).toContain(`[${resultTitle(ordinal)}](${resultUrl(ordinal)})`)
|
||||
}
|
||||
for (const ordinal of RESULT_ORDINALS.slice(WEB_SEARCH_MAX_RESULTS)) {
|
||||
expect(rendered).not.toContain(resultUrl(ordinal))
|
||||
}
|
||||
expect(rendered).toContain(
|
||||
`(Showing the first ${WEB_SEARCH_MAX_RESULTS} sources. Refine the query for more.)`,
|
||||
)
|
||||
expect(searchResult.data.meta).toMatchObject({
|
||||
sources: [{
|
||||
url: RESULT_URL,
|
||||
title: 'Snapshot Search Result',
|
||||
snippet: 'Snapshot search excerpt.',
|
||||
publishedAt: '2026-07-31',
|
||||
}],
|
||||
truncated: false,
|
||||
sources: RESULT_ORDINALS.slice(0, WEB_SEARCH_MAX_RESULTS).map(ordinal => ({
|
||||
url: resultUrl(ordinal),
|
||||
title: resultTitle(ordinal),
|
||||
snippet: resultSnippet(ordinal),
|
||||
publishedAt: resultPageAge(ordinal),
|
||||
})),
|
||||
truncated: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -199,6 +241,55 @@ describe('web e2e: shipped default web search', () => {
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('scrolls the capped source list inside the fixed-height container', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-search-sources-scroll'))
|
||||
const row = page.locator('[data-tool="web_search"] [data-expandable]').first()
|
||||
await row.click()
|
||||
await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
|
||||
|
||||
const card = page.locator('[data-web="search"]')
|
||||
const sources = card.locator('ol')
|
||||
await sources.waitFor({ timeout: 10_000 })
|
||||
// The card draws exactly the sources the model saw: the seam's cap, not the
|
||||
// provider's list length.
|
||||
expect(await sources.locator('li').count()).toBe(WEB_SEARCH_MAX_RESULTS)
|
||||
// The list is complete in the DOM, so the card carries no expand control.
|
||||
expect(await card.locator('button').count()).toBe(0)
|
||||
expect(await card.getByText('来源列表已截断').isVisible()).toBe(true)
|
||||
|
||||
const geometry = await sources.evaluate((element) => {
|
||||
const computed = getComputedStyle(element)
|
||||
return {
|
||||
maxHeight: computed.maxHeight,
|
||||
overflowY: computed.overflowY,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientHeight: element.clientHeight,
|
||||
}
|
||||
})
|
||||
expect(geometry.maxHeight).toBe('320px')
|
||||
expect(geometry.overflowY).toBe('auto')
|
||||
expect(geometry.scrollHeight).toBeGreaterThan(geometry.clientHeight)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('reserves marker room a scroll container cannot clip back', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-search-marker-room'))
|
||||
// `overflow-y: auto` clips inline-start overflow with no way to scroll it
|
||||
// back, and markers are right-aligned to the content edge, so a marker wider
|
||||
// than `padding-left` silently loses its leading digits. `searchMaxResults`
|
||||
// is an unbounded positive integer, so measure the widest three-digit marker
|
||||
// in the list's own font and require the shipped padding to hold it.
|
||||
const marker = await page.locator('[data-web="search"] ol').evaluate((element) => {
|
||||
const probe = document.createElement('span')
|
||||
probe.style.cssText = 'position:absolute;visibility:hidden;white-space:pre;font:inherit'
|
||||
probe.textContent = '999. '
|
||||
element.append(probe)
|
||||
const widest = probe.getBoundingClientRect().width
|
||||
probe.remove()
|
||||
return { widest, paddingLeft: parseFloat(getComputedStyle(element).paddingLeft) }
|
||||
})
|
||||
expect(marker.paddingLeft).toBeGreaterThanOrEqual(marker.widest)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
|
||||
@@ -2451,7 +2451,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi
|
||||
abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
|
||||
```
|
||||
|
||||
Source: [`packages/ui/tui/src/index.ts:245`](../../packages/ui/tui/src/index.ts)
|
||||
Source: [`packages/ui/tui/src/index.ts:246`](../../packages/ui/tui/src/index.ts)
|
||||
|
||||
## `ctx.typert` — `TypertRegistry`
|
||||
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
"steps": [
|
||||
{ "op": "initialize" },
|
||||
{ "op": "newSession" },
|
||||
{
|
||||
"op": "promptAndWaitForAgentMessage",
|
||||
"text": "Create a durable two-round goal for the ACP snapshot, inspect it, then report readiness.",
|
||||
"waitForText": "GOAL ROUND ONE"
|
||||
},
|
||||
{ "op": "promptAndWaitForAgentMessage", "text": "Create a durable two-round goal for the ACP snapshot, inspect it, then report readiness.", "waitForText": "GOAL ROUND ONE" },
|
||||
{ "op": "waitForTurnStart", "minimumTurn": 3 },
|
||||
{ "op": "cancel", "waitForFile": { "path": ".dsh-snapshot-goal-cancel-ready" } },
|
||||
{ "op": "waitForTurnEnd" }
|
||||
{ "op": "waitForTurnEnd" },
|
||||
{ "op": "waitForEventAfterTurnEnd", "type": "user/message" }
|
||||
]
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 78572ba0ab3ce9475dba31dee8844017564e2a18
|
||||
README.zh.md: 7708e980e24f4ea4365fbbacd641a5be6c61b138
|
||||
README.md: 7d3a4b5fe07cc8858c2f2059e6f65b6e27602b4d
|
||||
README.zh.md: d93af91381157bb4e8e4b6a14ad00edecd505246
|
||||
@@ -22,7 +22,7 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run
|
||||
|
||||
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound. A Bash execution failure that settles on the generic path instead exposes its original arguments and full error through the same bounded IN/OUT disclosure, while successful generic results such as a background-start acknowledgement remain summary-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
|
||||
|
||||
A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; it composes the shared `ToolRow`, feeding the card as ToolRow's `web` body, so the retrieval is the row's collapsed-by-default expanded card (the same unified expand every card row has). A web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which routes the card through ToolRow the same way, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)).
|
||||
A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; it composes the shared `ToolRow`, feeding the card as ToolRow's `web` body, so the retrieval is the row's collapsed-by-default expanded card (the same unified expand every card row has). A web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which routes the card through ToolRow the same way, and the details panel renders it and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Both render sites show the same complete source list — the one the tool returned and the model saw — bounded only by the card's own scroll container height, with no row-versus-panel cap ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), [source scroll](../../../.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md)).
|
||||
|
||||
A `read` call declaring the `read` render intent renders the returned file window inline, at both conversation render sites, through ui-primitives' `ReadBlock` — the line-numbered, syntax-highlighted content the tool projects. `contract/read-card-model.ts` is the single derivation from the snapshot's `resultView`; the read card is result-side only (a call carries no file content until `execute` returns), so a running read shows its summary alone and it yields null — the generic path — for a non-read result view or a `card` tag this client version does not know. The keyed `ReadRow` composes the shared `ToolRow`, feeding the card as ToolRow's `read` body, so it is the row's collapsed-by-default expanded card; the summary stays a path link that opens the file through the host. The render-site fallback and the details panel are read-aware too. Rows cap at `CHAT_READ_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md)).
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
|
||||
|
||||
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限。若 Bash 执行失败时落在通用路径,则改用同样有界的 IN/OUT 展开区暴露原始参数和完整错误;后台启动确认等成功的通用结果仍只显示摘要([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
|
||||
|
||||
声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;它组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `web` body 传入,因此检索成为该行默认折叠的展开卡片(与每个卡片行相同的统一展开交互)。没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它以同样方式经 ToolRow 渲染卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。
|
||||
声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;它组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `web` body 传入,因此检索成为该行默认折叠的展开卡片(与每个卡片行相同的统一展开交互)。没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它以同样方式经 ToolRow 渲染卡片,详情面板渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。两个渲染点显示同一份完整来源列表——工具返回、模型看到的那一份——仅受卡片自身滚动容器的高度约束,不存在行与面板的两级上限([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)、[来源滚动](../../../.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md))。
|
||||
|
||||
声明 `read` 渲染意图的 `read` 调用,会在两个对话渲染点上都通过 ui-primitives 的 `ReadBlock` 内联渲染返回的文件窗口——工具投影出的带行号、语法高亮的内容。`contract/read-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;read 卡片是仅结果侧的(调用在 `execute` 返回前不携带文件内容),所以运行中的 read 只显示摘要,且对非 read 的 result view 或本客户端版本不认识的 `card` 标签返回 null,落回通用路径。键控的 `ReadRow` 组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `read` body 传入,因此它是该行默认折叠的展开卡片;摘要仍是一个经 host 打开文件的路径链接。渲染点兜底行与详情面板同样感知 read。行的上限是 `CHAT_READ_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md))。
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../contract/diff-card-model.ts'
|
||||
import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../contract/read-card-model.ts'
|
||||
import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-card-model.ts'
|
||||
import { CHAT_WEB_MAX_SOURCES } from '../contract/web-card-model.ts'
|
||||
import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { DisclosureRow } from './DisclosureRow.tsx'
|
||||
@@ -276,7 +275,7 @@ export function ToolRow({
|
||||
</>
|
||||
)
|
||||
: webBody !== null
|
||||
? <WebBlock {...webBody} maxSources={CHAT_WEB_MAX_SOURCES} className={css.webBody} />
|
||||
? <WebBlock {...webBody} className={css.webBody} />
|
||||
: isThink
|
||||
? <div className={css.thinkBody}>{body}</div>
|
||||
: (
|
||||
|
||||
@@ -15,16 +15,6 @@
|
||||
import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolCallBlock } from './tool-call-model.ts'
|
||||
|
||||
/**
|
||||
* Sources the chat row's web body shows before collapsing the middle — half
|
||||
* the primitive's own default, which the details panel keeps. A chat row is a
|
||||
* summary surface inside the message flow: the flow must stay scannable across
|
||||
* many calls, while the details panel is the single-call reading surface. A
|
||||
* design constant of this UI's row geometry, not a deployment choice, so it is
|
||||
* fixed here rather than a plugin Config field.
|
||||
*/
|
||||
export const CHAT_WEB_MAX_SOURCES = 8
|
||||
|
||||
/**
|
||||
* Derive the web-card props for a tool call, or null when this call is not a
|
||||
* web card and belongs on the generic path.
|
||||
|
||||
@@ -180,13 +180,12 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
|
||||
)
|
||||
}
|
||||
const web = webCardModel(material.block)
|
||||
// Full source-list allowance here (the panel is the single-call reading
|
||||
// surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. Below the card the
|
||||
// panel also renders the flattened result content — the model-visible text
|
||||
// the card does not carry verbatim (a web_fetch card shows only the URL and
|
||||
// status, so its fetched body lives only here; a search card's answer and
|
||||
// sources are structured, so the flattened form repeats them as the raw text
|
||||
// the model saw).
|
||||
// The card shows every source the tool returned (the same list the model saw),
|
||||
// scrolling within its own capped height. Below the card the panel also renders
|
||||
// the flattened result content — the model-visible text the card does not carry
|
||||
// verbatim (a web_fetch card shows only the URL and status, so its fetched body
|
||||
// lives only here; a search card's answer and sources are structured, so the
|
||||
// flattened form repeats them as the raw text the model saw).
|
||||
if (web !== null) {
|
||||
const settled = 'kind' in material.block ? material.block : null
|
||||
const body = settled === null ? '' : resultText(settled)
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SelectionTarget, ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../src/client/contract/web-card-model.ts'
|
||||
import { webCardModel } from '../src/client/contract/web-card-model.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
@@ -135,8 +135,7 @@ describe('chat row web body', () => {
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
}
|
||||
|
||||
it('the WebRow collapses to the summary row, expanding to the search card capped tighter than the panel', () => {
|
||||
expect(CHAT_WEB_MAX_SOURCES).toBeLessThan(16)
|
||||
it('the WebRow collapses to the summary row, expanding to the full search card', () => {
|
||||
const view = render(<WebRow {...rowProps(settledSearch(), 'web_search')} />)
|
||||
// Collapsed: the summary row alone, no card in the DOM.
|
||||
expect(view.getByText('Search')).toBeTruthy()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
|
||||
README.md: 7318acd9b9a6047b1144789bcd2655132237f6c5
|
||||
README.zh.md: e326846dc2099472bc0a81dff093ff24b614559b
|
||||
README.md: 00e9560f43c83e1edc61c185a4fc562c6c923e8b
|
||||
README.zh.md: 21226ab211106b7722139828762605cb71a4b498
|
||||
@@ -30,7 +30,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
|
||||
|
||||
## Web retrieval
|
||||
|
||||
`WebBlock` renders a completed web retrieval, one component for both kinds of the `web` render intent (discriminated by `kind`). A `search` shows an optional provider answer (through `MarkdownText`) above an ordered citation list: each source is a safe external link labelled by its title, or its hostname, falling back to the raw URL when the URL does not parse or has no hostname (a `file:`/`data:` URL) so a label is never blank; its snippet and publication date render below it. Only http(s) URLs become anchors (`target`/`rel` set) — the http(s) subset of the allowlist `MarkdownText` applies to untrusted links (it also permits `mailto:`, excluded here); any other URL renders as plain text. A long list caps at `maxSources` (default 16, the TerminalBlock split arithmetic) with a head/tail collapse; the collapsed tail keeps each source's original citation number via `<li value>`, and the expand control is a marker-less `<li>` so the `<ol>` stays valid HTML. When a search legitimately returns no answer and no sources, the card shows an explicit empty-state note rather than a blank `<ol>` (the chat row does not surface the raw result content). A `fetch` shows a compact summary: the linked final URL and its HTTP status. Both mark a capped retrieval. Rationale: [the web result card note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md).
|
||||
`WebBlock` renders a completed web retrieval, one component for both kinds of the `web` render intent (discriminated by `kind`). A `search` shows an optional provider answer (through `MarkdownText`) above an ordered citation list: each source is a safe external link labelled by its title, or its hostname, falling back to the raw URL when the URL does not parse or has no hostname (a `file:`/`data:` URL) so a label is never blank; its snippet and publication date render below it. Only http(s) URLs become anchors (`target`/`rel` set) — the http(s) subset of the allowlist `MarkdownText` applies to untrusted links (it also permits `mailto:`, excluded here); any other URL renders as plain text. The whole list renders in one fixed-height scroll container (`max-height: 320px`, `overflow-y: auto`), so a list taller than that scrolls vertically in place instead of growing the card; `<li value>` pins each source's citation number, contiguous from 1, rather than leaving it to the `<ol>`'s implicit count. When a search legitimately returns no answer and no sources, the card shows an explicit empty-state note rather than a blank `<ol>` (the chat row does not surface the raw result content). A `fetch` shows a compact summary: the linked final URL and its HTTP status. Both mark a capped retrieval. Rationale: [the web result card note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md) and [the source scroll note](../../../.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -45,5 +45,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
|
||||
- **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
|
||||
- **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface.
|
||||
- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `HoverCard` (`copyLabel`/`copiedLabel`), `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props with the previous hardcoded strings as defaults. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing renders exactly the pre-localization output. `WebBlock` does not yet follow this pattern: its source expand/collapse controls, source-list and fetch truncation notes, and empty-search note stay inline Chinese, pending the same label-prop treatment.
|
||||
- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `HoverCard` (`copyLabel`/`copiedLabel`), `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props with the previous hardcoded strings as defaults. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing renders exactly the pre-localization output. `WebBlock` does not yet follow this pattern: its source-list and fetch truncation notes and its empty-search note stay inline Chinese, pending the same label-prop treatment.
|
||||
- **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb.
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
## Web 检索
|
||||
|
||||
`WebBlock` 渲染一次已完成的 web 检索,用一个组件绘制 `web` 渲染意图的两种 kind(由 `kind` 判别)。`search` 在有序引用列表上方显示可选的 provider answer(通过 `MarkdownText`):每个 source 是一个安全外链,以其标题为标签,或以其主机名为标签,当 URL 无法解析或没有主机名(`file:`/`data:` URL)时回退到原始 URL,因此标签绝不为空;其下渲染 snippet 与发布日期。只有 http(s) URL 会成为锚点(设置 `target`/`rel`)——这是 `MarkdownText` 对不受信任链接所用 allowlist 的 http(s) 子集(该 allowlist 还允许 `mailto:`,此处排除);任何其他 URL 渲染为纯文本。长列表在 `maxSources`(默认 16,即 TerminalBlock 的切分算术)处折叠为头部/尾部;折叠的尾部通过 `<li value>` 保留每个 source 原始的引用编号,展开控件是无 marker 的 `<li>`,使 `<ol>` 保持为合法 HTML。当一次 search 合法地返回无 answer 且无 source 时,卡片显示一个明确的空状态提示,而不是空的 `<ol>`(chat 行不呈现原始 result content)。`fetch` 显示一个紧凑摘要:带链接的最终 URL 及其 HTTP 状态。两者都会标记一次被截断的检索。原理:[Web result 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)。
|
||||
`WebBlock` 渲染一次已完成的 web 检索,用一个组件绘制 `web` 渲染意图的两种 kind(由 `kind` 判别)。`search` 在有序引用列表上方显示可选的 provider answer(通过 `MarkdownText`):每个 source 是一个安全外链,以其标题为标签,或以其主机名为标签,当 URL 无法解析或没有主机名(`file:`/`data:` URL)时回退到原始 URL,因此标签绝不为空;其下渲染 snippet 与发布日期。只有 http(s) URL 会成为锚点(设置 `target`/`rel`)——这是 `MarkdownText` 对不受信任链接所用 allowlist 的 http(s) 子集(该 allowlist 还允许 `mailto:`,此处排除);任何其他 URL 渲染为纯文本。整份列表渲染在一个定高滚动容器里(`max-height: 320px`、`overflow-y: auto`),因此超出该高度的列表在原地纵向滚动,而不是把卡片撑高;`<li value>` 固定每个 source 的引用编号,从 1 起连续,而不依赖 `<ol>` 的隐式计数。当一次 search 合法地返回无 answer 且无 source 时,卡片显示一个明确的空状态提示,而不是空的 `<ol>`(chat 行不呈现原始 result content)。`fetch` 显示一个紧凑摘要:带链接的最终 URL 及其 HTTP 状态。两者都会标记一次被截断的检索。原理:[Web result 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)与[来源滚动笔记](../../../.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -45,5 +45,5 @@
|
||||
- **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
|
||||
- **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
|
||||
- **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。
|
||||
- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `HoverCard`(`copyLabel`/`copiedLabel`)、`TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费者渲染与本地化之前逐字节一致。`WebBlock` 尚未跟进这一模式:它的来源展开/收起控件、来源列表与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。
|
||||
- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `HoverCard`(`copyLabel`/`copiedLabel`)、`TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费者渲染与本地化之前逐字节一致。`WebBlock` 尚未跟进这一模式:它的来源列表与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。
|
||||
- **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。
|
||||
@@ -1,7 +1,8 @@
|
||||
/* Geometry mirrors CodeBlock/TerminalBlock (12px radius, code-block surface,
|
||||
16px vertical margin) so a web card, a terminal card, and a fenced code block
|
||||
read as one family. A source list is prose, not aligned output, so it wraps
|
||||
normally rather than scrolling horizontally like a terminal card's output. */
|
||||
read as one family. A source list is prose, not aligned output, so each row
|
||||
wraps horizontally rather than scrolling sideways like a terminal card; the
|
||||
list as a whole scrolls vertically within a capped height (see .sources). */
|
||||
|
||||
.block {
|
||||
--dsl-web-radius: 12px;
|
||||
@@ -27,13 +28,29 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* The citation list: ordered so each source reads as a numbered reference. */
|
||||
/* The citation list: ordered so each source reads as a numbered reference. The
|
||||
whole list — the sources the tool returned, matching what the model saw —
|
||||
renders here; a max-height caps the card so a long list scrolls in place
|
||||
rather than growing the card unbounded. The height is a design constant of the
|
||||
card's geometry, not a deployment choice, so it lives here rather than a plugin
|
||||
config field.
|
||||
|
||||
`overflow-y` makes this a scroll container, which also clips inline-start
|
||||
overflow: a marker wider than `padding-left` loses its leading digits with no
|
||||
way to scroll them back. Markers are right-aligned to the content edge, so the
|
||||
padding must fit the widest one the list can produce. `searchMaxResults` is an
|
||||
unbounded positive integer, so the padding is sized in `em` — against this
|
||||
element's own font, the one a marker inherits — to hold a three-digit marker
|
||||
(`999. ` measures 2.35em in the app font stack) plus the gap the one-digit
|
||||
case already had. */
|
||||
.sources {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
padding-left: 2.5em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.source {
|
||||
@@ -65,26 +82,6 @@
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.expandItem {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.expand {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.expand:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.truncated {
|
||||
margin-top: 8px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
|
||||
@@ -9,22 +9,20 @@
|
||||
// allowlist MarkdownText applies to untrusted assistant-authored links (it also
|
||||
// permits mailto, excluded here); an unparseable or non-http URL renders as
|
||||
// plain text. Geometry, radius, and fonts mirror CodeBlock/TerminalBlock so a
|
||||
// web card reads as one family with them; a long source list caps at maxSources
|
||||
// with a head/tail collapse using the same arithmetic as TerminalBlock's output
|
||||
// cap.
|
||||
// web card reads as one family with them; the whole source list renders inside a
|
||||
// fixed-height scroll container (its `.sources` max-height), so a long list
|
||||
// scrolls in place rather than growing the card — and that container's
|
||||
// `padding-left` must stay wide enough for the widest `<li>` marker, since a
|
||||
// scroll container clips inline-start overflow irrecoverably. The card draws every source the
|
||||
// view carries: the tool already cut the list to its source cap, and `truncated`
|
||||
// reports that cut. A content-only transform downstream of the tool — spill-policy
|
||||
// replacing an oversized result's text while leaving its presentationMeta whole —
|
||||
// can still narrow what the model reads below this list.
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
import css from './WebBlock.module.css'
|
||||
|
||||
/**
|
||||
* Sources shown before the height cap collapses the middle of a citation list.
|
||||
* Matches TerminalBlock's default output budget so both cards cut a long body
|
||||
* at the same place; the chat row narrows it through the maxSources prop.
|
||||
*/
|
||||
export const DEFAULT_WEB_MAX_SOURCES = 16
|
||||
|
||||
/**
|
||||
* One citeable source drawn in a search card: the projection of the contract's
|
||||
* `WebSource`, with the optional fields kept optional so a provider that
|
||||
@@ -50,8 +48,6 @@ export interface WebSearchBlockProps {
|
||||
sources: WebSourceView[]
|
||||
/** True when the tool cut the source list to its result cap. */
|
||||
truncated: boolean
|
||||
/** Sources shown before the middle collapses (default {@link DEFAULT_WEB_MAX_SOURCES}). */
|
||||
maxSources?: number | undefined
|
||||
/** Extra class merged onto the wrapper (callers position; this component draws). */
|
||||
className?: string | undefined
|
||||
}
|
||||
@@ -65,13 +61,6 @@ export interface WebFetchBlockProps {
|
||||
statusCode: number
|
||||
/** True when the provider or the output cap cut the fetched content. */
|
||||
truncated: boolean
|
||||
/**
|
||||
* Accepted and ignored, so both card kinds take one uniform prop set (a fetch
|
||||
* card has no source list to cap) — the same way TerminalBlock accepts one
|
||||
* `maxLines` across its arms. Lets a render site spread `maxSources` onto
|
||||
* either kind without a per-kind conditional.
|
||||
*/
|
||||
maxSources?: number | undefined
|
||||
/** Extra class merged onto the wrapper (callers position; this component draws). */
|
||||
className?: string | undefined
|
||||
}
|
||||
@@ -137,9 +126,9 @@ function SafeLink({ url, label, className }: { url: string; label: string; class
|
||||
|
||||
/**
|
||||
* One source row in a search card: the safe link plus its snippet and date. The
|
||||
* `<li value>` pins the source's original 1-based position, so a collapsed list
|
||||
* whose tail is drawn after the head still numbers each source by its real
|
||||
* citation index rather than by its position in the visible subset.
|
||||
* `<li value>` pins the source's 1-based citation index explicitly rather than
|
||||
* relying on the `<ol>`'s implicit numbering, so a row reads by its real index
|
||||
* even inside the scroll container.
|
||||
* @param props.source - the source to render.
|
||||
* @param props.ordinal - the source's 1-based position in the full list.
|
||||
* @returns the source list item.
|
||||
@@ -159,21 +148,12 @@ function SourceItem({ source, ordinal }: { source: WebSourceView; ordinal: numbe
|
||||
}
|
||||
|
||||
/**
|
||||
* The search card body: the answer over the capped source list.
|
||||
* The search card body: the answer over the full source list, which scrolls in
|
||||
* place once it exceeds the `.sources` container height.
|
||||
* @param props - see {@link WebSearchBlockProps}.
|
||||
* @returns the search card element.
|
||||
*/
|
||||
function WebSearchBlock({ answer, sources, truncated, maxSources = DEFAULT_WEB_MAX_SOURCES, className }: WebSearchBlockProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
|
||||
const hidden = sources.length - maxSources
|
||||
const capped = hidden > 0 && !expanded
|
||||
// Same split arithmetic as TerminalBlock's output cap, so a long body's head
|
||||
// and tail slices agree between the two cards.
|
||||
const headCount = Math.ceil(maxSources / 2)
|
||||
const tailCount = maxSources - headCount
|
||||
const head = capped ? sources.slice(0, headCount) : sources
|
||||
const tail = capped ? sources.slice(sources.length - tailCount) : []
|
||||
function WebSearchBlock({ answer, sources, truncated, className }: WebSearchBlockProps) {
|
||||
// A provider may legitimately return no answer and no sources; the chat WebRow
|
||||
// does not show the raw result content, so without this the user would see an
|
||||
// empty card. Mirror the backend's `No results found.` render text.
|
||||
@@ -187,27 +167,7 @@ function WebSearchBlock({ answer, sources, truncated, maxSources = DEFAULT_WEB_M
|
||||
<div className={css.empty}>未找到结果</div>
|
||||
) : (
|
||||
<ol className={css.sources}>
|
||||
{head.map((source, index) => <SourceItem key={index} source={source} ordinal={index + 1} />)}
|
||||
{hidden > 0 && (
|
||||
<li className={css.expandItem}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.expand}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? '收起来源' : `展开其余 ${hidden} 条来源`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{expanded ? '收起' : `… 其余 ${hidden} 条来源`}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{tail.map((source, index) => (
|
||||
<SourceItem
|
||||
key={sources.length - tailCount + index}
|
||||
source={source}
|
||||
ordinal={sources.length - tailCount + index + 1}
|
||||
/>
|
||||
))}
|
||||
{sources.map((source, index) => <SourceItem key={index} source={source} ordinal={index + 1} />)}
|
||||
</ol>
|
||||
)}
|
||||
{truncated && <div className={css.truncated}>来源列表已截断</div>}
|
||||
|
||||
@@ -32,7 +32,7 @@ export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx'
|
||||
export type {
|
||||
SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch,
|
||||
} from './SearchBlock.tsx'
|
||||
export { WebBlock, DEFAULT_WEB_MAX_SOURCES } from './WebBlock.tsx'
|
||||
export { WebBlock } from './WebBlock.tsx'
|
||||
export type { WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView } from './WebBlock.tsx'
|
||||
export { CodeBlock } from './markdown/CodeBlock.tsx'
|
||||
export type { CodeBlockProps } from './markdown/CodeBlock.tsx'
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
// @vitest-environment jsdom
|
||||
// WebBlock: both kinds of the web card. The search card's answer, its citation
|
||||
// list with the title-or-hostname label fallback and optional snippet/date, the
|
||||
// source-list height cap and its expand control, and the truncated indicator;
|
||||
// the fetch card's linked URL, status, and truncation. Safe-link attributes on
|
||||
// both kinds: an http(s) URL becomes an external anchor (target/rel), any other
|
||||
// URL renders as plain text with no href.
|
||||
// full source list under one <ol>, and the truncated indicator; the fetch
|
||||
// card's linked URL, status, and truncation. Safe-link
|
||||
// attributes on both kinds: an http(s) URL becomes an external anchor
|
||||
// (target/rel), any other URL renders as plain text with no href.
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { DEFAULT_WEB_MAX_SOURCES, WebBlock } from '../src/index.ts'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { WebBlock } from '../src/index.ts'
|
||||
import type { WebSourceView } from '../src/index.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
/** `count` sources with sequential hostnames, so the cap slices read distinctly. */
|
||||
/** `count` sources with sequential hostnames, so each row reads distinctly. */
|
||||
function sources(count: number): WebSourceView[] {
|
||||
return Array.from({ length: count }, (_value, index) => ({
|
||||
url: `https://site-${index}.example.com/page`,
|
||||
@@ -123,58 +123,25 @@ describe('WebBlock search card', () => {
|
||||
expect(off.queryByText('来源列表已截断')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders every source and no expand control under the cap', () => {
|
||||
const view = render(<WebBlock kind="search" sources={sources(4)} truncated={false} maxSources={4} />)
|
||||
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4)
|
||||
it('renders every source in one <ol> with no expand control', () => {
|
||||
// The card shows the whole list the tool returned, with no head/tail
|
||||
// collapse and no expand button. jsdom does not resolve the CSS Modules
|
||||
// layout, so the scroll geometry the `.sources` max-height produces is
|
||||
// pinned by the assembled browser case in apps/web/tests/web-search-round.e2e.ts,
|
||||
// not here.
|
||||
const view = render(<WebBlock kind="search" sources={sources(30)} truncated={false} />)
|
||||
expect(view.container.querySelectorAll('li[class^="_source_"]')).toHaveLength(30)
|
||||
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
||||
})
|
||||
|
||||
it('slices head and tail over the cap and expands on click', () => {
|
||||
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
|
||||
// maxSources 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden.
|
||||
expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent))
|
||||
.toEqual(['Source 0', 'Source 1', 'Source 8', 'Source 9'])
|
||||
const toggle = view.getByRole('button', { name: '展开其余 6 条来源' })
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(toggle.textContent).toBe('… 其余 6 条来源')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(10)
|
||||
const collapse = view.getByRole('button', { name: '收起来源' })
|
||||
expect(collapse.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(collapse.textContent).toBe('收起')
|
||||
|
||||
fireEvent.click(collapse)
|
||||
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('numbers a collapsed tail by each source original position, not its visible slot', () => {
|
||||
// maxSources 4 over 10 sources: the tail is sources 8 and 9, which must read
|
||||
// as citations 9 and 10 (via <li value>), not renumbered 3 and 4.
|
||||
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
|
||||
const items = [...view.container.querySelectorAll('li[class^="_source_"]')]
|
||||
expect(items.map(li => li.getAttribute('value'))).toEqual(['1', '2', '9', '10'])
|
||||
})
|
||||
|
||||
it('keeps the expander out of the ordered-list numbering', () => {
|
||||
// The expander is a marker-less <li>, so it is valid inside <ol> and does not
|
||||
// consume a citation number between the head and tail sources.
|
||||
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
|
||||
expect(view.container.querySelector('button')).toBeNull()
|
||||
// Every direct child of the <ol> is a source <li> (no marker-less expander).
|
||||
const ol = view.container.querySelector('ol')!
|
||||
// Every direct child is an <li> (no bare <button> child — invalid HTML).
|
||||
expect([...ol.children].every(child => child.tagName === 'LI')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the head slice alone when the cap leaves no tail', () => {
|
||||
const view = render(<WebBlock kind="search" sources={sources(5)} truncated={false} maxSources={1} />)
|
||||
expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent)).toEqual(['Source 0'])
|
||||
expect(view.getByRole('button', { name: '展开其余 4 条来源' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('caps at the documented default when maxSources is absent', () => {
|
||||
const view = render(<WebBlock kind="search" sources={sources(DEFAULT_WEB_MAX_SOURCES + 1)} truncated={false} />)
|
||||
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(DEFAULT_WEB_MAX_SOURCES)
|
||||
expect(view.getByRole('button', { name: '展开其余 1 条来源' })).toBeTruthy()
|
||||
it('numbers every source by its 1-based citation index via <li value>', () => {
|
||||
const view = render(<WebBlock kind="search" sources={sources(4)} truncated={false} />)
|
||||
const items = [...view.container.querySelectorAll('li[class^="_source_"]')]
|
||||
expect(items.map(li => li.getAttribute('value'))).toEqual(['1', '2', '3', '4'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
encodeSegment, eventLines, logPath, logSuffix, parseHeaderMeta, projectDir, scanLog, sessionDir, toHeaderLine,
|
||||
type JsonlCompression,
|
||||
} from './format.ts'
|
||||
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from './zstd.ts'
|
||||
import { compressZstdFrame, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames } from './zstd.ts'
|
||||
import { ensureDurableDirectoryWin32, publishNewFileWin32 } from './win32.ts'
|
||||
|
||||
export type { JsonlCompression } from './format.ts'
|
||||
@@ -232,7 +232,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
let recoveredPlaintext: Buffer = Buffer.alloc(0)
|
||||
try {
|
||||
signal?.throwIfAborted()
|
||||
recoveredPlaintext = await decompressZstdFrame(buffer.subarray(tornStart))
|
||||
recoveredPlaintext = await decompressZstdPrefix(buffer.subarray(tornStart))
|
||||
} catch {
|
||||
/* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */
|
||||
if (signal?.aborted) signal.throwIfAborted()
|
||||
|
||||
@@ -14,6 +14,9 @@ const zstdDecompressAsync = promisify(zstdDecompress)
|
||||
const CHECKSUM_OPTIONS: ZstdOptions = {
|
||||
params: { [constants.ZSTD_c_checksumFlag]: 1 },
|
||||
}
|
||||
const INCOMPLETE_FRAME_OPTIONS: ZstdOptions = {
|
||||
finishFlush: constants.ZSTD_e_flush,
|
||||
}
|
||||
|
||||
/** Byte range occupied by one structurally complete Zstandard frame. */
|
||||
export interface ZstdFrameRange {
|
||||
@@ -106,11 +109,21 @@ export async function compressZstdFrame(input: Buffer | string): Promise<Buffer>
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress one complete frame or the available prefix of a torn final frame.
|
||||
* Complete-frame checksums are validated by Node's decoder.
|
||||
* @param input - bytes beginning at a Zstandard frame boundary.
|
||||
* @returns plaintext produced from the available input.
|
||||
* Decompress one complete frame and validate its checksum.
|
||||
* @param input - one structurally complete Zstandard frame.
|
||||
* @returns the frame plaintext.
|
||||
*/
|
||||
export async function decompressZstdFrame(input: Buffer): Promise<Buffer> {
|
||||
return zstdDecompressAsync(input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover available plaintext from a structurally incomplete final frame.
|
||||
* `ZSTD_e_flush` deliberately suppresses final-frame and checksum completion;
|
||||
* callers must establish the torn frame boundary before using this helper.
|
||||
* @param input - available bytes from a known incomplete Zstandard frame.
|
||||
* @returns plaintext produced from the available input.
|
||||
*/
|
||||
export async function decompressZstdPrefix(input: Buffer): Promise<Buffer> {
|
||||
return zstdDecompressAsync(input, INCOMPLETE_FRAME_OPTIONS)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from '../src/zstd.ts'
|
||||
import { compressZstdFrame, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames } from '../src/zstd.ts'
|
||||
|
||||
describe('JSONL Zstandard compatibility', () => {
|
||||
it('round-trips concatenated checksummed frames through the built-in Node API', async () => {
|
||||
@@ -19,6 +19,6 @@ describe('JSONL Zstandard compatibility', () => {
|
||||
const eventFrame = encoded.subarray(frames[1]!.start, frames[1]!.end)
|
||||
const missingChecksumByte = eventFrame.subarray(0, -1)
|
||||
expect(scanZstdFrames(missingChecksumByte)).toEqual({ frames: [], tornStart: 0 })
|
||||
expect((await decompressZstdFrame(missingChecksumByte)).toString()).toContain('"type":"turn/start"')
|
||||
expect((await decompressZstdPrefix(missingChecksumByte)).toString()).toContain('"type":"turn/start"')
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { logPath, scanLog, sessionDir, toHeaderLine, type JsonlCompression } from '../src/format.ts'
|
||||
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from '../src/zstd.ts'
|
||||
import { compressZstdFrame, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames } from '../src/zstd.ts'
|
||||
import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts'
|
||||
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
|
||||
|
||||
@@ -69,7 +69,7 @@ async function tornFrame(
|
||||
const candidate = frame.subarray(0, end)
|
||||
if (scanZstdFrames(candidate).tornStart !== 0) continue
|
||||
try {
|
||||
const decoded = (await decompressZstdFrame(candidate)).toString('utf8')
|
||||
const decoded = (await decompressZstdPrefix(candidate)).toString('utf8')
|
||||
if (accepts(decoded)) return candidate
|
||||
} catch {
|
||||
// Some early cuts precede the first decodable block; keep searching for
|
||||
|
||||
@@ -43,16 +43,22 @@ const WAIT_POLL_INTERVAL_MS = 10
|
||||
* reference, since a committed file cannot know the id in advance.
|
||||
*
|
||||
* `promptAndCancel` starts a prompt without awaiting completion, waits for a
|
||||
* readiness condition, then cancels and awaits completion. `waitForFile`
|
||||
* observes a cwd-relative marker; the default observes the durable turn start.
|
||||
* readiness condition, then cancels and awaits completion. Its optional
|
||||
* `waitForFile` observes a cwd-relative marker; otherwise it waits for the
|
||||
* durable turn start. The standalone `waitForFile` holds the next script step
|
||||
* behind the same marker.
|
||||
* `promptAndWaitForAgentMessage` arms an exact text-chunk waiter before sending
|
||||
* the prompt, then keeps the application live until that later update arrives.
|
||||
* `waitForTurnStart` waits for an open durable turn, optionally at or beyond a
|
||||
* specified turn number. `waitForTurnEnd` holds the subprocess open until the
|
||||
* selected session's latest complete raw-JSONL turn boundary is `turn/end`.
|
||||
* `waitForSubagentTurnEnd` waits until one background child has persisted a
|
||||
* closed model-work turn after its own descriptor; child progress has no ACP
|
||||
* update to wait on.
|
||||
* `waitForTitleAfterTurnEnd` additionally waits for a later durable title.
|
||||
* `waitForSubagentTurnEnd` applies the same work-turn boundary to one
|
||||
* background child, whose progress has no ACP update to wait on.
|
||||
* `waitForEventAfterTurnEnd` waits until a complete record of the given event
|
||||
* type follows the latest closed turn — for scenarios whose asserted state
|
||||
* (e.g. a goal pause) is appended only after cancellation reaches idle.
|
||||
* A standalone `cancel` may also wait for a cwd-relative readiness marker.
|
||||
* All wait timeouts default to 10s.
|
||||
*/
|
||||
@@ -73,6 +79,7 @@ export type InputStep =
|
||||
| { op: 'waitForTurnEnd'; timeoutMs?: number }
|
||||
| { op: 'waitForSubagentTurnEnd'; child?: number; timeoutMs?: number }
|
||||
| { op: 'waitForTitleAfterTurnEnd'; timeoutMs?: number }
|
||||
| { op: 'waitForEventAfterTurnEnd'; type: string; timeoutMs?: number }
|
||||
| { op: 'cancel'; waitForFile?: { path: string; timeoutMs?: number } }
|
||||
|
||||
/** A scenario's `input.json`: an ordered list of input steps. */
|
||||
@@ -296,6 +303,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
|
||||
(id, timeoutMs) => waitForPersistedTurnEnd(sessionsRoot, id, timeoutMs),
|
||||
(child, timeoutMs) => waitForPersistedChildTurnEnd(sessionsRoot, child, timeoutMs),
|
||||
(id, timeoutMs) => waitForPersistedTitleAfterTurnEnd(sessionsRoot, id, timeoutMs),
|
||||
(id, type, timeoutMs) => waitForPersistedEventAfterTurnEnd(sessionsRoot, id, type, timeoutMs),
|
||||
)
|
||||
// A permission exchange happens while a step's request is in flight, so
|
||||
// by the time the step settles any script bug it exposed is captured —
|
||||
@@ -371,6 +379,7 @@ async function runStep(
|
||||
waitForTurnEnd: (sessionId: string, timeoutMs?: number) => Promise<void>,
|
||||
waitForChildTurnEnd: (child: number, timeoutMs?: number) => Promise<void>,
|
||||
waitForTitleAfterTurnEnd: (sessionId: string, timeoutMs?: number) => Promise<void>,
|
||||
waitForEventAfterTurnEnd: (sessionId: string, type: string, timeoutMs?: number) => Promise<void>,
|
||||
): Promise<void> {
|
||||
switch (step.op) {
|
||||
case 'initialize':
|
||||
@@ -460,6 +469,12 @@ async function runStep(
|
||||
await waitForTitleAfterTurnEnd(sessionId, step.timeoutMs)
|
||||
return
|
||||
}
|
||||
case 'waitForEventAfterTurnEnd': {
|
||||
const sessionId = getSessionId()
|
||||
if (sessionId === undefined) throw new Error('snapshot-harness: waitForEventAfterTurnEnd before newSession')
|
||||
await waitForEventAfterTurnEnd(sessionId, step.type, step.timeoutMs)
|
||||
return
|
||||
}
|
||||
case 'waitForTurnStart': {
|
||||
const sessionId = getSessionId()
|
||||
if (sessionId === undefined) throw new Error('snapshot-harness: waitForTurnStart before newSession')
|
||||
@@ -576,6 +591,21 @@ async function waitForPersistedTitleAfterTurnEnd(
|
||||
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
|
||||
}
|
||||
|
||||
/** Wait until a complete record of `type` follows the latest closed turn. */
|
||||
async function waitForPersistedEventAfterTurnEnd(
|
||||
root: string,
|
||||
sessionId: string,
|
||||
type: string,
|
||||
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
|
||||
): Promise<void> {
|
||||
await vi.waitFor(async () => {
|
||||
const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId)
|
||||
if (log === undefined || !latestEventFollowsTurnEnd(log.content, type)) {
|
||||
throw new Error(`snapshot-harness: session "${sessionId}" did not persist ${type} after turn/end within ${timeoutMs}ms`)
|
||||
}
|
||||
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
|
||||
}
|
||||
|
||||
/** Wait for a cwd-relative marker proving an external action reached readiness. */
|
||||
async function waitForWorkspaceFile(
|
||||
cwd: string,
|
||||
@@ -604,6 +634,13 @@ function latestTitleFollowsTurnEnd(content: string): boolean {
|
||||
return turnEnd >= 0 && complete.lastIndexOf('\n{"type":"session/title",') > turnEnd
|
||||
}
|
||||
|
||||
/** Return whether a complete record of `type` occurs after the last complete turn end. */
|
||||
function latestEventFollowsTurnEnd(content: string, type: string): boolean {
|
||||
const complete = content.slice(0, content.lastIndexOf('\n') + 1)
|
||||
const turnEnd = complete.lastIndexOf('\n{"type":"turn/end",')
|
||||
return turnEnd >= 0 && complete.lastIndexOf(`\n{"type":"${type}",`) > turnEnd
|
||||
}
|
||||
|
||||
/** Return the latest open turn number, validating the persisted boundary record. */
|
||||
function latestOpenTurn(content: string): number | undefined {
|
||||
const complete = content.slice(0, content.lastIndexOf('\n') + 1)
|
||||
|
||||
@@ -844,6 +844,55 @@ describe('runScenario', () => {
|
||||
)).rejects.toThrow(/did not persist session\/title after turn\/end within 20ms/)
|
||||
})
|
||||
|
||||
it('waitForEventAfterTurnEnd holds the app for a typed post-boundary record and times out otherwise', { timeout: 20_000 }, async () => {
|
||||
const late = await scenario({
|
||||
prompt: 'hang-until-cancel',
|
||||
persistLogsOnCancel: true,
|
||||
logs: [{
|
||||
file: 'project/main/session.jsonl',
|
||||
lines: [
|
||||
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
|
||||
{ type: 'user/message', seq: 2, time: 3, data: { content: [{ type: 'text', text: 'late goal state' }], source: { kind: 'user' } } },
|
||||
],
|
||||
}],
|
||||
})
|
||||
const result = await runScenario(
|
||||
{
|
||||
steps: [
|
||||
...boot,
|
||||
{ op: 'promptAndCancel', text: 'hang' },
|
||||
{ op: 'waitForEventAfterTurnEnd', type: 'user/message' },
|
||||
],
|
||||
},
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile: late.fixtureFile },
|
||||
)
|
||||
expect(result.sessionLogs[0]?.content).toMatch(/"turn\/end"[\s\S]*"user\/message"/)
|
||||
|
||||
const early = await scenario({
|
||||
prompt: 'hang-until-cancel',
|
||||
persistLogsOnCancel: true,
|
||||
logs: [{
|
||||
file: 'project/main/session.jsonl',
|
||||
lines: [
|
||||
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
|
||||
{ type: 'user/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: 'early' }], source: { kind: 'user' } } },
|
||||
{ type: 'turn/end', seq: 2, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
|
||||
],
|
||||
}],
|
||||
})
|
||||
await expect(runScenario(
|
||||
{
|
||||
steps: [
|
||||
...boot,
|
||||
{ op: 'promptAndCancel', text: 'hang' },
|
||||
{ op: 'waitForEventAfterTurnEnd', type: 'user/message', timeoutMs: 20 },
|
||||
],
|
||||
},
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile: early.fixtureFile },
|
||||
)).rejects.toThrow(/did not persist user\/message after turn\/end within 20ms/)
|
||||
})
|
||||
|
||||
it('promptExpectError swallows a model-error response as the expected outcome', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({ prompt: 'error' })
|
||||
const result = await runScenario(
|
||||
@@ -963,6 +1012,7 @@ describe('runScenario', () => {
|
||||
[{ op: 'waitForTurnStart' }, /waitForTurnStart before newSession/],
|
||||
[{ op: 'waitForTurnEnd' }, /waitForTurnEnd before newSession/],
|
||||
[{ op: 'waitForTitleAfterTurnEnd' }, /waitForTitleAfterTurnEnd before newSession/],
|
||||
[{ op: 'waitForEventAfterTurnEnd', type: 'user/message' }, /waitForEventAfterTurnEnd before newSession/],
|
||||
[{ op: 'cancel' }, /cancel before newSession/],
|
||||
] as [InputStep, RegExp][])('rejects %j before newSession', { timeout: 20_000 }, async (step, message) => {
|
||||
const { fixtureFile } = await scenario({})
|
||||
|
||||
@@ -132,32 +132,58 @@ function timingTotalsAt(state: TimingState, at?: number): TimingTotals {
|
||||
return totals
|
||||
}
|
||||
|
||||
function stepKey(position: StepPosition): string {
|
||||
return `${position.turn}:${position.step}`
|
||||
}
|
||||
|
||||
interface TrackedStep extends TimingState {
|
||||
/** Set at the step's `step/end`; later same-coordinate events no longer advance the step. */
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay one step's accumulated per-phase timing up to clock `at`.
|
||||
* @param events - Session events to replay.
|
||||
* @param position - Turn/step coordinates of the step.
|
||||
* @param at - Render clock to accumulate the open bucket up to.
|
||||
* @returns The step's per-phase totals.
|
||||
* Incremental per-step timing accumulator shared by every step's timing footer
|
||||
* in one transcript. One forward pass over the append-only session log serves
|
||||
* all steps' totals: each query advances a cursor over the events appended
|
||||
* since the previous query, so a transcript of S steps costs O(events) in
|
||||
* total instead of the O(S × events) of replaying the whole log per footer
|
||||
* ([rationale](../../../../../.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.md)).
|
||||
*
|
||||
* The log must be append-only with stable indices (the session `seq = log
|
||||
* length` contract). Event times are consumed as logged: a backward wall-clock
|
||||
* step clamps each bucket at zero rather than cutting the scan off at the
|
||||
* query clock. The open bucket is accumulated to the query clock at lookup,
|
||||
* never during the scan.
|
||||
*/
|
||||
export function stepTimingAt(
|
||||
events: readonly SessionEvent[],
|
||||
position: StepPosition,
|
||||
at: number,
|
||||
): TimingTotals {
|
||||
const startIndex = events.findIndex(event => event.type === 'step/start' && sameStep(event, position))
|
||||
if (startIndex < 0) return emptyTimingTotals()
|
||||
const start = events[startIndex] as Extract<SessionEvent, { type: 'step/start' }>
|
||||
const state = timingState(start.time)
|
||||
for (let index = startIndex + 1; index < events.length; index += 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (event.time > at) break
|
||||
if ((event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end')
|
||||
&& sameStep(event, position)) {
|
||||
advanceStepTiming(state, event)
|
||||
if (event.type === 'step/end') break
|
||||
export class StepTimingTracker {
|
||||
private scanned = 0
|
||||
private readonly steps = new Map<string, TrackedStep>()
|
||||
|
||||
/**
|
||||
* Advance over events appended since the previous query, then return one
|
||||
* step's accumulated per-phase timing up to clock `at`.
|
||||
* @param events - Current session event log (append-only).
|
||||
* @param position - Turn/step coordinates of the queried step.
|
||||
* @param at - Render clock to accumulate the open bucket up to.
|
||||
* @returns The step's per-phase totals; empty when the step never started.
|
||||
*/
|
||||
totalsAt(events: readonly SessionEvent[], position: StepPosition, at: number): TimingTotals {
|
||||
for (; this.scanned < events.length; this.scanned += 1) {
|
||||
const event = events[this.scanned] as SessionEvent
|
||||
if (event.type === 'step/start') {
|
||||
const key = stepKey(event.data)
|
||||
if (!this.steps.has(key)) this.steps.set(key, { ...timingState(event.time), closed: false })
|
||||
} else if (event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end') {
|
||||
const state = this.steps.get(stepKey(event.data))
|
||||
if (state !== undefined && !state.closed) {
|
||||
advanceStepTiming(state, event)
|
||||
if (event.type === 'step/end') state.closed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
const state = this.steps.get(stepKey(position))
|
||||
return state === undefined ? emptyTimingTotals() : timingTotalsAt(state, at)
|
||||
}
|
||||
return timingTotalsAt(state, at)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,7 +217,7 @@ const COMPACTING_GLYPH = '⊙'
|
||||
/**
|
||||
* Derive the currently open step's active timing bucket, or `undefined` when no
|
||||
* step is open. The open step is the last `step/start` with no later matching
|
||||
* `step/end`; its bucket is replayed with the same rules as {@link stepTimingAt}.
|
||||
* `step/end`; its bucket is replayed with the same rules as {@link StepTimingTracker}.
|
||||
* @param events - Session events to scan.
|
||||
* @returns The open step's active bucket, or `undefined`.
|
||||
*/
|
||||
|
||||
@@ -33,8 +33,8 @@ import { contentText, type ParsedArguments } from './content.ts'
|
||||
import {
|
||||
formatCompletionTime,
|
||||
formatTimingTotals,
|
||||
stepTimingAt,
|
||||
type StepPosition,
|
||||
type StepTimingTracker,
|
||||
} from '../chat/timing.ts'
|
||||
|
||||
/** Concatenate the text of every block of one type, separated by blank lines. */
|
||||
@@ -228,6 +228,7 @@ class StepTimingComponent extends Container {
|
||||
constructor(
|
||||
private readonly position: StepPosition,
|
||||
private readonly events: () => readonly SessionEvent[],
|
||||
private readonly tracker: StepTimingTracker,
|
||||
private readonly now: () => number,
|
||||
private readonly palette: Palette,
|
||||
) {
|
||||
@@ -247,7 +248,7 @@ class StepTimingComponent extends Container {
|
||||
|
||||
private rebuild(): void {
|
||||
this.clear()
|
||||
const totals = stepTimingAt(this.events(), this.position, this.completionTime ?? this.now())
|
||||
const totals = this.tracker.totalsAt(this.events(), this.position, this.completionTime ?? this.now())
|
||||
const timing = formatTimingTotals(totals, true)
|
||||
const header = this.completionTime === undefined
|
||||
? timing
|
||||
@@ -277,13 +278,14 @@ export class StreamingAssistantComponent extends Container {
|
||||
/** The step's turn/step coordinates, used to group steps into their turn. */
|
||||
readonly position: StepPosition,
|
||||
events: () => readonly SessionEvent[],
|
||||
tracker: StepTimingTracker,
|
||||
now: () => number,
|
||||
private showReasoning: boolean,
|
||||
private readonly palette: Palette,
|
||||
private readonly mdTheme: MarkdownTheme,
|
||||
) {
|
||||
super()
|
||||
this.timing = new StepTimingComponent(position, events, now, palette)
|
||||
this.timing = new StepTimingComponent(position, events, tracker, now, palette)
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
@@ -409,8 +411,43 @@ interface CardBody {
|
||||
*/
|
||||
export type ToolCardVisibility = 'hidden' | 'collapsed' | 'expanded'
|
||||
|
||||
/**
|
||||
* Transcript card with a width-keyed rendered-row cache. pi-tui re-renders
|
||||
* every component each frame and relies on per-component line caches (its own
|
||||
* `Text`/`Markdown` do this); a card that rebuilds rows inside `render(width)`
|
||||
* would re-wrap its output every frame
|
||||
* ([rationale](../../../../../.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.md)).
|
||||
* Subclasses render through {@link renderLines} and call {@link dropLines}
|
||||
* from every state mutator; with `invalidate()` (pi-tui's tree-wide cascade)
|
||||
* also dropping, a state change always re-renders.
|
||||
*/
|
||||
abstract class CachedCardComponent implements Component {
|
||||
private cached: { width: number; lines: string[] } | undefined
|
||||
|
||||
/** Discard the cached rows so the next render recomputes them. */
|
||||
protected dropLines(): void {
|
||||
this.cached = undefined
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cached = undefined
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.cached?.width !== width) this.cached = { width, lines: this.renderLines(width) }
|
||||
return this.cached.lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the card's rows for `width` without caching.
|
||||
* @param width - Render width the rows are wrapped to.
|
||||
* @returns The card's rows.
|
||||
*/
|
||||
protected abstract renderLines(width: number): string[]
|
||||
}
|
||||
|
||||
/** A tool call and its result, rendered as a collapsible status card. */
|
||||
export class ToolCardComponent implements Component {
|
||||
export class ToolCardComponent extends CachedCardComponent {
|
||||
private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined
|
||||
private visibility: ToolCardVisibility = 'collapsed'
|
||||
private callView: ToolCallView
|
||||
@@ -426,6 +463,7 @@ export class ToolCardComponent implements Component {
|
||||
private readonly palette: Palette,
|
||||
private readonly mdTheme: MarkdownTheme,
|
||||
) {
|
||||
super()
|
||||
this.callView = this.presentCall()
|
||||
}
|
||||
|
||||
@@ -447,6 +485,7 @@ export class ToolCardComponent implements Component {
|
||||
*/
|
||||
updateResult(event: Extract<SessionEvent, { type: 'tool/result' }>['data']): void {
|
||||
this.diffBodyCache = undefined
|
||||
this.dropLines()
|
||||
const result = event.message.content[0]
|
||||
this.result = {
|
||||
content: [...result.content],
|
||||
@@ -469,11 +508,10 @@ export class ToolCardComponent implements Component {
|
||||
*/
|
||||
setVisibility(visibility: ToolCardVisibility): void {
|
||||
this.visibility = visibility
|
||||
this.dropLines()
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
protected renderLines(width: number): string[] {
|
||||
// Hidden renders nothing — not even the leading gap — so the transcript
|
||||
// keeps only the conversation, the way Codex hides tool calls.
|
||||
if (this.visibility === 'hidden') return []
|
||||
@@ -725,7 +763,7 @@ function stripReminderFrame(text: string): string {
|
||||
* well-formed XML, which made both the fold and the frame-line suppression
|
||||
* content-dependent.
|
||||
*/
|
||||
export class ContextCardComponent implements Component {
|
||||
export class ContextCardComponent extends CachedCardComponent {
|
||||
private expanded = false
|
||||
|
||||
constructor(
|
||||
@@ -733,7 +771,9 @@ export class ContextCardComponent implements Component {
|
||||
private readonly text: string,
|
||||
private readonly maxOutputLines: number,
|
||||
private readonly palette: Palette,
|
||||
) {}
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand or collapse the card body.
|
||||
@@ -741,11 +781,10 @@ export class ContextCardComponent implements Component {
|
||||
*/
|
||||
setExpanded(expanded: boolean): void {
|
||||
this.expanded = expanded
|
||||
this.dropLines()
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
protected renderLines(width: number): string[] {
|
||||
const header = this.palette.dim(`Context · ${displayText(this.label)}`)
|
||||
// Emptiness is decided on the stripped text: styling a blank body would yield
|
||||
// one escape-only row, which reads as a stray blank line under the header.
|
||||
|
||||
@@ -88,6 +88,7 @@ import {
|
||||
runningPhaseGlyph,
|
||||
STATUS_ANIMATION_INTERVAL_MS,
|
||||
STATUS_FADE_MS,
|
||||
StepTimingTracker,
|
||||
TIMING_BUCKET_GLYPHS,
|
||||
type StepPosition,
|
||||
} from './chat/timing.ts'
|
||||
@@ -358,6 +359,9 @@ export function createTuiChat(
|
||||
let toolsVisibility: ToolCardVisibility = 'collapsed'
|
||||
let streaming: StreamingAssistantComponent | undefined
|
||||
let completedStreaming: StreamingAssistantComponent | undefined
|
||||
// One shared accumulator serves every step's timing footer; per-footer
|
||||
// replay of the whole log is quadratic on a long resumed session.
|
||||
const stepTimingTracker = new StepTimingTracker()
|
||||
// Assistant step components in model order per turn, for hidden-mode folding:
|
||||
// with tool cards hidden, a turn keeps one Assistant header and later steps
|
||||
// render as headerless continuations (see applyTurnFolding).
|
||||
@@ -769,6 +773,7 @@ export function createTuiChat(
|
||||
streaming = new StreamingAssistantComponent(
|
||||
position,
|
||||
() => agent.session.events,
|
||||
stepTimingTracker,
|
||||
now,
|
||||
showReasoning,
|
||||
palette,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { StepTimingTracker } from '../src/chat/timing.ts'
|
||||
|
||||
/** One completed two-phase step plus a tool call, in event-log order. */
|
||||
function stepEvents(turn: number, step: number, base: number, seq: number): SessionEvent[] {
|
||||
return [
|
||||
{ type: 'step/start', seq: seq, time: base, data: { turn, step } },
|
||||
{ type: 'assistant/chunk', seq: seq + 1, time: base + 100, data: { turn, step, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } } },
|
||||
{ type: 'assistant/chunk', seq: seq + 2, time: base + 300, data: { turn, step, chunk: { type: 'text-delta', index: 1, text: 'hi' } } },
|
||||
{ type: 'tool/call', seq: seq + 3, time: base + 450, data: { turn, step, callId: 'call-1', name: 'bash', arguments: '{}' } },
|
||||
{ type: 'step/end', seq: seq + 4, time: base + 700, data: { turn, step } },
|
||||
] as SessionEvent[]
|
||||
}
|
||||
|
||||
describe('StepTimingTracker', () => {
|
||||
it('accumulates each phase from the step lifecycle', () => {
|
||||
const tracker = new StepTimingTracker()
|
||||
const events = stepEvents(1, 1, 1_000, 0)
|
||||
expect(tracker.totalsAt(events, { turn: 1, step: 1 }, 2_000)).toEqual({
|
||||
ttft: 100, // step/start -> first chunk
|
||||
thinking: 200, // reasoning block-start -> text delta
|
||||
responding: 150, // text delta -> tool call
|
||||
tools: 250, // tool call -> step/end
|
||||
})
|
||||
})
|
||||
|
||||
it('returns empty totals for a step that never started', () => {
|
||||
const tracker = new StepTimingTracker()
|
||||
expect(tracker.totalsAt(stepEvents(1, 1, 1_000, 0), { turn: 9, step: 9 }, 2_000)).toEqual({
|
||||
ttft: 0, thinking: 0, responding: 0, tools: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('accumulates the open bucket to the query clock without mutating tracked state', () => {
|
||||
const tracker = new StepTimingTracker()
|
||||
const events = [
|
||||
{ type: 'step/start', seq: 0, time: 1_000, data: { turn: 1, step: 1 } },
|
||||
] as SessionEvent[]
|
||||
expect(tracker.totalsAt(events, { turn: 1, step: 1 }, 1_250).ttft).toBe(250)
|
||||
expect(tracker.totalsAt(events, { turn: 1, step: 1 }, 1_400).ttft).toBe(400)
|
||||
})
|
||||
|
||||
it('matches a fresh replay when queried incrementally across appends', () => {
|
||||
const incremental = new StepTimingTracker()
|
||||
const first = stepEvents(1, 1, 1_000, 0)
|
||||
incremental.totalsAt(first, { turn: 1, step: 1 }, 5_000)
|
||||
const events = [...first, ...stepEvents(1, 2, 3_000, first.length)]
|
||||
const fresh = new StepTimingTracker()
|
||||
for (const position of [{ turn: 1, step: 1 }, { turn: 1, step: 2 }]) {
|
||||
expect(incremental.totalsAt(events, position, 5_000)).toEqual(fresh.totalsAt(events, position, 5_000))
|
||||
}
|
||||
})
|
||||
|
||||
it('serves interleaved steps from one shared scan', () => {
|
||||
const tracker = new StepTimingTracker()
|
||||
const events = [
|
||||
{ type: 'step/start', seq: 0, time: 1_000, data: { turn: 1, step: 1 } },
|
||||
{ type: 'step/start', seq: 1, time: 1_100, data: { turn: 1, step: 2 } },
|
||||
{ type: 'assistant/chunk', seq: 2, time: 1_200, data: { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'x' } } },
|
||||
{ type: 'step/end', seq: 3, time: 1_500, data: { turn: 1, step: 2 } },
|
||||
{ type: 'step/end', seq: 4, time: 1_600, data: { turn: 1, step: 1 } },
|
||||
] as SessionEvent[]
|
||||
expect(tracker.totalsAt(events, { turn: 1, step: 1 }, 9_000)).toEqual({ ttft: 600, thinking: 0, responding: 0, tools: 0 })
|
||||
expect(tracker.totalsAt(events, { turn: 1, step: 2 }, 9_000)).toEqual({ ttft: 100, thinking: 0, responding: 300, tools: 0 })
|
||||
})
|
||||
|
||||
it('keeps the first step/start when a duplicate arrives while the step is open', () => {
|
||||
const tracker = new StepTimingTracker()
|
||||
const events = [
|
||||
{ type: 'step/start', seq: 0, time: 1_000, data: { turn: 1, step: 1 } },
|
||||
{ type: 'step/start', seq: 1, time: 1_500, data: { turn: 1, step: 1 } },
|
||||
] as SessionEvent[]
|
||||
expect(tracker.totalsAt(events, { turn: 1, step: 1 }, 2_000).ttft).toBe(1_000)
|
||||
})
|
||||
|
||||
it('ignores same-coordinate events after the step closed', () => {
|
||||
const tracker = new StepTimingTracker()
|
||||
const events = [
|
||||
...stepEvents(1, 1, 1_000, 0),
|
||||
// A stray duplicate start and a late chunk reuse the coordinates; the
|
||||
// closed step's totals stay pinned.
|
||||
{ type: 'step/start', seq: 5, time: 9_000, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/chunk', seq: 6, time: 9_100, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'late' } } },
|
||||
] as SessionEvent[]
|
||||
expect(tracker.totalsAt(events, { turn: 1, step: 1 }, 10_000)).toEqual({
|
||||
ttft: 100, thinking: 200, responding: 150, tools: 250,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { createToolResultMessage, CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { ContextCardComponent, ToolCardComponent } from '../src/components/transcript.ts'
|
||||
import { parseArguments } from '../src/components/content.ts'
|
||||
import { createPalette, markdownTheme } from '../src/components/theme.ts'
|
||||
|
||||
const palette = createPalette(false)
|
||||
const mdTheme = markdownTheme(palette)
|
||||
|
||||
function toolCard(): ToolCardComponent {
|
||||
return new ToolCardComponent('bash', parseArguments('{"command":"ls"}'), undefined, 10, 2_000, palette, mdTheme)
|
||||
}
|
||||
|
||||
function toolResult(text: string): Extract<SessionEvent, { type: 'tool/result' }>['data'] {
|
||||
const message = createToolResultMessage({
|
||||
callId: CallId('call-1'),
|
||||
content: [{ type: 'text', text }],
|
||||
isError: false,
|
||||
})
|
||||
return { turn: 1, step: 1, message }
|
||||
}
|
||||
|
||||
// pi-tui re-renders every component each frame; the cards must serve repeat
|
||||
// same-width renders from their line cache and drop it on every state change.
|
||||
describe('transcript card render caches', () => {
|
||||
it('tool card: repeat same-width renders return the cached rows', () => {
|
||||
const card = toolCard()
|
||||
const first = card.render(80)
|
||||
expect(card.render(80)).toBe(first)
|
||||
const narrower = card.render(60)
|
||||
expect(narrower).not.toBe(first)
|
||||
expect(card.render(60)).toBe(narrower)
|
||||
})
|
||||
|
||||
it('tool card: result, visibility, and invalidate() each drop the cache', () => {
|
||||
const card = toolCard()
|
||||
const pending = card.render(80)
|
||||
card.updateResult(toolResult('output line'))
|
||||
const settled = card.render(80)
|
||||
expect(settled).not.toBe(pending)
|
||||
expect(settled.join('\n')).toContain('●')
|
||||
|
||||
card.setVisibility('hidden')
|
||||
expect(card.render(80)).toEqual([])
|
||||
|
||||
card.setVisibility('collapsed')
|
||||
const restored = card.render(80)
|
||||
expect(restored).toEqual(settled)
|
||||
expect(restored).not.toBe(settled)
|
||||
|
||||
card.invalidate()
|
||||
expect(card.render(80)).not.toBe(restored)
|
||||
})
|
||||
|
||||
it('context card: caches by width and drops on setExpanded and invalidate()', () => {
|
||||
const card = new ContextCardComponent('workspace-context', 'line one\nline two', 10, palette)
|
||||
const first = card.render(80)
|
||||
expect(card.render(80)).toBe(first)
|
||||
|
||||
// Same width across the mutation, so a hit here would prove a kept cache.
|
||||
card.setExpanded(true)
|
||||
const expanded = card.render(80)
|
||||
expect(expanded).not.toBe(first)
|
||||
expect(card.render(80)).toBe(expanded)
|
||||
|
||||
card.invalidate()
|
||||
const reRendered = card.render(80)
|
||||
expect(reRendered).not.toBe(expanded)
|
||||
expect(reRendered).toEqual(expanded)
|
||||
|
||||
expect(card.render(60)).not.toBe(reRendered)
|
||||
})
|
||||
})
|
||||
@@ -55,12 +55,17 @@ export default defineConfig({
|
||||
'packages/sdk/*/tests/**/*.snapshot.ts',
|
||||
'packages/ui/tui/tests/**/*.snapshot.ts',
|
||||
],
|
||||
// Each test boots a subprocess; give it room and keep the worker file singular. Replay tests
|
||||
// opt into bounded in-file concurrency, while record/refresh stay serial because they write
|
||||
// fixtures. The environment knob restores serial replay with value 1 on constrained machines.
|
||||
// Replay never writes committed outputs and every scenario owns its
|
||||
// mutable runtime state (the subprocess suites use a unique temp dir and
|
||||
// fixture set per scenario), so replay runs the snapshot files in
|
||||
// parallel and bounds in-file concurrency with the environment knob
|
||||
// (value 1 restores fully serial replay on constrained machines). Record
|
||||
// and refresh stay serial: record spends real API quota per scenario, and
|
||||
// refresh write-back harvests volatile values from fixtures already on
|
||||
// disk, so concurrent writers would corrupt goldens.
|
||||
testTimeout: 120_000,
|
||||
hookTimeout: 30_000,
|
||||
fileParallelism: false,
|
||||
fileParallelism: (process.env.DSH_SNAPSHOT || 'replay') === 'replay' && snapshotMaxConcurrency > 1,
|
||||
maxConcurrency: snapshotMaxConcurrency,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user