Procházet zdrojové kódy

test(web): lifecycle & chrome scenarios — workspace flow, reload recovery, dark mode

One tiny recorded text turn drives three whole-page concerns:

- workspace flow over the real wire: the empty-state hero's first send
  materializes a real Workspace + Session (the jsdom workspace-flow suite
  pins this state machine over the fixture client; this scenario pins it
  through HTTP RPC + SSE + the gateway). Durable proof: the session
  header's cwd is the create-by-name target <workspaceRoot>/workspace.
  Adds the hero waiting-state aria golden.
- reload recovery: collapse the sidebar (persisted dsh.layout.panels),
  page.reload, and the surface comes back whole from persistence alone —
  layout collapsed, selection restored (dsh.sessions.current), the
  recorded turn re-rendered from session.history with zero model calls
  (the drained replay cursor makes any stray request fail loud at close).
- dark mode: no product control flips the theme yet, so the scenario
  drives the ThemeService's entire DOM contract — body[data-ds-dark-theme]
  — and pins the shipped cascade: the alias token flips, a painted surface
  repaints, and removing the attribute restores the light sample exactly.
  TODO(web-theme-gesture) upgrades to a real settings control; no theme
  golden per the lane's scope ruling (aria is color-blind).

Agent Note scenario list extended in both languages; pairing re-recorded.
Tianyi Cui před 1 měsícem
rodič
revize
cbb5fc7a51

+ 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
-2026-07-24-web-gui-browser-e2e-lane.md: f97bcfa77e3e6949945197cfe33abd7e1eec8008
-2026-07-24-web-gui-browser-e2e-lane.zh.md: 3ec27956dd3c2ed985600d9e24f90155f99dc932
+2026-07-24-web-gui-browser-e2e-lane.md: 88730cdecf527ece8033ddab1151afcbc6edd83f
+2026-07-24-web-gui-browser-e2e-lane.zh.md: 9850023a49a860a8f4bbdacc8c48fc389ec77210

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

@@ -48,6 +48,7 @@ The typecheck plane split is structural: the three files that boot the host spin
 4. **`question-composer`** — the shipped composition's resident `ask_user_question` takeover: a recorded turn blocks mid-step on the real userInteraction seam, the composer (`[data-question-key]`) renders in the browser, the test answers through it (the ONE sanctioned place a drive step reacts to model content: the turn cannot complete without the answer, in record and replay alike), and the tool result carries the chosen label. Golden: the composer's stable waiting state.
 5. **`steering`** — mid-turn steer while the question composer blocks the step (the deterministic mid-turn window; no timing dependence). The composer locks while running, so the steer POSTs `session.prompt` `mode:'steer'` from the page over the same same-origin `/api` wire the client uses (`TODO(web-steer-composer)`: drive a composer gesture once one exists); everything downstream is product — gateway → `Agent.steer` → step-boundary drain → durable `steering/message` → SSE → badged interjection bubble. Record-mode fixture honesty: the recording is rejected unless the live model's final reply obeys an instruction only the steering message carries.
 6. **`navigation-panes`** — one rich two-turn seed (turn 1: bash + two parallel reads in one assistant message; turn 2: a markdown-heavy reply) rendered cold through the seeded-history pattern (zero model calls), serving four surfaces: sidebar search (client-side title filter — asserted only after the durable title lands with the attach baseline, because a cold `SessionSummary` carries no title and search matches the `displayTitle` the user sees; negative query empties the tree, positive narrows, clear restores), the Trajectory tab (turn sections + the step group's tool mix plus the view-area aria golden), the Waterfall tab (span stats + one lane per span — the P-I fold counts a turn-0 prologue span because only assistant/steering nodes carry a turn number, pinned as-is), and the details column (the bash toolview row routes click to openDetails; open/closed is asserted on the frame's `data-details-collapsed` attribute because close collapses the grid column to width 0 without unmounting the subtree).
+7. **`lifecycle-chrome`** — one tiny recorded text turn drives three whole-page concerns. Workspace flow over the real wire: the empty-state hero's first send materializes a real Workspace + Session (the jsdom `workspace-flow.snapshot.ts` suite pins this state machine over the fixture client; this scenario pins it through HTTP RPC + SSE + the gateway), proven durably by the session header's cwd being the create-by-name target `<workspaceRoot>/workspace`, plus the hero waiting-state aria golden. Reload recovery: collapse the sidebar (persisted `dsh.layout.panels`), `page.reload`, and the surface comes back whole from persistence alone — layout collapsed, selection restored (`dsh.sessions.current`), the recorded turn re-rendered from `session.history` with zero model calls (the drained replay cursor makes any stray request fail loud at close). Dark mode: no product control flips the theme yet, so the scenario drives the ThemeService's entire DOM contract — the `body[data-ds-dark-theme]` attribute — and pins the shipped cascade: the alias token flips, a painted surface repaints, and removing the attribute restores the light sample exactly (`TODO(web-theme-gesture)` upgrades to a real settings control); per the scope ruling there is no theme/layout golden (aria is color-blind).
 
 ### CI stance
 

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

@@ -48,6 +48,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
 4. **`question-composer`**——已交付组合中常驻的 `ask_user_question` 接管:一段已录轮次在真实的 userInteraction seam 上阻塞于步骤中途,提问输入框(`[data-question-key]`)在浏览器中渲染,测试经它作答(这是驱动步骤对模型内容作出反应的唯一获准之处:没有这个回答,轮次无法完成,record 与 replay 皆然),工具结果携带所选的 label。预期输出:提问输入框稳定的等待态。
 5. **`steering`**——在提问输入框阻塞该步骤时做轮次中途 steering(中途引导),此即确定性的轮次中途窗口,不依赖任何时序。输入框在运行期间锁定,因此这一 steer 由页面经客户端所用的同一条同源 `/api` wire POST `session.prompt` `mode:'steer'`(`TODO(web-steer-composer)`:待有输入框手势后改为驱动它);下游的一切都是产品路径——gateway → `Agent.steer` → 步骤边界排空 → 持久的 `steering/message` → SSE → 带徽标的插话气泡。record 模式的 fixture 诚实性:除非真实模型的最终回复遵循了一条只有 steering 消息才携带的指令,否则该次录制被拒绝。
 6. **`navigation-panes`**——一份内容丰富的双轮次种子(轮次 1:同一条 assistant 消息内的 bash + 两次并行 read;轮次 2:一段 markdown 密集的回复)经 seeded-history 模式冷渲染(零模型调用),承载四个表面:侧栏搜索(客户端标题过滤;仅在持久的标题随 attach 基线一同到达后才断言,因为冷的 `SessionSummary` 不携带标题,而搜索匹配的是用户所见的 `displayTitle`;反例查询清空整棵树,正例查询收窄,清除后复原)、Trajectory 标签页(轮次分节 + 步骤组的工具构成,外加视图区 aria 预期输出)、Waterfall 标签页(span 统计 + 每个 span 一条泳道;只有 assistant/steering 节点携带轮次编号,因此 P-I 折叠会将一个轮次 0 的序幕 span 计入,按原样钉住)与详情列(bash 工具视图行把点击路由到 openDetails;打开/关闭状态断言在 frame 的 `data-details-collapsed` 属性上,因为关闭把网格列收缩到宽度 0 而不卸载子树)。
+7. **`lifecycle-chrome`**——一段极小的已录纯文本轮次驱动三个整页关注点。真实 wire 上的 Workspace 动线:空态 hero 的首次发送物化出真实的 Workspace + Session(jsdom 的 `workspace-flow.snapshot.ts` 套件基于 fixture 客户端钉住这一状态机;本场景则经 HTTP RPC + SSE + gateway 钉住它),其持久证据是会话头部的 cwd 恰为按名创建的目标 `<workspaceRoot>/workspace`,外加 hero 等待态的 aria 预期输出。重新加载恢复:折叠侧栏(持久化于 `dsh.layout.panels`),`page.reload`,整个表面纯凭持久化完整归来——布局保持折叠,选中项恢复(`dsh.sessions.current`),已录轮次从 `session.history` 重新渲染且零模型调用(已耗尽的回放游标使任何离群请求都在 close 时大声失败)。暗色模式:产品尚无任何控件能切换主题,因此本场景驱动 ThemeService 的整个 DOM 契约(即 `body[data-ds-dark-theme]` 属性),并钉住已交付的级联:alias token 翻转,某个实际绘制的表面重绘,移除该属性则精确还原亮色采样值(`TODO(web-theme-gesture)`:待有真实设置控件后升级为驱动它);按范围裁定,主题/布局不设预期输出(aria 感知不到颜色)。
 
 ### CI 立场
 

+ 152 - 0
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -0,0 +1,152 @@
+// Web e2e scenarios: lifecycle & chrome — the workspace-aware first-send
+// flow over the real wire, reload recovery, and the dark-mode token cascade.
+// One tiny recorded turn (text-only) drives the whole spec: the empty-state
+// hero materializes a real Workspace + Session on first send (the jsdom
+// workspace-flow suite pins the object-layer state machine over the fixture
+// client; THIS spec pins the same flow through HTTP RPC + SSE + the host
+// gateway), reload replays everything from the log (zero further model
+// calls), and the theme scenario proves the shipped dark palette actually
+// cascades: attribute -> alias token flip -> painted surface change. Per the
+// lane's scope ruling there is no theme/layout golden (aria is color-blind);
+// the hero's waiting state gets the one golden here.
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+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 { SessionEvent } from '@deepseek-ai/dsh-session'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url))
+const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
+const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
+const MODE = webSnapshotMode()
+
+const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
+
+describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+  const sessionEvents: SessionEvent[] = []
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
+    scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
+    browser = await chromium.launch()
+    page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('sends the first prompt from the empty-state hero (all modes)', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send'))
+    if (MODE !== 'record') {
+      expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
+    }
+    // The blank frame renders the hero, not the resident composer: the
+    // headline plus the guidance placeholder are the empty state's anchors.
+    await expect.poll(() => page.getByText("Let's start building", { exact: false }).count(), { timeout: 15_000 }).toBe(1)
+    const input = page.locator('textarea').first()
+    await input.waitFor({ timeout: 10_000 })
+    if (MODE !== 'record') {
+      // Golden of the hero's stable waiting state (captured before any send;
+      // the conversation-region goldens belong to the other scenarios).
+      const snapshot = await captureStableAria(page, '[class*="frame"]', scaffold.workspaceCwd)
+      await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE)
+    }
+    const settled = scaffold.whenTurnSettled()
+    await input.fill(PROMPT)
+    await input.press('Enter')
+    const sessionId = await settled
+    if (MODE === 'record') {
+      await recordFixture(scaffold, sessionId, FIXTURE)
+    }
+  }, 200_000)
+
+  it.skipIf(MODE === 'record')('materialized a real Workspace and Session over the wire', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-materialize'))
+    // Browser: the sidebar tree now carries the auto-created workspace group
+    // with its one session, and the opened session is the selected row.
+    await expect.poll(() => page.getByText('1 session', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
+    await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
+    await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
+    // Host: the session's durable header cwd is the workspace flow's
+    // create-by-name target (<workspaceRoot>/workspace, the composer's
+    // default draft name) — the proof the send went through workspace
+    // materialization rather than a bare default-cwd session.
+    const cwds = scaffold.ctx.sessions.list().map(session => session.header.cwd)
+    expect(cwds).toEqual([join(scaffold.workspaceCwd, 'workspace')])
+    const turnEnds = sessionEvents.filter(e => e.type === 'turn/end')
+    expect(turnEnds).toHaveLength(1)
+    expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')
+  }, 60_000)
+
+  it.skipIf(MODE === 'record')('recovers the whole surface across a reload from the log alone', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-reload'))
+    // Fold a layout preference into the same reload: collapse the sidebar
+    // (persisted under dsh.layout.panels) before reloading.
+    await page.getByRole('button', { name: 'Collapse sidebar' }).click()
+    await expect.poll(() => page.getByRole('button', { name: 'Open sidebar' }).count(), { timeout: 10_000 }).toBe(1)
+    await page.reload({ waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    // Layout persisted: the sidebar comes back collapsed.
+    await expect.poll(() => page.getByRole('button', { name: 'Open sidebar' }).count(), { timeout: 10_000 }).toBe(1)
+    // Selection persisted (dsh.sessions.current) and history replayed: the
+    // recorded turn re-renders from session.history with zero model calls —
+    // the replay cursor was fully consumed before the reload, so any stray
+    // request would fail the scenario loudly at close().
+    await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
+    // Expand back and confirm the tree still lists the materialized session.
+    await page.getByRole('button', { name: 'Open sidebar' }).click()
+    await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 90_000)
+
+  it.skipIf(MODE === 'record')('cascades the dark theme from the body attribute to painted surfaces', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-dark'))
+    // No product control flips the theme yet — the ThemeService's whole DOM
+    // contract is the body[data-ds-dark-theme] attribute, so the scenario
+    // drives exactly that seam and pins the shipped stylesheet's cascade.
+    // TODO(web-theme-gesture): drive a real settings control once one exists.
+    const sample = async (): Promise<{ token: string; sidebarBg: string; bodyBg: string }> =>
+      await page.evaluate(() => {
+        const sidebar = document.querySelector('[class*="sidebar"], [class*="rail"]') ?? document.body
+        return {
+          token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(),
+          sidebarBg: getComputedStyle(sidebar).backgroundColor,
+          bodyBg: getComputedStyle(document.body).backgroundColor,
+        }
+      })
+    const light = await sample()
+    await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
+    const dark = await sample()
+    // The alias token itself must flip — the cascade's root fact.
+    expect(dark.token).not.toBe(light.token)
+    // And a real painted surface must consume it (not just variables in a
+    // void): at least one of the sampled backgrounds repaints.
+    expect(dark.sidebarBg !== light.sidebarBg || dark.bodyBg !== light.bodyBg).toBe(true)
+    // Removing the attribute restores the light values exactly (the palettes
+    // live in one stylesheet; activation is attribute-only by design).
+    await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
+    const restored = await sample()
+    expect(restored).toEqual(light)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
+  it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
+    await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'hero.expected.md'])
+  })
+})

+ 35 - 0
apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md

@@ -0,0 +1,35 @@
+- button "Collapse sidebar":
+  - img
+- button "New session":
+  - img
+  - text: New Session
+- text: Workspaces
+- button "Group by":
+  - img
+- button "Create workspace":
+  - img
+- button "Search sessions":
+  - img
+- textbox "Search name, keywords..."
+- tree "Sessions": No sessions yet
+- button "Settings":
+  - img
+  - text: Settings
+- text: Let's start building
+- button "Choose workspace":
+  - img
+  - text: workspace
+  - img
+- textbox "Describe what you want to build"
+- button "Add attachment":
+  - img
+- combobox "Plan mode":
+  - option "Plan" [selected]
+  - option "Agent"
+- combobox "Access mode":
+  - option "Read-only" [selected]
+  - option "Read-write"
+- combobox "Model":
+  - option "DeepSeek-V4-Pro High" [selected]
+  - option "DeepSeek-V4-Pro"
+- button "Send message" [disabled]

+ 35 - 0
apps/web/tests/snapshots/lifecycle-chrome/session.jsonl

@@ -0,0 +1,35 @@
+{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785015039278,"cwd":"{{cwd}}/workspace"}
+{"type":"turn/start","seq":0,"time":1785015039291,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
+{"type":"user/message","seq":1,"time":1785015039292,"data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"session/title","seq":2,"time":1785015039294,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}}
+{"type":"step/start","seq":3,"time":1785015039362,"data":{"turn":1,"step":1}}
+{"type":"request/header","seq":4,"time":1785015039363,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
+{"type":"assistant/chunk","seq":5,"time":1785015039930,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"assistant/chunk","seq":6,"time":1785015039930,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
+{"type":"assistant/chunk","seq":7,"time":1785015040092,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
+{"type":"assistant/chunk","seq":8,"time":1785015040120,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}
+{"type":"assistant/chunk","seq":9,"time":1785015040121,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
+{"type":"assistant/chunk","seq":10,"time":1785015040121,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
+{"type":"assistant/chunk","seq":11,"time":1785015040121,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
+{"type":"assistant/chunk","seq":12,"time":1785015040167,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
+{"type":"assistant/chunk","seq":13,"time":1785015040168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}}
+{"type":"assistant/chunk","seq":14,"time":1785015040168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}}
+{"type":"assistant/chunk","seq":15,"time":1785015040168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}}
+{"type":"assistant/chunk","seq":16,"time":1785015040168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
+{"type":"assistant/chunk","seq":17,"time":1785015040179,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}}
+{"type":"assistant/chunk","seq":18,"time":1785015040179,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
+{"type":"assistant/chunk","seq":19,"time":1785015040179,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" comply"}}}
+{"type":"assistant/chunk","seq":20,"time":1785015040209,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
+{"type":"assistant/chunk","seq":21,"time":1785015040209,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
+{"type":"assistant/chunk","seq":22,"time":1785015040209,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"L"}}}
+{"type":"assistant/chunk","seq":23,"time":1785015040210,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"IGH"}}}
+{"type":"assistant/chunk","seq":24,"time":1785015040210,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"TH"}}}
+{"type":"assistant/chunk","seq":25,"time":1785015040240,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"O"}}}
+{"type":"assistant/chunk","seq":26,"time":1785015040241,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"USE"}}}
+{"type":"assistant/chunk","seq":27,"time":1785015040241,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."}}}}
+{"type":"assistant/chunk","seq":28,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"LIGHTHOUSE"}}}}
+{"type":"assistant/chunk","seq":29,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}}}}
+{"type":"assistant/chunk","seq":30,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":31,"time":1785015040244,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."},{"type":"text","text":"LIGHTHOUSE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"}
+{"type":"step/end","seq":32,"time":1785015040246,"data":{"turn":1,"step":1}}
+{"type":"turn/end","seq":33,"time":1785015040247,"data":{"turn":1,"reason":{"kind":"completed"}}}

+ 1 - 0
apps/web/tsconfig.json

@@ -27,6 +27,7 @@
     "tests/question-composer.e2e.ts",
     "tests/steering.e2e.ts",
     "tests/navigation-panes.e2e.ts",
+    "tests/lifecycle-chrome.e2e.ts",
     "tests/replay-round-trip.e2e.ts",
     "tests/seeded-history.e2e.ts"
   ],

+ 1 - 0
tsconfig.host.json

@@ -14,6 +14,7 @@
     "apps/web/tests/question-composer.e2e.ts",
     "apps/web/tests/steering.e2e.ts",
     "apps/web/tests/navigation-panes.e2e.ts",
+    "apps/web/tests/lifecycle-chrome.e2e.ts",
     "apps/web/tests/replay-round-trip.e2e.ts",
     "apps/web/tests/seeded-history.e2e.ts",
     "apps/cli/tests/**/*.ts",