瀏覽代碼

test: add opt-in web performance scenario

kingwl 2 月之前
父節點
當前提交
a435aae538

+ 2 - 2
.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: 4bdfa511b18a1e12199c1235179420afd903018a
+2026-07-24-web-gui-browser-e2e-lane.zh.md: f0f1957ad8e64f4cdb557f93102758a51ff7c946

+ 6 - 2
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md

@@ -48,6 +48,8 @@ The lane covers three behavior families. Live-turn scenarios pin ordinary tool e
 
 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 `complex-history.perf.ts` case reuses the real scaffold, seeds 1,000 compact sessions plus one 500-turn history containing 500 tool calls, and reports Chromium main-thread, DOM, listener, heap, paging, search, and Trajectory measurements. It carries structural assertions for the intended load shape but no timing thresholds 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/<spec>` 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/<spec>` 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
 
@@ -87,4 +91,4 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot
 
 ## 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.

+ 6 - 2
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md

@@ -48,6 +48,8 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
 
 根据[浏览器快照 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 轮次历史,并报告 Chromium 主线程、DOM、监听器、堆内存、分页、搜索和 Trajectory 测量结果。它对预期负载形状设有结构性断言,但不设时间阈值,因为机器速度不属于正确性契约。必需的 `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/<spec>` 对真实模型录制一个发起提示的场景,`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/<spec>` 对真实模型录制一个发起提示的场景,`DSH_SNAPSHOT=refresh pnpm run test:web` 则无密钥重写 aria 预期输出。CI 显式选择回放模式。live-interactions AUTH 场景会把不可重试的终态失败钉为 Chat 内联状态,其中携带适合展示的消息与错误码,并验证提供方回显的凭据片段不会出现在 Chat 或 Trajectory 中;该场景同时覆盖输入框恢复与 `turn/end` 错误。scaffold 环境隔离场景会在全部 3 个环境 skill 根目录中分别填入不同条目,并要求这些条目都不得进入组装后的目录。`dsh-llm-replay` 单元覆盖率钉住节奏控制、取消、消费诊断、sidecar 校验、按索引替换与唯一的追加位置。
 
 ## 暂缓
 
@@ -87,4 +91,4 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
 
 ## 后果
 
-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 差异影响的时长或内存预期;在仓库拥有经校准的基准测试环境之前,性能回归仍是需要人工解读的信号。

+ 492 - 0
apps/web/tests/complex-history.perf.ts

@@ -0,0 +1,492 @@
+// 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 { 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 {
+  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 the fixture builder.
+import type {} from '@deepseek-ai/dsh-session-title'
+import {
+  launchWebScaffold,
+  seedSession,
+  watchConsole,
+  type WebScaffold,
+} from './scaffold.ts'
+import { 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
+
+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
+}
+
+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: 'synthetic-perf', model: 'synthetic-perf' },
+      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: 'synthetic-perf', model: 'synthetic-perf' },
+    }),
+    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: 'synthetic-perf', model: 'synthetic-perf' },
+    }),
+    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 rounded(value: number): number {
+  return Math.round(value * 1_000) / 1_000
+}
+
+async function chromiumMetrics(cdp: CDPSession): Promise<ChromiumMetrics> {
+  const payload = await cdp.send('Performance.getMetrics')
+  return Object.fromEntries(payload.metrics.map(metric => [metric.name, metric.value]))
+}
+
+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<T>(
+  cdp: CDPSession,
+  action: () => Promise<T>,
+): 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 stableCount(
+  locator: Locator,
+  accepts: (count: number) => boolean,
+  timeoutMs = 60_000,
+): Promise<number> {
+  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<number> {
+  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])
+}
+
+describe('manual web performance: complex workspace and history', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let setupMs = 0
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    const setupStarted = performance.now()
+    scaffold = await launchWebScaffold({})
+    const small = smallSidebarFixture()
+    for (let index = 0; index < SIDEBAR_SESSION_COUNT; index += 1) {
+      await seedSession(scaffold, small, `perf-sidebar-${String(index).padStart(4, '0')}`)
+    }
+    await seedSession(scaffold, longHistoryFixture(), LONG_SESSION_ID)
+    setupMs = performance.now() - setupStarted
+
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    tripwire = watchConsole(page)
+  })
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('reports sidebar, paging, and trajectory rendering costs', async () => {
+    const bootStarted = performance.now()
+    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    const group = page.getByRole('treeitem').first()
+    await expect.poll(() => group.textContent(), { timeout: 30_000 })
+      .toContain(`${String(SIDEBAR_SESSION_COUNT + 1)} sessions`)
+    const bootReadyMs = performance.now() - bootStarted
+
+    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)
+      const result = results.first()
+      await result.waitFor({ timeout: 60_000 })
+      return result
+    })
+
+    const openLongHistory = await measure(cdp, async () => {
+      await contentSearch.value.click()
+      await page.getByRole('tab', { name: 'Trajectory', exact: true }).waitFor({ timeout: 30_000 })
+      return conversationTurns(page)
+    })
+    expect(openLongHistory.value).toBeGreaterThan(0)
+
+    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)
+    for (let pageIndex = 0; pageIndex < 5; pageIndex += 1) {
+      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 report = {
+      fixture: {
+        sidebarSessions: SIDEBAR_SESSION_COUNT,
+        totalSessions: SIDEBAR_SESSION_COUNT + 1,
+        longHistoryTurns: LONG_HISTORY_TURNS,
+        toolCalls: EXPECTED_TOOL_CALLS,
+        trajectoryRows: EXPECTED_TRAJECTORY_ROWS,
+      },
+      setupMs: rounded(setupMs),
+      boot: {
+        readyMs: rounded(bootReadyMs),
+        firstContentfulPaintMs: firstContentfulPaintMs === undefined
+          ? null
+          : rounded(firstContentfulPaintMs),
+      },
+      sidebarExpand: sidebar.measurement,
+      contentSearch: contentSearch.measurement,
+      openLongHistory: {
+        initialTurns: openLongHistory.value,
+        ...openLongHistory.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,
+      },
+    }
+    console.info(`WEB_PERF_RESULT ${JSON.stringify(report, null, 2)}`)
+    expect(tripwire.warnings).toEqual([])
+    expect(tripwire.pageErrors).toEqual([])
+  })
+})

+ 2 - 1
apps/web/tsconfig.json

@@ -56,7 +56,8 @@
     "tests/goal-bar.e2e.ts",
     "tests/startup-auto-selection.e2e.ts",
     "tests/subagent-conversation.e2e.ts",
-    "tests/bash-abort-row.e2e.ts"
+    "tests/bash-abort-row.e2e.ts",
+    "tests/complex-history.perf.ts"
   ],
   "references": [
     {

+ 2 - 0
package.json

@@ -33,6 +33,8 @@
     "test:web": "npm run build && npm run test:web:built",
     "test:web:refresh": "npm run build && DSH_SNAPSHOT=refresh vitest run --config vitest.web.config.ts",
     "test:web:built": "vitest run --config vitest.web.config.ts",
+    "test:web:perf": "npm run build && npm run test:web:perf:built",
+    "test:web:perf:built": "vitest run --config vitest.web.perf.config.ts",
     "test:gui": "vitest run packages/client packages/host",
     "check:all": "tsx scripts/run-gates.ts check-all",
     "check:ci": "tsx scripts/run-gates.ts ci-primary",

+ 1 - 0
tsconfig.host.json

@@ -44,6 +44,7 @@
     "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/complex-history.perf.ts",
     "apps/cli/tests/**/*.ts",
     "examples/*/src/**/*.ts",
     "examples/*/start.ts",

+ 15 - 0
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,
+  },
+})