Browse Source

fix(web): enter plan mode without a model round in the regression test

The /plan command handler commits plan/mode active immediately on the
live agent (the lifecycle-chrome precedent), so the test drops the
recorded fixture, the record/replay mode split, and the turn-settled
wait. The golden comparison stays in replay/refresh modes; the fixture
file is removed and the note describes the no-model path.
Chinesezjc 2 months ago
parent
commit
aae246ef6d

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.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-08-06-plan-narrow-viewport-regression.md
-2026-08-06-plan-narrow-viewport-regression.md: a183a17ce90eecbf0d1af524dbbefe8e417dd463
-2026-08-06-plan-narrow-viewport-regression.zh.md: 5ef610b12e9e5c8c79e2a2279f70f20e1e59aee7
+2026-08-06-plan-narrow-viewport-regression.md: c4d7281d09b706c1270e9c43592d558825c63250
+2026-08-06-plan-narrow-viewport-regression.zh.md: aed430dc95793b8086a838ae534ffaed0256012e

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.md

@@ -14,7 +14,7 @@ The browser regression test reproduced the report on current master: at 800×720
 
 The row wraps instead of shrinking its left group into the right group's area: `.row { flex-wrap: wrap }` plus `margin-left: auto` on `.trailing`, which re-anchors the trailing group (model + send) to the right edge of its wrapped line while `space-between` already pins it right on a single line. Wrapping is the acceptance's "wrap, fold, or re-arrange controls when space runs out" option, keeps every control at full width (no label folding that would hide the model name or the Plan wordmark), and holds at every viewport width by construction instead of at a calibrated container-query threshold.
 
-Add `apps/web/tests/plan-control-row.e2e.ts`: enter plan mode once through the real `/plan` command during record (the model replies OK and calls no tool, so the review takeover never replaces the control row), then replay the recorded turn keyless. Plan state folds from the session log (`plan/mode`, last one wins), so the chip renders at replay time without a model call. The file joins the host-plane e2e pairing like every sibling: excluded from the client graph in `apps/web/tsconfig.json` (it imports host-plane types) AND included in the host aggregate in `tsconfig.host.json`, so exactly one TypeScript program owns it — the pairing that also gives the lint type service its program.
+Add `apps/web/tests/plan-control-row.e2e.ts`: enter plan mode with the real `/plan` command (no argument — the command handler commits plan/mode active without a model round, the lifecycle-chrome precedent), so the test needs no fixture and no API key. The file joins the host-plane e2e pairing like every sibling: excluded from the client graph in `apps/web/tsconfig.json` (it imports host-plane types) AND included in the host aggregate in `tsconfig.host.json`, so exactly one TypeScript program owns it — the pairing that also gives the lint type service its program.
 
 The geometry golden records stable facts — viewport membership on both axes and disjoint click areas — never absolute coordinates, whose pixel values depend on installed fonts and differ between macOS and Linux. The behavior assertions implement the acceptance directly: the click areas are disjoint, the click at the chip's center (Playwright's actionability check) leaves plan mode through the real command channel (`/plan off` via `commands.execute`), and the last `plan/mode` event in the session log flips inactive.
 
@@ -30,4 +30,4 @@ The geometry golden records stable facts — viewport membership on both axes an
 
 ## Consequences
 
-Any future change to the control row layout — fonts, gaps, media or container queries — that re-introduces overlap or moves the chip out of the viewport on either axis fails this test. Recording needs a real API key locally; CI replays keyless. The fixture's recorded user prompt is the single source tying the drive step to the recorded reality (`fixtureUserPrompts`), so prompt and fixture cannot drift.
+Any future change to the control row layout — fonts, gaps, media or container queries — that re-introduces overlap or moves the chip out of the viewport on either axis fails this test. The test needs no API key: plan mode toggles through the command handler without a model round, and the golden is compared in replay/refresh modes.

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md

@@ -14,7 +14,7 @@ Status: implemented
 
 控制行换行而不是把左侧组收缩进右侧组的区域:`.row { flex-wrap: wrap }` 加上 `.trailing` 的 `margin-left: auto`——后者把 trailing 组(模型选择 + 发送)重新锚定到换行后的右缘,单行时 `space-between` 已把它钉在右侧。换行是验收中"空间不足时允许换行、折叠或重新排列控件"的选项,保持每个控件全宽(不做会隐藏模型名或 Plan 字样的 label 折叠),并且按构造在所有视口宽度下成立,而非依赖标定的容器查询阈值。
 
-新增 `apps/web/tests/plan-control-row.e2e.ts`:录制时通过真实 `/plan` 命令进入一次 Plan 模式(模型只回复 OK 且不调用任何工具,因此 review takeover 不会替换控制行),随后 keyless 回放录制的回合。Plan 状态从会话日志折叠(`plan/mode`,最后一条生效),回放时无需模型调用即可渲染 chip。该文件与所有同类 host 平面 e2e 一样采用成对登记:在 `apps/web/tsconfig.json` 的 exclude 列表(它导入 host 平面类型,client 图绝不编译它),同时在 `tsconfig.host.json` 的 host 聚合 include 中——恰好一个 TypeScript 程序拥有它,这也是 lint 类型服务获得程序的配对方式。
+新增 `apps/web/tests/plan-control-row.e2e.ts`:通过真实 `/plan` 命令(无参数——命令 handler 不经模型回合即提交 plan/mode active,lifecycle-chrome 先例)进入 Plan 模式,因此测试无需 fixture 与 API key。该文件与所有同类 host 平面 e2e 一样采用成对登记:在 `apps/web/tsconfig.json` 的 exclude 列表(它导入 host 平面类型,client 图绝不编译它),同时在 `tsconfig.host.json` 的 host 聚合 include 中——恰好一个 TypeScript 程序拥有它,这也是 lint 类型服务获得程序的配对方式。
 
 几何 golden 记录稳定事实——两个轴上的视口内位置与点击区域不相交——绝不记录绝对坐标,其像素值依赖安装字体且在 macOS 与 Linux 间不同。行为断言直接实现验收:点击区域不相交、点击 chip 中心(Playwright 的可操作性检查)经真实命令通道(`commands.execute` 执行 `/plan off`)退出 Plan 模式,且会话日志中最后一条 `plan/mode` 事件翻转为 inactive。
 
@@ -30,4 +30,4 @@ Status: implemented
 
 ## 后果
 
-任何改变控制行布局的后续改动——字体、间距、媒体查询或容器查询——一旦重新引入重叠或把 chip 沿任一轴移出视口,本测试即失败。录制需要本地真实 API key;CI keyless 回放。fixture 中录制的用户 prompt 是驱动步骤与录制事实之间的唯一纽带(`fixtureUserPrompts`),prompt 与 fixture 不会漂移。
+任何改变控制行布局的后续改动——字体、间距、媒体查询或容器查询——一旦重新引入重叠或把 chip 沿任一轴移出视口,本测试即失败。测试无需 API key:Plan 模式经命令 handler 切换,不经模型回合;golden 在 replay/refresh 模式下比较。

+ 25 - 51
apps/web/tests/plan-control-row.e2e.ts

@@ -5,13 +5,12 @@
 // deepseek-harness#1406): "increase an 800×720 browser regression test and
 // assert that the plan center hits the plan button".
 //
-// Plan mode is entered through the real /plan command once, during record,
-// against the live model; replay replays the recorded turn keyless. Plan
-// state folds from the session log (`plan/mode`, last one wins), so the chip
-// is present at replay time without any model call. A cold seeded session
-// cannot serve the exit path: the chip executes /plan off through
-// commands.execute, which needs the live agent the recorded turn keeps — the
-// product's own user path for this scenario.
+// Plan mode is entered through the real /plan command with no argument:
+// the command handler commits plan/mode active on the live agent without a
+// model round (the lifecycle-chrome precedent), so the test needs no
+// fixture and no API key. Plan state folds from the session log (`plan/mode`,
+// last one wins); the chip executes /plan off through commands.execute, which
+// needs the live agent connectFreshWorkspace keeps.
 //
 // The geometry golden records stable facts — viewport membership on both
 // axes for the chip and the trigger, and disjoint click areas — never
@@ -20,8 +19,6 @@
 // actionability check: clicking the chip fails in a real engine when the
 // element center does not receive pointer events. jsdom resolves no
 // layout, so only a real engine can answer any of these facts.
-import { readFile } from 'node:fs/promises'
-import { mkdirSync } from 'node:fs'
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
 import type { Browser, Page } from 'playwright'
@@ -32,13 +29,12 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import type {} from '@deepseek-ai/dsh-plan-mode'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
-  assertFixtureInventory, compareOrRefreshGolden, fixtureUserPrompts,
-  launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
+  assertFixtureInventory, compareOrRefreshGolden,
+  launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/plan-narrow-viewport', import.meta.url))
-const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md')
 const MODE = webSnapshotMode()
 
@@ -48,15 +44,6 @@ const VIEWPORT = { width: 800, height: 720 } as const
 /** Chip aria-label on the English page; the seat renders only while plan is the effective target. */
 const CHIP_ARIA = 'Plan mode on, press to turn off'
 
-/**
- * The recorded user prompt. The model must not call exit_plan_mode: that
- * would raise the review takeover and replace the composer's control row,
- * which is the surface under test. The guidance section still asks it to
- * produce a plan, so the prompt overrides that for the recorded turn.
- */
-const TASK = 'Reply with exactly the single word OK and call no tools. Do not produce a plan. This is a layout test, not a planning session.'
-const LINE = `/plan ${TASK}`
-
 describe('web e2e: plan chip click area at the narrow viewport', () => {
   let scaffold: WebScaffold
   let browser: Browser
@@ -65,7 +52,7 @@ describe('web e2e: plan chip click area at the narrow viewport', () => {
   const sessionEvents: SessionEvent[] = []
 
   beforeAll(async () => {
-    scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
+    scaffold = await launchWebScaffold({})
     scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
     browser = await chromium.launch()
     page = await newEnglishPage(browser, VIEWPORT.height)
@@ -83,24 +70,18 @@ describe('web e2e: plan chip click area at the narrow viewport', () => {
 
   it('keeps the plan chip and model trigger disjoint and exits plan mode by click', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-narrow-viewport'))
-    if (MODE !== 'record') {
-      expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([TASK])
-    }
     const input = page.locator('textarea').first()
     await input.waitFor({ timeout: 10_000 })
-    const settled = scaffold.whenTurnSettled(MODE === 'record' ? 180_000 : 30_000)
-    await input.fill(LINE)
+    await input.fill('/plan ')
     await input.press('Enter')
 
-    // Plan mode is on once the recorded turn settles: the fold of plan/mode
-    // events is active and the review takeover never appeared (the model
-    // called no tool), so the composer control row — the surface under test —
-    // is the one visible.
+    // The command handler commits plan/mode active immediately (no model
+    // round), so the chip renders and the composer control row — the surface
+    // under test — is the one visible.
     const chip = page.getByRole('button', { name: CHIP_ARIA })
     const trigger = page.getByRole('button', { name: /Select model/ })
-    await chip.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
+    await chip.waitFor({ timeout: 30_000 })
     await trigger.waitFor({ timeout: 10_000 })
-    const sessionId = await settled
     const chipBox = await chip.boundingBox()
     const triggerBox = await trigger.boundingBox()
     expect(chipBox).not.toBeNull()
@@ -119,25 +100,18 @@ describe('web e2e: plan chip click area at the narrow viewport', () => {
     const overlapBottom = Math.min(chipBox!.y + chipBox!.height, triggerBox!.y + triggerBox!.height)
     const overlapArea = Math.max(0, overlapRight - overlapLeft) * Math.max(0, overlapBottom - overlapTop)
 
-    if (MODE !== 'record') {
-      const golden = [
-        '# Plan chip and model trigger at the 800×720 viewport',
-        '',
-        '- Plan chip fully in viewport: ' + (chipInViewport ? 'true' : 'false'),
-        '- Model trigger fully in viewport: ' + (triggerInViewport ? 'true' : 'false'),
-        '- Click areas disjoint: ' + (overlapArea === 0 ? 'true' : 'false'),
-      ].join('\n').trimEnd()
-      await compareOrRefreshGolden(LAYOUT_EXPECTED, golden, MODE)
-    }
+    const golden = [
+      '# Plan chip and model trigger at the 800×720 viewport',
+      '',
+      '- Plan chip fully in viewport: ' + (chipInViewport ? 'true' : 'false'),
+      '- Model trigger fully in viewport: ' + (triggerInViewport ? 'true' : 'false'),
+      '- Click areas disjoint: ' + (overlapArea === 0 ? 'true' : 'false'),
+    ].join('\n').trimEnd()
+    await compareOrRefreshGolden(LAYOUT_EXPECTED, golden, MODE)
     expect(overlapArea).toBe(0)
     expect(chipInViewport).toBe(true)
     expect(triggerInViewport).toBe(true)
 
-    if (MODE === 'record') {
-      mkdirSync(SNAPSHOT_DIR, { recursive: true })
-      await recordFixture(scaffold, sessionId, FIXTURE)
-      return
-    }
     // Exit through the real command channel: the click at the chip's center
     // executes /plan off and the folded projection flips inactive, so the chip
     // unmounts. Playwright's click() targets the element center by default and
@@ -147,7 +121,7 @@ describe('web e2e: plan chip click area at the narrow viewport', () => {
     await chip.click()
     await expect.poll(() => page.getByRole('button', { name: CHIP_ARIA }).count(), { timeout: 15_000 }).toBe(0)
     // The click must have committed the exit: the last plan/mode event flips
-    // inactive (the recorded turn's entry event stays active:true earlier in
+    // inactive (the /plan command's entry event stays active:true earlier in
     // the log, so the pair proves the exit and not just the entry).
     const planModes = sessionEvents.filter(
       (event): event is SessionEvent<'plan/mode'> => event.type === 'plan/mode',
@@ -157,7 +131,7 @@ describe('web e2e: plan chip click area at the narrow viewport', () => {
     expect(tripwire.warnings).toEqual([])
   }, 200_000)
 
-  it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'layout.expected.md'])
+  it('keeps the snapshot inventory closed', async () => {
+    await assertFixtureInventory(SNAPSHOT_DIR, ['layout.expected.md'])
   })
 })

+ 0 - 27
apps/web/tests/snapshots/plan-narrow-viewport/session.jsonl

@@ -1,27 +0,0 @@
-{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1786004477969,"cwd":"{{cwd}}/workspace"}
-{"type":"permission/preset","seq":0,"time":1786004477971,"data":{"preset":"workspace-write"}}
-{"type":"sandbox/mode","seq":1,"time":1786004477973,"data":{"mode":"workspace-write"}}
-{"type":"approval/policy","seq":2,"time":1786004477973,"data":{"policy":"ask"}}
-{"type":"command/run","seq":3,"time":1786004478028,"data":{"commandId":"cmd-777e6094-1","name":"plan","args":" Reply with exactly the single word OK and call no tools. Do not produce a plan. This is a layout test, not a planning session.","source":{"kind":"user"}}}
-{"type":"plan/mode","seq":4,"time":1786004478028,"data":{"active":true}}
-{"type":"agent/inbox/spliced","seq":5,"time":1786004478029,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the single word OK and call no tools. Do not produce a plan. This is a layout test, not a planning session."}],"source":{"kind":"user"},"role":"user","id":"b642b6de-ca13-4227-8889-00c385675ffb"}]}}
-{"type":"turn/start","seq":6,"time":1786004478029,"data":{"turn":1}}
-{"type":"agent/inbox/spliced","seq":7,"time":1786004478030,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
-{"type":"command/done","seq":8,"time":1786004478031,"data":{"commandId":"cmd-777e6094-1","kind":"success","text":"Plan mode on. Use /plan off to leave."}}
-{"type":"step/start","seq":9,"time":1786004478045,"data":{"turn":1,"step":1}}
-{"type":"user/message","seq":10,"time":1786004478046,"data":{"content":[{"type":"text","text":"Reply with exactly the single word OK and call no tools. Do not produce a plan. This is a layout test, not a planning session."}],"source":{"kind":"user"},"role":"user","id":"b642b6de-ca13-4227-8889-00c385675ffb"},"surfaceOp":"append"}
-{"type":"user/message","seq":11,"time":1786004478047,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}/workspace\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}/workspace\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"fc76937e-33ad-430d-a201-269a50ac2261"},"surfaceOp":"append"}
-{"type":"session/title","seq":12,"time":1786004478048,"data":{"title":"Reply with exactly the single","messageSeqs":[10],"source":{"kind":"fallback"}}}
-{"type":"request/header","seq":13,"time":1786004478050,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
-{"type":"request/context","seq":14,"time":1786004478050,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}
-{"type":"assistant/chunk","seq":15,"time":1786004479125,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
-{"type":"reasoning-chunks","seq0":16,"time0":1786004479125,"data":{"turn":1,"step":1,"index":0,"dt":[101,25,22,1,0,0,1,0,22,1,0,21,1,23,0,0,0,1,0,21,0,23,23,0,1,0,22,0,1,23,0,0,0,1,0],"texts":["The"," user"," asks"," me"," to"," reply"," with"," exactly"," the"," single"," word"," OK"," and"," call"," no"," tools","."," This"," is"," a"," layout"," test","."," I"," should"," comply"," —"," just"," reply"," \"","OK","\""," with"," no"," tools","."]}}
-{"type":"assistant/chunk","seq":52,"time":1786004479481,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
-{"type":"assistant/chunk","seq":53,"time":1786004479481,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}}
-{"type":"assistant/chunk","seq":54,"time":1786004479483,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asks me to reply with exactly the single word OK and call no tools. This is a layout test. I should comply — just reply \"OK\" with no tools."}}}}
-{"type":"assistant/chunk","seq":55,"time":1786004479483,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}}
-{"type":"assistant/chunk","seq":56,"time":1786004479483,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":8207,"outputTokens":38,"cacheReadTokens":0,"reasoningTokens":36}}}}
-{"type":"assistant/chunk","seq":57,"time":1786004479483,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":58,"time":1786004479486,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asks me to reply with exactly the single word OK and call no tools. This is a layout test. I should comply — just reply \"OK\" with no tools."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f9367815-e6e6-4f48-9048-942e0bf66f9a"},"usage":{"inputTokens":8207,"outputTokens":38,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57],"surfaceOp":"append"}
-{"type":"step/end","seq":59,"time":1786004479487,"data":{"turn":1,"step":1}}
-{"type":"turn/end","seq":60,"time":1786004479487,"data":{"turn":1,"reason":{"kind":"completed"}}}