Merge remote-tracking branch 'origin/master' into fix/landlock-runner-failure-classification
This commit is contained in:
@@ -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,
|
||||
|
||||
90
packages/ui/tui/tests/timing-tracker.spec.ts
Normal file
90
packages/ui/tui/tests/timing-tracker.spec.ts
Normal file
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
74
packages/ui/tui/tests/transcript-card-cache.spec.ts
Normal file
74
packages/ui/tui/tests/transcript-card-cache.spec.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user