diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml index 5689b73bf8..9c5e373acb 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml @@ -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/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md -2026-07-29-sticky-composer-conversation-scroll.md: 7ceae95dafffdb756ef49bb5612cd4e711eb59ca -2026-07-29-sticky-composer-conversation-scroll.zh.md: d925d82f94635b5fe67b0be119c041d003def393 +2026-07-29-sticky-composer-conversation-scroll.md: 69d46894a53b0113f3e4f0fe871bbf3f9697969b +2026-07-29-sticky-composer-conversation-scroll.zh.md: c0d5a0640468207282316ecd2fa1f209708df7b5 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md index 7ceae95daf..69d46894a5 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md @@ -14,6 +14,8 @@ While a session exists, `ConversationRoot` always supplies a `wrapActiveBody` ow Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, chains `wheel` with `{ passive: false }`: while the capped textarea can still scroll in that direction it keeps the native gesture; only at its own edge does it `preventDefault` and apply `deltaY` to the host. +Chat history prepend follows reader intent through stable rendered node/call identities rather than whole-scrollport height deltas. `ChatView` records the first visible `data-chat-anchor-key` and its top relative to the scrollport when paging starts, reselects the currently visible stable anchor after every reader scroll while the request is in flight, and compensates by that row's post-prepend rectangle delta. Reaching the bottom or appending the reader's own message cancels the paging anchor, so a late page cannot pull the view away from the newest content. Bottom follow is stored state rather than raw scroll geometry. A passive wheel listener takes its pre-input baseline from the last main-thread-delivered or programmatically written `scrollTop`, because Chromium may advance compositor geometry before delivering the event; the current non-negative floor excludes a concurrent layout clamp from reader movement. A scroll without matching wheel movement re-pins while following and only refreshes the semantic position while reading. ChatView's single `ResizeObserver` follows streaming, tool disclosure, and draft resize only while bottom ownership remains pinned, without a second per-chunk scroll write. + ## Alternatives considered **Sticky header and sticky composer inside one column scrollport.** Rejected for the header: it must occupy the top as fixed layout chrome, not participate in the scrollport's sticky layer. @@ -24,6 +26,8 @@ Session stats live on `'conversation.composer.dock'` (above `'conversation.input **Keep StatsLine inside ChatView below the message column.** Rejected: outside the sticky composer it would scroll away while the input stayed pinned. +**Model every browser scroll input source.** Rejected for this narrow fix: the reproduced desktop path uses wheel/trackpad input. Pointer/touch scrolling, native-scrollbar dragging, keyboard scrolling, focus navigation, and nested overflow ownership remain outside the provenance model instead of adding a general input state machine. + ## Consequences -Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Hero → active keeps the same textarea DOM node (assembled slash-flow snapshot) and the InputHub draft. +Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Concurrent history, streaming, tool expansion, and composer reflow preserve wheel/trackpad scroll decisions, including Chromium's compositor-first delivery and stream-finalization clamp/regrow. Other browser scroll inputs do not change follow ownership under this narrow provenance rule. Hero → active keeps the same textarea DOM node (assembled slash-flow snapshot) and the InputHub draft. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md index d925d82f94..c0d5a06404 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md @@ -14,6 +14,8 @@ Status: implemented 会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上)。InputBar 的 textarea 在宿主内以 `{ passive: false }` 链式处理 `wheel`:在限高 textarea 仍能沿该方向滚动时保留原生手势;仅在自身边缘才 `preventDefault` 并将 `deltaY` 施加到宿主。 +Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图,而不是使用整个滚动容器的高度差。分页开始时,`ChatView` 记录第一个可见的 `data-chat-anchor-key` 及其相对滚动容器的顶部位置;请求在途期间,每次读者滚动都会重新选择当前可见的稳定锚点;页面到达后则按该行矩形的前后差值补偿。到达底部或追加读者自己的消息会取消分页锚点,因此迟到的页面不能把视图从最新内容拉走。贴底跟随采用存储状态,而不是原始滚动几何状态。passive wheel 监听器以最近一次由主线程交付或由程序写入的 `scrollTop` 作为输入前基线,因为 Chromium 可能先推进合成器几何状态,之后才交付事件;当前使用的非负下限不会将并发的布局钳制计入读者移动。没有对应滚轮/触控板输入位移的滚动,在跟随状态下会重新贴底,在阅读状态下则只刷新语义位置。`ChatView` 的单个 `ResizeObserver` 只会在贴底所有权仍保持时跟随流式输出、工具展开与草稿尺寸变化,且每个 chunk 不会触发第二次滚动写入。 + ## Alternatives considered **标题栏与编辑器都在同一列滚动容器内 sticky。** 标题栏否决:它必须作为固定布局 chrome 占据顶部,而不是参与滚动容器的 sticky 层。 @@ -24,6 +26,8 @@ Status: implemented **把 StatsLine 留在 ChatView 消息列下方。** 否决:落在 sticky 编辑器之外会随内容滚走,而输入区仍钉在底部。 +**为每一种浏览器滚动输入来源建模。** 此次窄范围修复不采用:已复现的桌面端路径使用滚轮/触控板输入。指针/触控滚动、拖动原生滚动条、键盘滚动、焦点导航与嵌套 overflow 所有权仍不纳入输入来源模型,也不为此新增通用输入状态机。 + ## Consequences -在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。hero → active 保持同一 textarea DOM 节点(assembled slash-flow 快照)以及 InputHub 草稿。 +在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。并发历史加载、流式输出、工具展开与编辑器重排会保留滚轮/触控板的滚动决定,包括 Chromium 先推进合成器几何状态再交付事件,以及流收尾阶段滚动位置受钳制后滚动容器重新增长的情况。在这条窄范围的输入来源规则下,其他浏览器滚动输入不会改变贴底跟随所有权。hero → active 保持同一 textarea DOM 节点(assembled slash-flow 快照)以及 InputHub 草稿。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml index 455c89ebcd..a2710c73f9 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml @@ -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-tool-row-unified-expand-and-inspect.md -2026-07-30-web-tool-row-unified-expand-and-inspect.md: ba2f4ead8023772fad578ca0b647241ecc332905 -2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md: ac4835c7429a3ff7d3042f73d26d267911533132 +2026-07-30-web-tool-row-unified-expand-and-inspect.md: 98f1595564f0bd0d22f1ca4318b4c7fe15c6900d +2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md: 8f00349a975f777cc4d556ade3a9abe9676b8848 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md index ba2f4ead80..98f1595564 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md @@ -10,14 +10,14 @@ The chat view's tool rows had drifted into per-surface interaction dialects: Too ## Decision -**Every expandable tool row shares one interaction — the whole row toggles (click / Enter / Space) with an icon→chevron hover preview — and one expanded body: an IN/OUT gutter-labeled card with per-section scroll caps; a hover-revealed Inspect pill jumps to the call's trajectory record through a one-shot store handoff; the chat view preserves its scroll offset across view switches through an in-memory per-session map.** +**Every expandable tool row shares one interaction — the whole row toggles (click / Enter / Space) with an icon→chevron hover preview — and one expanded body: an IN/OUT gutter-labeled card with per-section scroll caps; a hover-revealed Inspect pill jumps to the call's trajectory record through a one-shot store handoff; the chat view preserves its semantic reading position across view switches through an in-memory per-session map.** - `toolRowModel` now derives result material alongside args: `output` (the `resultText` flatten, moved from DetailsPanel into the contract), and `errorSummary` (the failure's first line, shown as the collapsed summary in the error color). A row with body, output, or terminal material is expandable; the row itself is the toggle (`role="button"`, `aria-expanded`), and file-path summaries stay independent links via `stopPropagation`. - The expanded card (figma 1249:35657) is a column of IN/OUT sections: each section is its own scrollport (max-height 150px) with a sticky gutter label, and the l2 divider spans the full card width. Think prose and the run_code CodeBlock keep their non-card bodies; context injection reuses the row with a label-less `plainBody` card. - `terminalFailed` reads a settled terminal card's exit status so BashRow and GenericToolCard surface a failing command as the row's red state dot — the only failure signal the collapsed row has, since the call itself settles `isError:false`. - TerminalBlock's banner joins the same reading model: it shares the card surface (no banner token), an l2 hairline separates it from the body, the command column caps at 150px and scrolls with sticky copy/status controls top-aligned to the first prompt row. - Inspect: `ToolRowOwnerProps.inspect` (absent for rows without a call identity) renders a pill in real flow under the expanded body's bottom-left, revealed by hovering anywhere on the tool call. Clicking writes `{ callId }` to the chat store's one-shot `inspect` field and switches to the trajectory view; TrajectoryTable finds the record, opens its summary, and acknowledges by clearing the field. -- Scroll preservation: the chat view saves its offset on every scroll (null when pinned to bottom) into an apply-scope `Map` exposed as `chatScroll` on the injected props; the open-jump branch restores it on remount. Deliberately not persisted — a fresh page load keeps the open-jump-to-bottom default. +- Scroll preservation: on every non-bottom scroll, the chat view saves `{ anchorKey, anchorTop, scrollTop }` into an apply-scope per-session map exposed as `chatScroll`; a remount first uses `scrollTop` to reach the approximate window, then corrects by the stable node/call anchor's rectangle delta so width reflow keeps the same reading row in place. Every pinned path, including Back to bottom, clears the entry synchronously before a tab or session switch. The map remains deliberately unpersisted — a fresh page load keeps the open-jump-to-bottom default. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md index ac4835c742..8f00349a97 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md @@ -10,14 +10,14 @@ ## 决定 -**所有可展开工具行共享同一交互——整行即开关(点击 / Enter / 空格),图标 hover 时渐变为 chevron 预览——以及同一展开体:带 IN/OUT 侧栏标签的卡片,各分区独立滚动上限;hover 显示的 Inspect 胶囊通过 store 的一次性交接跳到该调用的 trajectory 记录;聊天视图用内存态的按会话 Map 在视图切换间保留滚动位置。** +**所有可展开工具行共享同一交互——整行即开关(点击 / Enter / 空格),图标 hover 时渐变为 chevron 预览——以及同一展开体:带 IN/OUT 侧栏标签的卡片,各分区独立滚动上限;hover 显示的 Inspect 胶囊通过 store 的一次性交接跳到该调用的 trajectory 记录;聊天视图用内存态的按会话 Map 在视图切换间保留语义阅读位置。** - `toolRowModel` 在 args 之外同时派生结果材料:`output`(`resultText` 拍平逻辑从 DetailsPanel 移入 contract)和 `errorSummary`(失败首行,以错误色作为折叠摘要)。有 body、output 或 terminal 材料的行即可展开;行本身是开关(`role="button"`、`aria-expanded`),文件路径摘要通过 `stopPropagation` 保持独立链接。 - 展开卡片(figma 1249:35657)是 IN/OUT 分区列:每个分区是独立滚动区(max-height 150px),侧栏标签 sticky 固定,l2 分割线横贯整卡宽度。Think 的推理文本和 run_code 的 CodeBlock 保持非卡片体;上下文注入复用此行并以无标签的 `plainBody` 卡片展开。 - `terminalFailed` 读取已结算 terminal 卡片的退出状态,让 BashRow 和 GenericToolCard 把失败命令显示为行的红色状态点——这是折叠行唯一的失败信号,因为调用本身结算为 `isError:false`。 - TerminalBlock 的横幅并入同一阅读模型:与卡片共用同一表面(不再用 banner token),与正文之间是 l2 细线,命令列上限 150px 内部滚动,复制/状态控件 sticky 且顶对齐第一行提示符。 - Inspect:`ToolRowOwnerProps.inspect`(无调用身份的行不提供)在展开体左下角以真实布局位置渲染胶囊,hover 整个 tool call 任意位置显示。点击将 `{ callId }` 写入 chat store 的一次性 `inspect` 字段并切换到 trajectory 视图;TrajectoryTable 找到记录、打开其摘要,并通过清空字段确认。 -- 滚动保留:聊天视图在每次滚动时保存偏移(贴底时为 null)到 apply 作用域的 `Map`,经注入 props 的 `chatScroll` 暴露;重挂载时 open-jump 分支恢复它。刻意不持久化——新页面加载保持打开即贴底的默认行为。 +- 滚动保留:每次非贴底滚动时,聊天视图把 `{ anchorKey, anchorTop, scrollTop }` 保存到 apply 作用域的按会话 Map,并经注入 props 的 `chatScroll` 暴露;重挂载时先用 `scrollTop` 到达近似窗口,再按稳定 node/call 锚点的矩形差值校正,因此宽度重排后仍把同一阅读行保持在原位。包括「回到底部」在内的每条贴底路径都会在切换 tab 或会话前同步清除该项。Map 仍刻意不持久化——新页面加载保持打开即贴底的默认行为。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index b219e6245f..638e91a016 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -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/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: f05fc7268cfb613d0af8240bbb65cb154252a620 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 3fd3805053a570a32e63601d7b039db41365309c +2026-07-24-web-gui-browser-e2e-lane.md: f8519a9622d2f7216226a695db95dbebdbf24ea1 +2026-07-24-web-gui-browser-e2e-lane.zh.md: 294f3e840e0242d9a0d9c53ac510d44d3b0d100f diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index f05fc7268c..f8519a9622 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -24,7 +24,7 @@ Keyless model displacement is the disabled adapter row plus `installLlmReplay` f ### Determinism rules -The barrier stack for replay-mode browser assertions is, in order: (1) host-side `await agent.whenIdle()` under a timeout, keyed off the in-process `turn/end` — the idle flip follows the persistence flush, so one await covers turn completion and durability; (2) browser settled poll (streaming detached, final text visible). Record-mode log harvest runs after `whenIdle()` and before scaffold disposal while the live session remains available. An in-process `turn/end` listener alone is a wrong barrier (it fires before the SSE frame reaches the browser and before the fsync); file polling is banned (slow on NFS, superseded by `whenIdle`); `networkidle` is banned outright (never resolves while an SSE stream is open). +The barrier stack for replay-mode browser assertions is, in order: (1) host-side `await agent.whenIdle()` under a timeout, keyed off the in-process `turn/end` — the idle flip follows the persistence flush, so one await covers turn completion and durability; (2) browser settled poll (streaming detached, final text visible). Record-mode log harvest runs after `whenIdle()` and before scaffold disposal while the live session remains available. An in-process `turn/end` listener alone is a wrong barrier (it fires before the SSE frame reaches the browser and before the fsync); polling persistence files as a turn-completion or durability barrier is banned (slow on NFS, superseded by `whenIdle`), while a tool-controlled temp readiness marker may be polled only as an interaction gate before that completion barrier; `networkidle` is banned outright (never resolves while an SSE stream is open). Navigation assertions arm both initial `session.list` and `workspace.list` responses before page load, then wait for the seeded DOM projection; the mounted shell alone is not readiness because late bootstrap can replace controlled state. No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted from the persisted `assistant/chunk` events (model-visible ⟺ logged makes the log the authoritative proof). `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly. @@ -42,12 +42,14 @@ The typecheck plane split is structural: the host scaffold, its support module, ### Coverage contract -The lane covers three behavior families. Live-turn scenarios pin ordinary tool execution, cancellation, non-retryable failure, transient retry, resident questions, and mid-turn steering; synchronization uses durable events, `whenIdle()`, or an explicit replay marker rather than delays. Cold-history scenarios seed through the real persistence API and cover history rendering, sidebar search, trajectory and waterfall views, and tool details without model calls. Browser-lifecycle scenarios cover first-send workspace materialization, reload recovery, layout reset, theme and locale preferences, and workspace create/rename/view operations. Each family asserts the browser surface and the authoritative host state; a stray model call or under-consumed fixture fails teardown. +The lane covers three behavior families. Live-turn scenarios pin ordinary tool execution, cancellation, non-retryable failure, transient retry, resident questions, and mid-turn steering; synchronization uses durable events, `whenIdle()`, or an explicit replay marker rather than delays. Cold-history scenarios seed through the real persistence API and cover history rendering, sidebar search, trajectory and waterfall views, and tool details without model calls. Browser-lifecycle scenarios cover first-send workspace materialization, reload recovery, layout reset, theme and locale preferences, and workspace create/rename/view operations. Each family asserts the browser surface and the authoritative host state; a stray model call or under-consumed fixture fails teardown. The required lane additionally carries an 88-turn synthetic Chat scroll contract mixing wrapped Markdown, fenced code, and paired bash calls/results. Real wheel, composer, tool, tab, session, and viewport interactions assert a named settled row's top relative to the transcript scrollport and distance from the true bottom across concurrent history prepend plus paced streaming, pinned/away streaming, tool-disclosure offscreen cycles, expanded-history view/session remount, width reflow, immediate pinned remount, composer resize, and textarea wheel chaining; it deliberately pins neither DOM cardinality nor absolute `scrollTop`, so the same contract can qualify a virtualized implementation. A separate interaction contract over the same fixture pins heterogeneous-row order, independent adjacent tool disclosure, exact user-message clipboard content, a turn-bounded message fork, source/child isolation, and a real follow-up turn in the child; wheel input only navigates to semantic targets and carries no geometry expectation. A short live-history contract starts from a blank workspace and drives consecutive composer turns, including real bash call/result rounds and a paced long final response, pinning one session identity, exact per-turn event ownership, browser echo uniqueness, and composer recovery without timing thresholds. ### CI stance The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The `node 24 / snapshots and artifacts` consumer job owns the [single Linux build](../process/2026-07-30-independent-ci-consumer-build.md), installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. This is an intentional plane split: the host and specs use the [tsx source-launch contract](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md), while the browser consumes `apps/web/dist` and package `lib/client.js` artifacts, so the gate depends on `built-package-invariants` for those client artifacts. The hosted and self-hosted default-branch Linux serial jobs run the same gate; the hosted job produces the browser cache consumed by pull requests, while the persistent self-hosted pool needs no hosted cache. CI never records or refreshes goldens. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices. +High-cardinality performance diagnostics use the separate opt-in `apps/web/tests/**/*.perf.ts` inventory selected only by `vitest.web.perf.config.ts`. The isolated `complex-history.perf.ts` cases reuse the real scaffold: the workspace case seeds 1,000 compact sessions plus one 500-turn history containing 500 tool calls, exhausts and remounts that history in Chat, and reports Chromium main-thread, DOM, listener, heap, paging, search, and Trajectory measurements. Two continuation cases seed the same long history but compare the default 24-turn Chat window with all 500 turns expanded before each continues eight identical turns through the real composer, agent loop, SSE wire, tools, and persistence; two turns execute a real `bash` call and assert its durable result, while the final turn fills an 8,232-character mixed-language prompt and replays 120 paced text deltas. A separate soak case starts from a blank session, drives 100 consecutive real composer turns with a `bash` call and result every tenth turn, forces GC every ten turns, and reports ten-turn latency windows plus retained browser state. It then submits a 101st text-only turn with a trusted browser click and measures browser-clock send-to-transcript-DOM and send-to-post-paint latency, excluding the composer's draft mirrors, separately from full-turn completion. Per-turn diagnostics cover composer fill, click-to-user-echo, click-to-first-chunk, completion, browser mutations, persisted chunks, and tool events; the synthetic replay model has enough context capacity to keep fixture cardinality stable instead of consuming scripted calls through compaction. Structural assertions pin the intended load, stream, and tool shapes, but timing remains threshold-free because machine speed is not a correctness contract. The required `vitest.web.config.ts` inventory remains limited to `*.e2e.ts` and `*.snapshot.ts`, so neither `test:web:built` nor its CI gate collects performance cases. + ## Prior art Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot + AI SDK, lobe-chat, open-webui, OpenHands, Chainlit, continue, cline, langfuse, gradio/streamlit; Playwright HAR/route, MSW, Polly/nock, WireMock, aimock). The dominant proven architecture for apps that own their backend is an in-process fake/replay model behind the real backend seam with everything downstream real (LibreChat's `LIBRECHAT_TEST_RUN_HOOK` fake model; ai-chatbot's `MockLanguageModelV3` + `simulateReadableStream`; continue's scripted mock provider classes) — which is what `dsh-llm-replay` already is. Browser-level SSE interception cannot exercise incremental rendering (`route.fulfill` delivers the whole body at once; playwright#33564) and leaves the server SSE stack untested, so projects use it only for edge cases. Chunk pacing as a fixture parameter recurs everywhere (LibreChat 10ms default with slow profiles; ai-chatbot 500ms); real models in CI rot (open-webui's suite grew 120-second timeouts, was disabled, then deleted); sessions are seeded at the persistence layer with controlled timestamps (LibreChat inserts backdated Mongo documents; langfuse seeds its DB). No surveyed project replays a recorded agent-event log through the real backend for UI tests — the closest are provider-level recorded fixtures (aimock) and frontend-level socket history emission (OpenHands MSW) — so the session-log-as-fixture design goes one step beyond prior art along the axis this repo's model-visible ⟺ logged invariant makes natural. @@ -72,11 +74,13 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot **Real-model browser tests as the keyless lane.** Rejected: nondeterministic by construction; the surveyed cautionary case (open-webui) grew unbounded timeouts and was deleted. The with-key W5 smoke stays as the live-model complement. +**Running the high-cardinality performance case in the required browser gate.** Rejected: its fixture setup and full-history render add tens of seconds, while wall-clock and memory values vary with the host and cannot supply a stable correctness threshold. The required lane keeps deterministic behavior assertions; contributors run the diagnostic case when investigating or changing large-list and long-history rendering. + **A client `data-dsh-busy` settled signal.** Deferred: the host-side `whenIdle` barrier plus stable DOM polls cover the current scenarios. Reconsider after the first settled-poll flake or when a required state is not observable in the DOM. ## Testing -`pnpm run test:web` builds and runs the lane keylessly; `test:web:built` runs it against existing build artifacts. `DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` records a prompting scenario against the live model, and `DSH_SNAPSHOT=refresh pnpm run test:web` rewrites aria goldens keylessly. CI explicitly selects replay mode. The live-interactions AUTH scenario pins a non-retryable terminal failure as an inline Chat status carrying the display-safe message and code, verifies that provider-echoed credential fragments stay absent from both Chat and Trajectory, and covers composer recovery plus the `turn/end` error. The scaffold hermeticity scenario populates distinct entries in all three ambient skill roots and requires none to enter the assembled catalog. `dsh-llm-replay` unit coverage pins pacing, cancellation, consumption diagnostics, sidecar validation, indexed replacement, and the single append position. +`pnpm run test:web` builds and runs the lane keylessly; `test:web:built` runs it against existing build artifacts. `pnpm run test:web:perf` builds and runs the manual performance inventory; `test:web:perf:built` reuses existing artifacts. `DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` records a prompting scenario against the live model, and `DSH_SNAPSHOT=refresh pnpm run test:web` rewrites aria goldens keylessly. CI explicitly selects replay mode. The live-interactions AUTH scenario pins a non-retryable terminal failure as an inline Chat status carrying the display-safe message and code, verifies that provider-echoed credential fragments stay absent from both Chat and Trajectory, and covers composer recovery plus the `turn/end` error. The scaffold hermeticity scenario populates distinct entries in all three ambient skill roots and requires none to enter the assembled catalog. `dsh-llm-replay` unit coverage pins pacing, cancellation, consumption diagnostics, sidecar validation, indexed replacement, and the single append position. ## Deferred @@ -84,7 +88,8 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot - **Follow-up-prompt-after-resume scenario**: the history/live stitch path over the real wire; add as its own scenario when that code changes or regresses. - **Composer steering gesture**: the input locks while running (stop-or-wait), so the steering scenario steers over the wire from the page; `TODO(web-steer-composer)` upgrades the drive step to a real composer gesture when the product grows one. - **Drag session reorder**: `workspace.insertSessionBefore` has no browser scenario; it needs two sessions materialized in one workspace plus synthesized HTML5 drag events. Add it when that surface changes or regresses. The inert session Rename/Fork/Delete and workspace Delete menu rows get scenarios when they gain behavior. +- **Long-history Chat-to-Trajectory Inspect**: the independent inspection source exhausts history after the view opens, while the selected record is addressed by a derived table index that can move as older pages prepend. Short-history Inspect remains covered; the long-history interaction contract excludes this handoff until selection has a stable semantic identity. ## Consequences -The web surface gains its record-once/replay-forever tier: the real chromium → SSE → apiproxy → loop → tools → persistence chain runs keylessly in ~10-30s, deterministic across repeat runs, with fixtures owned and re-recordable by the lane itself. Costs accepted: every intentional conversation-UI change ends with a keyless `DSH_SNAPSHOT=refresh` (golden churn is reviewed diff, anchors keep semantic green); the aria format is Playwright-owned — the one committed snapshot format the repo does not control — so playwright version bumps must be deliberate bump-and-refresh commits (the dependency floats `^1.49.0` in `apps/web/package.json`; pin exactly if churn bites); replay's first-call-order binding constrains scenarios to one prompting session each, with the consumption assertion as the tripwire; `compact-basic` shares the session's replay cursor and stays inert only under the published 128k catalog window; and the required consumer job pays for Chromium provisioning and one browser run so the PR that changes the assembled UI owns its expected-output diff. +The web surface gains its record-once/replay-forever tier: the real chromium → SSE → apiproxy → loop → tools → persistence chain runs keylessly in ~10-30s, deterministic across repeat runs, with fixtures owned and re-recordable by the lane itself. Costs accepted: every intentional conversation-UI change ends with a keyless `DSH_SNAPSHOT=refresh` (golden churn is reviewed diff, anchors keep semantic green); the aria format is Playwright-owned — the one committed snapshot format the repo does not control — so playwright version bumps must be deliberate bump-and-refresh commits (the dependency floats `^1.49.0` in `apps/web/package.json`; pin exactly if churn bites); replay's first-call-order binding constrains scenarios to one prompting session each, with the consumption assertion as the tripwire; `compact-basic` shares the session's replay cursor and stays inert only under the published 128k catalog window; and the required consumer job pays for Chromium provisioning and one browser run so the PR that changes the assembled UI owns its expected-output diff. The opt-in performance lane preserves a repeatable diagnostic workload without adding host-sensitive duration or memory expectations to CI; performance regressions remain a manually interpreted signal until the repository owns a calibrated benchmark environment. diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 3fd3805053..294f3e840e 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -24,7 +24,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ### 确定性规则 -回放模式下浏览器断言的屏障栈,按序:(1)host 侧 `await agent.whenIdle()` 加超时,以进程内 `turn/end` 为锚——空闲翻转发生在持久化落盘之后,一次等待同时覆盖轮次完成与持久性;(2)浏览器安定轮询(流式输出节点已卸载、最终文本可见)。录制模式下,日志采收在 `whenIdle()` 之后、scaffold 释放之前进行,此时运行中的会话仍然可用。单独监听进程内 `turn/end` 是错误屏障(它先于 SSE 帧到达浏览器、先于 fsync 触发);文件轮询被禁止(NFS 上慢,且被 `whenIdle` 取代);`networkidle` 被彻底禁止(SSE 流保持打开时它永不解析)。 +回放模式下浏览器断言的屏障栈,按序:(1)host 侧 `await agent.whenIdle()` 加超时,以进程内 `turn/end` 为锚——空闲翻转发生在持久化落盘之后,一次等待同时覆盖轮次完成与持久性;(2)浏览器安定轮询(流式输出节点已卸载、最终文本可见)。录制模式下,日志采收在 `whenIdle()` 之后、scaffold 释放之前进行,此时运行中的会话仍然可用。单独监听进程内 `turn/end` 是错误屏障(它先于 SSE 帧到达浏览器、先于 fsync 触发);禁止轮询持久化文件来充当轮次完成或持久性屏障(NFS 上慢,且被 `whenIdle` 取代),但工具控制的临时就绪标记可以仅作为该完成屏障之前的交互门控进行轮询;`networkidle` 被彻底禁止(SSE 流保持打开时它永不解析)。导航断言会在页面加载前同时监听 `session.list` 和 `workspace.list` 的初始响应,随后等待播种数据投影到 DOM;仅凭 shell 已挂载不能判定就绪,因为较晚完成的 bootstrap 可能替换受控状态。 不做单次瞬态 DOM 断言:从回放产出到 React 提交的每一跳都可能合并分片,采样 `[data-streaming]` 天然就是竞态。流式输出的增量性由持久化的 `assistant/chunk` 事件断言(模型可见 ⟺ 已记录,使日志成为权威证据)。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = 突发)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且节奏等待期间中止会即时取消。 @@ -42,12 +42,14 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ### 覆盖契约 -该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering;同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种,在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局重置、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。 +该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering;同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种,在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局重置、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。必需车道还包含一份合成的 88 轮 Chat 滚动契约,其中混合了换行 Markdown、围栏代码以及成对的 bash 调用/结果。真实 wheel、输入框、工具、tab、会话与 viewport 交互会在并发历史前插加带节奏流式输出、贴底/离底流式输出、工具 disclosure 离屏循环、扩展历史后的视图/会话重新挂载、宽度重排、贴底后立即重新挂载、输入框尺寸变化以及 textarea wheel 链场景中,断言一个具名已结算行相对 transcript scrollport 的顶部位置和到真实底部的距离;它刻意不钉 DOM 基数或绝对 `scrollTop`,因此同一契约可以验收虚拟化实现。另一份基于同一 fixture 的交互契约钉住异构行顺序、相邻工具 disclosure 的独立状态、用户消息剪贴板内容的精确值、以轮次为边界的消息 fork、源会话/子会话隔离,以及子会话中的一次真实追问轮次;wheel 输入只用于导航到语义目标,不承载几何预期。一份简短的实时历史契约从空白工作区开始,连续驱动输入框轮次,其中包括真实的 bash 调用/结果轮次和一段带节奏的长篇最终响应;它钉住单一会话身份、每轮事件的精确归属、浏览器回显唯一性与输入框恢复,不设置时间阈值。 ### CI 立场 根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。`node 24 / snapshots and artifacts` 消费方任务在[消费方独立构建](../process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。这是有意的平面切分:host 与 spec 使用 [tsx 源码启动契约](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md),浏览器则消费 `apps/web/dist` 和包的 `lib/client.js` 产物,因此门禁依赖 `built-package-invariants` 提供这些客户端产物。托管和自托管的默认分支 Linux 串行任务运行同一门禁;托管任务生成供 PR 消费的浏览器缓存,持久化自托管池则不需要托管侧缓存。CI 从不录制或刷新预期输出。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。 +高基数性能诊断使用单独按需启用的 `apps/web/tests/**/*.perf.ts` 清单,并且只由 `vitest.web.perf.config.ts` 选中。`complex-history.perf.ts` 的隔离用例复用真实 scaffold:工作区用例播种 1,000 个紧凑会话以及一份包含 500 次工具调用的 500 轮次历史,在 Chat 中穷尽并重新挂载该历史,并报告 Chromium 主线程、DOM、监听器、堆内存、分页、搜索和 Trajectory 测量结果。两个续聊用例播种同一份长历史,但比较默认的 24 轮次 Chat 窗口与展开全部 500 轮次的状态,然后各自通过真实输入框、agent loop、SSE wire、工具和持久化继续进行 8 个相同轮次;其中两轮执行真实 `bash` 调用并断言其持久化结果,最后一轮则填入一条包含 8,232 个字符的混合语言提示词,并回放 120 个带节奏的文本增量。一个单独的 soak 用例从空白会话开始,通过真实输入框连续驱动 100 轮,每第 10 轮执行一次 `bash` 调用并产生结果,每 10 轮强制执行一次 GC,并报告每 10 轮的延迟窗口及保留的浏览器状态。随后它通过受信任的浏览器点击提交第 101 个纯文本轮次,并使用浏览器时钟分别测量发送到 transcript DOM 和发送到绘制后的延迟,排除输入框的草稿镜像,并与完整轮次完成时间分开。逐轮诊断涵盖输入框填入、点击到用户消息回显、点击到首个分片、完成、浏览器变更、持久化分片和工具事件;合成回放模型拥有足够的上下文容量,可使 fixture 基数保持稳定,而不会因压缩(compaction)消耗脚本化调用。结构性断言钉住预期的负载、流和工具形状,但时间仍不设阈值,因为机器速度不属于正确性契约。必需的 `vitest.web.config.ts` 清单仍仅限 `*.e2e.ts` 和 `*.snapshot.ts`,因此 `test:web:built` 及其 CI 门禁都不会收集性能用例。 + ## 业界先例 调研了 AI 聊天/agent web UI 与 mock 层(LibreChat、vercel/ai-chatbot + AI SDK、lobe-chat、open-webui、OpenHands、Chainlit、continue、cline、langfuse、gradio/streamlit;Playwright HAR/route、MSW、Polly/nock、WireMock、aimock)。自有后端的应用的主流成熟架构是:真实后端 seam 后放一个进程内伪造/回放模型,下游全部真实(LibreChat 的 `LIBRECHAT_TEST_RUN_HOOK` 伪模型;ai-chatbot 的 `MockLanguageModelV3` + `simulateReadableStream`;continue 的脚本化 mock 提供方类)——这正是 `dsh-llm-replay` 已然所是。浏览器层 SSE 拦截无法检验增量渲染(`route.fulfill` 一次性交付整个响应体;playwright#33564),且服务端 SSE 栈完全失测,因此各项目只把它用于边缘用例。分片节奏作为 fixture 参数反复出现(LibreChat 默认 10ms 附慢速档;ai-chatbot 500ms);CI 里的真实模型会腐烂(open-webui 的套件长出 120 秒超时,先被禁用后被删除);会话在持久化层以受控时间戳播种(LibreChat 直插回拨时间的 Mongo 文档;langfuse 播种其数据库)。没有任何被调研项目为 UI 测试把录制的 agent 事件日志经真实后端回放——最接近的是提供方层录制 fixture(aimock)与前端层 socket 历史发射(OpenHands MSW)——因此会话日志即 fixture 的设计沿着本仓库「模型可见 ⟺ 已记录」不变式所指的方向比业界先例多走了一步。 @@ -72,11 +74,13 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu **以真实模型浏览器测试充当无密钥车道。** 已否决:按构造即不确定;被调研的前车之鉴(open-webui)长出无界超时后被删除。带密钥的 W5 冒烟仍是真实模型侧的补充。 +**在必需的浏览器门禁中运行高基数性能用例。** 已否决:其 fixture 设置和完整历史渲染会增加数十秒耗时,而壁钟时间和内存值随 host 不同而变化,无法提供稳定的正确性阈值。必需车道保留确定性行为断言;贡献者在调查或更改大列表和长历史渲染时运行该诊断用例。 + **客户端 `data-dsh-busy` 安定信号。** 暂缓:host 侧 `whenIdle` 屏障配合稳定 DOM 轮询,足以覆盖当前场景。第一次安定轮询抖动,或必要状态在 DOM 中不可观察时,再重新考虑。 ## Testing -`pnpm run test:web` 构建并无密钥运行该车道;`test:web:built` 基于现有构建产物运行。`DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` 对真实模型录制一个发起提示的场景,`DSH_SNAPSHOT=refresh pnpm run test:web` 则无密钥重写 aria 预期输出。CI 显式选择回放模式。live-interactions AUTH 场景会把不可重试的终态失败钉为 Chat 内联状态,其中携带适合展示的消息与错误码,并验证提供方回显的凭据片段不会出现在 Chat 或 Trajectory 中;该场景同时覆盖输入框恢复与 `turn/end` 错误。scaffold 环境隔离场景会在全部 3 个环境 skill 根目录中分别填入不同条目,并要求这些条目都不得进入组装后的目录。`dsh-llm-replay` 单元覆盖率钉住节奏控制、取消、消费诊断、sidecar 校验、按索引替换与唯一的追加位置。 +`pnpm run test:web` 构建并无密钥运行该车道;`test:web:built` 基于现有构建产物运行。`pnpm run test:web:perf` 构建并运行手动性能清单;`test:web:perf:built` 复用现有产物。`DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` 对真实模型录制一个发起提示的场景,`DSH_SNAPSHOT=refresh pnpm run test:web` 则无密钥重写 aria 预期输出。CI 显式选择回放模式。live-interactions AUTH 场景会把不可重试的终态失败钉为 Chat 内联状态,其中携带适合展示的消息与错误码,并验证提供方回显的凭据片段不会出现在 Chat 或 Trajectory 中;该场景同时覆盖输入框恢复与 `turn/end` 错误。scaffold 环境隔离场景会在全部 3 个环境 skill 根目录中分别填入不同条目,并要求这些条目都不得进入组装后的目录。`dsh-llm-replay` 单元覆盖率钉住节奏控制、取消、消费诊断、sidecar 校验、按索引替换与唯一的追加位置。 ## 暂缓 @@ -84,7 +88,8 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu - **恢复后追问场景**:真实 wire 上的历史/实时缝合路径;当该代码变更或回归时作为独立场景补充。 - **输入框 steering 手势**:输入在运行期间锁定(只能停止或等待),因此 steering 场景从页面走 wire 做 steer;`TODO(web-steer-composer)` 待产品长出真实的输入框手势后,把驱动步骤升级为该手势。 - **拖拽会话重排**:`workspace.insertSessionBefore` 尚无浏览器场景;它需要在同一个工作区里物化两个会话,并合成 HTML5 拖拽事件。当该表面变更或回归时再补充。无行为的会话 Rename/Fork/Delete 和工作区 Delete 菜单行待获得行为后再补充场景。 +- **长历史 Chat 到 Trajectory 的 Inspect**:独立的检查数据源会在视图打开后穷尽历史,而所选记录由一个派生的表格索引定位;随着较早页面前插,该索引可能移动。短历史 Inspect 仍有覆盖;在选中项具有稳定的语义身份之前,长历史交互契约不包含这项交接。 ## 后果 -Web 表面获得了录制一次/永久回放的层级:真实 chromium → SSE → apiproxy → 循环 → 工具 → 持久化的链路以约 10-30 秒无密钥运行,重复运行结果确定,fixture 由车道自身持有并可重录。接受的成本:每次有意的会话 UI 变更都以一次无密钥 `DSH_SNAPSHOT=refresh` 收尾(预期输出变动是受评审的 diff,锚断言保住语义绿色);aria 格式归 Playwright 所有——仓库唯一不受自己控制的提交快照格式——因此 playwright 版本升级必须是刻意的升级加刷新提交(依赖在 `apps/web/package.json` 中浮动为 `^1.49.0`;若变动伤人则改为精确锁定);回放的首次调用顺序绑定把每个场景限制为至多一个发起提示的会话,消费断言是绊线;`compact-basic` 与会话共享回放游标,仅在发布的 128k 目录窗口下保持闲置;必需的消费方任务承担 Chromium 供给与一次浏览器运行的成本,使改动组装后 UI 的 PR(Pull Request)持有相应的预期输出 diff。 +Web 表面获得了录制一次/永久回放的层级:真实 chromium → SSE → apiproxy → 循环 → 工具 → 持久化的链路以约 10-30 秒无密钥运行,重复运行结果确定,fixture 由车道自身持有并可重录。接受的成本:每次有意的会话 UI 变更都以一次无密钥 `DSH_SNAPSHOT=refresh` 收尾(预期输出变动是受评审的 diff,锚断言保住语义绿色);aria 格式归 Playwright 所有——仓库唯一不受自己控制的提交快照格式——因此 playwright 版本升级必须是刻意的升级加刷新提交(依赖在 `apps/web/package.json` 中浮动为 `^1.49.0`;若变动伤人则改为精确锁定);回放的首次调用顺序绑定把每个场景限制为至多一个发起提示的会话,消费断言是绊线;`compact-basic` 与会话共享回放游标,仅在发布的 128k 目录窗口下保持闲置;必需的消费方任务承担 Chromium 供给与一次浏览器运行的成本,使改动组装后 UI 的 PR(Pull Request)持有相应的预期输出 diff。按需启用的性能车道保留了可重复的诊断工作负载,又不会向 CI 添加受 host 差异影响的时长或内存预期;在仓库拥有经校准的基准测试环境之前,性能回归仍是需要人工解读的信号。 diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts new file mode 100644 index 0000000000..61701c1cdc --- /dev/null +++ b/apps/web/tests/chat-continuous-conversation.e2e.ts @@ -0,0 +1,328 @@ +// Web e2e contract for a conversation grown through the real composer rather +// than pre-seeded history. Twelve deterministic replay turns exercise repeated +// send/settle/render cycles, including two real bash executions and one long, +// multi-chunk final turn. Assertions stay semantic: no host timing, heap, or +// mounted-row cardinality is treated as a correctness contract. +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' +import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { + launchWebScaffold, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const MODE = webSnapshotMode() +const TURN_COUNT = 12 +const TOOL_TURNS = [4, 9] as const +const STREAM_PACE_MS = 10 + +interface TurnSpec { + readonly index: number + readonly prompt: string + readonly userMarker: string + readonly firstMarker: string + readonly doneMarker: string + readonly deltas: readonly string[] + readonly callId?: ReturnType + readonly toolResultMarker?: string +} + +function suffix(index: number): string { + return String(index).padStart(3, '0') +} + +function longFinalPrompt(userMarker: string): string { + return [ + `${userMarker} Reconcile this accumulated conversation without losing earlier turn ownership.`, + ...Array.from( + { length: 36 }, + (_, index) => `Context ${String(index + 1).padStart(2, '0')}: preserve token-${String(index)} and verify ${'payload '.repeat(12).trimEnd()}.`, + ), + 'Return one continuous response and finish with the requested completion marker.', + ].join('\n') +} + +function turnSpec(index: number): TurnSpec { + const id = suffix(index) + const userMarker = `CONTINUOUS_CHAT_USER_${id}` + const firstMarker = `CONTINUOUS_CHAT_FIRST_${id}` + const doneMarker = `CONTINUOUS_CHAT_DONE_${id}` + const deltaCount = index === TURN_COUNT ? 36 : 8 + const deltas = Array.from({ length: deltaCount }, (_, chunkIndex) => { + if (chunkIndex === 0) return `${firstMarker} ` + if (chunkIndex === deltaCount - 1) return `${doneMarker}.` + return `turn-${id}-chunk-${String(chunkIndex).padStart(2, '0')} keeps semantic ownership stable. ` + }) + if (!TOOL_TURNS.includes(index as (typeof TOOL_TURNS)[number])) { + return { + index, + prompt: index === TURN_COUNT + ? longFinalPrompt(userMarker) + : `${userMarker} Continue this same conversation through turn ${String(index)}.`, + userMarker, + firstMarker, + doneMarker, + deltas, + } + } + return { + index, + prompt: `${userMarker} Run the requested deterministic tool for turn ${String(index)}, then continue.`, + userMarker, + firstMarker, + doneMarker, + deltas, + callId: CallId(`continuous-chat-tool-${id}`), + toolResultMarker: `CONTINUOUS_CHAT_TOOL_RESULT_${id}`, + } +} + +function textStream(spec: TurnSpec): StreamChunk[] { + const response = spec.deltas.join('') + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + ...spec.deltas.map(text => ({ type: 'text-delta' as const, index: 0, text })), + { type: 'block-end', index: 0, block: { type: 'text', text: response } }, + { + type: 'usage', + usage: { + inputTokens: Math.ceil(spec.prompt.length / 4), + outputTokens: Math.ceil(response.length / 4), + }, + }, + { type: 'finish', reason: { kind: 'stop' } }, + ] +} + +function toolStream(spec: TurnSpec): StreamChunk[] { + if (spec.callId === undefined || spec.toolResultMarker === undefined) { + throw new Error(`turn ${String(spec.index)} has no tool identity`) + } + const args = JSON.stringify({ + command: `printf '${spec.toolResultMarker}\\n'`, + description: spec.toolResultMarker, + }) + return [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { + type: 'tool-call-delta', + index: 0, + id: spec.callId, + name: 'bash', + argumentsDelta: args, + }, + { + type: 'block-end', + index: 0, + block: { type: 'tool-call', id: spec.callId, name: 'bash', arguments: args }, + }, + { type: 'usage', usage: { inputTokens: 256, outputTokens: 24 } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] +} + +function replayScript(specs: readonly TurnSpec[]): ReplayOverrideDoc { + return specs.flatMap((spec): ReplayEntry[] => { + const final: ReplayEntry = { kind: 'chunks', chunks: textStream(spec) } + return spec.callId === undefined + ? [final] + : [{ kind: 'chunks', chunks: toolStream(spec) }, final] + }) +} + +function userText(event: Extract): string { + return event.data.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') +} + +function assistantText(event: Extract): string { + return event.data.message.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') +} + +function toolResultText(event: Extract): string { + return event.data.message.content[0].content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') +} + +describe('web e2e: continuous conversation grown through the composer', () => { + let browser: Browser + let page: Page + let replayDir: string + let scaffold: WebScaffold + let tripwire: ReturnType + const consoleWarnings: string[] = [] + const sessionEvents: SessionEvent[] = [] + const specs = Array.from({ length: TURN_COUNT }, (_, offset) => turnSpec(offset + 1)) + + beforeAll(async () => { + replayDir = await mkdtemp(join(tmpdir(), 'dsh-continuous-chat-replay-')) + const replayOverride = join(replayDir, 'replay.override.json') + await writeFile(replayOverride, JSON.stringify(replayScript(specs))) + scaffold = await launchWebScaffold({ + replayFixture: join(replayDir, 'override-only.jsonl'), + replayOverride, + replayContextWindow: 10_000_000, + paceMs: STREAM_PACE_MS, + }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { + sessionEvents.push(event) + }) + browser = await chromium.launch() + page = await newEnglishPage(browser, 900) + tripwire = watchConsole(page) + page.on('console', (message) => { + if (message.type() === 'warning') consoleWarnings.push(message.text()) + }) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd, 'continuous-chat-e2e') + }, 120_000) + + afterAll(async () => { + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (replayDir !== undefined) { + await rm(replayDir, { recursive: true, force: true }) + .catch((error: unknown) => failures.push(error)) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'continuous Chat e2e cleanup failed') + }) + + it.skipIf(MODE === 'record')('keeps twelve generated turns and tool rows bound to one live session', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-continuous-conversation')) + const composer = page.locator('textarea:enabled').last() + await composer.waitFor({ timeout: 15_000 }) + let sessionId: SessionId | undefined + + for (const spec of specs) { + const eventStart = sessionEvents.length + expect(await composer.inputValue()).toBe('') + expect(await composer.isEnabled()).toBe(true) + await composer.fill(spec.prompt) + expect(await composer.inputValue()).toBe(spec.prompt) + + const settled = scaffold.whenTurnSettled(60_000) + await page.getByRole('button', { name: 'Send message', exact: true }).click() + await page.getByText(spec.userMarker, { exact: false }).last().waitFor({ timeout: 15_000 }) + const echoedUser = sessionEvents.slice(eventStart).find( + (event): event is SessionEvent<'user/message'> => ( + event.type === 'user/message' + && event.data.source.kind === 'user' + && userText(event).includes(spec.userMarker) + ), + ) + if (echoedUser === undefined) throw new Error(`turn ${String(spec.index)} has no user echo event`) + const userRow = page.locator(`[data-chat-anchor-key="node:${String(echoedUser.seq)}"]`) + await expect.poll(() => userRow.count(), { timeout: 10_000 }).toBe(1) + expect(await userRow.getAttribute('data-chat-flow-kind')).toBe('user') + expect(await userRow.textContent()).toContain(spec.userMarker) + await page.getByText(spec.firstMarker, { exact: false }).last().waitFor({ timeout: 15_000 }) + const settledSessionId = await settled + if (sessionId === undefined) { + sessionId = settledSessionId + } else { + expect(settledSessionId).toBe(sessionId) + } + + await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0) + await page.getByText(spec.doneMarker, { exact: false }).last().waitFor({ timeout: 15_000 }) + await expect.poll(() => composer.inputValue(), { timeout: 10_000 }).toBe('') + await expect.poll(() => composer.isEnabled(), { timeout: 10_000 }).toBe(true) + + const turnEvents = sessionEvents.slice(eventStart) + const turnStarts = turnEvents.filter((event): event is SessionEvent<'turn/start'> => ( + event.type === 'turn/start' + )) + const users = turnEvents.filter((event): event is SessionEvent<'user/message'> => ( + event.type === 'user/message' && event.data.source.kind === 'user' + )) + const assistants = turnEvents.filter((event): event is SessionEvent<'assistant/message'> => ( + event.type === 'assistant/message' + )) + const finalAssistants = assistants.filter(event => assistantText(event).includes(spec.doneMarker)) + const turnEnds = turnEvents.filter((event): event is SessionEvent<'turn/end'> => ( + event.type === 'turn/end' + )) + const chunks = turnEvents.filter(event => event.type === 'assistant/chunk') + + expect(turnStarts).toHaveLength(1) + expect(turnStarts[0]?.data.turn).toBe(spec.index) + expect(users).toHaveLength(1) + expect(users[0]?.seq).toBe(echoedUser.seq) + expect(userText(users[0]!)).toBe(spec.prompt) + expect(finalAssistants).toHaveLength(1) + expect(assistants).toHaveLength(spec.callId === undefined ? 1 : 2) + expect(turnEnds).toHaveLength(1) + expect(turnEnds[0]?.data).toEqual({ turn: spec.index, reason: { kind: 'completed' } }) + expect(chunks).toHaveLength(spec.deltas.length + (spec.callId === undefined ? 4 : 9)) + + const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(finalAssistants[0]!.seq)}"]`) + await expect.poll(() => assistantRow.count(), { timeout: 10_000 }).toBe(1) + expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant') + expect(await assistantRow.textContent()).toContain(spec.doneMarker) + + const calls = turnEvents.filter((event): event is SessionEvent<'tool/call'> => event.type === 'tool/call') + const results = turnEvents.filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result') + if (spec.callId === undefined || spec.toolResultMarker === undefined) { + expect(calls).toHaveLength(0) + expect(results).toHaveLength(0) + continue + } + + expect(calls).toHaveLength(1) + expect(results).toHaveLength(1) + expect(calls[0]?.data).toMatchObject({ + turn: spec.index, + callId: spec.callId, + name: 'bash', + }) + expect(results[0]?.data.turn).toBe(spec.index) + expect(results[0]?.data.message.source.callId).toBe(spec.callId) + expect(results[0]?.data.message.content[0].isError).toBe(false) + expect(toolResultText(results[0]!)).toBe(`${spec.toolResultMarker}\n`) + + const toolRow = page.locator(`[data-chat-call-id="${spec.callId}"]`) + await expect.poll(() => toolRow.count(), { timeout: 10_000 }).toBe(1) + expect(await toolRow.textContent()).toContain(spec.toolResultMarker) + const disclosure = toolRow.locator('[data-sample="bash"]') + expect(await disclosure.getAttribute('aria-expanded')).toBe('false') + await disclosure.click() + await expect.poll(() => disclosure.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true') + // The collapsed summary deliberately repeats the result marker; the + // last exact match is the expanded terminal output owned by this call. + await toolRow.getByText(spec.toolResultMarker, { exact: true }).last().waitFor({ timeout: 10_000 }) + await disclosure.click() + await expect.poll(() => disclosure.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('false') + } + + if (sessionId === undefined) throw new Error('continuous conversation completed no turn') + expect(scaffold.ctx.agents.get(sessionId)?.session.events.filter(event => ( + event.type === 'turn/end' && event.data.reason.kind === 'completed' + ))).toHaveLength(TURN_COUNT) + expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000) + expect(sessionEvents.filter(event => ( + event.type === 'assistant/chunk' && event.data.turn === TURN_COUNT + )).length).toBeGreaterThan(30) + expect(consoleWarnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 180_000) +}) diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts new file mode 100644 index 0000000000..44bc6f0012 --- /dev/null +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -0,0 +1,271 @@ +// Long-history Chat behavior contract for a future virtualized renderer. Wheel +// input only navigates to the semantic target; assertions pin content identity +// and interaction routing rather than scroll geometry or mounted row counts. +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import { createChatScrollFixture } from './chat-scroll-fixture.ts' +import { + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const MODE = webSnapshotMode() +const SESSION_ID = 'chat-long-interactions-e2e' +const FIXTURE_TURNS = 88 +const TOOL_TURN = FIXTURE_TURNS +const BRANCH_TURN = 80 +const TARGET_CALL_1 = 'chat-scroll-088-1' +const TARGET_CALL_2 = 'chat-scroll-088-2' +const CONTINUE_PROMPT = 'CHAT_INTERACTION_CONTINUE Continue from this exact branch point.' +const CONTINUE_FIRST = 'CHAT_INTERACTION_CONTINUE_FIRST' +const CONTINUE_DONE = 'CHAT_INTERACTION_CONTINUE_DONE' +const FIXTURE = createChatScrollFixture({ + markerPrefix: 'INTERACTION', + title: 'CHAT_INTERACTION long semantic identity session', + turns: FIXTURE_TURNS, +}) + +function continuationChunks(): StreamChunk[] { + const response = `${CONTINUE_FIRST} The fork retained the intended prefix. ${CONTINUE_DONE}.` + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: `${CONTINUE_FIRST} ` }, + { type: 'text-delta', index: 0, text: `The fork retained the intended prefix. ${CONTINUE_DONE}.` }, + { type: 'block-end', index: 0, block: { type: 'text', text: response } }, + { type: 'usage', usage: { inputTokens: 512, outputTokens: 32 } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] +} + +function replayEntry(chunks: StreamChunk[]): ReplayEntry { + return { kind: 'chunks', chunks } +} + +function carries(event: SessionEvent, marker: string): boolean { + return JSON.stringify(event).includes(marker) +} + +function textContent(content: readonly unknown[]): string { + return content.flatMap((block) => { + if (typeof block !== 'object' || block === null) return [] + const candidate = block as { type?: unknown; text?: unknown } + return candidate.type === 'text' && typeof candidate.text === 'string' + ? [candidate.text] + : [] + }).join('') +} + +async function nextPaint(page: Page): Promise { + await page.evaluate(async () => { + await document.fonts.ready + await new Promise(resolve => requestAnimationFrame(() => { + requestAnimationFrame(() => { resolve() }) + })) + }) +} + +async function openSeed(page: Page): Promise { + await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 }) + const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true }) + await search.fill(FIXTURE.markers.user(1)) + const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await results.first().waitFor({ timeout: 60_000 }) + const resultCount = await results.count() + if (resultCount !== 1) throw new Error(`expected one seeded search result, received ${String(resultCount)}`) + await results.click() + await results.click() + await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }) + .last().waitFor({ timeout: 30_000 }) + await nextPaint(page) +} + +async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise { + const scrollport = page.locator('[data-conversation-scroll]') + const box = await scrollport.boundingBox() + if (box === null) throw new Error('conversation scrollport has no layout box') + await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3)) + for (let attempt = 0; attempt < 20; attempt += 1) { + if (await page.locator(selector).count() > 0) return + await page.mouse.wheel(0, deltaY) + await nextPaint(page) + } + throw new Error(`semantic Chat target did not mount: ${selector}`) +} + +function requiredEvent( + events: readonly SessionEvent[], + type: T, + marker: string, +): Extract { + const event = events.find((candidate): candidate is Extract => ( + candidate.type === type && carries(candidate, marker) + )) + if (event === undefined) throw new Error(`${type} carrying ${marker} is absent`) + return event +} + +describe('web e2e: long Chat interaction contract', () => { + let browser: Browser + let page: Page + let replayDir: string + let scaffold: WebScaffold + let tripwire: ReturnType + + beforeAll(async () => { + replayDir = await mkdtemp(join(tmpdir(), 'dsh-chat-interaction-replay-')) + const replayOverride = join(replayDir, 'replay.override.json') + const replay: ReplayOverrideDoc = [replayEntry(continuationChunks())] + await writeFile(replayOverride, JSON.stringify(replay)) + scaffold = await launchWebScaffold({ + replayFixture: join(replayDir, 'override-only.jsonl'), + replayOverride, + replayContextWindow: 10_000_000, + paceMs: 18, + }) + await seedSession(scaffold, FIXTURE.log, SESSION_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser, 900) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await openSeed(page) + }, 120_000) + + afterAll(async () => { + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (replayDir !== undefined) { + await rm(replayDir, { recursive: true, force: true }) + .catch((error: unknown) => failures.push(error)) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'long Chat interaction cleanup failed') + }) + + it.skipIf(MODE === 'record')('keeps heterogeneous rows and their actions bound to exact semantic identities', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-long-interactions')) + const source = scaffold.ctx.agents.get(SessionId(SESSION_ID)) + if (source === undefined) throw new Error('seeded long-history agent is not attached') + + const toolUserMarker = FIXTURE.markers.user(TOOL_TURN) + const toolAssistantMarker = FIXTURE.markers.assistant(TOOL_TURN) + const toolMarker1 = FIXTURE.markers.tool(TOOL_TURN, 1) + const toolMarker2 = FIXTURE.markers.tool(TOOL_TURN, 2) + const toolUserEvent = requiredEvent(source.session.events, 'user/message', toolUserMarker) + const toolAssistantEvent = requiredEvent(source.session.events, 'assistant/message', toolAssistantMarker) + const branchUserMarker = FIXTURE.markers.user(BRANCH_TURN) + const branchAssistantMarker = FIXTURE.markers.assistant(BRANCH_TURN) + const branchUserEvent = requiredEvent(source.session.events, 'user/message', branchUserMarker) + const branchAssistantEvent = requiredEvent(source.session.events, 'assistant/message', branchAssistantMarker) + const boundary = source.session.events.find((event): event is SessionEvent<'turn/end'> => ( + event.type === 'turn/end' && event.data.turn === BRANCH_TURN + )) + if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no completed boundary`) + const expectedUserText = textContent(branchUserEvent.data.content) + + await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100) + const toolUserRow = page.locator(`[data-chat-anchor-key="node:${String(toolUserEvent.seq)}"]`) + const toolAssistantRow = page.locator(`[data-chat-anchor-key="node:${String(toolAssistantEvent.seq)}"]`) + const call1 = page.locator(`[data-chat-call-id="${TARGET_CALL_1}"]`) + const call2 = page.locator(`[data-chat-call-id="${TARGET_CALL_2}"]`) + + await expect.poll(() => toolUserRow.count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => toolAssistantRow.count(), { timeout: 10_000 }).toBe(1) + expect(await call1.count()).toBe(1) + expect(await call2.count()).toBe(1) + expect(await toolUserRow.getAttribute('data-chat-flow-kind')).toBe('user') + expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant') + expect(await toolUserRow.textContent()).toContain(toolUserMarker) + expect(await toolAssistantRow.textContent()).toContain(toolAssistantMarker) + expect(await call1.textContent()).toContain(toolMarker1) + expect(await call2.textContent()).toContain(toolMarker2) + + const expectedOrder = [ + `node:${String(toolUserEvent.seq)}`, + `call:${TARGET_CALL_1}`, + `call:${TARGET_CALL_2}`, + `node:${String(toolAssistantEvent.seq)}`, + ] + const actualOrder = await page.locator('[data-chat-anchor-key]').evaluateAll((rows, keys) => ( + rows.map(row => (row as HTMLElement).dataset.chatAnchorKey) + .filter((key): key is string => key !== undefined && keys.includes(key)) + ), expectedOrder) + expect(actualOrder).toEqual(expectedOrder) + const groupKeys = await Promise.all([call1, call2].map(row => row.evaluate(element => ( + element.closest('[data-chat-flow-kind="tool-group"]')?.dataset.chatFlowKey ?? null + )))) + expect(groupKeys[0]).not.toBeNull() + expect(groupKeys[1]).toBe(groupKeys[0]) + + const summary1 = call1.locator('[data-sample="bash"]') + const summary2 = call2.locator('[data-sample="bash"]') + expect(await summary1.getAttribute('aria-expanded')).toBe('false') + expect(await summary2.getAttribute('aria-expanded')).toBe('false') + await summary2.focus() + await summary2.press('Enter') + await expect.poll(() => summary2.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true') + expect(await summary1.getAttribute('aria-expanded')).toBe('false') + await call2.getByText(`${toolMarker2} output line 12`, { exact: true }).waitFor({ timeout: 10_000 }) + + await wheelUntilMounted(page, `[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`, -1_100) + const userRow = page.locator(`[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`) + const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(branchAssistantEvent.seq)}"]`) + expect(await userRow.textContent()).toContain(branchUserMarker) + expect(await assistantRow.textContent()).toContain(branchAssistantMarker) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + await userRow.hover() + await userRow.getByRole('button', { name: 'Copy', exact: true }).click() + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 5_000 }) + .toBe(expectedUserText) + + await assistantRow.hover() + await assistantRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click() + await expect.poll( + () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)), + { timeout: 15_000 }, + ).toBeDefined() + const child = scaffold.ctx.agents.list() + .find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)) + if (child === undefined) throw new Error('message branch did not create a child session') + expect(child.session.header.seedLength).toBe(boundary.seq + 1) + expect(child.session.events.some(event => carries(event, branchAssistantMarker))).toBe(true) + expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(BRANCH_TURN + 1)))).toBe(false) + expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(FIXTURE.turns)))).toBe(false) + + const currentCrumb = page.getByRole('navigation', { name: 'Session hierarchy' }) + .getByRole('button').last() + await expect.poll(() => currentCrumb.textContent(), { timeout: 15_000 }) + .toBe(`${FIXTURE.title} (1)`) + await page.getByText(branchAssistantMarker, { exact: false }).last().waitFor({ timeout: 15_000 }) + const settled = scaffold.whenTurnSettled(60_000) + const composer = page.locator('textarea:enabled').last() + await composer.fill(CONTINUE_PROMPT) + await page.getByRole('button', { name: 'Send message', exact: true }).click() + await expect.poll(() => page.getByText(CONTINUE_PROMPT, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + expect(await settled).toBe(child.session.id) + await page.getByText(CONTINUE_DONE, { exact: false }).last().waitFor({ timeout: 15_000 }) + await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0) + expect(await composer.inputValue()).toBe('') + expect(await composer.isEnabled()).toBe(true) + expect(source.session.events.some(event => carries(event, CONTINUE_PROMPT))).toBe(false) + expect(child.session.events.filter(event => carries(event, CONTINUE_PROMPT))).toHaveLength(1) + const lastTurnEnd = child.session.events.findLast((event): event is SessionEvent<'turn/end'> => ( + event.type === 'turn/end' + )) + expect(lastTurnEnd?.data.reason).toEqual({ kind: 'completed' }) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 180_000) +}) diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts new file mode 100644 index 0000000000..b4b67bf3a3 --- /dev/null +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -0,0 +1,683 @@ +// Browser geometry contracts for a long Chat transcript. These scenarios are +// deliberately virtualizer-neutral: they assert semantic-row position, +// bottom ownership, interaction state, and the real outer scroll host rather +// than DOM cardinality or implementation-specific spacer markup. +import { access, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts' +import { + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const MODE = webSnapshotMode() +const HISTORY_SESSION_ID = 'chat-scroll-history-e2e' +const TOOL_SESSION_ID = 'chat-scroll-tool-e2e' +const RESTORE_SESSION_A_ID = 'chat-scroll-restore-a-e2e' +const RESTORE_SESSION_B_ID = 'chat-scroll-restore-b-e2e' +const REPLAY_CONTEXT_WINDOW = 10_000_000 +const STREAM_PACE_MS = 24 +const GEOMETRY_TOLERANCE = 2 +const LIVE_TEXT_PROMPT = 'CHAT_SCROLL_LIVE_USER Continue this long conversation while I inspect older history.' +const LIVE_TEXT_FIRST = 'CHAT_SCROLL_LIVE_FIRST' +const LIVE_TEXT_DONE = 'CHAT_SCROLL_LIVE_DONE' +const LIVE_TOOL_PROMPT = 'CHAT_SCROLL_TOOL_USER Run the requested diagnostic and then summarize it.' +const LIVE_TOOL_CALL_ID = CallId('chat-scroll-live-tool-call') +const LIVE_TOOL_RESULT = 'CHAT_SCROLL_LIVE_TOOL_RESULT' +const LIVE_TOOL_FIRST = 'CHAT_SCROLL_TOOL_STREAM_FIRST' +const LIVE_TOOL_DONE = 'CHAT_SCROLL_TOOL_STREAM_DONE' +const TOOL_READY_FILE = '.chat-scroll-tool-ready' +const TOOL_RELEASE_FILE = '.chat-scroll-tool-release' + +const HISTORY_FIXTURE = createChatScrollFixture({ + markerPrefix: 'HISTORY', + title: 'CHAT_SCROLL_HISTORY long paging session', +}) +const TOOL_FIXTURE = createChatScrollFixture({ + markerPrefix: 'TOOL', + title: 'CHAT_SCROLL_TOOL live tool session', +}) +const RESTORE_FIXTURE_A = createChatScrollFixture({ + markerPrefix: 'RESTORE_A', + title: 'CHAT_SCROLL_RESTORE_A long session', +}) +const RESTORE_FIXTURE_B = createChatScrollFixture({ + markerPrefix: 'RESTORE_B', + title: 'CHAT_SCROLL_RESTORE_B comparison session', + turns: 32, +}) + +interface ScrollGeometry { + readonly distanceFromBottom: number + readonly scrollTop: number +} + +interface FlowAnchor { + readonly key: string + readonly top: number +} + +interface ScrollWorld { + readonly events: SessionEvent[] + readonly page: Page + readonly replayDir?: string + readonly scaffold: WebScaffold + readonly tripwire: ReturnType +} + +interface ScrollWorldOptions { + readonly failureShot: string + readonly replay?: ReplayOverrideDoc + readonly seeds: readonly { fixture: ChatScrollFixture; id: string }[] +} + +function textStream(first: string, done: string, deltaCount: number): StreamChunk[] { + const deltas = Array.from({ length: deltaCount }, (_, index) => { + if (index === 0) return `${first} ` + if (index === deltaCount - 1) return `${done}.` + return `stream-chunk-${String(index).padStart(3, '0')} ${'incremental response '.repeat(3)}` + }) + const response = deltas.join('') + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + ...deltas.map(text => ({ type: 'text-delta' as const, index: 0, text })), + { type: 'block-end', index: 0, block: { type: 'text', text: response } }, + { + type: 'usage', + usage: { inputTokens: 512, outputTokens: Math.ceil(response.length / 4) }, + }, + { type: 'finish', reason: { kind: 'stop' } }, + ] +} + +function toolStream(): StreamChunk[] { + const command = [ + `: > ${TOOL_READY_FILE}`, + `while [ ! -f ${TOOL_RELEASE_FILE} ]; do sleep 0.02; done`, + 'line=1', + `while [ "$line" -le 64 ]; do printf '${LIVE_TOOL_RESULT} line %02d\\n' "$line"; line=$((line + 1)); done`, + ].join('; ') + const args = JSON.stringify({ command, description: LIVE_TOOL_RESULT }) + return [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { + type: 'tool-call-delta', + index: 0, + id: LIVE_TOOL_CALL_ID, + name: 'bash', + argumentsDelta: args, + }, + { + type: 'block-end', + index: 0, + block: { type: 'tool-call', id: LIVE_TOOL_CALL_ID, name: 'bash', arguments: args }, + }, + { type: 'usage', usage: { inputTokens: 256, outputTokens: 48 } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] +} + +function replayEntry(chunks: StreamChunk[]): ReplayEntry { + return { kind: 'chunks', chunks } +} + +async function launchScrollWorld(options: ScrollWorldOptions): Promise { + let replayDir: string | undefined + let scaffold: WebScaffold | undefined + let page: Page | undefined + try { + if (options.replay !== undefined) { + replayDir = await mkdtemp(join(tmpdir(), 'dsh-chat-scroll-replay-')) + const replayOverride = join(replayDir, 'replay.override.json') + await writeFile(replayOverride, JSON.stringify(options.replay)) + scaffold = await launchWebScaffold({ + replayFixture: join(replayDir, 'override-only.jsonl'), + replayOverride, + paceMs: STREAM_PACE_MS, + replayContextWindow: REPLAY_CONTEXT_WINDOW, + }) + } else { + scaffold = await launchWebScaffold({}) + } + for (const seed of options.seeds) await seedSession(scaffold, seed.fixture.log, seed.id) + const events: SessionEvent[] = [] + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) }) + page = await newEnglishPage(browser, 900) + const tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + // Session-list bootstrap can replace the controlled search state. Wait + // for the seeded baseline before openSeed starts the lazy content query. + await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 }) + return { + events, + page, + scaffold, + tripwire, + ...(replayDir === undefined ? {} : { replayDir }), + } + } catch (error) { + const failures: unknown[] = [error] + if (page !== undefined) await page.context().close().catch((cleanupError: unknown) => failures.push(cleanupError)) + if (scaffold !== undefined) await scaffold.close().catch((cleanupError: unknown) => failures.push(cleanupError)) + if (replayDir !== undefined) { + await rm(replayDir, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError)) + } + if (failures.length === 1) throw error + throw new AggregateError(failures, 'chat-scroll browser world setup failed and cleanup was incomplete') + } +} + +async function closeScrollWorld(world: ScrollWorld): Promise { + const failures: unknown[] = [] + // newEnglishPage/browser.newPage owns an isolated context. Close the whole + // context so its SSE connection and cache cannot leak into the next world + // in this file's shared Chromium process. + await world.page.context().close().catch((error: unknown) => failures.push(error)) + await world.scaffold.close().catch((error: unknown) => failures.push(error)) + if (world.replayDir !== undefined) { + await rm(world.replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'chat-scroll browser world cleanup failed') +} + +async function withScrollWorld( + options: ScrollWorldOptions, + run: (world: ScrollWorld) => Promise, +): Promise { + const world = await launchScrollWorld(options) + let runFailure: unknown + try { + await run(world) + } catch (error) { + runFailure = error + try { + await saveFailureShot(world.page, options.failureShot) + } catch { + // Best-effort evidence must never prevent cleanup of the owned world. + } + } + let cleanupFailure: unknown + try { + await closeScrollWorld(world) + } catch (error) { + cleanupFailure = error + } + if (runFailure !== undefined && cleanupFailure !== undefined) { + throw new AggregateError([runFailure, cleanupFailure], 'chat-scroll scenario and cleanup both failed') + } + if (runFailure !== undefined) throw runFailure + if (cleanupFailure !== undefined) throw cleanupFailure +} + +async function nextPaint(page: Page): Promise { + await page.evaluate(async () => { + await document.fonts.ready + await new Promise(resolve => requestAnimationFrame(() => { + requestAnimationFrame(() => { resolve() }) + })) + }) +} + +function scrollGeometry(page: Page): Promise { + return page.locator('[data-conversation-scroll]').evaluate(host => ({ + distanceFromBottom: host.scrollHeight - host.clientHeight - host.scrollTop, + scrollTop: host.scrollTop, + })) +} + +async function conversationTurns(page: Page): Promise { + const stats = page.getByText(/\d+ turns · \d+ steps/, { exact: true }).last() + await stats.waitFor({ timeout: 15_000 }) + const value = await stats.textContent() + const match = value?.match(/^(\d+) turns · \d+ steps$/) + if (match?.[1] === undefined) throw new Error(`unexpected conversation stats ${JSON.stringify(value)}`) + return Number(match[1]) +} + +async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise { + const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true }) + // Cold summaries initially show the temporary workspace basename, so the + // persisted first-message marker is the stable user-facing identity. The + // query itself triggers lazy content-index reconciliation; no transient + // empty-state paint is used as a barrier. + await search.fill(fixture.markers.user(1)) + const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1) + await results.click() + await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 }) + if (tailMarker !== undefined) { + await page.getByText(tailMarker, { exact: false }).last().waitFor({ timeout: 30_000 }) + } + await nextPaint(page) +} + +async function wheelTranscript(page: Page, deltaY: number): Promise { + const box = await page.locator('[data-conversation-scroll]').boundingBox() + if (box === null) throw new Error('conversation scrollport has no layout box') + await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3)) + await page.mouse.wheel(0, deltaY) + await nextPaint(page) +} + +async function wheelToHistoryStart(page: Page): Promise { + for (let attempt = 0; attempt < 12; attempt += 1) { + if ((await scrollGeometry(page)).scrollTop <= 1) break + await wheelTranscript(page, -2_400) + } + await expect.poll(async () => (await scrollGeometry(page)).scrollTop, { timeout: 10_000 }) + .toBeLessThanOrEqual(1) +} + +async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise { + for (let attempt = 0; attempt < 16; attempt += 1) { + if (await page.locator(selector).count() > 0) return + await wheelTranscript(page, deltaY) + } + throw new Error(`selector did not mount during transcript wheel: ${selector}`) +} + +async function wheelUntilVisible(page: Page, selector: string, deltaY: number): Promise { + const target = page.locator(selector) + for (let attempt = 0; attempt < 32; attempt += 1) { + if (await target.count() > 0 && await target.evaluate((row) => { + const host = row.closest('[data-conversation-scroll]') + if (host === null) return false + const viewport = host.getBoundingClientRect() + const composer = host.querySelector('[data-composer-seat]') + const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom + const rect = row.getBoundingClientRect() + return rect.bottom > viewport.top && rect.top < visibleBottom + })) return + await wheelTranscript(page, deltaY) + } + throw new Error(`selector did not become visible during transcript wheel: ${selector}`) +} + +function visibleFlowAnchor(page: Page): Promise { + return page.locator('[data-conversation-scroll]').evaluate((host) => { + const rows = [...host.querySelectorAll('[data-chat-anchor-key]')] + const viewport = host.getBoundingClientRect() + const composer = host.querySelector('[data-composer-seat]') + const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom + const visible = rows.filter((candidate) => { + const rect = candidate.getBoundingClientRect() + return rect.bottom > viewport.top && rect.top < visibleBottom + }) + const row = visible[0] + if (row?.dataset.chatAnchorKey === undefined) { + throw new Error(`no visible settled Chat row: ${JSON.stringify({ + composerTop: visibleBottom, + host: { bottom: viewport.bottom, top: viewport.top }, + rows: rows.slice(0, 4).map(candidate => ({ + callId: candidate.dataset.chatCallId, + key: candidate.dataset.chatAnchorKey, + rect: { + bottom: candidate.getBoundingClientRect().bottom, + top: candidate.getBoundingClientRect().top, + }, + })), + totalRows: rows.length, + })}`) + } + return { + key: row.dataset.chatAnchorKey, + top: row.getBoundingClientRect().top - viewport.top, + } + }) +} + +function flowTop(page: Page, key: string): Promise { + return page.locator('[data-chat-anchor-key]').evaluateAll((rows, anchorKey) => { + const row = rows.find(candidate => (candidate as HTMLElement).dataset.chatAnchorKey === anchorKey) + if (!(row instanceof HTMLElement)) throw new Error(`stable Chat anchor ${anchorKey} is not mounted`) + const host = row.closest('[data-conversation-scroll]') + if (!(host instanceof HTMLElement)) throw new Error('flow row has no conversation scrollport') + return row.getBoundingClientRect().top - host.getBoundingClientRect().top + }, key) +} + +async function expectSameFlowTop(page: Page, anchor: FlowAnchor): Promise { + await expect.poll(async () => Math.abs((await flowTop(page, anchor.key)) - anchor.top), { + timeout: 10_000, + message: `flow row ${anchor.key} moved relative to the transcript viewport`, + }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) +} + +async function expectBottom(page: Page): Promise { + await expect.poll(async () => Math.abs((await scrollGeometry(page)).distanceFromBottom), { + timeout: 10_000, + }).toBeLessThanOrEqual(1) +} + +async function expectMarkerAboveComposer(page: Page, marker: string): Promise { + const geometry = await page.getByText(marker, { exact: false }).last().evaluate((node) => { + const row = node.closest('[data-chat-flow-key], [data-streaming]') + const composer = node.closest('[data-conversation-scroll]')?.querySelector('[data-composer-seat]') + if (!(row instanceof HTMLElement) || !(composer instanceof HTMLElement)) { + throw new Error('latest marker or composer geometry is unavailable') + } + return { + composerTop: composer.getBoundingClientRect().top, + rowBottom: row.getBoundingClientRect().bottom, + } + }) + expect(geometry.rowBottom).toBeLessThanOrEqual(geometry.composerTop + GEOMETRY_TOLERANCE) +} + +async function loadEarlierWithAnchor(page: Page): Promise { + await wheelToHistoryStart(page) + const older = page.getByRole('button', { name: 'Load earlier', exact: true }) + await older.waitFor({ timeout: 10_000 }) + const anchor = await visibleFlowAnchor(page) + const before = await conversationTurns(page) + await older.click() + await expect.poll(() => conversationTurns(page), { timeout: 30_000 }).toBeGreaterThan(before) + await nextPaint(page) + await expectSameFlowTop(page, anchor) +} + +async function fileExists(path: string): Promise { + try { + await access(path) + return true + } catch { + return false + } +} + +function eventCarries(event: SessionEvent, marker: string): boolean { + return JSON.stringify(event).includes(marker) +} + +function assertClean(world: ScrollWorld): void { + expect(world.tripwire.pageErrors).toEqual([]) + expect(world.tripwire.warnings).toEqual([]) +} + +let browser: Browser + +describe('web e2e: long Chat scroll contract', () => { + beforeAll(async () => { + browser = await chromium.launch() + }) + + afterAll(async () => { + await browser?.close() + }) + + it.skipIf(MODE === 'record')('preserves the reader anchor when history and streaming arrive concurrently', async () => { + await withScrollWorld({ + failureShot: 'web-e2e-chat-scroll-history-stream', + replay: [replayEntry(textStream(LIVE_TEXT_FIRST, LIVE_TEXT_DONE, 120))], + seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }], + }, async (world) => { + await openSeed( + world.page, + HISTORY_FIXTURE, + HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns), + ) + await expectBottom(world.page) + + let releaseHistory = (): void => {} + let held = false + let releaseGate: (() => void) | undefined + const gate = new Promise((resolve) => { releaseGate = resolve }) + releaseHistory = () => { releaseGate?.() } + await world.page.route('**/api/session.history', async (route) => { + const request = route.request().postDataJSON() as { + method?: string + payload?: { beforeSeq?: number } + } + if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) { + held = true + await gate + } + await route.continue() + }) + + const settled = world.scaffold.whenTurnSettled(60_000) + try { + const composer = world.page.locator('textarea:enabled').last() + await composer.fill(LIVE_TEXT_PROMPT) + await world.page.getByRole('button', { name: 'Send message', exact: true }).click() + await world.page.getByText(LIVE_TEXT_FIRST, { exact: false }).last().waitFor({ timeout: 15_000 }) + await wheelToHistoryStart(world.page) + const beforeTurns = await conversationTurns(world.page) + await world.page.getByRole('button', { name: 'Load earlier', exact: true }).click() + await expect.poll(() => held, { timeout: 10_000 }).toBe(true) + + await wheelTranscript(world.page, 420) + const readerAnchor = await visibleFlowAnchor(world.page) + const chunksAfterAnchor = world.events.filter(event => event.type === 'assistant/chunk').length + await expect.poll( + () => world.events.filter(event => event.type === 'assistant/chunk').length, + { timeout: 10_000 }, + ).toBeGreaterThan(chunksAfterAnchor + 5) + + releaseHistory() + await expect.poll(() => conversationTurns(world.page), { timeout: 30_000 }).toBeGreaterThan(beforeTurns) + await nextPaint(world.page) + await expectSameFlowTop(world.page, readerAnchor) + } finally { + releaseHistory() + } + + await settled + await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0) + await world.page.getByText(LIVE_TEXT_DONE, { exact: false }).last().waitFor({ timeout: 15_000 }) + await world.page.unroute('**/api/session.history') + + let additionalPages = 0 + while (additionalPages < 8) { + await wheelToHistoryStart(world.page) + if (await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count() === 0) break + await loadEarlierWithAnchor(world.page) + additionalPages += 1 + } + expect(additionalPages).toBeGreaterThan(0) + expect(await conversationTurns(world.page)).toBe(HISTORY_FIXTURE.turns + 1) + expect(await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count()).toBe(0) + assertClean(world) + }) + }, 180_000) + + it.skipIf(MODE === 'record')('keeps streaming ownership and tool disclosure state across a long scroll-away cycle', async () => { + await withScrollWorld({ + failureShot: 'web-e2e-chat-scroll-live-tool', + replay: [ + replayEntry(toolStream()), + replayEntry(textStream(LIVE_TOOL_FIRST, LIVE_TOOL_DONE, 84)), + ], + seeds: [{ fixture: TOOL_FIXTURE, id: TOOL_SESSION_ID }], + }, async (world) => { + const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE) + const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE) + await openSeed(world.page, TOOL_FIXTURE, TOOL_FIXTURE.markers.assistant(TOOL_FIXTURE.turns)) + const settled = world.scaffold.whenTurnSettled(60_000) + let released = false + try { + const composer = world.page.locator('textarea:enabled').last() + await composer.fill(LIVE_TOOL_PROMPT) + await world.page.getByRole('button', { name: 'Send message', exact: true }).click() + await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true) + const liveRow = world.page.locator(`[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`) + await liveRow.waitFor({ timeout: 15_000 }) + expect(await liveRow.getAttribute('data-state')).toBe('running') + await expectBottom(world.page) + + await wheelTranscript(world.page, -1_200) + await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).waitFor({ timeout: 10_000 }) + const awayAnchor = await visibleFlowAnchor(world.page) + const chunksBeforeRelease = world.events.filter(event => event.type === 'assistant/chunk').length + await writeFile(releasePath, 'release\n') + released = true + await expect.poll( + () => world.events.some(event => event.type === 'tool/result'), + { timeout: 15_000 }, + ).toBe(true) + await expect.poll( + () => world.events.some(event => eventCarries(event, LIVE_TOOL_FIRST)), + { timeout: 15_000 }, + ).toBe(true) + await expect.poll( + () => world.events.filter(event => event.type === 'assistant/chunk').length, + { timeout: 15_000 }, + ).toBeGreaterThan(chunksBeforeRelease + 5) + await expectSameFlowTop(world.page, awayAnchor) + + const chunksAtRepin = world.events.filter(event => event.type === 'assistant/chunk').length + await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click() + await expectBottom(world.page) + await expect.poll( + () => world.events.filter(event => event.type === 'assistant/chunk').length, + { timeout: 15_000 }, + ).toBeGreaterThan(chunksAtRepin + 5) + await expectBottom(world.page) + } finally { + if (!released) await writeFile(releasePath, 'release\n').catch(() => {}) + } + + await settled + await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0) + await world.page.getByText(LIVE_TOOL_DONE, { exact: false }).last().waitFor({ timeout: 15_000 }) + await expectBottom(world.page) + await expectMarkerAboveComposer(world.page, LIVE_TOOL_DONE) + + const liveRowSelector = `[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]` + const liveRow = world.page.locator(liveRowSelector) + await wheelUntilVisible(world.page, liveRowSelector, -300) + const toolAnchor = await liveRow.evaluate((row) => { + const flow = row.closest('[data-chat-anchor-key]') + const host = row.closest('[data-conversation-scroll]') + if (flow?.dataset.chatAnchorKey === undefined || host === null) { + throw new Error('live tool row has no settled flow identity') + } + return { + key: flow.dataset.chatAnchorKey, + top: flow.getBoundingClientRect().top - host.getBoundingClientRect().top, + } + }) + await liveRow.click() + await expect.poll(() => liveRow.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true') + await expectSameFlowTop(world.page, toolAnchor) + await wheelToHistoryStart(world.page) + await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click() + await expectBottom(world.page) + await wheelUntilMounted(world.page, liveRowSelector, -1_100) + const restoredRow = world.page.locator(liveRowSelector) + await restoredRow.waitFor({ timeout: 10_000 }) + expect(await restoredRow.getAttribute('aria-expanded')).toBe('true') + expect(await world.page.getByText(LIVE_TOOL_RESULT, { exact: false }).count()).toBeGreaterThan(0) + assertClean(world) + }) + }, 180_000) + + it.skipIf(MODE === 'record')('restores tab/session position and keeps composer resizing on the correct scroll owner', async () => { + await withScrollWorld({ + failureShot: 'web-e2e-chat-scroll-restore-composer', + seeds: [ + { fixture: RESTORE_FIXTURE_A, id: RESTORE_SESSION_A_ID }, + { fixture: RESTORE_FIXTURE_B, id: RESTORE_SESSION_B_ID }, + ], + }, async (world) => { + await openSeed( + world.page, + RESTORE_FIXTURE_A, + RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns), + ) + await loadEarlierWithAnchor(world.page) + await loadEarlierWithAnchor(world.page) + await wheelToHistoryStart(world.page) + await wheelTranscript(world.page, 1_300) + const sessionAnchor = await visibleFlowAnchor(world.page) + + await world.page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) + await world.page.setViewportSize({ width: 700, height: 900 }) + await world.page.getByRole('tab', { name: 'Chat', exact: true }).click() + await nextPaint(world.page) + await expectSameFlowTop(world.page, sessionAnchor) + + await openSeed( + world.page, + RESTORE_FIXTURE_B, + RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns), + ) + await openSeed( + world.page, + RESTORE_FIXTURE_A, + ) + await expectSameFlowTop(world.page, sessionAnchor) + + const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true }) + await backToBottom.evaluate((button) => { + if (!(button instanceof HTMLElement)) throw new Error('Back-to-bottom control is not an HTML element') + button.click() + const trajectory = [...document.querySelectorAll('[role="tab"]')] + .find(tab => tab.textContent?.trim() === 'Trajectory') + if (!(trajectory instanceof HTMLElement)) { + throw new Error('Trajectory tab is unavailable during pinned remount') + } + trajectory.click() + }) + await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) + await world.page.getByRole('tab', { name: 'Chat', exact: true }).click() + await expectBottom(world.page) + await openSeed( + world.page, + RESTORE_FIXTURE_B, + RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns), + ) + await openSeed( + world.page, + RESTORE_FIXTURE_A, + RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns), + ) + await expectBottom(world.page) + const composer = world.page.locator('textarea:enabled').last() + const longDraft = Array.from( + { length: 18 }, + (_, index) => `composer resize line ${String(index + 1).padStart(2, '0')}`, + ).join('\n') + await composer.fill(longDraft) + await nextPaint(world.page) + await expectBottom(world.page) + await expectMarkerAboveComposer( + world.page, + RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns), + ) + + await composer.fill('short draft') + await nextPaint(world.page) + await wheelTranscript(world.page, -900) + const resizeAnchor = await visibleFlowAnchor(world.page) + await composer.fill(longDraft) + await nextPaint(world.page) + await expectSameFlowTop(world.page, resizeAnchor) + await composer.fill('short draft') + await nextPaint(world.page) + await expectSameFlowTop(world.page, resizeAnchor) + + const beforeChain = await scrollGeometry(world.page) + await composer.hover() + await world.page.mouse.wheel(0, -320) + await expect.poll(async () => (await scrollGeometry(world.page)).scrollTop, { timeout: 10_000 }) + .toBeLessThan(beforeChain.scrollTop) + assertClean(world) + }) + }, 180_000) +}) diff --git a/apps/web/tests/chat-scroll-fixture.ts b/apps/web/tests/chat-scroll-fixture.ts new file mode 100644 index 0000000000..e46f0c336a --- /dev/null +++ b/apps/web/tests/chat-scroll-fixture.ts @@ -0,0 +1,235 @@ +// Synthetic long-chat history for browser behavior contracts. The fixture is +// generated through Session so pagination exercises the same event shapes as +// persisted conversations, while unique markers let tests identify semantic +// rows without depending on CSS-module names or the eventual virtualizer DOM. +import { + CallId, + createAssistantMessage, + createToolResultMessage, + createUserMessage, +} from '@deepseek-ai/dsh-llm' +import { + SESSION_FORMAT_VERSION, + Session, + SessionId, +} from '@deepseek-ai/dsh-session' +// Carries the session/title event declaration into this fixture builder. +import type {} from '@deepseek-ai/dsh-session-title' + +/** Options for one deterministic long-chat fixture. */ +export interface ChatScrollFixtureOptions { + /** Marker namespace, used when two sessions share one browser world. */ + readonly markerPrefix: string + /** Searchable title projected into the sidebar. */ + readonly title: string + /** Number of closed turns to generate. */ + readonly turns?: number +} + +/** Semantic marker helpers returned with a generated fixture. */ +interface ChatScrollMarkers { + /** Marker painted in the human message for a turn. */ + user(turn: number): string + /** Marker painted in the final assistant message for a turn. */ + assistant(turn: number): string + /** Marker painted in one seeded bash call and result. */ + tool(turn: number, index: number): string +} + +/** Generated JSONL plus the stable facts browser scenarios assert. */ +export interface ChatScrollFixture { + readonly log: string + readonly markers: ChatScrollMarkers + readonly title: string + readonly turns: number +} + +const DEFAULT_TURNS = 88 +const TOOL_INTERVAL = 8 +const CODE_INTERVAL = 11 + +function text(value: string): { type: 'text'; text: string }[] { + return [{ type: 'text', text: value }] +} + +function suffix(turn: number): string { + return String(turn).padStart(3, '0') +} + +function markerHelpers(prefix: string): ChatScrollMarkers { + return { + user: turn => `CHAT_SCROLL_${prefix}_USER_${suffix(turn)}`, + assistant: turn => `CHAT_SCROLL_${prefix}_ASSISTANT_${suffix(turn)}`, + tool: (turn, index) => `CHAT_SCROLL_${prefix}_TOOL_${suffix(turn)}_${String(index)}`, + } +} + +function appendRequestHeader(session: Session, turn: number, step: number): void { + session.append('request/header', { + header: { + config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + system: `Synthetic chat-scroll request for turn ${String(turn)}, step ${String(step)}.`, + }, + reason: turn === 1 && step === 1 ? 'initial' : 'change', + }) +} + +function appendAssistant(session: Session, turn: number, step: number, body: string): void { + session.append('assistant/message', { + turn, + step, + message: createAssistantMessage({ + content: text(body), + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + usage: { + inputTokens: 2_000 + turn * 7, + outputTokens: 180 + step * 20, + }, + }, { surfaceOp: 'append' }) +} + +function codeBlock(turn: number): string { + if (turn % CODE_INTERVAL !== 0) return '' + const lines = Array.from( + { length: 30 }, + (_, index) => `const scroll_case_${suffix(turn)}_${String(index).padStart(2, '0')} = ${String(turn + index)}`, + ) + return `\n\n\`\`\`ts\n${lines.join('\n')}\n\`\`\`` +} + +function appendToolStep( + session: Session, + markers: ChatScrollMarkers, + turn: number, +): void { + const calls = [1, 2].map((index) => { + const marker = markers.tool(turn, index) + const callId = CallId(`chat-scroll-${suffix(turn)}-${String(index)}`) + const args = JSON.stringify({ + command: `printf '${marker}\\n'`, + description: marker, + }) + return { args, callId, marker } + }) + + session.append('assistant/message', { + turn, + step: 1, + message: createAssistantMessage({ + content: [ + { type: 'reasoning', text: `Inspecting two scroll fixtures for turn ${String(turn)}.` }, + ...calls.map(call => ({ + type: 'tool-call' as const, + id: call.callId, + name: 'bash', + arguments: call.args, + })), + ], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + usage: { inputTokens: 2_000 + turn * 7, outputTokens: 240, reasoningTokens: 30 }, + }, { surfaceOp: 'append' }) + + for (const call of calls) { + const source = session.append('tool/call', { + turn, + step: 1, + callId: call.callId, + name: 'bash', + arguments: call.args, + }) + session.append('tool/result', { + turn, + step: 1, + message: createToolResultMessage({ + callId: call.callId, + content: text(Array.from( + { length: 12 }, + (_, line) => `${call.marker} output line ${String(line + 1).padStart(2, '0')}`, + ).join('\n')), + isError: false, + }), + }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] }) + } +} + +function fixtureLog(session: Session): string { + return [ + JSON.stringify({ + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: Date.now() - 60_000, + cwd: '{{cwd}}', + delegationDepth: 0, + }), + ...session.events.map(event => JSON.stringify(event)), + '', + ].join('\n') +} + +/** + * Build a multi-page conversation with prose, fenced code, and paired bash + * calls/results. Every turn is closed, so cold resume cannot repair or mutate + * the seed before the browser observes it. + * @param options - Fixture identity and optional turn count. + * @returns Canonical JSONL and semantic marker helpers. + */ +export function createChatScrollFixture(options: ChatScrollFixtureOptions): ChatScrollFixture { + const turns = options.turns ?? DEFAULT_TURNS + const markers = markerHelpers(options.markerPrefix) + const session = new Session(SessionId(`chat-scroll-${options.markerPrefix.toLowerCase()}-template`)) + + for (let turn = 1; turn <= turns; turn += 1) { + session.append('turn/start', { + turn, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + const user = session.append('user/message', createUserMessage({ + content: text( + `${markers.user(turn)} Review the long-running conversation state for turn ${String(turn)}. ` + + 'Keep the visible message stable while history, tools, and new output change around it.', + ), + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + if (turn === 1) { + session.append('session/title', { + title: options.title, + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + } + + session.append('step/start', { turn, step: 1 }) + appendRequestHeader(session, turn, 1) + if (turn % TOOL_INTERVAL === 0) { + appendToolStep(session, markers, turn) + session.append('step/end', { turn, step: 1 }) + session.append('step/start', { turn, step: 2 }) + appendRequestHeader(session, turn, 2) + appendAssistant( + session, + turn, + 2, + `${markers.assistant(turn)} Both tool results are accounted for. ` + + `This settled response keeps turn ${String(turn)} identifiable after paging.${codeBlock(turn)}`, + ) + session.append('step/end', { turn, step: 2 }) + } else { + appendAssistant( + session, + turn, + 1, + `${markers.assistant(turn)} The conversation remains readable after several paragraphs.\n\n` + + `Turn ${String(turn)} deliberately carries enough prose to wrap at narrower viewport widths. ` + + 'The semantic marker stays near the start so geometry probes can find the same rendered row.\n\n' + + `The closing paragraph makes this a realistic assistant response rather than a one-line list item.${codeBlock(turn)}`, + ) + session.append('step/end', { turn, step: 1 }) + } + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + + return { log: fixtureLog(session), markers, title: options.title, turns } +} diff --git a/apps/web/tests/complex-history.perf.ts b/apps/web/tests/complex-history.perf.ts new file mode 100644 index 0000000000..f2c8c5332d --- /dev/null +++ b/apps/web/tests/complex-history.perf.ts @@ -0,0 +1,1434 @@ +// Opt-in browser benchmark for high-cardinality workspace and history +// rendering. It reports measurements without timing assertions because host +// speed is not a correctness contract; structural assertions keep the load +// shape from silently shrinking. +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import type { Browser, CDPSession, Locator, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import { + CallId, + createAssistantMessage, + createToolResultMessage, + createUserMessage, +} from '@deepseek-ai/dsh-llm' +import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + SESSION_FORMAT_VERSION, + Session, + SessionId, +} from '@deepseek-ai/dsh-session' +// Carries the session/title event declaration into the fixture builder. +import type {} from '@deepseek-ai/dsh-session-title' +import { + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage } from './support.ts' + +const SIDEBAR_SESSION_COUNT = 1_000 +const LONG_SESSION_ID = 'perf-long-history' +const LONG_SESSION_TITLE = 'LONG_PERF_SENTINEL 500-turn session' +const LONG_HISTORY_TURNS = 500 +const TOOL_TURN_INTERVAL = 10 +const TOOLS_PER_TOOL_TURN = 10 +const EXPECTED_TOOL_CALLS = LONG_HISTORY_TURNS / TOOL_TURN_INTERVAL * TOOLS_PER_TOOL_TURN +const EXPECTED_TRAJECTORY_ROWS = 2_100 +const DEFAULT_HISTORY_TURNS = 24 +const PERF_REPLAY_CONTEXT_WINDOW = 10_000_000 +const STREAM_PACE_MS = 8 +const STREAM_DELTA_COUNT = 120 +const COMPARISON_TURNS = 8 +const COMPARISON_DELTA_COUNT = 24 +const COMPARISON_TOOL_INTERVAL = 3 +const SOAK_TURNS = 100 +const POST_SOAK_RENDER_TURN = SOAK_TURNS + 1 +const SOAK_DELTA_COUNT = 8 +const SOAK_TOOL_INTERVAL = 10 +const SOAK_CHECKPOINT_INTERVAL = 10 +const LONG_CONTINUATION_USER_PREFIX = 'LONG_CONTINUATION_USER' +const LONG_CONTINUATION_FIRST_PREFIX = 'LONG_CONTINUATION_FIRST' +const LONG_CONTINUATION_DONE_PREFIX = 'LONG_CONTINUATION_DONE' +const SOAK_USER_PREFIX = 'SOAK_CONVERSATION_USER' +const SOAK_FIRST_PREFIX = 'SOAK_CONVERSATION_FIRST' +const SOAK_DONE_PREFIX = 'SOAK_CONVERSATION_DONE' +const LIVE_PROMPT_MARKER = 'STREAM_PERF_USER_INPUT' +const STREAM_FIRST_MARKER = 'STREAM_PERF_FIRST' +const STREAM_DONE_MARKER = 'STREAM_PERF_DONE' +const LIVE_PROMPT = [ + LIVE_PROMPT_MARKER, + 'Analyze the following mixed-language project context and return a concise diagnostic.', + ...Array.from( + { length: 48 }, + (_, index) => + `Context ${String(index + 1).padStart(2, '0')}: 用户正在检查长会话中的增量渲染性能。` + + ` Preserve item ${String(index)} and compare ${'payload'.repeat(8)}.`, + ), + '```ts', + ...Array.from( + { length: 40 }, + (_, index) => `const sample_${String(index)} = ${JSON.stringify(`value-${String(index)}-${'x'.repeat(32)}`)}`, + ), + '```', +].join('\n') +const STREAM_DELTAS = Array.from({ length: STREAM_DELTA_COUNT }, (_, index) => { + if (index === 0) return `${STREAM_FIRST_MARKER} ` + if (index === STREAM_DELTA_COUNT - 1) return `${STREAM_DONE_MARKER}.` + return `chunk-${String(index).padStart(3, '0')} ${'response'.repeat(3)} ` +}) + +interface ChromiumMetrics { + readonly [name: string]: number +} + +interface Measurement { + readonly wallMs: number + readonly taskMs: number + readonly scriptMs: number + readonly layoutMs: number + readonly recalcStyleMs: number + readonly devtoolsMs: number + readonly nodesDelta: number + readonly listenersDelta: number + readonly heapDeltaMb: number + readonly totalNodes: number + readonly heapMb: number +} + +interface MutationProbeResult { + readonly batches: number + readonly records: number +} + +interface UserRenderProbeResult { + readonly trustedClick: boolean + readonly sendToDomMs: number + readonly sendToPaintMs: number + readonly domToPaintMs: number + readonly mutationBatches: number + readonly mutationRecords: number +} + +interface RetainedBrowserState { + readonly domElements: number + readonly nodes: number + readonly listeners: number + readonly heapMb: number +} + +interface ContinuedTurnReport { + readonly ordinal: number + readonly resultingTurns: number + readonly kind: 'text' | 'tool' + readonly promptChars: number + readonly composerFill: Measurement + readonly stream: MutationProbeResult & Measurement & { + readonly paceMs: number + readonly deltaChunks: number + readonly persistedChunks: number + readonly toolCalls: number + readonly toolResults: number + readonly clickToUserEchoMs: number + readonly clickToFirstChunkMs: number + readonly firstChunkToSettledMs: number + } +} + +interface ConversationTurnSpec { + readonly prompt: string + readonly deltas: readonly string[] + readonly userMarker: string + readonly firstMarker: string + readonly doneMarker: string + readonly toolResultMarker?: string +} + +interface RetainedCheckpoint { + readonly turns: number + readonly state: RetainedBrowserState +} + +interface ConversationReport { + readonly startingTurns: number + readonly turnsAdded: number + readonly toolTurns: number + readonly retainedBefore: RetainedBrowserState + readonly retainedAfter: RetainedBrowserState + readonly retainedDelta: RetainedBrowserState + readonly checkpoints: readonly RetainedCheckpoint[] + readonly turns: readonly ContinuedTurnReport[] +} + +interface PerformanceWorld { + readonly scaffold: WebScaffold + readonly page: Page + readonly tripwire: ReturnType + readonly sessionEvents: SessionEvent[] + readonly setupMs: number + readonly replayDir?: string +} + +interface PerformanceWorldOptions { + readonly browser: Browser + readonly replay?: ReplayOverrideDoc + readonly sidebarSessions?: number + readonly seedLongHistory?: boolean +} + +function text(value: string): { type: 'text'; text: string }[] { + return [{ type: 'text', text: value }] +} + +function appendTitle(session: Session, title: string, messageSeq: number): void { + session.append('session/title', { + title, + messageSeqs: [messageSeq], + source: { kind: 'fallback' }, + }) +} + +function appendRequestHeader(session: Session, turn: number, step: number): void { + session.append('request/header', { + header: { + config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + system: `Synthetic performance system prompt for turn ${String(turn)}, step ${String(step)}.`, + }, + reason: turn === 1 && step === 1 ? 'initial' : 'change', + }) +} + +function appendAssistant( + session: Session, + turn: number, + step: number, + body: string, +): void { + session.append('assistant/message', { + turn, + step, + message: createAssistantMessage({ + content: text(body), + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + usage: { + inputTokens: 4_000 + turn * 10, + outputTokens: 200 + step * 20, + cacheReadTokens: turn % 2 === 0 ? 2_000 : 0, + }, + }, { surfaceOp: 'append' }) +} + +function appendToolStep( + session: Session, + turn: number, + step: number, + toolCount: number, +): void { + const calls = Array.from({ length: toolCount }, (_, index) => { + const callId = CallId(`perf-call-${String(turn)}-${String(index)}`) + const args = JSON.stringify({ + turn, + index, + payload: 'x'.repeat(120), + }) + return { callId, index, args } + }) + + session.append('assistant/message', { + turn, + step, + message: createAssistantMessage({ + content: [ + { + type: 'reasoning', + text: `Dispatching ${String(toolCount)} synthetic tools for turn ${String(turn)}.`, + }, + ...calls.map(({ callId, args }) => ({ + type: 'tool-call' as const, + id: callId, + name: 'synthetic_tool', + arguments: args, + })), + ], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + usage: { + inputTokens: 6_000 + turn * 10, + outputTokens: 500, + cacheReadTokens: 3_000, + reasoningTokens: 50, + }, + }, { surfaceOp: 'append' }) + + const callEvents = calls.map(({ callId, args }) => + session.append('tool/call', { + turn, + step, + callId, + name: 'synthetic_tool', + arguments: args, + })) + + for (const [index, call] of calls.entries()) { + const source = callEvents[index] + if (source === undefined) throw new Error(`missing synthetic tool call ${String(index)}`) + session.append('tool/result', { + turn, + step, + message: createToolResultMessage({ + callId: call.callId, + content: text( + `synthetic result turn=${String(turn)} index=${String(call.index)} ${'r'.repeat(400)}`, + ), + isError: false, + }), + }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] }) + } +} + +function fencedCode(turn: number): string { + if (turn % 25 !== 0) return '' + const lines = Array.from( + { length: 80 }, + (_, index) => `const value_${String(index)} = ${String(turn + index)}`, + ) + return `\n\n\`\`\`ts\n${lines.join('\n')}\n\`\`\`` +} + +function fixtureLog(session: Session): string { + const header = { + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: Date.now() - 60_000, + cwd: '{{cwd}}', + } + return [ + JSON.stringify(header), + ...session.events.map(event => JSON.stringify(event)), + '', + ].join('\n') +} + +function smallSidebarFixture(): string { + const session = new Session(SessionId('perf-small-template')) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + const user = session.append('user/message', createUserMessage({ + content: text('Inspect this compact synthetic session.'), + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + appendTitle(session, 'Synthetic sidebar session', user.seq) + session.append('step/start', { turn: 1, step: 1 }) + appendRequestHeader(session, 1, 1) + appendToolStep(session, 1, 1, 2) + session.append('step/end', { turn: 1, step: 1 }) + session.append('step/start', { turn: 1, step: 2 }) + appendRequestHeader(session, 1, 2) + appendAssistant(session, 1, 2, 'Synthetic sidebar fixture complete.') + session.append('step/end', { turn: 1, step: 2 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return fixtureLog(session) +} + +function longHistoryFixture(): string { + const session = new Session(SessionId(LONG_SESSION_ID)) + for (let turn = 1; turn <= LONG_HISTORY_TURNS; turn += 1) { + session.append('turn/start', { + turn, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + const user = session.append('user/message', createUserMessage({ + content: text( + `LONG_PERF_SENTINEL turn ${String(turn)}: analyze payload ${'u'.repeat(200)}`, + ), + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + if (turn === 1) appendTitle(session, LONG_SESSION_TITLE, user.seq) + + session.append('step/start', { turn, step: 1 }) + appendRequestHeader(session, turn, 1) + if (turn % TOOL_TURN_INTERVAL === 0) { + appendToolStep(session, turn, 1, TOOLS_PER_TOOL_TURN) + session.append('step/end', { turn, step: 1 }) + session.append('step/start', { turn, step: 2 }) + appendRequestHeader(session, turn, 2) + appendAssistant( + session, + turn, + 2, + `All synthetic tools completed for turn ${String(turn)}. ${'z'.repeat(320)}${fencedCode(turn)}`, + ) + session.append('step/end', { turn, step: 2 }) + } else { + appendAssistant( + session, + turn, + 1, + `Synthetic assistant response for turn ${String(turn)}. ${'a'.repeat(320)}${fencedCode(turn)}`, + ) + session.append('step/end', { turn, step: 1 }) + } + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + return fixtureLog(session) +} + +function textStream(deltas: readonly string[], inputTokens: number): StreamChunk[] { + const response = deltas.join('') + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + ...deltas.map(text => ({ + type: 'text-delta' as const, + index: 0, + text, + })), + { + type: 'block-end', + index: 0, + block: { type: 'text', text: response }, + }, + { + type: 'usage', + usage: { + inputTokens, + outputTokens: Math.ceil(response.length / 4), + }, + }, + { type: 'finish', reason: { kind: 'stop' } }, + ] +} + +function comparisonPrompt(index: number): string { + if (index === COMPARISON_TURNS) return LIVE_PROMPT + return (`${LONG_CONTINUATION_USER_PREFIX}_${String(index)} ` + + `继续分析这个长会话的第 ${String(index)} 个增量问题,并保留当前滚动和输入响应。 ` + + 'context '.repeat(80)).trimEnd() +} + +function comparisonDeltas(index: number): string[] { + if (index === COMPARISON_TURNS) return STREAM_DELTAS + return Array.from({ length: COMPARISON_DELTA_COUNT }, (_, chunkIndex) => { + if (chunkIndex === 0) return `${LONG_CONTINUATION_FIRST_PREFIX}_${String(index)} ` + if (chunkIndex === COMPARISON_DELTA_COUNT - 1) { + return `${LONG_CONTINUATION_DONE_PREFIX}_${String(index)}.` + } + return `turn-${String(index)}-chunk-${String(chunkIndex).padStart(2, '0')} ${'response'.repeat(2)} ` + }) +} + +function comparisonTurn(index: number): ConversationTurnSpec { + const toolResultMarker = index % COMPARISON_TOOL_INTERVAL === 0 + ? `LONG_CONTINUATION_TOOL_RESULT_${String(index)}` + : undefined + return { + prompt: comparisonPrompt(index), + deltas: comparisonDeltas(index), + userMarker: index === COMPARISON_TURNS + ? LIVE_PROMPT_MARKER + : `${LONG_CONTINUATION_USER_PREFIX}_${String(index)}`, + firstMarker: index === COMPARISON_TURNS + ? STREAM_FIRST_MARKER + : `${LONG_CONTINUATION_FIRST_PREFIX}_${String(index)}`, + doneMarker: index === COMPARISON_TURNS + ? STREAM_DONE_MARKER + : `${LONG_CONTINUATION_DONE_PREFIX}_${String(index)}`, + ...toolResultMarker === undefined ? {} : { toolResultMarker }, + } +} + +function soakTurn(index: number): ConversationTurnSpec { + const suffix = String(index).padStart(3, '0') + const toolResultMarker = index % SOAK_TOOL_INTERVAL === 0 + ? `SOAK_CONVERSATION_TOOL_RESULT_${suffix}` + : undefined + return { + prompt: (`${SOAK_USER_PREFIX}_${suffix} ` + + `持续对话第 ${String(index)} 轮,检查增量渲染与保留状态。 ` + + 'context '.repeat(20)).trimEnd(), + deltas: Array.from({ length: SOAK_DELTA_COUNT }, (_, chunkIndex) => { + if (chunkIndex === 0) return `${SOAK_FIRST_PREFIX}_${suffix} ` + if (chunkIndex === SOAK_DELTA_COUNT - 1) return `${SOAK_DONE_PREFIX}_${suffix}.` + return `soak-${suffix}-${String(chunkIndex).padStart(2, '0')} response ` + }), + userMarker: `${SOAK_USER_PREFIX}_${suffix}`, + firstMarker: `${SOAK_FIRST_PREFIX}_${suffix}`, + doneMarker: `${SOAK_DONE_PREFIX}_${suffix}`, + ...toolResultMarker === undefined ? {} : { toolResultMarker }, + } +} + +function toolStream(index: number, marker: string): StreamChunk[] { + const callId = CallId(`performance-tool-${marker.toLowerCase()}-${String(index)}`) + const args = JSON.stringify({ + command: `printf '${marker}\\n'`, + description: `Emit performance marker ${String(index)}`, + }) + return [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { + type: 'tool-call-delta', + index: 0, + id: callId, + name: 'bash', + argumentsDelta: args, + }, + { + type: 'block-end', + index: 0, + block: { type: 'tool-call', id: callId, name: 'bash', arguments: args }, + }, + { type: 'usage', usage: { inputTokens: 256, outputTokens: 32 } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] +} + +function performanceReplayOverride( + turnCount: number, + turnSpec: (index: number) => ConversationTurnSpec, +): ReplayOverrideDoc { + const continuationEntries = Array.from( + { length: turnCount }, + (_, offset): ReplayEntry[] => { + const index = offset + 1 + const spec = turnSpec(index) + const finalResponse: ReplayEntry = { + kind: 'chunks', + chunks: textStream(spec.deltas, Math.ceil(spec.prompt.length / 4)), + } + return spec.toolResultMarker === undefined + ? [finalResponse] + : [{ kind: 'chunks', chunks: toolStream(index, spec.toolResultMarker) }, finalResponse] + }, + ).flat() + return continuationEntries +} + +function rounded(value: number): number { + return Math.round(value * 1_000) / 1_000 +} + +async function chromiumMetrics(cdp: CDPSession): Promise { + const payload = await cdp.send('Performance.getMetrics') + return Object.fromEntries(payload.metrics.map(metric => [metric.name, metric.value])) +} + +async function retainedBrowserState( + cdp: CDPSession, + page: Page, +): Promise { + await cdp.send('HeapProfiler.collectGarbage') + const metrics = await chromiumMetrics(cdp) + return { + domElements: await page.evaluate(() => document.querySelectorAll('*').length), + nodes: requiredMetric(metrics, 'Nodes'), + listeners: requiredMetric(metrics, 'JSEventListeners'), + heapMb: rounded(requiredMetric(metrics, 'JSHeapUsedSize') / 1_048_576), + } +} + +function requiredMetric(metrics: ChromiumMetrics, name: string): number { + const value = metrics[name] + if (value === undefined) throw new Error(`Chromium performance metric ${name} is unavailable`) + return value +} + +function metricDelta( + before: ChromiumMetrics, + after: ChromiumMetrics, + wallMs: number, +): Measurement { + return { + wallMs: rounded(wallMs), + taskMs: rounded((requiredMetric(after, 'TaskDuration') - requiredMetric(before, 'TaskDuration')) * 1_000), + scriptMs: rounded((requiredMetric(after, 'ScriptDuration') - requiredMetric(before, 'ScriptDuration')) * 1_000), + layoutMs: rounded((requiredMetric(after, 'LayoutDuration') - requiredMetric(before, 'LayoutDuration')) * 1_000), + recalcStyleMs: rounded( + (requiredMetric(after, 'RecalcStyleDuration') - requiredMetric(before, 'RecalcStyleDuration')) * 1_000, + ), + devtoolsMs: rounded( + (requiredMetric(after, 'DevToolsCommandDuration') - requiredMetric(before, 'DevToolsCommandDuration')) * 1_000, + ), + nodesDelta: requiredMetric(after, 'Nodes') - requiredMetric(before, 'Nodes'), + listenersDelta: requiredMetric(after, 'JSEventListeners') - requiredMetric(before, 'JSEventListeners'), + heapDeltaMb: rounded( + (requiredMetric(after, 'JSHeapUsedSize') - requiredMetric(before, 'JSHeapUsedSize')) / 1_048_576, + ), + totalNodes: requiredMetric(after, 'Nodes'), + heapMb: rounded(requiredMetric(after, 'JSHeapUsedSize') / 1_048_576), + } +} + +async function measure( + cdp: CDPSession, + action: () => Promise, +): Promise<{ measurement: Measurement; value: T }> { + const before = await chromiumMetrics(cdp) + const started = performance.now() + const value = await action() + const wallMs = performance.now() - started + const after = await chromiumMetrics(cdp) + return { measurement: metricDelta(before, after, wallMs), value } +} + +async function startMutationProbe(page: Page): Promise { + await page.evaluate(() => { + const target = document.querySelector('[class*="centerCol"]') + if (target === null) throw new Error('stream mutation probe target is unavailable') + const probe = { + batches: 0, + records: 0, + observer: undefined as MutationObserver | undefined, + } + const observer = new MutationObserver((records) => { + probe.batches += 1 + probe.records += records.length + }) + probe.observer = observer + observer.observe(target, { + attributes: true, + attributeFilter: ['data-streaming'], + characterData: true, + childList: true, + subtree: true, + }) + Reflect.set(globalThis, '__dshPerfMutationProbe', probe) + }) +} + +async function stopMutationProbe(page: Page): Promise { + return page.evaluate(() => { + const probe = Reflect.get(globalThis, '__dshPerfMutationProbe') as + | { batches: number; records: number; observer: MutationObserver } + | undefined + if (probe === undefined) throw new Error('stream mutation probe was not started') + probe.observer.disconnect() + Reflect.deleteProperty(globalThis, '__dshPerfMutationProbe') + return { batches: probe.batches, records: probe.records } + }) +} + +async function startUserRenderProbe( + page: Page, + marker: string, +): Promise { + const send = page.getByRole('button', { name: 'Send message', exact: true }) + await send.waitFor({ timeout: 15_000 }) + await expect.poll(() => send.isEnabled(), { timeout: 15_000 }).toBe(true) + await page.evaluate((expectedMarker) => { + const target = document.querySelector('[data-conversation-scroll]') + if (target === null) throw new Error('user render probe target is unavailable') + const probe: { + sendAt?: number + domAt?: number + paintAt?: number + trustedClick?: boolean + batches: number + records: number + observer?: MutationObserver + pollForMarker?: () => void + } = { batches: 0, records: 0 } + const transcriptContainsMarker = (): boolean => { + // The composer projects the draft into a backdrop and hidden sizing + // mirror; only a matching text node outside that card proves delivery. + const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT) + for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) { + if (!node.textContent?.includes(expectedMarker)) continue + const parent = node.parentElement + if (parent !== null && parent.closest('[data-composer-card]') === null) return true + } + return false + } + const markDomVisible = (): void => { + if ( + probe.sendAt === undefined + || probe.domAt !== undefined + || !transcriptContainsMarker() + ) return + probe.domAt = globalThis.performance.now() + // The second callback runs only after a render opportunity for the DOM + // insertion observed before the first callback. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + probe.paintAt = globalThis.performance.now() + observer.disconnect() + }) + }) + } + const pollForMarker = (): void => { + markDomVisible() + if (probe.domAt === undefined) requestAnimationFrame(pollForMarker) + } + const observer = new MutationObserver((records) => { + if (probe.sendAt === undefined) return + probe.batches += 1 + probe.records += records.length + markDomVisible() + }) + probe.observer = observer + probe.pollForMarker = pollForMarker + observer.observe(target, { + attributes: true, + attributeFilter: ['data-streaming'], + characterData: true, + childList: true, + subtree: true, + }) + Reflect.set(globalThis, '__dshPerfUserRenderProbe', probe) + }, marker) + await send.evaluate((button) => { + button.addEventListener('click', (event) => { + const probe = Reflect.get(globalThis, '__dshPerfUserRenderProbe') as + | { sendAt?: number; trustedClick?: boolean; pollForMarker?: () => void } + | undefined + if (probe?.pollForMarker === undefined) throw new Error('user render probe was not started') + probe.trustedClick = event.isTrusted + probe.sendAt = globalThis.performance.now() + requestAnimationFrame(probe.pollForMarker) + }, { capture: true, once: true }) + }) +} + +async function triggerUserRenderProbe(page: Page): Promise { + const send = page.getByRole('button', { name: 'Send message', exact: true }) + await send.click() +} + +async function stopUserRenderProbe( + page: Page, + marker: string, +): Promise { + try { + await page.waitForFunction(() => { + const probe = Reflect.get(globalThis, '__dshPerfUserRenderProbe') as + | { paintAt?: number } + | undefined + return probe?.paintAt !== undefined + }, undefined, { timeout: 15_000 }) + } catch (error) { + const diagnostic = await page.evaluate((expectedMarker) => { + const probe = Reflect.get(globalThis, '__dshPerfUserRenderProbe') as + | { + sendAt?: number + domAt?: number + paintAt?: number + trustedClick?: boolean + batches: number + records: number + } + | undefined + const target = document.querySelector('[data-conversation-scroll]') + let markerOutsideComposer = false + if (target !== null) { + const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT) + for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) { + if (!node.textContent?.includes(expectedMarker)) continue + const parent = node.parentElement + if (parent !== null && parent.closest('[data-composer-card]') === null) { + markerOutsideComposer = true + break + } + } + } + return { + probe, + markerOutsideComposer, + markerInComposer: document.querySelector('[data-composer-card]') + ?.textContent?.includes(expectedMarker) ?? false, + } + }, marker) + throw new Error(`user render probe timed out: ${JSON.stringify(diagnostic)}`, { cause: error }) + } + return page.evaluate(() => { + const probe = Reflect.get(globalThis, '__dshPerfUserRenderProbe') as + | { + sendAt?: number + domAt?: number + paintAt?: number + trustedClick?: boolean + batches: number + records: number + } + | undefined + Reflect.deleteProperty(globalThis, '__dshPerfUserRenderProbe') + if ( + probe?.trustedClick !== true + || probe.sendAt === undefined + || probe.domAt === undefined + || probe.paintAt === undefined + ) { + throw new Error('user render probe did not observe a trusted click, DOM insertion, and paint') + } + return { + trustedClick: probe.trustedClick, + sendToDomMs: probe.domAt - probe.sendAt, + sendToPaintMs: probe.paintAt - probe.sendAt, + domToPaintMs: probe.paintAt - probe.domAt, + mutationBatches: probe.batches, + mutationRecords: probe.records, + } + }) +} + +async function stableCount( + locator: Locator, + accepts: (count: number) => boolean, + timeoutMs = 60_000, +): Promise { + const deadline = performance.now() + timeoutMs + let previous = -1 + let stableReads = 0 + while (performance.now() < deadline) { + const count = await locator.count() + stableReads = accepts(count) && count === previous ? stableReads + 1 : 0 + if (stableReads >= 4) return count + previous = count + await new Promise(resolve => setTimeout(resolve, 50)) + } + throw new Error(`browser row count did not stabilize; last count ${String(previous)}`) +} + +async function conversationTurns(page: Page): Promise { + const stats = page.getByText(/\d+ turns · \d+ steps/, { exact: true }).last() + await stats.waitFor({ timeout: 15_000 }) + const value = await stats.textContent() + const match = value?.match(/^(\d+) turns · \d+ steps$/) + if (match?.[1] === undefined) throw new Error(`unexpected conversation stats ${JSON.stringify(value)}`) + return Number(match[1]) +} + +function retainedDelta( + before: RetainedBrowserState, + after: RetainedBrowserState, +): RetainedBrowserState { + return { + domElements: after.domElements - before.domElements, + nodes: after.nodes - before.nodes, + listeners: after.listeners - before.listeners, + heapMb: rounded(after.heapMb - before.heapMb), + } +} + +async function launchPerformanceWorld( + options: PerformanceWorldOptions, +): Promise { + const setupStarted = performance.now() + let replayDir: string | undefined + let scaffold: WebScaffold | undefined + let page: Page | undefined + try { + if (options.replay === undefined) { + scaffold = await launchWebScaffold() + } else { + replayDir = await mkdtemp(join(tmpdir(), 'dsh-web-perf-replay-')) + const replayOverride = join(replayDir, 'replay.override.json') + await writeFile(replayOverride, JSON.stringify(options.replay)) + scaffold = await launchWebScaffold({ + // The override-only fixture supplies one positional script for this + // world's single live session. + replayFixture: join(replayDir, 'override-only.jsonl'), + replayOverride, + paceMs: STREAM_PACE_MS, + replayContextWindow: PERF_REPLAY_CONTEXT_WINDOW, + }) + } + + const sessionEvents: SessionEvent[] = [] + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { + sessionEvents.push(event) + }) + if ((options.sidebarSessions ?? 0) > 0) { + const small = smallSidebarFixture() + for (let index = 0; index < (options.sidebarSessions ?? 0); index += 1) { + await seedSession(scaffold, small, `perf-sidebar-${String(index).padStart(4, '0')}`) + } + } + if (options.seedLongHistory === true) { + await seedSession(scaffold, longHistoryFixture(), LONG_SESSION_ID) + } + const setupMs = performance.now() - setupStarted + page = await newEnglishPage(options.browser) + return { + scaffold, + page, + tripwire: watchConsole(page), + sessionEvents, + setupMs, + ...replayDir === undefined ? {} : { replayDir }, + } + } catch (error) { + const failures: unknown[] = [error] + if (page !== undefined) { + try { + await page.close() + } catch (cleanupError) { + failures.push(cleanupError) + } + } + if (scaffold !== undefined) { + try { + await scaffold.close() + } catch (cleanupError) { + failures.push(cleanupError) + } + } + if (replayDir !== undefined) { + try { + await rm(replayDir, { recursive: true, force: true }) + } catch (cleanupError) { + failures.push(cleanupError) + } + } + if (failures.length === 1) throw error + throw new AggregateError(failures, 'web performance setup and cleanup failed') + } +} + +async function closePerformanceWorld(world: PerformanceWorld): Promise { + const failures: unknown[] = [] + await world.page.close().catch((error: unknown) => failures.push(error)) + await world.scaffold.close().catch((error: unknown) => failures.push(error)) + if (world.replayDir !== undefined) { + await rm(world.replayDir, { recursive: true, force: true }) + .catch((error: unknown) => failures.push(error)) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'web performance teardown failed') +} + +async function openPerformancePage( + world: PerformanceWorld, + expectedSessions: number, +): Promise { + await world.page.goto(world.scaffold.baseUrl, { waitUntil: 'load' }) + await world.page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const group = world.page.getByRole('treeitem').first() + await expect.poll(() => group.textContent(), { timeout: 30_000 }) + .toContain(`${String(expectedSessions)} ${expectedSessions === 1 ? 'session' : 'sessions'}`) + return group +} + +async function openLongHistory(page: Page): Promise { + await page.getByRole('textbox', { name: 'Search name, keywords...', exact: true }) + .fill('LONG_PERF_SENTINEL') + const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1) + await results.first().click() + await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 }) + return conversationTurns(page) +} + +async function continueConversation( + world: PerformanceWorld, + cdp: CDPSession, + options: { + readonly startingTurns: number + readonly turnCount: number + readonly turnSpec: (index: number) => ConversationTurnSpec + readonly expectedSessionId?: SessionId + readonly checkpointInterval?: number + }, +): Promise { + const composer = world.page.locator('textarea:enabled').last() + await composer.waitFor({ timeout: 15_000 }) + const retainedBefore = await retainedBrowserState(cdp, world.page) + const checkpoints: RetainedCheckpoint[] = [{ turns: options.startingTurns, state: retainedBefore }] + const turns: ContinuedTurnReport[] = [] + let liveSessionId = options.expectedSessionId + + for (let index = 1; index <= options.turnCount; index += 1) { + const spec = options.turnSpec(index) + const composerFill = await measure(cdp, async () => { + await composer.fill(spec.prompt) + await expect.poll(() => composer.inputValue()).toBe(spec.prompt) + return (await composer.inputValue()).length + }) + expect(composerFill.value).toBe(spec.prompt.length) + + const eventStart = world.sessionEvents.length + await startMutationProbe(world.page) + const streamBefore = await chromiumMetrics(cdp) + const streamStarted = performance.now() + const settled = world.scaffold.whenTurnSettled(60_000) + await world.page.getByRole('button', { name: 'Send message', exact: true }).click() + await world.page.getByText(spec.userMarker, { exact: false }).last().waitFor({ timeout: 15_000 }) + const clickToUserEchoMs = performance.now() - streamStarted + await world.page.getByText(spec.firstMarker, { exact: false }).last().waitFor({ timeout: 15_000 }) + const clickToFirstChunkMs = performance.now() - streamStarted + const settledSessionId = await settled + if (liveSessionId === undefined) { + liveSessionId = settledSessionId + } else { + expect(settledSessionId).toBe(liveSessionId) + } + await expect.poll( + () => world.page.locator('[data-streaming="true"]').count(), + { timeout: 15_000 }, + ).toBe(0) + await world.page.getByText(spec.doneMarker, { exact: false }).last().waitFor({ timeout: 15_000 }) + const clickToSettledMs = performance.now() - streamStarted + const streamAfter = await chromiumMetrics(cdp) + const mutations = await stopMutationProbe(world.page) + const turnEvents = world.sessionEvents.slice(eventStart) + const chunks = turnEvents.filter(event => event.type === 'assistant/chunk') + const toolCalls = turnEvents.filter(event => event.type === 'tool/call') + const toolResults = turnEvents.filter(event => event.type === 'tool/result') + const toolTurn = spec.toolResultMarker !== undefined + expect(chunks).toHaveLength(spec.deltas.length + (toolTurn ? 9 : 4)) + expect(toolCalls).toHaveLength(toolTurn ? 1 : 0) + expect(toolResults).toHaveLength(toolTurn ? 1 : 0) + if (spec.toolResultMarker !== undefined) { + expect(toolCalls[0]?.data.name).toBe('bash') + const toolResult = toolResults[0] + if (toolResult?.type !== 'tool/result') { + throw new Error(`continued turn ${String(index)} did not log its tool result`) + } + const resultBlock = toolResult.data.message.content.find(block => block.type === 'tool-result') + expect(resultBlock?.isError).toBe(false) + expect(resultBlock?.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('')).toContain(spec.toolResultMarker) + } + const user = turnEvents.find( + event => event.type === 'user/message' && event.data.source.kind === 'user', + ) + if (user?.type !== 'user/message') { + throw new Error(`continued turn ${String(index)} did not log its user message`) + } + expect(user.data.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('')).toBe(spec.prompt) + const resultingTurns = await conversationTurns(world.page) + expect(resultingTurns).toBe(options.startingTurns + index) + turns.push({ + ordinal: index, + resultingTurns, + kind: toolTurn ? 'tool' : 'text', + promptChars: spec.prompt.length, + composerFill: composerFill.measurement, + stream: { + paceMs: STREAM_PACE_MS, + deltaChunks: spec.deltas.length, + persistedChunks: chunks.length, + toolCalls: toolCalls.length, + toolResults: toolResults.length, + clickToUserEchoMs: rounded(clickToUserEchoMs), + clickToFirstChunkMs: rounded(clickToFirstChunkMs), + firstChunkToSettledMs: rounded(clickToSettledMs - clickToFirstChunkMs), + ...mutations, + ...metricDelta(streamBefore, streamAfter, clickToSettledMs), + }, + }) + + if (options.checkpointInterval !== undefined && index % options.checkpointInterval === 0) { + checkpoints.push({ + turns: options.startingTurns + index, + state: await retainedBrowserState(cdp, world.page), + }) + } + } + + const lastCheckpoint = checkpoints.at(-1) + const retainedAfter = lastCheckpoint?.turns === options.startingTurns + options.turnCount + ? lastCheckpoint.state + : await retainedBrowserState(cdp, world.page) + if (lastCheckpoint?.turns !== options.startingTurns + options.turnCount) { + checkpoints.push({ turns: options.startingTurns + options.turnCount, state: retainedAfter }) + } + return { + startingTurns: options.startingTurns, + turnsAdded: options.turnCount, + toolTurns: turns.filter(turn => turn.kind === 'tool').length, + retainedBefore, + retainedAfter, + retainedDelta: retainedDelta(retainedBefore, retainedAfter), + checkpoints, + turns, + } +} + +async function measurePostSoakUserRender( + world: PerformanceWorld, + cdp: CDPSession, +): Promise { + const spec = soakTurn(POST_SOAK_RENDER_TURN) + if (spec.toolResultMarker !== undefined) { + throw new Error('post-soak render probe must remain a text-only turn') + } + const composer = world.page.locator('textarea:enabled').last() + const composerFill = await measure(cdp, async () => { + await composer.fill(spec.prompt) + await expect.poll(() => composer.inputValue()).toBe(spec.prompt) + return (await composer.inputValue()).length + }) + expect(composerFill.value).toBe(spec.prompt.length) + + const eventStart = world.sessionEvents.length + await startUserRenderProbe(world.page, spec.userMarker) + const browserBefore = await chromiumMetrics(cdp) + const fullTurnStarted = performance.now() + const settled = world.scaffold.whenTurnSettled(60_000) + await triggerUserRenderProbe(world.page) + const browserTiming = await stopUserRenderProbe(world.page, spec.userMarker) + const browserAfter = await chromiumMetrics(cdp) + const browserAfterPaintSample = metricDelta( + browserBefore, + browserAfter, + performance.now() - fullTurnStarted, + ) + await world.page.getByText(spec.firstMarker, { exact: false }).last().waitFor({ timeout: 15_000 }) + const settledSessionId = await settled + await expect.poll( + () => world.page.locator('[data-streaming="true"]').count(), + { timeout: 15_000 }, + ).toBe(0) + await world.page.getByText(spec.doneMarker, { exact: false }).last().waitFor({ timeout: 15_000 }) + const fullTurnMs = performance.now() - fullTurnStarted + + const turnEvents = world.sessionEvents.slice(eventStart) + const chunks = turnEvents.filter(event => event.type === 'assistant/chunk') + const user = turnEvents.find( + event => event.type === 'user/message' && event.data.source.kind === 'user', + ) + if (user?.type !== 'user/message') throw new Error('post-soak render probe did not log its user message') + expect(user.data.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('')).toBe(spec.prompt) + expect(chunks).toHaveLength(spec.deltas.length + 4) + expect(turnEvents.filter(event => event.type === 'tool/call')).toHaveLength(0) + expect(turnEvents.filter(event => event.type === 'tool/result')).toHaveLength(0) + expect(world.scaffold.ctx.agents.get(settledSessionId)).toBeDefined() + expect(await conversationTurns(world.page)).toBe(POST_SOAK_RENDER_TURN) + + return { + ordinal: POST_SOAK_RENDER_TURN, + resultingTurns: POST_SOAK_RENDER_TURN, + promptChars: spec.prompt.length, + composerFill: composerFill.measurement, + trustedClick: browserTiming.trustedClick, + sendToDomMs: rounded(browserTiming.sendToDomMs), + sendToPaintMs: rounded(browserTiming.sendToPaintMs), + domToPaintMs: rounded(browserTiming.domToPaintMs), + mutationBatchesThroughPaint: browserTiming.mutationBatches, + mutationRecordsThroughPaint: browserTiming.mutationRecords, + browserAfterPaintSample, + fullTurnMs: rounded(fullTurnMs), + persistedChunks: chunks.length, + } +} + +function average(values: readonly number[]): number { + return rounded(values.reduce((sum, value) => sum + value, 0) / values.length) +} + +function p95(values: readonly number[]): number { + const sorted = [...values].sort((left, right) => left - right) + return sorted[Math.max(0, Math.ceil(sorted.length * 0.95) - 1)] ?? 0 +} + +function summarizeTurnWindows( + turns: readonly ContinuedTurnReport[], + windowSize: number, +): object[] { + const windows: object[] = [] + for (let start = 0; start < turns.length; start += windowSize) { + const window = turns.slice(start, start + windowSize) + const streamWall = window.map(turn => turn.stream.wallMs) + const userEcho = window.map(turn => turn.stream.clickToUserEchoMs) + const firstChunk = window.map(turn => turn.stream.clickToFirstChunkMs) + windows.push({ + turns: `${String(start + 1)}-${String(start + window.length)}`, + toolTurns: window.filter(turn => turn.kind === 'tool').length, + average: { + composerFillWallMs: average(window.map(turn => turn.composerFill.wallMs)), + streamWallMs: average(streamWall), + clickToUserEchoMs: average(userEcho), + clickToFirstChunkMs: average(firstChunk), + taskMs: average(window.map(turn => turn.stream.taskMs)), + scriptMs: average(window.map(turn => turn.stream.scriptMs)), + recalcStyleMs: average(window.map(turn => turn.stream.recalcStyleMs)), + mutationBatches: average(window.map(turn => turn.stream.batches)), + }, + p95: { + streamWallMs: rounded(p95(streamWall)), + clickToUserEchoMs: rounded(p95(userEcho)), + clickToFirstChunkMs: rounded(p95(firstChunk)), + }, + }) + } + return windows +} + +describe('manual web performance: complex workspace and history', () => { + let browser: Browser + + beforeAll(async () => { + if (webSnapshotMode() === 'record') { + throw new Error('manual web performance runs only with deterministic replay') + } + browser = await chromium.launch() + }) + + afterAll(async () => { + await browser?.close() + }) + + it('reports workspace, history, and trajectory cardinality costs', async () => { + const world = await launchPerformanceWorld({ + browser, + sidebarSessions: SIDEBAR_SESSION_COUNT, + seedLongHistory: true, + }) + try { + const bootStarted = performance.now() + const group = await openPerformancePage(world, SIDEBAR_SESSION_COUNT + 1) + const bootReadyMs = performance.now() - bootStarted + const page = world.page + const cdp = await page.context().newCDPSession(page) + await cdp.send('Performance.enable') + const firstContentfulPaintMs = await page.evaluate( + () => globalThis.performance.getEntriesByName('first-contentful-paint')[0]?.startTime, + ) + + const sidebar = await measure(cdp, async () => { + await group.click() + return stableCount( + page.getByRole('treeitem'), + count => count === SIDEBAR_SESSION_COUNT + 2, + ) + }) + expect(sidebar.value).toBe(SIDEBAR_SESSION_COUNT + 2) + await group.click() + await expect.poll(() => page.getByRole('treeitem').count()).toBe(1) + + const contentSearch = await measure(cdp, async () => { + await page.getByRole('textbox', { name: 'Search name, keywords...', exact: true }) + .fill('LONG_PERF_SENTINEL') + const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1) + await results.first().waitFor({ timeout: 60_000 }) + return results.first() + }) + const opened = await measure(cdp, async () => { + await contentSearch.value.click() + await page.getByRole('tab', { name: 'Trajectory', exact: true }).waitFor({ timeout: 30_000 }) + return conversationTurns(page) + }) + expect(opened.value).toBe(DEFAULT_HISTORY_TURNS) + + const trajectoryRows = page.getByRole('row') + const coldTrajectory = await measure(cdp, async () => { + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + return stableCount(trajectoryRows, count => count === EXPECTED_TRAJECTORY_ROWS) + }) + expect(coldTrajectory.value).toBe(EXPECTED_TRAJECTORY_ROWS) + + const collapseTurns = await measure(cdp, async () => { + await page.getByRole('button', { name: 'Collapse turns', exact: true }).click() + return stableCount(trajectoryRows, count => count > 0 && count < EXPECTED_TRAJECTORY_ROWS) + }) + expect(collapseTurns.value).toBeLessThan(EXPECTED_TRAJECTORY_ROWS) + const trajectorySearch = await measure(cdp, async () => { + await page.getByRole('searchbox', { name: 'Search trajectory', exact: true }).fill('turn 499') + return stableCount(trajectoryRows, count => count > 0 && count < 20) + }) + expect(trajectorySearch.value).toBeLessThan(20) + + await page.getByRole('tab', { name: 'Chat', exact: true }).click() + const historyPages: { turns: number; measurement: Measurement }[] = [] + let turns = await conversationTurns(page) + while (turns < LONG_HISTORY_TURNS) { + const previousTurns = turns + const older = await measure(cdp, async () => { + await page.getByRole('button', { name: 'Load earlier', exact: true }).click() + await expect.poll(() => conversationTurns(page), { timeout: 30_000 }) + .toBeGreaterThan(previousTurns) + return conversationTurns(page) + }) + turns = older.value + historyPages.push({ turns, measurement: older.measurement }) + } + + const warmTrajectory = await measure(cdp, async () => { + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + return stableCount(trajectoryRows, count => count === EXPECTED_TRAJECTORY_ROWS) + }) + expect(warmTrajectory.value).toBe(EXPECTED_TRAJECTORY_ROWS) + const warmConversation = await measure(cdp, async () => { + await page.getByRole('tab', { name: 'Chat', exact: true }).click() + return conversationTurns(page) + }) + expect(warmConversation.value).toBe(LONG_HISTORY_TURNS) + + console.info(`WEB_PERF_RESULT ${JSON.stringify({ + scenario: 'workspace-history-trajectory', + fixture: { + sidebarSessions: SIDEBAR_SESSION_COUNT, + totalSessions: SIDEBAR_SESSION_COUNT + 1, + longHistoryTurns: LONG_HISTORY_TURNS, + toolCalls: EXPECTED_TOOL_CALLS, + trajectoryRows: EXPECTED_TRAJECTORY_ROWS, + }, + setupMs: rounded(world.setupMs), + boot: { + readyMs: rounded(bootReadyMs), + firstContentfulPaintMs: firstContentfulPaintMs === undefined + ? null + : rounded(firstContentfulPaintMs), + }, + sidebarExpand: sidebar.measurement, + contentSearch: contentSearch.measurement, + openLongHistory: { initialTurns: opened.value, ...opened.measurement }, + coldTrajectory: { rows: coldTrajectory.value, ...coldTrajectory.measurement }, + collapseTurns: { rows: collapseTurns.value, ...collapseTurns.measurement }, + trajectorySearch: { rows: trajectorySearch.value, ...trajectorySearch.measurement }, + historyPages, + warmTrajectory: { rows: warmTrajectory.value, ...warmTrajectory.measurement }, + warmConversation: { turns: warmConversation.value, ...warmConversation.measurement }, + }, null, 2)}`) + expect(world.tripwire.warnings).toEqual([]) + expect(world.tripwire.pageErrors).toEqual([]) + } finally { + await closePerformanceWorld(world) + } + }) + + it('reports default 24-turn history plus eight continued turns', async () => { + const world = await launchPerformanceWorld({ + browser, + replay: performanceReplayOverride(COMPARISON_TURNS, comparisonTurn), + seedLongHistory: true, + }) + try { + await openPerformancePage(world, 1) + const cdp = await world.page.context().newCDPSession(world.page) + await cdp.send('Performance.enable') + const opened = await measure(cdp, () => openLongHistory(world.page)) + expect(opened.value).toBe(DEFAULT_HISTORY_TURNS) + const conversation = await continueConversation(world, cdp, { + startingTurns: DEFAULT_HISTORY_TURNS, + turnCount: COMPARISON_TURNS, + turnSpec: comparisonTurn, + expectedSessionId: SessionId(LONG_SESSION_ID), + }) + expect(conversation.toolTurns).toBe(2) + console.info(`WEB_PERF_RESULT ${JSON.stringify({ + scenario: 'default-resume-24-plus-8', + setupMs: rounded(world.setupMs), + replayContextWindow: PERF_REPLAY_CONTEXT_WINDOW, + openLongHistory: { turns: opened.value, ...opened.measurement }, + conversation, + }, null, 2)}`) + expect(world.tripwire.warnings).toEqual([]) + expect(world.tripwire.pageErrors).toEqual([]) + } finally { + await closePerformanceWorld(world) + } + }) + + it('reports fully expanded 500-turn history plus eight continued turns', async () => { + const world = await launchPerformanceWorld({ + browser, + replay: performanceReplayOverride(COMPARISON_TURNS, comparisonTurn), + seedLongHistory: true, + }) + try { + await openPerformancePage(world, 1) + const cdp = await world.page.context().newCDPSession(world.page) + await cdp.send('Performance.enable') + expect(await openLongHistory(world.page)).toBe(DEFAULT_HISTORY_TURNS) + const historyPages: { turns: number; measurement: Measurement }[] = [] + let turns = DEFAULT_HISTORY_TURNS + while (turns < LONG_HISTORY_TURNS) { + const previousTurns = turns + const older = await measure(cdp, async () => { + await world.page.getByRole('button', { name: 'Load earlier', exact: true }).click() + await expect.poll(() => conversationTurns(world.page), { timeout: 30_000 }) + .toBeGreaterThan(previousTurns) + return conversationTurns(world.page) + }) + turns = older.value + historyPages.push({ turns, measurement: older.measurement }) + } + expect(turns).toBe(LONG_HISTORY_TURNS) + const conversation = await continueConversation(world, cdp, { + startingTurns: LONG_HISTORY_TURNS, + turnCount: COMPARISON_TURNS, + turnSpec: comparisonTurn, + expectedSessionId: SessionId(LONG_SESSION_ID), + }) + expect(conversation.toolTurns).toBe(2) + console.info(`WEB_PERF_RESULT ${JSON.stringify({ + scenario: 'expanded-history-500-plus-8', + setupMs: rounded(world.setupMs), + replayContextWindow: PERF_REPLAY_CONTEXT_WINDOW, + historyPages, + conversation, + }, null, 2)}`) + expect(world.tripwire.warnings).toEqual([]) + expect(world.tripwire.pageErrors).toEqual([]) + } finally { + await closePerformanceWorld(world) + } + }) + + it('reports one hundred generated turns and the next user-message paint', async () => { + const world = await launchPerformanceWorld({ + browser, + replay: performanceReplayOverride(POST_SOAK_RENDER_TURN, soakTurn), + }) + let testFailure: unknown + try { + await world.page.goto(world.scaffold.baseUrl, { waitUntil: 'load' }) + await world.page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(world.page, world.scaffold.workspaceCwd, 'continuous-conversation-perf') + const cdp = await world.page.context().newCDPSession(world.page) + await cdp.send('Performance.enable') + const conversation = await continueConversation(world, cdp, { + startingTurns: 0, + turnCount: SOAK_TURNS, + turnSpec: soakTurn, + checkpointInterval: SOAK_CHECKPOINT_INTERVAL, + }) + expect(conversation.toolTurns).toBe(SOAK_TURNS / SOAK_TOOL_INTERVAL) + const postSoakUserRender = await measurePostSoakUserRender(world, cdp) + const { turns, ...retained } = conversation + console.info(`WEB_PERF_RESULT ${JSON.stringify({ + scenario: 'continuous-100-turn-soak', + setupMs: rounded(world.setupMs), + replayContextWindow: PERF_REPLAY_CONTEXT_WINDOW, + ...retained, + windows: summarizeTurnWindows(turns, SOAK_CHECKPOINT_INTERVAL), + postSoakUserRender, + }, null, 2)}`) + expect(world.tripwire.warnings).toEqual([]) + expect(world.tripwire.pageErrors).toEqual([]) + } catch (error) { + testFailure = error + throw error + } finally { + try { + await closePerformanceWorld(world) + } catch (cleanupError) { + if (testFailure === undefined) throw cleanupError + throw new AggregateError( + [testFailure, cleanupError], + 'continuous conversation performance test and teardown failed', + ) + } + } + }) +}) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 4f13ea21cf..a49501584f 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -9,9 +9,9 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' -import type { Browser, Page } from 'playwright' +import type { Browser, Page, Response } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { @@ -34,12 +34,46 @@ const SEED_ID = 'navigation-panes-web-e2e' const PROMPT_TURN1 = 'NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop.' const PROMPT_TURN2 = 'Reply in markdown with: a level-2 heading "Navigation Summary", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop.' +async function baselineResponse( + page: Page, + method: 'session.list' | 'workspace.list', +): Promise { + return page.waitForResponse(response => ( + response.request().method() === 'POST' + && new URL(response.url()).pathname === `/api/${method}` + ), { timeout: 30_000 }) +} + +async function assertBaselineSucceeded(response: Response, method: string): Promise { + expect(response.ok(), `${method} baseline HTTP response`).toBe(true) + const body = await response.json() as { result?: { ok?: unknown } } + expect(body.result?.ok, `${method} baseline RPC result`).toBe(true) +} + +async function ensureSeedOpen(page: Page): Promise { + const chat = page.getByRole('tab', { name: 'Chat', exact: true }) + const search = page.getByPlaceholder('Search name, keywords', { exact: false }) + if (await chat.count() === 0) { + await search.fill('WATERFALL') + const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1) + await result.click() + await chat.waitFor({ timeout: 15_000 }) + } + await chat.click() + await page.getByText('FIRST_DONE', { exact: true }).waitFor({ timeout: 15_000 }) + if (await search.inputValue() !== '') { + await search.fill('') + await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('') + } +} + describe('web e2e: navigation & panes over a rich seeded session', () => { let scaffold: WebScaffold let browser: Browser let page: Page - let tripwire: ReturnType - let slotErrors: string[] + let tripwire: ReturnType = { warnings: [], pageErrors: [] } + let slotErrors: string[] = [] beforeAll(async () => { scaffold = await launchWebScaffold({}) @@ -57,6 +91,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await seedSession(scaffold, raw, SEED_ID) } browser = await chromium.launch() + }, 120_000) + + beforeEach(async () => { page = await newEnglishPage(browser) tripwire = watchConsole(page) slotErrors = [] @@ -65,7 +102,19 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { slotErrors.push(message.text()) } }) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + // Initial navigation and list ownership settle only after both independent + // RPC baselines succeed; arm before navigation so neither response is missed. + const sessionBaseline = baselineResponse(page, 'session.list') + const workspaceBaseline = baselineResponse(page, 'workspace.list') + const [, sessionResponse, workspaceResponse] = await Promise.all([ + page.goto(scaffold.baseUrl, { waitUntil: 'load' }), + sessionBaseline, + workspaceBaseline, + ]) + await Promise.all([ + assertBaselineSucceeded(sessionResponse, 'session.list'), + assertBaselineSucceeded(workspaceResponse, 'workspace.list'), + ]) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) // The frame mounts before the asynchronous session-list baseline lands. // Search must target the settled seeded row, not the startup input that @@ -73,9 +122,32 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 }) }, 120_000) + afterEach(async () => { + const failures: unknown[] = [] + try { + expect({ + pageErrors: tripwire.pageErrors, + slotErrors, + warnings: tripwire.warnings, + }).toEqual({ + pageErrors: [], + slotErrors: [], + warnings: [], + }) + } catch (error) { + failures.push(error) + } + await page?.close().catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'navigation case cleanup failed') + }) + afterAll(async () => { - await browser?.close() - await scaffold?.close() + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'navigation e2e cleanup failed') }) it.skipIf(MODE !== 'record')('records the two-turn seed live through the composer', async () => { @@ -102,6 +174,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search')) + // The API baselines can settle before React commits their projection. The + // seeded count is the final user-visible barrier before editing search. + await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 }) const search = page.getByPlaceholder('Search name, keywords', { exact: false }) // The cold row has not been opened, so only the persisted log can satisfy // this query. First search lazily reconciles the SQLite content index. @@ -136,6 +211,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory')) + await ensureSeedOpen(page) await page.getByRole('tab', { name: 'Trajectory' }).click() await page.waitForTimeout(100) const overlayLayout = await page.getByRole('table').evaluate((table) => { @@ -200,7 +276,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline')) + await ensureSeedOpen(page) + await page.getByRole('tab', { name: 'Trajectory' }).click() const plot = page.getByLabel('Timeline overview; drag horizontally to focus events') + await plot.waitFor({ timeout: 15_000 }) const before = await page.locator('tr[data-kind]').count() const box = await plot.boundingBox() if (box === null) throw new Error('trajectory timeline plot has no layout box') @@ -217,7 +296,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column closed', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details')) - await page.getByRole('tab', { name: 'Chat' }).click() + await ensureSeedOpen(page) const bashRow = page.locator('[data-sample="bash"]').first() await bashRow.waitFor({ timeout: 15_000 }) const frame = page.locator('[style*="grid-template-columns"]').first() @@ -239,9 +318,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-terminal')) - await page.getByRole('tab', { name: 'Chat' }).click() + await ensureSeedOpen(page) // The card is expand-gated behind the whole-row toggle (the unified - // tool-row interaction): open it if a previous case left it collapsed. + // tool-row interaction): open it if this fresh view leaves it collapsed. // Expanded, the recorded command's own output sits in the message flow, // derived from the logged call/result presentations alone. const bashRow = page.locator('[data-sample="bash"]').first() @@ -322,10 +401,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('NAVIGATION_OK') }, 60_000) - it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { - expect(tripwire.pageErrors).toEqual([]) - expect(slotErrors).toEqual([]) - expect(tripwire.warnings).toEqual([]) + it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'seed.jsonl', 'search-results.expected.md', 'trajectory.expected.md', 'terminal-card.expected.md', diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 8316e75e3d..6b8067235f 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -85,6 +85,14 @@ const REPLAY_PROVIDERS = [{ models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }], }] +function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS { + if (contextWindow === undefined) return REPLAY_PROVIDERS + return REPLAY_PROVIDERS.map(provider => ({ + ...provider, + models: provider.models.map(model => ({ ...model, contextWindow })), + })) +} + /** A booted web scaffold: real composition, mode-selected model backend, temp world. */ export interface WebScaffold { /** The active snapshot mode this scaffold booted under. */ @@ -134,6 +142,8 @@ export interface LaunchOptions { replayOverride?: string /** Per-chunk replay pacing (ms) so the browser observes genuinely incremental SSE; replay/refresh only. */ paceMs?: number + /** Synthetic model capacity for UI scenarios whose seeded history must remain uncompacted. */ + replayContextWindow?: number /** * Tool presentation mode patched onto the shipped `tools` row (`code` * collapses the wire to run_code + the SDK prompt section). Omit for the @@ -344,7 +354,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { row.dispose() } }, 'ui-conversation: Enter behavior settings row') - // Chat scroll offsets by session, surviving view switches (the chat view - // unmounts under the tab ring). Deliberately not persisted: a fresh page - // load should keep the open-jump-to-bottom default. - const chatScrollTops = new Map() + // Chat semantic reader positions by session, surviving view switches and + // width reflow when the tab ring remounts the view. Deliberately not + // persisted: a fresh page load keeps the open-jump-to-bottom default. + const chatScrollPositions = new Map() const viewTabs = (): ViewTab[] => { const tabs: ViewTab[] = [] @@ -316,11 +316,11 @@ export function apply(ctx: Context): void { actions.setView('trajectory') }, chatScroll: { - save: (top) => { - if (top === null) chatScrollTops.delete(sessionId) - else chatScrollTops.set(sessionId, top) + save: (position) => { + if (position === null) chatScrollPositions.delete(sessionId) + else chatScrollPositions.set(sessionId, position) }, - read: () => chatScrollTops.get(sessionId) ?? null, + read: () => chatScrollPositions.get(sessionId) ?? null, }, forkAt: (seq) => { sessions.fork({ sessionId, atSeq: seq, increaseTitle: true }) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index 55270946ba..87f965b9a4 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -42,6 +42,12 @@ gap: 16px; } +/* Settled-flow identity boundary. It is neutral today and becomes the natural + measurement/mount unit for a virtualizer without changing the column gap. */ +.flowItem { + min-width: 0; +} + .toolGroup { display: flex; flex-direction: column; diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 076da6c6ab..c50a9443d4 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -44,6 +44,59 @@ function scrollerOf(from: HTMLElement): HTMLElement { return (from.closest('[data-conversation-scroll]')) ?? from } +interface PagingAnchor { + /** Stable node/call identity, independent of boundary-spanning group keys. */ + key: string + /** Row top relative to the scrollport after the latest user scroll. */ + top: number +} + +/** Find an already-rendered settled row without interpolating a selector. */ +function anchorElement(list: HTMLElement, key: string): HTMLElement | null { + for (const row of list.querySelectorAll('[data-chat-anchor-key]')) { + if (row.dataset.chatAnchorKey === key) return row + } + return null +} + +/** Row position in scrollport coordinates (viewport-independent). */ +function flowTop(row: HTMLElement, scrollport: HTMLElement): number { + return row.getBoundingClientRect().top - scrollport.getBoundingClientRect().top +} + +/** Select a visible stable node/call identity, falling back only when layout + * has not exposed a visible box yet. */ +function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement | null { + const viewport = scrollport.getBoundingClientRect() + const composer = scrollport.querySelector('[data-composer-seat]') + const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom + // Scroll events are hot: hit-test a few points through the stretched flow + // rows before considering the full mounted set. The fallback keeps jsdom + // and pre-layout states deterministic; a virtualizer naturally bounds it. + if (typeof document.elementsFromPoint === 'function' && visibleBottom > viewport.top) { + const content = list.getBoundingClientRect() + const left = Math.max(viewport.left, content.left) + const right = Math.min(viewport.right, content.right) + const x = left + Math.max(0, right - left) / 2 + const height = visibleBottom - viewport.top + const points = [1, Math.min(32, height / 3), height / 2, Math.max(1, height - 1)] + for (const offset of points) { + for (const element of document.elementsFromPoint(x, viewport.top + offset)) { + const row = element instanceof HTMLElement + ? element.closest('[data-chat-anchor-key]') + : null + if (row !== null && list.contains(row)) return row + } + } + } + const rows = [...list.querySelectorAll('[data-chat-anchor-key]')] + const visibleRows = rows.filter((row) => { + const rect = row.getBoundingClientRect() + return rect.bottom > viewport.top && rect.top < visibleBottom + }) + return visibleRows[0] ?? rows[0] ?? null +} + type OpenFile = (path: string) => void type InspectCall = (callId: string) => void @@ -51,6 +104,8 @@ type InspectCall = (callId: string) => void /** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */ type RenderToolRow = ChatViewSlotProps['renderSlot'] +type ChatScrollPosition = NonNullable> + /** ui-slots' UseSession is deliberately wide (dependency direction); the * chat view narrows once to the runtime snapshot the binding actually feeds. */ type UseConversation = SnapshotSelectorHook @@ -66,6 +121,18 @@ function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): n return null } +/** Capture a reflow-resistant reader position from the current rendered window. */ +function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollPosition | null { + const row = pagingAnchor(list, scrollport) + const anchorKey = row?.dataset.chatAnchorKey + if (row === null || anchorKey === undefined) return null + return { + anchorKey, + anchorTop: flowTop(row, scrollport), + scrollTop: scrollport.scrollTop, + } +} + /** One `run_code` sub-dispatch row: the identical keyed-slot dispatch as a * top-level call (same registrations, same fallback), nested by the parent. * A started-but-unsettled sub-call arrives as the RunningToolCall shape and @@ -86,7 +153,12 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, select inspect: () => { inspectCall(node.callId) }, }), [node, toolName, openFile, cwd, inspectCall]) return ( -
+
{renderSlot('conversation.chat.toolview', owner, { entryKey: toolName, fallback: , @@ -124,7 +196,12 @@ const CallRow = memo(function CallRow({ inspect: () => { inspectCall(callId) }, }), [callId, toolName, block, openFile, cwd, inspectCall]) return ( -
+
{renderSlot('conversation.chat.toolview', owner, { entryKey: toolName, fallback: , @@ -213,17 +290,13 @@ function TurnStatus() { ) } -/** The streaming partial, isolated so chunk batches re-render only this tail. - * onGrow lets the scroll owner follow content the parent never re-renders for. */ -function StreamingTail({ useSession, onGrow, t }: { +/** The streaming partial, isolated so chunk batches re-render only this tail; + * the column ResizeObserver owns bottom-follow when its box grows. */ +function StreamingTail({ useSession, t }: { useSession: UseConversation - onGrow: () => void t: ChatViewSlotProps['t'] }) { const partial = useSession(s => s.partial) - useLayoutEffect(() => { - onGrow() - }) if (partial === null) return null return } @@ -261,10 +334,17 @@ export function ChatView({ const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds]) const listRef = useRef(null) + const columnRef = useRef(null) const atBottomRef = useRef(true) const [atBottom, setAtBottom] = useState(true) - /** Paging anchor: height/position at click, compensated after the prepend lands. */ - const anchorRef = useRef<{ h: number; t: number } | null>(null) + /** Last position delivered or written on the main thread. */ + const observedTopRef = useRef(0) + /** Pre-input position for the current wheel gesture. */ + const wheelStartRef = useRef(null) + const wheelEpochRef = useRef(0) + /** Paging anchor: semantic row/position at click, updated by reader scrolls + * while the request is pending and restored after the prepend lands. */ + const anchorRef = useRef(null) const firstSeqRef = useRef(null) const openedRef = useRef(false) const lastKeyRef = useRef(null) @@ -281,9 +361,14 @@ export function ChatView({ const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}` const toBottom = (el: HTMLElement): void => { + wheelStartRef.current = null + wheelEpochRef.current += 1 + anchorRef.current = null el.scrollTop = el.scrollHeight + observedTopRef.current = el.scrollTop atBottomRef.current = true setAtBottom(true) + chatScroll.save(null) } useLayoutEffect(() => { @@ -300,10 +385,16 @@ export function ChatView({ if (saved === null) { toBottom(el) } else { - el.scrollTop = saved + el.scrollTop = saved.scrollTop + const row = anchorElement(local, saved.anchorKey) + if (row !== null) el.scrollTop += flowTop(row, el) - saved.anchorTop + observedTopRef.current = el.scrollTop const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1 atBottomRef.current = isAtBottom setAtBottom(isAtBottom) + const normalized = isAtBottom ? null : scrollPosition(local, el) + if (isAtBottom) chatScroll.save(null) + else if (normalized !== null) chatScroll.save(normalized) } firstSeqRef.current = firstSeq lastKeyRef.current = lastKey @@ -311,10 +402,15 @@ export function ChatView({ followSigRef.current = followSig return } - // Prepend (head seq decreased): compensate by the height delta. + // Prepend (head seq decreased): preserve the same settled row at the + // position established by the reader's latest scroll. This excludes + // unrelated tail/composer growth while the request was in flight. if (anchorRef.current !== null && firstSeq !== null && firstSeqRef.current !== null && firstSeq < firstSeqRef.current) { - el.scrollTop = anchorRef.current.t + (el.scrollHeight - anchorRef.current.h) + const anchor = anchorRef.current anchorRef.current = null + const row = anchorElement(local, anchor.key) + if (row !== null) el.scrollTop += flowTop(row, el) - anchor.top + observedTopRef.current = el.scrollTop firstSeqRef.current = firstSeq /* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */ lastKeyRef.current = lastKey @@ -343,26 +439,66 @@ export function ChatView({ /* v8 ignore next -- ref-null guard: the handler only fires while mounted. */ if (local === null) return const el = scrollerOf(local) - const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1 + // Only wheel input may make raw scroll geometry change follow ownership. + // Browser clamping and delayed programmatic scroll events otherwise have + // the same event shape and must preserve the current ownership state. + const floor = Math.max(0, el.scrollHeight - el.clientHeight) + const wheelStart = wheelStartRef.current + const movedByWheel = wheelStart !== null + && Math.abs(el.scrollTop - Math.min(wheelStart, floor)) > 0.5 + const isAtBottom = movedByWheel + ? floor - el.scrollTop <= FOLLOW_THRESHOLD + 1 + : atBottomRef.current + if (!movedByWheel && isAtBottom) { + toBottom(el) + return + } atBottomRef.current = isAtBottom setAtBottom(isAtBottom) + const position = isAtBottom ? null : scrollPosition(local, el) + if (isAtBottom) { + anchorRef.current = null + } else if (anchorRef.current !== null && position !== null) { + anchorRef.current = { key: position.anchorKey, top: position.anchorTop } + } // Continuous save (unmount happens after ref detach, so saving there is // too late); pinned-to-bottom clears so a remount keeps following. - chatScroll.save(isAtBottom ? null : el.scrollTop) + if (isAtBottom) chatScroll.save(null) + else if (position !== null) chatScroll.save(position) + observedTopRef.current = el.scrollTop } - // Bind scroll to the resolved scrollport (host or local) once per mount. + // Bind scroll and the wheel provenance needed to distinguish reader input + // from layout-driven scrolls on the resolved scrollport once per mount. useEffect(() => { const local = listRef.current /* v8 ignore next -- ref-null guard: effect runs after the list node commits. */ if (local === null) return const el = scrollerOf(local) const onScroll = (): void => { onScrollRef.current() } + const onWheel = (event: WheelEvent): void => { + if (event.ctrlKey || event.deltaY === 0) return + const startTop = observedTopRef.current + const floor = Math.max(0, el.scrollHeight - el.clientHeight) + const canMove = event.deltaY < 0 ? startTop > 1 : startTop < floor - 1 + if (!canMove) return + wheelStartRef.current = startTop + const epoch = ++wheelEpochRef.current + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (wheelEpochRef.current === epoch) wheelStartRef.current = null + }) + }) + } el.addEventListener('scroll', onScroll, { passive: true }) - return () => { el.removeEventListener('scroll', onScroll) } + el.addEventListener('wheel', onWheel, { capture: true, passive: true }) + return () => { + wheelStartRef.current = null + el.removeEventListener('scroll', onScroll) + el.removeEventListener('wheel', onWheel, true) + } }, []) - // Follow streaming growth the parent never re-renders for (stable ref). // The ref starts null and is assigned every render, so the placeholder // initializer a function initial value would need never exists. const followRef = useRef<(() => void) | null>(null) @@ -371,16 +507,43 @@ export function ChatView({ if (local !== null && atBottomRef.current) { const el = scrollerOf(local) el.scrollTop = el.scrollHeight + observedTopRef.current = el.scrollTop + chatScroll.save(null) } } - const onGrow = useRef(() => followRef.current?.()).current + // Streaming, tool disclosures, and other flow changes resize the column; + // the sticky composer resizes outside it. This observer owns ChatView's + // dynamic-height follow decisions and writes only while the reader is pinned. + useEffect(() => { + const column = columnRef.current + const local = listRef.current + if (column === null || local === null || typeof ResizeObserver === 'undefined') return + const scrollport = scrollerOf(local) + const composer = scrollport.querySelector('[data-composer-seat]') + const observer = new ResizeObserver(() => { followRef.current?.() }) + observer.observe(column) + if (composer !== null) observer.observe(composer) + return () => { observer.disconnect() } + }, []) + + // A failed/empty page leaves the head unchanged. Once the request leaves + // its busy state there is no future prepend for the saved anchor to own. + useEffect(() => { + if (!loadingOlder) anchorRef.current = null + }, [loadingOlder]) const loadOlderAnchored = (): void => { const local = listRef.current /* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */ if (local !== null) { const el = scrollerOf(local) - anchorRef.current = { h: el.scrollHeight, t: el.scrollTop } + const row = pagingAnchor(local, el) + if (row !== null && row.dataset.chatAnchorKey !== undefined) { + anchorRef.current = { + key: row.dataset.chatAnchorKey, + top: flowTop(row, el), + } + } } loadOlder() } @@ -392,7 +555,6 @@ export function ChatView({ || codeDispatches.get(r.callId)?.some(sub => sub.callId === selectedCallId) === true) return ( + return } /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */ if (node.kind === 'tool-result') return null return (
-
+
{openState === 'loading' &&
{t('chat.loadingHistory')}
} {openState === 'error' && openError !== null && (
@@ -454,8 +614,18 @@ export function ChatView({
)} - {items.map(renderItem)} - + {items.map(item => ( +
+ {renderItem(item)} +
+ ))} + {runningCalls.length > 0 && (
{runningCalls.map(call => ( diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 20a791c556..6abfb592f5 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -435,6 +435,16 @@ export class PendingApproval { export type ApprovalComposerProps = PropsRuntime<'conversation.composer'> & { matched: ApprovalWait } & PropsLocale<'conversation'> +/** In-memory reader position resilient to transcript width reflow. */ +export interface ChatScrollPosition { + /** Stable rendered node/call identity nearest the visible reading edge. */ + readonly anchorKey: string + /** Anchor top relative to the transcript scrollport when saved. */ + readonly anchorTop: number + /** Approximate offset used before the semantic anchor is measured. */ + readonly scrollTop: number +} + /** * Injected share of the chat view entry: the two callbacks whose targets live * outside the view (layout orchestration; the session object layer). @@ -456,10 +466,10 @@ export interface ChatViewInjected { * fresh page load starts empty and keeps the open-jump-to-bottom default. */ chatScroll: { - /** Record the scroll offset; null clears it (pinned to bottom). */ - save: (top: number | null) => void - /** Last recorded offset, or null when pinned or never recorded. */ - read: () => number | null + /** Record a semantic reader position; null clears it when pinned. */ + save: (position: ChatScrollPosition | null) => void + /** Last reader position, or null when pinned or never recorded. */ + read: () => ChatScrollPosition | null } /** Fork through the completed turn ending at the eligible message `seq`, then open the child. */ forkAt: (seq: number) => void diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index a041aca214..796d8b38d2 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -22,7 +22,10 @@ import { ChatView } from '../src/client/chat/ChatView.tsx' import { zh } from '../src/client/locales.ts' import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs } from '../src/client/chat/chat-flow.ts' -afterEach(cleanup) +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) // Keyless create() persists under the bare declared key; clear between cases // so one harness's selection cannot rehydrate into the next. beforeEach(() => { @@ -112,10 +115,10 @@ function makeHarness(init?: Partial) { const loadOlder = vi.fn() const inspectCall = vi.fn<(callId: string) => void>() // In-memory scroll memory matching the apply.ts per-session map contract. - let savedScrollTop: number | null = null - const chatScroll = { - save: (top: number | null) => { savedScrollTop = top }, - read: () => savedScrollTop, + let savedScroll: ReturnType = null + const chatScroll: ChatViewSlotProps['chatScroll'] = { + save: (position) => { savedScroll = position }, + read: () => savedScroll, } const forkAt = vi.fn() // Selection rides the REAL chat store (same construction path as @@ -154,6 +157,32 @@ function makeHarness(init?: Partial) { return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection } } +/** Simulate reader input before the browser delivers the host scroll event. */ +function readerScroll(element: HTMLElement, top: number): void { + fireEvent.wheel(element, { deltaY: top < element.scrollTop ? -120 : 120 }) + element.scrollTop = top + fireEvent.scroll(element) +} + +function installScrollMetrics(element: HTMLElement, initialHeight: number, clientHeight: number) { + let scrollHeight = initialHeight + let scrollTop = 0 + Object.defineProperty(element, 'scrollHeight', { configurable: true, get: () => scrollHeight }) + Object.defineProperty(element, 'clientHeight', { configurable: true, get: () => clientHeight }) + Object.defineProperty(element, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value: number) => { scrollTop = Math.max(0, Math.min(value, scrollHeight - clientHeight)) }, + }) + return { + setHeight: (value: number) => { scrollHeight = value }, + setLayout: (height: number, top: number) => { + scrollHeight = height + scrollTop = Math.max(0, Math.min(top, scrollHeight - clientHeight)) + }, + } +} + describe('chat-flow derivation', () => { it('groups consecutive tool results and keeps stable keys', () => { const nodes: ConversationNode[] = [ @@ -247,20 +276,36 @@ describe('ChatView', () => { expect(view.getByText('w1')).toBeTruthy() }) - it('prepend keeps the viewport anchored when the reader is NOT at the bottom (no lastKey force)', () => { - // Covers the prepend early-return arm where lastItem exists but the key - // path is not taken (anchor branch wins before the appended-user check). - const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true }) + it('prepend keeps the reader\'s latest pending-request scroll position anchored', () => { + const h = makeHarness({ nodes: [user(9, 'first visible'), user(10, 'next visible')], hasMore: true }) const view = render() const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const first = view.container.querySelector('[data-chat-flow-key="n9"]') as HTMLDivElement + const next = view.container.querySelector('[data-chat-flow-key="n10"]') as HTMLDivElement + let firstTop = 100 + let nextTop = 300 + vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation( + () => ({ top: 0, bottom: 200 } as DOMRect), + ) + vi.spyOn(first, 'getBoundingClientRect').mockImplementation( + () => ({ top: firstTop, bottom: firstTop + 40 } as DOMRect), + ) + vi.spyOn(next, 'getBoundingClientRect').mockImplementation( + () => ({ top: nextTop, bottom: nextTop + 40 } as DOMRect), + ) Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true }) Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true }) - scroller.scrollTop = 50 - fireEvent.scroll(scroller) + readerScroll(scroller, 50) fireEvent.click(view.getByText('加载更早')) + // The reader moves after the request starts; this, not the click-time + // row, is the intent the arriving page must preserve. + firstTop = -200 + nextTop = 60 + readerScroll(scroller, 90) Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true }) - act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) }) - expect(scroller.scrollTop).toBe(550) // 50 + (1300 - 800) + nextTop = 560 + act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'first visible'), user(10, 'next visible')] }) }) + expect(scroller.scrollTop).toBe(590) // latest 90 + the anchored row's 500px prepend shift }) it('renders the fixture main line: bubble, narration, grouped tool rows', () => { @@ -272,6 +317,18 @@ describe('ChatView', () => { expect(view.getByText('running tools')).toBeTruthy() expect(view.getAllByText('Bash')).toHaveLength(2) expect(view.getByText('run a')).toBeTruthy() + expect([...view.container.querySelectorAll('[data-chat-flow-key]')].map(row => ({ + key: row.getAttribute('data-chat-flow-key'), + kind: row.getAttribute('data-chat-flow-kind'), + }))).toEqual([ + { key: 'n1', kind: 'user' }, + { key: 'n2', kind: 'assistant' }, + { key: 'g3', kind: 'tool-group' }, + ]) + expect([...view.container.querySelectorAll('[data-chat-call-id]')].map(row => row.getAttribute('data-chat-call-id'))) + .toEqual(['a', 'b']) + expect([...view.container.querySelectorAll('[data-chat-anchor-key]')].map(row => row.getAttribute('data-chat-anchor-key'))) + .toEqual(['node:1', 'node:2', 'call:a', 'call:b']) }) it('renders Host-pending steering at the flow tail and hands off to the durable node', () => { @@ -622,31 +679,106 @@ describe('ChatView', () => { expect(calls).toEqual([{ key: 'conversation.chat.toolview', entryKey: 'bash' }]) }) - it('prepend compensates scrollTop by the height delta; a trailing user node force-scrolls', () => { + it('prepend preserves a semantic row; a trailing user node force-scrolls', () => { const h = makeHarness({ nodes: [user(5, 'later'), assistant(6, 'a')], hasMore: true }) const view = render() const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement // jsdom has no layout: fake the metrics the anchor math reads. Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true }) Object.defineProperty(scroller, 'clientHeight', { value: 400, writable: true }) + const anchored = view.container.querySelector('[data-chat-flow-key="n5"]') as HTMLDivElement + let anchoredTop = 100 + vi.spyOn(anchored, 'getBoundingClientRect').mockImplementation( + () => ({ top: anchoredTop, bottom: anchoredTop + 40 } as DOMRect), + ) + readerScroll(scroller, 80) // Arm the paging anchor, then deliver an older page (head seq decreases). fireEvent.click(view.getByText('加载更早')) Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true }) + anchoredTop = 700 act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }) }) - expect(scroller.scrollTop).toBe(600) // 0 + (1600 - 1000) + expect(scroller.scrollTop).toBe(680) // reader offset 80 + the anchored row's 600px shift // A new trailing user bubble (own words) force-scrolls to the bottom. act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }) }) expect(scroller.scrollTop).toBe(1600) }) + it('uses stable call identity when a prepend changes the tool-group key amid unrelated growth', () => { + const h = makeHarness({ nodes: [toolResult(5, 'late')], hasMore: true }) + const view = render() + const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + let prepended = false + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.dataset.chatAnchorKey === 'call:late') { + const top = prepended ? 400 : 100 + return { top, bottom: top + 40 } as DOMRect + } + return { top: 0, bottom: 200 } as DOMRect + }) + try { + Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true }) + Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true }) + readerScroll(scroller, 80) + fireEvent.click(view.getByText('加载更早')) + // Total height grows by 500, but only 300 belongs before the call row. + Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true }) + prepended = true + act(() => { h.set({ nodes: [toolResult(4, 'early'), toolResult(5, 'late')] }) }) + expect(scroller.scrollTop).toBe(380) + } finally { + rect.mockRestore() + } + }) + + it('uses the latest retry identity when prepending an earlier retry changes the flow key', () => { + const h = makeHarness({ nodes: [retry(5)], hasMore: true }) + const view = render() + const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + let prepended = false + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.dataset.chatAnchorKey === 'node:5') { + const top = prepended ? 400 : 100 + return { top, bottom: top + 40 } as DOMRect + } + return { top: 0, bottom: 200 } as DOMRect + }) + try { + Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true }) + Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true }) + readerScroll(scroller, 80) + fireEvent.click(view.getByText('加载更早')) + Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true }) + prepended = true + act(() => { h.set({ nodes: [retry(4), retry(5)] }) }) + expect(scroller.scrollTop).toBe(380) + expect(view.container.querySelector('[data-chat-flow-key="n4"][data-chat-anchor-key="node:5"]')).not.toBeNull() + } finally { + rect.mockRestore() + } + }) + + it('back-to-bottom cancels an in-flight paging anchor', () => { + const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true }) + const view = render() + const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true }) + Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true }) + readerScroll(scroller, 50) + fireEvent.click(view.getByText('加载更早')) + fireEvent.click(view.getByLabelText('回到底部')) + Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true }) + act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) }) + expect(scroller.scrollTop).toBe(1_300) + expect(h.chatScroll.read()).toBeNull() + }) + it('scrolling away disables follow and shows the back-to-bottom button; clicking returns', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) const view = render() const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true }) Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true }) - scroller.scrollTop = 100 // far from bottom - fireEvent.scroll(scroller) + readerScroll(scroller, 100) // far from bottom const backButton = view.getByLabelText('回到底部') expect(backButton).toBeTruthy() // Streaming growth must NOT drag a scrolled-away reader down. @@ -658,6 +790,71 @@ describe('ChatView', () => { expect(view.queryByLabelText('回到底部')).toBeNull() }) + it('keeps following when a delayed clamp scroll arrives after layout regrows', () => { + const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) + const view = render() + const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const metrics = installScrollMetrics(scroller, 1_000, 300) + scroller.scrollTop = 700 + fireEvent.scroll(scroller) + + // The wheel cannot move farther down. A stream-finalization shrink clamps + // the old position, then reflow grows the layout before scroll delivery. + fireEvent.wheel(scroller, { deltaY: 120 }) + metrics.setLayout(1_040, 500) + fireEvent.scroll(scroller) + expect(scroller.scrollTop).toBe(740) + expect(view.queryByLabelText('回到底部')).toBeNull() + expect(h.chatScroll.read()).toBeNull() + + metrics.setHeight(1_200) + act(() => { h.set({ running: true }) }) + expect(scroller.scrollTop).toBe(900) + }) + + it('uses the last delivered top when compositor scrolling precedes passive wheel delivery', () => { + const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) + const view = render() + const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + installScrollMetrics(scroller, 1_000, 300) + scroller.scrollTop = 700 + fireEvent.scroll(scroller) + + scroller.scrollTop = 500 + fireEvent.wheel(scroller, { deltaY: -200 }) + fireEvent.scroll(scroller) + expect(view.getByLabelText('回到底部')).toBeTruthy() + }) + + it('one ResizeObserver owns pinned dynamic-height follow and ignores growth while away', () => { + let notify: (() => void) | undefined + const observe = vi.fn() + class ResizeObserverStub { + constructor(callback: ResizeObserverCallback) { + notify = () => { callback([], this as unknown as ResizeObserver) } + } + + observe = observe + disconnect = vi.fn() + } + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) + const view = render() + const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true }) + Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true }) + scroller.scrollTop = 700 + fireEvent.scroll(scroller) + Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true }) + act(() => { notify?.() }) + expect(scroller.scrollTop).toBe(1_200) + readerScroll(scroller, 200) + Object.defineProperty(scroller, 'scrollHeight', { value: 1_400, writable: true }) + act(() => { notify?.() }) + expect(scroller.scrollTop).toBe(200) + expect(observe).toHaveBeenCalledTimes(1) + }) + it('entering the at-bottom threshold does not snap the remaining scroll distance', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) const view = render() @@ -666,8 +863,7 @@ describe('ChatView', () => { Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true }) // Inside FOLLOW_THRESHOLD (24) but not flush with the floor — the chrome // re-render from setAtBottom must not force scrollTop to scrollHeight. - scroller.scrollTop = 690 // distance-to-bottom = 10 - fireEvent.scroll(scroller) + readerScroll(scroller, 690) // distance-to-bottom = 10 expect(view.queryByLabelText('回到底部')).toBeNull() expect(scroller.scrollTop).toBe(690) }) @@ -684,8 +880,7 @@ describe('ChatView', () => { const view = render(, { container: host }) // Open jump uses the host, not the local .scroll node. expect(host.scrollTop).toBe(2000) - host.scrollTop = 100 - fireEvent.scroll(host) + readerScroll(host, 100) expect(view.getByLabelText('回到底部')).toBeTruthy() fireEvent.click(view.getByLabelText('回到底部')) expect(host.scrollTop).toBe(2000) @@ -694,29 +889,72 @@ describe('ChatView', () => { } }) - it('a remount restores the saved scroll position instead of re-jumping to the bottom', () => { + it('a remount restores the saved semantic row after width reflow', () => { const host = document.createElement('div') host.setAttribute('data-conversation-scroll', '') Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true }) Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true }) Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true }) document.body.appendChild(host) + let anchorTop = 80 + vi.spyOn(host, 'getBoundingClientRect').mockImplementation( + () => ({ top: 0, bottom: 500 } as DOMRect), + ) + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.dataset.chatAnchorKey === 'node:1') { + return { top: anchorTop, bottom: anchorTop + 40 } as DOMRect + } + return { top: 0, bottom: 40 } as DOMRect + }) try { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) // Fresh open (nothing saved): the bottom jump stands. const view = render(, { container: host }) expect(host.scrollTop).toBe(2000) // Reader scrolls up; the position is recorded continuously. - host.scrollTop = 100 - fireEvent.scroll(host) + readerScroll(host, 100) // View-tab switch away and back: the view unmounts, then remounts. view.rerender(
) + anchorTop = 560 host.scrollTop = 0 view.rerender() - expect(host.scrollTop).toBe(100) + expect(host.scrollTop).toBe(580) // approximate 100 + the row's 480px reflow shift // The restored position is above the floor: follow stays disarmed. expect(view.getByLabelText('回到底部')).toBeTruthy() } finally { + rect.mockRestore() + host.remove() + } + }) + + it('normalizes a semantic restore clamped to the bottom before an immediate remount', () => { + const host = document.createElement('div') + host.setAttribute('data-conversation-scroll', '') + Object.defineProperty(host, 'scrollHeight', { value: 2_000, writable: true, configurable: true }) + Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true }) + let scrollTop = 0 + Object.defineProperty(host, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value: number) => { scrollTop = Math.min(value, 1_500) }, + }) + document.body.appendChild(host) + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.dataset.chatAnchorKey === 'node:1') return { top: 300, bottom: 340 } as DOMRect + return { top: 0, bottom: 500 } as DOMRect + }) + try { + const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) + h.chatScroll.save({ anchorKey: 'node:1', anchorTop: 80, scrollTop: 1_400 }) + const view = render(, { container: host }) + expect(host.scrollTop).toBe(1_500) + expect(h.chatScroll.read()).toBeNull() + view.rerender(
) + host.scrollTop = 0 + view.rerender() + expect(host.scrollTop).toBe(1_500) + } finally { + rect.mockRestore() host.remove() } }) diff --git a/tsconfig.host.json b/tsconfig.host.json index 6f5030c80c..2634ed58a4 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -44,6 +44,11 @@ "apps/web/tests/startup-auto-selection.e2e.ts", "apps/web/tests/subagent-conversation.e2e.ts", "apps/web/tests/bash-abort-row.e2e.ts", + "apps/web/tests/chat-scroll-fixture.ts", + "apps/web/tests/chat-scroll-contract.e2e.ts", + "apps/web/tests/chat-long-interactions.e2e.ts", + "apps/web/tests/chat-continuous-conversation.e2e.ts", + "apps/web/tests/complex-history.perf.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts", diff --git a/vitest.web.perf.config.ts b/vitest.web.perf.config.ts new file mode 100644 index 0000000000..ddb37f0fb7 --- /dev/null +++ b/vitest.web.perf.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config' +import webConfig from './vitest.web.config.ts' + +// Manual high-cardinality diagnostics stay outside vitest.web.config.ts's +// .e2e.ts/.snapshot.ts inventory and therefore outside the CI web gate. +export default defineConfig({ + ...webConfig, + test: { + ...webConfig.test, + include: ['apps/web/tests/**/*.perf.ts'], + disableConsoleIntercept: true, + hookTimeout: 180_000, + testTimeout: 600_000, + }, +})