From f688cd32aff6b16de611419f0d50002940b9b8d9 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:10:45 -0700 Subject: [PATCH 01/18] fix(workspace-context): escape instruction metadata --- .../2026-06-24-workspace-context.i18n.yaml | 4 +- .../feature/2026-06-24-workspace-context.md | 2 +- .../2026-06-24-workspace-context.zh.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 22 +++++++++-- .../snapshots/workspace-context/input.json | 2 +- .../workspace-context/replay.override.json | 10 +++++ .../snapshots/workspace-context/session.jsonl | 39 ++++++++++++------- .../workspace-context/README.i18n.yaml | 4 +- packages/context/workspace-context/README.md | 2 +- .../context/workspace-context/README.zh.md | 2 +- .../context/workspace-context/src/render.ts | 21 +++++----- .../tests/workspace-context.spec.ts | 33 +++++++++++++++- .../support/acp-snapshot/README.i18n.yaml | 4 +- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/README.zh.md | 2 +- packages/support/acp-snapshot/src/harness.ts | 8 ++++ packages/support/acp-snapshot/src/suite.ts | 7 ++++ .../acp-snapshot/tests/harness.spec.ts | 26 ++++++++++++- .../support/acp-snapshot/tests/suite.spec.ts | 3 ++ 19 files changed, 151 insertions(+), 44 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 8e25425874..073aa9fa4b 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.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-06-24-workspace-context.md -2026-06-24-workspace-context.md: f86e227be615c9b54e2a9013d3c7dca75d3975f0 -2026-06-24-workspace-context.zh.md: 154b5260955570e2de3c88d98286c5ea6afaa3b5 +2026-06-24-workspace-context.md: 8baced0143abb38ff34d16a072761ec016a53d6e +2026-06-24-workspace-context.zh.md: 392d57f344b97c1816f691fef75440f815bccb50 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index f86e227be6..8baced0143 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -34,7 +34,7 @@ The injection becomes a durable `user/message` with a typed `workspace-instructi A resumed agent creates a new loop instance and injects a baseline composed from current files before its first request. This permits current baseline content on resume without mutating an earlier history event. A resume and a hot plugin remount both face a log that may already hold a baseline; they are told apart by `agent/session-start`, which a startup or resume emits before the first step while a remount attaches to an already-live session and never sees it. A remount retains the existing baseline only when its typed event remains in the current visible surface, and still rebuilds scope and provider-version tracking from current files. If compaction has shadowed that event, the remount injects a current baseline. A resume always re-composes. -The baseline is a user-role `` with `Instructions from: ` sections and explicit authority and precedence language. This familiar model-facing frame avoids a harness-specific XML vocabulary. Project paths are root-relative and the user-global path is `~/.dsh/AGENTS.md` for the default home or `$DSH_HOME/AGENTS.md` for a configured home. A literal `` inside file content is escaped. The package README owns the exact current [prompt shape](../../../../packages/context/workspace-context/README.md#prompt-shape). +The baseline is a user-role `` with `Instructions from: ` sections and explicit authority and precedence language. This familiar model-facing frame avoids a harness-specific XML vocabulary. Project paths are root-relative and the user-global path is `~/.dsh/AGENTS.md` for the default home or `$DSH_HOME/AGENTS.md` for a configured home. The final rendering boundary escapes a literal `` anywhere in instruction content or model-visible path, scope, and budget metadata before byte accounting completes. The package README owns the exact current [prompt shape](../../../../packages/context/workspace-context/README.md#prompt-shape). ### Dynamic Discovery And Refresh diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index 154b526095..392d57f344 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -34,7 +34,7 @@ Status: implemented 恢复 agent 会创建新的循环实例,并在其第一次请求前注入由当前文件组合的基线。这样,恢复时可以使用当前基线内容,而无需修改先前的历史事件。恢复与插件热重挂都会面对日志中可能已存在基线的情况;二者通过 `agent/session-start` 区分:启动或恢复会在第一步前发出该事件,而热重挂附着到一个已存活的会话、永远不会看到它。只有当基线的类型化事件仍在当前可见表层中时,热重挂才保留既有基线,同时仍会根据当前文件重建 scope 与提供方版本跟踪。如果压缩(compaction)已遮蔽该事件,热重挂会注入当前基线。恢复则始终重新组合。 -基线是一条 user 角色的 ``,包含 `Instructions from: ` 章节,以及明确的权威性与优先级说明。这种熟悉的模型可见框架避免引入 harness 专用的 XML 词汇。项目路径相对于根目录;使用默认 home 时,用户全局路径为 `~/.dsh/AGENTS.md`,使用已配置 home 时则为 `$DSH_HOME/AGENTS.md`。文件内容中的字面量 `` 会被转义。包 README 负责规定当前准确的[提示词形态](../../../../packages/context/workspace-context/README.md#prompt-shape)。 +基线是一条 user 角色的 ``,包含 `Instructions from: ` 章节,以及明确的权威性与优先级说明。这种熟悉的模型可见框架避免引入 harness 专用的 XML 词汇。项目路径相对于根目录;使用默认 home 时,用户全局路径为 `~/.dsh/AGENTS.md`,使用已配置 home 时则为 `$DSH_HOME/AGENTS.md`。最终渲染边界会在完成字节核算前,转义指令内容或模型可见的路径、scope 与预算元数据中出现的字面量 ``。包 README 负责规定当前准确的[提示词形态](../../../../packages/context/workspace-context/README.md#prompt-shape)。 ### 动态发现与刷新 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index a6c91bd5cd..248a5ac88e 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -1,5 +1,6 @@ import { fileURLToPath } from 'node:url' import { readFileSync } from 'node:fs' +import { mkdir, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { homedir } from 'node:os' import { expect, it } from 'vitest' @@ -45,6 +46,15 @@ const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' +async function prepareDelimiterPathWorkspace(cwd: string): Promise { + const dir = join(cwd, 'scope') + await mkdir(dir, { recursive: true }) + await Promise.all([ + writeFile(join(dir, 'AGENTS.md'), 'Delimiter path snapshot instruction.\n'), + writeFile(join(dir, 'task.txt'), 'delimiter path snapshot task\n'), + ]) +} + // FIXME: Migrate backend-oriented scenarios to the headless stream-json suite; // this ACP suite should eventually retain only automation-protocol contracts. @@ -152,11 +162,13 @@ const SCENARIOS: Scenario[] = [ { name: 'repeat-tool-guard', hasModelTurn: true, recorded: false }, // Authored replay: a root AGENTS.md pins the session prefix, then a read in // nested/ discovers its narrower AGENTS.md as a raw, metadata-bearing - // injected user/message. Both AGENTS.md fixtures are symlinks to a sibling + // injected user/message. Both portable AGENTS.md fixtures are symlinks to a sibling // AGENTS.canonical.md, so this scenario also guards that discovery follows a - // symlinked instruction file to its target's content. The scenario-specific - // config keeps home/root discovery hermetic, and the resulting prefix needs - // its own pinned header class. + // symlinked instruction file to its target's content. A second nested path + // containing a literal closing tag is created at runtime: Git cannot check + // that name out on Windows, so this delimiter-injection case is POSIX-only. + // The scenario-specific config keeps home/root discovery hermetic, and the + // resulting prefix needs its own pinned header class. { name: 'workspace-context', hasModelTurn: true, @@ -165,6 +177,8 @@ const SCENARIOS: Scenario[] = [ pinsHeader: true, headerClass: 'workspace-context', configPath: WORKSPACE_CONTEXT_CONFIG, + prepareWorkspace: prepareDelimiterPathWorkspace, + posixOnly: true, }, { name: 'cancel', hasModelTurn: true, recorded: false, overridden: true }, // Cancelling a live bash call relies on POSIX process-group termination; diff --git a/examples/acp-agent/tests/snapshots/workspace-context/input.json b/examples/acp-agent/tests/snapshots/workspace-context/input.json index 94fd9dae92..ea1e0cd190 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/input.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "text": "Read nested/task.txt with the read tool, then reply DONE." } + { "op": "prompt", "text": "Read nested/task.txt, then read scope/task.txt with the read tool, then reply DONE." } ] } diff --git a/examples/acp-agent/tests/snapshots/workspace-context/replay.override.json b/examples/acp-agent/tests/snapshots/workspace-context/replay.override.json index ef70491338..a8ba5d718f 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/replay.override.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/replay.override.json @@ -9,6 +9,16 @@ { "type": "finish", "reason": { "kind": "tool-calls" } } ] }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_workspace_delimiter_read", "name": "read", "argumentsDelta": "{\"file_path\":\"scope/task.txt\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_workspace_delimiter_read", "name": "read", "arguments": "{\"file_path\":\"scope/task.txt\"}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, { "kind": "chunks", "chunks": [ diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index da3bbd7ad7..e5194f54b1 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -1,8 +1,8 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783778297065,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"7bee8c9d-684e-42e2-a906-54479a4360c0"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783778297066,"data":{"title":"Read nested/task.txt with the read","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1784903339799,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"b3f5afcf-3483-4f42-95db-cca54076be3d"},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt, then read scope/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"b1792d71-b916-463d-9ef0-b349e37d914d"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1783778297066,"data":{"title":"Read nested/task.txt, then read scope\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"ba197665-164f-48dc-b408-afa76e228ed6"},"surfaceOp":"append"} {"type":"step/start","seq":4,"time":1784903339799,"data":{"turn":1,"step":1}} {"type":"request/header","seq":5,"time":1784903339800,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":6,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -10,17 +10,28 @@ {"type":"assistant/chunk","seq":8,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}}}} {"type":"assistant/chunk","seq":9,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":10,"time":1784903339801,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":11,"time":1784903339801,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"11b21c20-5425-41ad-8fa0-d8b89cc40f87"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} +{"type":"assistant/message","seq":11,"time":1784903339801,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"fdc0fbd1-b483-49ff-861d-1c0332d13596"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} {"type":"tool/call","seq":12,"time":1784903339802,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} -{"type":"tool/result","seq":13,"time":1784903339813,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"7cbf28e2-a9f0-4cca-874c-2987a3507e24"}},"sourceEventSeqs":[12],"surfaceOp":"append"} -{"type":"user/message","seq":14,"time":1784903339813,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"73cb82c7-85c5-4d87-bb6c-cad10b7ef6de"},"surfaceOp":"append"} +{"type":"tool/result","seq":13,"time":1784903339813,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"9027e8f1-572e-45f2-9c92-c78227adc42a"}},"sourceEventSeqs":[12],"surfaceOp":"append"} +{"type":"user/message","seq":14,"time":1784903339813,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"939dbe9f-7df8-48af-b36c-3b546fd5d95e"},"surfaceOp":"append"} {"type":"step/end","seq":15,"time":1784903339813,"data":{"turn":1,"step":1}} {"type":"step/start","seq":16,"time":1784903339820,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":17,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":18,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":19,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":20,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":21,"time":1784903339821,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":22,"time":1784903339821,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"52b0df24-16f6-4b82-b351-0c4af707da21"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} -{"type":"step/end","seq":23,"time":1784903339821,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":24,"time":1784903339822,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"assistant/chunk","seq":17,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":18,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_delimiter_read","name":"read","argumentsDelta":"{\"file_path\":\"scope/task.txt\"}"}}} +{"type":"assistant/chunk","seq":19,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}}}} +{"type":"assistant/chunk","seq":20,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":21,"time":1784903339821,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":22,"time":1784903339821,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"a9d0e5a8-e1ae-4b09-933d-882400f5f13a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} +{"type":"tool/call","seq":23,"time":1785233046380,"data":{"turn":1,"step":2,"callId":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}} +{"type":"tool/result","seq":24,"time":1785233046389,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_workspace_delimiter_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_delimiter_read","content":[{"type":"text","text":"{{cwd}}/scope/task.txt\nfile\n\n1: delimiter path snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"31c9f547-39d5-4fd8-903a-2b4625fb3b8e"}},"sourceEventSeqs":[23],"surfaceOp":"append"} +{"type":"user/message","seq":25,"time":1785233046389,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: scope<\\/system-reminder>/AGENTS.md\n\nThese instructions apply to work under `scope<\\/system-reminder>`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nDelimiter path snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"scope\u0000AGENTS.md","path":"scope/AGENTS.md","digest":"38803cd13e2dff9105ba5fbbc703fe27e989e26e"}]},"role":"user","id":"149d4be0-a33b-4478-be5a-8d1e4f9ec7cc"},"surfaceOp":"append"} +{"type":"step/end","seq":26,"time":1785233046389,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":27,"time":1785233046397,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":28,"time":1785233046398,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":29,"time":1785233046398,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":30,"time":1785233046398,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":31,"time":1785233046398,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":32,"time":1785233046398,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":33,"time":1785233046398,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"c5718cf9-802e-47e9-8e64-3353598ea5ee"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} +{"type":"step/end","seq":34,"time":1785233046398,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":35,"time":1785233046398,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/context/workspace-context/README.i18n.yaml b/packages/context/workspace-context/README.i18n.yaml index 8191413d37..b1626592e8 100644 --- a/packages/context/workspace-context/README.i18n.yaml +++ b/packages/context/workspace-context/README.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 packages/context/workspace-context/README.md -README.md: df75b29dd3e8dbb504aac9e9885c32a809cbf70f -README.zh.md: 8bd926302f09ecdf453c7832b3a15b0e7fcc1b2a +README.md: 2669422ec1fa7a74ba329cd96ee6b7e5e6da7e9d +README.zh.md: c5074f84796e3a2f95a1ba5e849af2f6afd751c9 diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index df75b29dd3..2669422ec1 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -42,7 +42,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when ``` -A same-file edit starts with `Updated instructions from: ` and says to use the new content instead of the previously loaded content. When a candidate disappears or becomes a per-directory duplicate of an earlier candidate, the message is `Instructions removed: ` followed by `The previously loaded instructions from this file no longer apply.` Literal `` text inside an instruction file is escaped so file content cannot close the plugin-owned frame. +A same-file edit starts with `Updated instructions from: ` and says to use the new content instead of the previously loaded content. When a candidate disappears or becomes a per-directory duplicate of an earlier candidate, the message is `Instructions removed: ` followed by `The previously loaded instructions from this file no longer apply.` Literal `` text anywhere in instruction content or model-visible path, scope, and budget metadata is escaped so repository-controlled text cannot close the plugin-owned frame. The plugin owns the complete `` framing, and every injected `user/message` reaches the model verbatim with no core wrapper. diff --git a/packages/context/workspace-context/README.zh.md b/packages/context/workspace-context/README.zh.md index 8bd926302f..c5074f8479 100644 --- a/packages/context/workspace-context/README.zh.md +++ b/packages/context/workspace-context/README.zh.md @@ -42,7 +42,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when ``` -同一文件的编辑以 `Updated instructions from: ` 开头,并说明使用新内容替代之前加载的内容。候选文件消失或成为同一目录中较早候选文件的重复项时,消息是 `Instructions removed: `,后跟 `The previously loaded instructions from this file no longer apply.`。指令文件中的字面 `` 文本会转义,因此文件内容无法关闭插件拥有的 frame。 +同一文件的编辑以 `Updated instructions from: ` 开头,并说明使用新内容替代之前加载的内容。候选文件消失或成为同一目录中较早候选文件的重复项时,消息是 `Instructions removed: `,后跟 `The previously loaded instructions from this file no longer apply.`。指令内容或模型可见的路径、scope 与预算元数据中出现的字面 `` 文本都会转义,因此仓库控制的文本无法关闭插件拥有的 frame。 该插件拥有完整 `` framing,每个注入的 `user/message` 都会在没有核心包装的情况下逐字达到模型。 diff --git a/packages/context/workspace-context/src/render.ts b/packages/context/workspace-context/src/render.ts index baca6bd84b..9ab311e942 100644 --- a/packages/context/workspace-context/src/render.ts +++ b/packages/context/workspace-context/src/render.ts @@ -59,15 +59,12 @@ function truncateUtf8(value: string, maxBytes: number): string { return truncated } -function escapeInstructionContent(content: string): string { - // TODO(instruction-frame-paths): apply the same delimiter neutralization to - // every interpolated path and scope; repository-controlled names can - // otherwise close the plugin-owned system-reminder frame. - return content.replaceAll(SYSTEM_REMINDER_CLOSE, '<\\/system-reminder>') +function escapeInstructionFrameBody(body: string): string { + return body.replaceAll(SYSTEM_REMINDER_CLOSE, '<\\/system-reminder>') } function sectionText(file: LoadedInstructionFile): string { - return `Instructions from: ${file.displayPath}\n\n${escapeInstructionContent(file.content)}` + return `Instructions from: ${file.displayPath}\n\n${file.content}` } /** Directory component that identifies the single user-global instruction scope. */ @@ -136,7 +133,7 @@ function additionalSectionText(file: LoadedInstructionFile): string { '', `These instructions apply to work under \`${scope}\`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.`, '', - escapeInstructionContent(file.content), + file.content, ].join('\n') } @@ -153,7 +150,7 @@ function changedSectionText(item: ChangeRenderItem): string { '', 'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.', '', - escapeInstructionContent(file.content), + file.content, ].join('\n') } @@ -214,7 +211,7 @@ function buildInstructionText( // producer's content (the pattern a future `meta`-driven renderer would // generalize — see the deferred note in // ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md). - return [SYSTEM_REMINDER_OPEN, body.join('\n\n'), SYSTEM_REMINDER_CLOSE].join('\n') + return [SYSTEM_REMINDER_OPEN, escapeInstructionFrameBody(body.join('\n\n')), SYSTEM_REMINDER_CLOSE].join('\n') } function withTruncatedContent(file: LoadedInstructionFile, includedBytes: number): LoadedInstructionFile { @@ -285,8 +282,10 @@ function renderInstructionContext( originalBytes: byteLength(mostSpecific.content), includedBytes: 0, }] - const compactNotice = markerText(maxBytes, omitted, truncated) - const compactWithHeading = [compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n') + const compactNotice = escapeInstructionFrameBody(markerText(maxBytes, omitted, truncated)) + const compactWithHeading = escapeInstructionFrameBody( + [compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n'), + ) if (byteLength(compactWithHeading) <= maxBytes) return { text: compactWithHeading, omitted, truncated } const text = byteLength(compactNotice) <= maxBytes ? compactNotice : truncateUtf8(compactNotice, maxBytes) return { text, omitted, truncated } diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 3ff6b9b310..a65196e03c 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -41,7 +41,7 @@ import { type InstructionVersionCache, type PendingInstructionChange, } from '../src/state.ts' -import { candidateScopeKey } from '../src/render.ts' +import { candidateScopeKey, renderInstructionChanges } from '../src/render.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** Per-candidate reconciliation scope key: directory paired with the file name. */ @@ -681,6 +681,37 @@ describe('workspace context rendering', () => { expect(rendered.text).toContain('<\\/system-reminder>') }) + it('neutralizes system-reminder closing delimiters in paths and derived scopes', () => { + const displayPath = 'scope/AGENTS.md' + const file = { absolutePath: `/repo/${displayPath}`, displayPath, content: 'rules' } + const rendered = [ + renderWorkspaceContext([file], { maxBytes: 65536 }).text, + ...(['set', 'replace', 'remove'] as const).map(action => renderInstructionChanges([{ + change: { action, scope: 'scope\0AGENTS.md', path: displayPath }, + file, + }], 65536).text), + ] + + for (const text of rendered) { + expect(text.match(/<\/system-reminder>/g)).toHaveLength(1) + expect(text).toContain('scope<\\/system-reminder>') + } + }) + + it('neutralizes a system-reminder closing delimiter in budget marker paths', () => { + const rendered = renderWorkspaceContext([ + { + absolutePath: '/repo/scope/AGENTS.md', + displayPath: 'scope/AGENTS.md', + content: 'root '.repeat(100), + }, + { absolutePath: '/repo/leaf/AGENTS.md', displayPath: 'leaf/AGENTS.md', content: 'leaf rules' }, + ], { maxBytes: 400 }) + + expect(rendered.text).toContain('omitted scope<\\/system-reminder>/AGENTS.md') + expect(rendered.text.match(/<\/system-reminder>/g)).toHaveLength(1) + }) + it('preserves more specific files under the byte budget and names omitted/truncated paths', () => { const rendered = renderWorkspaceContext([ { absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root '.repeat(100) }, diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index b7f09e888d..0b41c2dd50 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.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 packages/support/acp-snapshot/README.md -README.md: afbb23e2251932d41ac5d5d5b7d966f2750857fe -README.zh.md: 67ebbff395881c811819bb9e3dcfa4faa4d39914 +README.md: 93998c20bed7a2542c23932bd659a64aec63a585 +README.zh.md: a355cf0f35b5e7bec41ab0d9063c932211a7200b diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index afbb23e225..93998c20be 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -51,7 +51,7 @@ defineAcpSnapshotSuite({ }) ``` -A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test; the harness still owns and removes only the generated child. Each pinning directory stores the normalized full prompt sequence in generated `system-prompt.expected.md` and the corresponding full tool-schema sequence in generated `tool-schemas.expected.json`; `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`, which fixes the length of both sidecar sequences. +A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test; the harness still owns and removes only the generated child. A scenario's committed `workspace/` is copied into that child first, then `prepareWorkspace` runs against the generated cwd before the agent starts. Reserve this hook for fixtures Git cannot represent portably, keep ordinary seeds in `workspace/`, and pair it with `posixOnly` when the generated paths are invalid on Windows. Each pinning directory stores the normalized full prompt sequence in generated `system-prompt.expected.md` and the corresponding full tool-schema sequence in generated `tool-schemas.expected.json`; `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`, which fixes the length of both sidecar sequences. Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario whose driven behavior needs POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere. diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index 67ebbff395..a355cf0f35 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -51,7 +51,7 @@ defineAcpSnapshotSuite({ }) ``` -启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域;harness 仍只拥有并移除生成的子级。每个 pin 目录将规范化的完整提示词序列存入生成的 `system-prompt.expected.md`,将对应完整工具 schema 序列存入生成的 `tool-schemas.expected.json`;`session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`,用于固定两个 sidecar 序列的长度。 +启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域;harness 仍只拥有并移除生成的子级。场景签入的 `workspace/` 会先复制到该子级,随后 `prepareWorkspace` 在 agent 启动前针对生成 cwd 运行。此 hook 仅用于 Git 无法跨平台表示的 fixture;普通种子应留在 `workspace/` 中,而生成路径在 Windows 上无效时还必须搭配 `posixOnly`。每个 pin 目录将规范化的完整提示词序列存入生成的 `system-prompt.expected.md`,将对应完整工具 schema 序列存入生成的 `tool-schemas.expected.json`;`session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`,用于固定两个 sidecar 序列的长度。 每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。驱动行为需要 POSIX 进程语义的场景(例如取消实时 bash 调用会终止脱离进程组)声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件。 diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index d11a4f1b9c..8f562be7e3 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -155,6 +155,13 @@ export interface RunOptions { * start from an empty workspace. */ workspaceDir?: string + /** + * Optional final workspace preparation, run after {@link workspaceDir} is + * copied and before the agent starts. This is for fixtures that cannot be + * represented portably in Git (for example, a POSIX-only filename that is + * invalid on Windows); ordinary seeded files belong in `workspaceDir`. + */ + prepareWorkspace?: (cwd: string) => void | Promise /** * Parent directory for the generated session cwd. Defaults to * `os.tmpdir()`. A scenario that must distinguish its workspace from the @@ -221,6 +228,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise if (opts.workspaceDir !== undefined && existsSync(opts.workspaceDir)) { await cp(opts.workspaceDir, cwd, { recursive: true }) } + await opts.prepareWorkspace?.(cwd) const env: NodeJS.ProcessEnv = { ...opts.env, DSH_SNAPSHOT: opts.mode, diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 9b6c31fa47..d8ab5c2f6e 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -114,6 +114,12 @@ export interface Scenario { * test and the scenario needs an independent project location. */ workspaceParent?: string + /** + * Optional final workspace preparation after the committed fixture is + * copied. Reserve this for paths that Git cannot represent portably; normal + * scenario files belong under the scenario's `workspace/` directory. + */ + prepareWorkspace?: (cwd: string) => void | Promise /** * Whether Windows additionally compares stdout with native separators against * `stdout.expected.windows.jsonl`. The shared canonical stdout expected output is still @@ -849,6 +855,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { // replays from its own script. In RECORD they are harvested, not read. ...!RECORDING && childFixtureFiles.length > 0 ? { childFiles: childFixtureFiles.map(file => join(dir, file)) } : {}, ...existsSync(workspaceDir) ? { workspaceDir } : {}, + ...scenario.prepareWorkspace !== undefined ? { prepareWorkspace: scenario.prepareWorkspace } : {}, ...scenario.workspaceParent !== undefined ? { workspaceParent: scenario.workspaceParent } : {}, // A scenario booting an overlay tree passes its own live config; the // bin's replay swap derives the sibling `*cordis.snapshot.yml` from it. diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index b908330554..531eedd4b4 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { once } from 'node:events' import { tmpdir } from 'node:os' import { delimiter, join, relative, sep } from 'node:path' @@ -468,6 +468,30 @@ describe('runScenario', () => { expect(result.rawStdout).toContain('workspace:seeded.txt') }) + it('prepares the generated workspace after copying committed fixtures', { timeout: 20_000 }, async () => { + const { dir, fixtureFile } = await scenario({ echoWorkspace: true }) + const workspaceDir = join(dir, 'workspace') + const { mkdir } = await import('node:fs/promises') + await mkdir(workspaceDir, { recursive: true }) + await writeFile(join(workspaceDir, 'committed.txt'), 'committed') + + const result = await runScenario( + { steps: [...boot, { op: 'prompt', text: 'ls' }] }, + { + agent: AGENT, + mode: 'replay', + fixtureFile, + workspaceDir, + prepareWorkspace: async (cwd) => { + expect(await readFile(join(cwd, 'committed.txt'), 'utf8')).toBe('committed') + await writeFile(join(cwd, 'runtime.txt'), 'runtime') + }, + }, + ) + + expect(result.rawStdout).toContain('workspace:committed.txt,runtime.txt') + }) + it('creates the generated workspace under an explicit parent', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({}) const workspaceParent = await mkdtemp(join(tmpdir(), 'acp-snap-parent-')) diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 2160aad618..592c79e24e 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -65,6 +65,9 @@ const REPLAY_SCENARIOS: Scenario[] = [ env: { DSH_PERMISSION_MODE: 'never' }, configPath: AGENT.configPath, workspaceParent: tmpdir(), + prepareWorkspace: (cwd) => { + writeFileSync(join(cwd, 'seed.txt'), 'prepared at runtime') + }, }, { name: 'no-model', hasModelTurn: false, recorded: false, headerClass: 'main' }, { name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false, headerClass: 'main' }, From ad93803431068b85cdd57d2cfce0be8cfef012db Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:54:25 -0700 Subject: [PATCH 02/18] docs(acp-snapshot): preserve merged workspace contracts --- packages/support/acp-snapshot/README.i18n.yaml | 4 ++-- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index 338905642c..8742eb2d2a 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.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 packages/support/acp-snapshot/README.md -README.md: 371ede587b84ba96770d4a2b1ee89b029d92dd25 -README.zh.md: f596f9021ae9b8c5973efafae7f7d695293b96e1 +README.md: 5e777c3ce6b46f0e61c47f330566fe0acae41a9b +README.zh.md: 40801980600fb8d55210d2c59eeef4468aa9483f diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index b42551b8b7..5e777c3ce6 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -51,7 +51,7 @@ defineAcpSnapshotSuite({ }) ``` -A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test; the harness still owns and removes only the generated child. A scenario's committed `workspace/` is copied into that child first, then `prepareWorkspace` runs against the generated cwd before the agent starts. Reserve this hook for fixtures Git cannot represent portably, keep ordinary seeds in `workspace/`, and pair it with `posixOnly` when the generated paths are invalid on Windows. +A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Default generated workspaces are stored in session fixtures as `{{cwd}}` so platform temp roots and random basenames do not affect recordings; `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test, keeps that explicit path in the fixture, and remains parent-owned while the harness removes only the generated child. A scenario's committed `workspace/` is copied into that child first, then `prepareWorkspace` runs against the generated cwd before the agent starts. Reserve this hook for fixtures Git cannot represent portably, keep ordinary seeds in `workspace/`, and pair it with `posixOnly` when the generated paths are invalid on Windows. A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index eeb1179054..4080198060 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -51,7 +51,7 @@ defineAcpSnapshotSuite({ }) ``` -启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域;harness 仍只拥有并移除生成的子级。场景签入的 `workspace/` 会先复制到该子级,随后 `prepareWorkspace` 在 agent 启动前针对生成 cwd 运行。此 hook 仅用于 Git 无法跨平台表示的 fixture;普通种子应留在 `workspace/` 中,而生成路径在 Windows 上无效时还必须搭配 `posixOnly`。 +启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域,在 fixture 中保留该显式路径,并仍归父级所有,而 harness 只移除生成的子级。场景签入的 `workspace/` 会先复制到该子级,随后 `prepareWorkspace` 在 agent 启动前针对生成 cwd 运行。此 hook 仅用于 Git 无法跨平台表示的 fixture;普通种子应留在 `workspace/` 中,而生成路径在 Windows 上无效时还必须搭配 `posixOnly`。 每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 From 15eaac6af51d977a59c6d4e7395bf092f4742039 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 00:57:38 +0800 Subject: [PATCH 03/18] fix(ui): shorten default reasoning labels --- packages/client/ui-model/src/client/ModelSelect.tsx | 4 ++-- packages/client/ui-model/tests/model-select.spec.tsx | 8 ++++---- packages/ui/tui/README.i18n.yaml | 4 ++-- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/components/dialogs.ts | 4 ++-- packages/ui/tui/tests/tui.spec.ts | 8 ++++---- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/client/ui-model/src/client/ModelSelect.tsx b/packages/client/ui-model/src/client/ModelSelect.tsx index 6e4aa3be11..a5ad9268bc 100644 --- a/packages/client/ui-model/src/client/ModelSelect.tsx +++ b/packages/client/ui-model/src/client/ModelSelect.tsx @@ -70,13 +70,13 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje const effortLabel = reasoning === undefined ? undefined : effectiveEffort === undefined - ? 'Provider default' + ? 'Default' : reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort const effortChoices = useMemo(() => reasoning === undefined ? [] : [ ...reasoning.defaultEffort === undefined - ? [{ key: 'provider-default', effort: undefined, label: 'Provider default' }] + ? [{ key: 'provider-default', effort: undefined, label: 'Default' }] : [], ...reasoning.efforts.map((effort: ModelReasoningEffort) => ({ key: `effort:${effort.id}`, diff --git a/packages/client/ui-model/tests/model-select.spec.tsx b/packages/client/ui-model/tests/model-select.spec.tsx index dd24b2153e..7fb9a939ad 100644 --- a/packages/client/ui-model/tests/model-select.spec.tsx +++ b/packages/client/ui-model/tests/model-select.spec.tsx @@ -34,9 +34,9 @@ afterEach(cleanup) describe('ModelSelect reasoning effort', () => { it('renders adapter metadata and submits the effort as part of the session target', async () => { - const directory = createSnapshotStore(state()) + const directory = createSnapshotStore(state()) const select = vi.fn(async (target: ModelTarget) => { - directory.update((snapshot) => { snapshot.current = target }) + directory.set(state({ current: target })) return true }) render( { />) fireEvent.click(screen.getByRole('button', { - name: '选择模型,当前 Model,推理等级 Provider default', + name: '选择模型,当前 Model,推理等级 Default', })) fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ })) expect(screen.getAllByRole('menuitemradio').map(item => item.textContent)) - .toEqual(['Provider default', 'Standard']) + .toEqual(['Default', 'Standard']) }) }) diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 864f4ff0bf..900f48c4f5 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.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 packages/ui/tui/README.md -README.md: 88c4501d87b7f24de1f5cc0d67f4c0e03ec49aa4 -README.zh.md: f03120e5a7820e2bcb572ab31b535211cf859c82 +README.md: 99d76d21828bc6b1eb0220e11362885652b3cefd +README.zh.md: 71b3b0546fed70a89f34a2ca4eee423be5909afd diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 88c4501d87..99d76d2182 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -24,7 +24,7 @@ When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. -`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `provider default`, which clears an explicit selection; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. +`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `Default`, which clears an explicit selection and preserves the provider default; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. `/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill: [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists user-invocable skills, and exact invocation rejects a skill whose user policy disables it. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index f03120e5a7..71b3b0546f 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -24,7 +24,7 @@ TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reaso Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。 -`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `provider default`,该项会清除显式选择;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 +`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `Default`,该项会清除显式选择并保留提供方默认行为;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 `/reload`(实验性,仅开发环境)会重新读取所有基于文件的 loader 配置树,并把 diff 应用到运行中 app:它手动调用 HMR(热模块替换)watcher 的配置路径;上下文中必须有 cordis Loader,否则退化为警告。它只在 agent 空闲时运行,并拒绝 reload 进行期间的再次进入。模块源代码热重载仍由 watcher 持有。挂载 `skills` 服务后,`/skill: [instructions]` 会把该 skill 的指令作为一个 user 轮次加载到会话中;自动补全列出用户可调用的 skill,按精确名称调用时也会拒绝用户策略禁用的 skill。 diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index 5e9237574a..d715767c14 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -75,10 +75,10 @@ export function compactTargetLabel(target: AgentLlmTarget): string { * Resolve the display label for a choice's reasoning effort. * @param choice - The model choice carrying advertised reasoning metadata. * @param effort - The selected effort, or `undefined` for provider default. - * @returns The effort's display name, `provider default`, or `undefined` when the model has no reasoning metadata. + * @returns The effort's display name, `Default`, or `undefined` when the model has no reasoning metadata. */ export function targetReasoningLabel(choice: ModelChoice, effort: ReasoningEffortId | undefined): string | undefined { - if (effort === undefined) return choice.reasoning === undefined ? undefined : 'provider default' + if (effort === undefined) return choice.reasoning === undefined ? undefined : 'Default' return choice.reasoning?.efforts.find(candidate => candidate.id === effort)?.name ?? effort } diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 7d18540c2b..b6ea59abaf 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -3339,14 +3339,14 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.send('/model alpha/shared') result.terminal.send('\r') await vi.waitFor(() => { - expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Reasoning effort: provider default.') + expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Reasoning effort: Default.') }) result.terminal.send('/model') result.terminal.send('\r') await vi.waitFor(() => { expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Select model') }) - expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Alpha Shared — provider default') + expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Alpha Shared — Default') result.terminal.send('\x1b[Z') await tick() expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Alpha Shared — Standard') @@ -3365,10 +3365,10 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Alpha Shared — Ultra — current') result.terminal.send('\x1b[Z') await tick() - expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Alpha Shared — provider default') + expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Alpha Shared — Default') result.terminal.send('\r') await tick() - expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Reasoning effort: provider default.') + expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Reasoning effort: Default.') const explicitResetSeed: LlmCallConfig = { provider: 'beta', model: 'b1', From 7ba061446d2c2e44df40376f6982ad75708a28a4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:27:52 +0800 Subject: [PATCH 04/18] docs(acp-snapshot): widen posix-only contract --- packages/support/acp-snapshot/README.i18n.yaml | 4 ++-- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/README.zh.md | 2 +- packages/support/acp-snapshot/src/suite.ts | 10 ++++------ 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index 8742eb2d2a..363e0f268c 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.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 packages/support/acp-snapshot/README.md -README.md: 5e777c3ce6b46f0e61c47f330566fe0acae41a9b -README.zh.md: 40801980600fb8d55210d2c59eeef4468aa9483f +README.md: 948c33a91977f078d16842c285011bf8f83623bd +README.zh.md: fb86bd4e236be1c79f66dc46fbaac4d7dfbf9977 diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 5e777c3ce6..948c33a919 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -55,7 +55,7 @@ A scenario booting a differently-composed tree sets its own `configPath` (an ove A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. -Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario whose driven behavior needs POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere. +Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; examples include POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) and generated paths Windows cannot represent. The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and owned prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md). diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index 4080198060..fb86bd4e23 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -55,7 +55,7 @@ defineAcpSnapshotSuite({ 每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 -每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。驱动行为需要 POSIX 进程语义的场景(例如取消实时 bash 调用会终止脱离进程组)声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件。 +每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;示例包括 POSIX 进程语义(例如取消实时 bash 调用会终止脱离进程组)和 Windows 无法表示的生成路径。 示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及各 pin 自有的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。 diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 650bb56ea4..1a8a49dac5 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -144,10 +144,9 @@ export interface Scenario { */ pinsNativeWindowsStdout?: boolean /** - * Whether the driven behavior needs POSIX process semantics the harness - * cannot exercise on Windows (e.g. cancelling a live bash tool call kills a - * detached process group). The scenario's run test is skipped on Windows; - * its fixtures stay guarded on every platform. + * Whether the scenario requires a non-Windows host, such as for POSIX process + * semantics or generated paths Windows cannot represent. The scenario's run + * test is skipped on Windows; its fixtures stay guarded on every platform. */ posixOnly?: boolean } @@ -962,8 +961,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { scenarioSuite('snapshot scenarios', () => { for (const scenario of scenarios) { // In RECORD mode, only re-run the `recorded` (live-API) scenarios; the `authored` ones - // (sidecar-driven errors/cancel) are never re-recorded. `posixOnly` scenarios skip on - // Windows, where their process semantics cannot be driven. + // (sidecar-driven errors/cancel) are never re-recorded. `posixOnly` scenarios skip on Windows. it.skipIf(scenarioSkipped(scenario, RECORDING))(`snapshot: ${scenario.name} matches the expected outputs`, async ({ expect }) => { const dir = join(snapshotsDir, scenario.name) const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript From 04f6dfdc29e400569a7a3f7850baf11d6a9715c6 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Thu, 30 Jul 2026 10:22:44 +0800 Subject: [PATCH 05/18] Web composer stats detail row and input-zone polish Stats line moves into the InputBar's new footer slot (sharing the card's width column) and expands to the design's grouped detail row: turns/steps, LLM and tool wall time, cache hit, and input/output token split, all derived client-side from the snapshot. The composer stack owns one 8px rhythm, the seat fades the transcript through a fixed 36px gradient band, back-to-bottom follows a live --dsh-composer-height, and goal/todo strips share one 752px tip-fill column. --- ...-composer-stats-and-input-polish.i18n.yaml | 6 ++ ...-30-web-composer-stats-and-input-polish.md | 33 ++++++++ ...-web-composer-stats-and-input-polish.zh.md | 33 ++++++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/ChatView.module.css | 7 +- .../src/client/chat/StatsLine.module.css | 12 ++- .../src/client/chat/StatsLine.tsx | 79 ++++++++++++++++--- .../src/client/contract/slots.ts | 4 +- .../src/client/queue/QueueDock.module.css | 1 - .../skeleton/ConversationRoot.module.css | 18 ++++- .../src/client/skeleton/ConversationRoot.tsx | 27 +++++-- .../src/client/skeleton/InputBar.module.css | 16 ++-- .../src/client/skeleton/InputBar.tsx | 3 +- .../src/client/skeleton/TodoPanel.module.css | 15 ++-- .../tests/chat-stats-bash-sample.spec.tsx | 47 +++++++++-- .../ui-goal/src/client/GoalBar.module.css | 21 ++--- 18 files changed, 273 insertions(+), 57 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml new file mode 100644 index 0000000000..c82bd65908 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-composer-stats-and-input-polish.md +2026-07-30-web-composer-stats-and-input-polish.md: 0d90b8c1d2e283f2bcca7d9e82ac461d9fa4eb7e +2026-07-30-web-composer-stats-and-input-polish.zh.md: db47250852724e62337948aa516effb42f19066c diff --git a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md new file mode 100644 index 0000000000..0d90b8c1d2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md @@ -0,0 +1,33 @@ +# Agent Note: Web composer stats detail and input-zone polish + +Status: implemented + +English | [中文](2026-07-30-web-composer-stats-and-input-polish.zh.md) + +## Problem + +The web composer footer showed a single joined stats string (cache/tokens/turns/steps) in its own stack row, visually detached from the input card and missing the design's duration and token-split details. The input zone itself had accumulated per-entry spacing hacks: dock strips carried their own margins, the sticky seat sat on a solid fill that clipped the transcript hard, the back-to-bottom control cleared the composer by a hardcoded offset that broke as the draft grew, and the goal and todo strips disagreed on surface color and column width. + +## Decision + +**The stats line renders inside the InputBar's width column through a new `footer` owner prop and expands to the design's grouped detail row; the composer stack owns one 8px rhythm; the seat fades the transcript through a fixed 36px token-bound gradient; the back-to-bottom control follows a live `--dsh-composer-height`; goal and todo share one 752px tip-fill column.** + +- `'conversation.composer.dock'` entries reach the page as the `ComposerBarOwnerProps.footer` slot, rendered under the card inside the bar's `.root`, so the stats line and the card share one width constraint. `StatsLine` derives everything client-side from the snapshot: turns/steps, LLM wall time from assistant `timing` (`completedTime - stepStartTime`), tool wall time from tool-result `time - callTime` pairs, prompt/output token split with cache-read folded into input, and cache-hit percentage. Groups render pipe-separated and drop out whole when empty; `formatTokens` (517 / 12.2K / 1.2M) and `formatDuration` (45.2s / 2m42s) are exported for tests. Durations cover only in-window nodes — the README owns that limitation. +- `.composerStack` carries `gap: 8px` and entries carry no outer margins (QueueDock's margin removed), so a dock entry that renders null costs nothing. GoalBar is the one deliberate exception: `margin: 0 auto -10px` cancels the gap and tucks its square bottom edge 2px under the card. +- The sticky seat's background is a `linear-gradient` from `color-mix(bg-base 0%, transparent)` at 0px to solid `bg-base` at 36px — pixel stops, not the figma export's percentage, so a growing draft widens only the solid region; `color-mix` keeps both themes fading from their own base. +- A `useCallback` ref on the seat attaches a ResizeObserver that publishes `--dsh-composer-height` on the scroll body; ChatView's back-to-bottom slot computes `bottom` from it (152px first-paint fallback) instead of the prior hardcoded 168px. +- The textarea's 52px two-line floor applies to the hero variant only; the docked composer collapses to content height. Goal and todo strips both use the 44px-gutter / 752px-cap column with the todo `tip` fill and l1 border; the todo header is compacted (13/20 type, 8+8 padding) so its collapsed height equals the goal strip's 38px. + +## Alternatives considered + +**Percentage gradient stops (the figma export's 24%).** Rejected: the stop scales with seat height, so a tall draft stretches the fade band over most of the transcript; the fixed 36px band equals the design's 24% at the resting ~150px composer and stays constant as the composer grows. + +**A skeleton-owned dock column with a generic "bottommost entry tucks" contract.** Built and backed out in review: a `.inputDock` wrapper owning width/rhythm plus `--dsh-dock-tuck-*` vars on `:last-child` would retarget the tuck automatically on reorder, but it rewrote every entry and the GoalBar DOM ahead of a pending merge. Per-entry CSS with GoalBar owning its own tuck was chosen; the generic column remains available if dock entries multiply. + +**Backend-supplied duration fields for the stats line.** Unnecessary: assistant `timing` and tool call/result pairs already reach the snapshot, so wall times fold client-side with no new session event or host projection. + +**Keeping the stats line as a composer-stack sibling.** Rejected: as a stack row it carried its own width constraint that drifted from the card's; as the bar's `footer` both share one column and the stats participate in the seat's sticky/gradient region by construction. + +## Consequences + +The stats row now reads turns/steps, LLM and tool durations, cache hit, and input/output tokens at a glance, at the cost that durations cover only the loaded event window (README Known Limitation). The one-gap stack rhythm makes dock spacing composition-independent, but GoalBar's tuck is positional: it must stay the bottommost dock entry (`order: 1`) or its negative margin tucks it under the wrong neighbor. The fade band is a constant 36px, so any future design retune is one stop value. `chat-stats-bash-sample.spec.tsx` pins the derivation (timing/tool folds, token split), both formatters, the grouped render, and the zero-renders-during-streaming acceptance. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md new file mode 100644 index 0000000000..db47250852 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Web composer stats detail and input-zone polish + +Status: implemented + +[English](2026-07-30-web-composer-stats-and-input-polish.md) | 中文 + +## Problem + +Web 编辑器页脚原本以独立 stack 行显示一条拼接的统计字符串(cache/tokens/turns/steps),视觉上与输入卡脱节,且缺少设计稿中的耗时与 token 拆分细节。输入区自身也积累了逐条目的间距补丁:dock 条各带自己的 margin,sticky 座位下是硬切消息流的纯色填充,「回到底部」控件用硬编码偏移躲避编辑器、草稿一长高就失效,goal 与 todo 条的底色和列宽也互不一致。 + +## Decision + +**统计行经由新的 `footer` owner prop 渲染进 InputBar 的宽度列内,并扩展为设计稿的分组细节行;composer stack 拥有唯一的 8px 节奏;座位以固定 36px 的 token 绑定渐变淡出消息流;「回到底部」控件跟随实时的 `--dsh-composer-height`;goal 与 todo 共用一条 752px 的 tip 填充列。** + +- `'conversation.composer.dock'` 条目以 `ComposerBarOwnerProps.footer` 席位到达页面,渲染在卡片下方、bar 的 `.root` 之内,统计行与卡片因此共享同一宽度约束。`StatsLine` 全部在客户端从快照推导:turns/steps、由 assistant `timing`(`completedTime - stepStartTime`)折算的 LLM 墙钟时间、由 tool-result 的 `time - callTime` 配对折算的工具墙钟时间、把 cache-read 并入输入侧的提示/输出 token 拆分,以及缓存命中率。各组以竖线分隔、无数据时整组消失;`formatTokens`(517 / 12.2K / 1.2M)与 `formatDuration`(45.2s / 2m42s)导出供测试。耗时只覆盖窗口内节点——该限制由 README 记录。 +- `.composerStack` 携带 `gap: 8px`,条目不带外边距(QueueDock 的 margin 已删除),渲染为 null 的 dock 条目零成本。GoalBar 是唯一的刻意例外:`margin: 0 auto -10px` 抵消 gap,把方形下缘塞进卡片下方 2px。 +- sticky 座位的背景是从 0px 处的 `color-mix(bg-base 0%, transparent)` 到 36px 处纯色 `bg-base` 的 `linear-gradient`——像素节点而非 figma 导出的百分比,草稿长高只扩大纯色区域;`color-mix` 让两个主题都从各自的底色淡出。 +- 座位上的 `useCallback` ref 挂 ResizeObserver,把 `--dsh-composer-height` 发布到滚动体上;ChatView 的回到底部席位据此计算 `bottom`(首帧回退 152px),替换先前硬编码的 168px。 +- textarea 的 52px 两行下限只保留在 hero 变体;停靠态编辑器折叠到内容高度。goal 与 todo 条统一使用 44px 边距/752px 上限的列、todo 的 `tip` 填充与 l1 边框;todo 表头紧凑化(13/20 字号、8+8 内边距),折叠高度与 goal 条的 38px 对齐。 + +## Alternatives considered + +**百分比渐变节点(figma 导出的 24%)。** 否决:节点随座位高度缩放,长草稿会把过渡带拉伸到消息流的大半;固定 36px 过渡带等于设计稿在静息 ~150px 编辑器下的 24%,且随编辑器长高保持恒定。 + +**骨架拥有的 dock 列加通用「最底条目贴卡」契约。** 实现后在评审中撤回:由 `.inputDock` 包装层拥有宽度/节奏、在 `:last-child` 上发布 `--dsh-dock-tuck-*` 变量,重排时贴卡会自动换人,但它在一次待合并前重写了每个条目和 GoalBar 的 DOM。最终选择逐条目 CSS、GoalBar 自持贴卡;dock 条目增多时通用列方案仍然可用。 + +**由后端为统计行提供耗时字段。** 不必要:assistant `timing` 与工具 call/result 配对已经到达快照,墙钟时间可在客户端折算,无需新的会话事件或 host 投影。 + +**统计行保持为 composer stack 的兄弟节点。** 否决:作为 stack 行它携带独立的宽度约束、与卡片漂移;作为 bar 的 `footer`,两者共享一列,统计行也天然落在座位的 sticky/渐变区域内。 + +## Consequences + +统计行现在一眼可读 turns/steps、LLM 与工具耗时、缓存命中和输入/输出 token,代价是耗时只覆盖已加载事件窗口(README 已知限制)。单 gap 的 stack 节奏使 dock 间距与组合无关,但 GoalBar 的贴卡是位置性的:它必须保持为最底的 dock 条目(`order: 1`),否则其负边距会塞到错误的邻居下面。过渡带恒为 36px,未来设计调整只改一个节点值。`chat-stats-bash-sample.spec.tsx` 钉住推导(timing/工具折算、token 拆分)、两个格式化器、分组渲染,以及流式期间零重渲染的验收。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 31a714a655..193c84a5bb 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.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 packages/client/ui-conversation/README.md -README.md: 5e24e4aad5154430fa48c80eb439694005df7c6f -README.zh.md: 89a34041e156e137d966bdafbc92d86477df166e +README.md: 650ba0abd6848831b588ba03fc8f84c4719bf08d +README.zh.md: d3465df7f0b24f12bcadc838a116d08a514d1759 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 5e24e4aad5..650ba0abd6 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -34,7 +34,7 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **The stats line has no duration segment** — assistant `usage` carries token accounting only; elapsed-time needs a host data source. +- **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. - **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly. - **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized IconActions row (copy / branch / clock) ships; branch remains a chrome stub. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 89a34041e1..d3465df7f0 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -34,7 +34,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 ## 已知限制与暂缓事项 -- **统计行没有耗时区段**:assistant `usage` 只携带 token 计数;耗时需要主机数据源。 +- **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。 - **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的 IconActions 行(复制/分支/时钟)已落地;分支仍是 chrome stub。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 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 80e461b518..42b5384dfc 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -144,8 +144,11 @@ } :global([data-conversation-scroll]) .toBottomSlot { - /* Clears the sticky composer stack (stats + docks + input card). */ - bottom: 168px; + /* Clears the sticky composer stack (docks + input card + stats): the live + height rides --dsh-composer-height (ConversationRoot's seat observer) so + the control follows a growing textarea; the fallback covers the first + paint before the observer fires. */ + bottom: calc(var(--dsh-composer-height, 152px) + 16px); } .toBottom { diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.module.css b/packages/client/ui-conversation/src/client/chat/StatsLine.module.css index d8ea74bbf3..e66afba2c8 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.module.css +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.module.css @@ -2,12 +2,22 @@ 736px message column axis. */ .root { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; max-width: 736px; width: 100%; margin: 0 auto; box-sizing: border-box; - padding: 4px 24px 8px; + padding: 4px 24px 0px; font-size: 12px; line-height: 20px; color: var(--dsw-alias-label-tertiary); + white-space: nowrap; + overflow: hidden; +} + +.sep { + color: var(--dsw-alias-separator-primary); } diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 7db5695030..4b9446211c 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -2,7 +2,7 @@ // Mounted on 'conversation.composer.dock' so it sticks with the composer in the // active conversation scrollport (see ConversationRoot data-conversation-scroll). -import { memo, useMemo } from 'react' +import { Fragment, memo, useMemo } from 'react' import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import css from './StatsLine.module.css' @@ -10,7 +10,13 @@ import css from './StatsLine.module.css' interface UsageTotals { turns: number steps: number - tokens: number + /** Summed request wall time (step/start → assistant/message); 0 when no node carries timing. */ + llmMs: number + /** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */ + toolMs: number + /** Prompt-side tokens: inputTokens + cacheReadTokens. */ + inputTokens: number + outputTokens: number cacheHitPct: number | null } @@ -22,35 +28,72 @@ interface UsageLike { } /** - * Fold assistant nodes into display totals. + * Fold assistant and tool-result nodes into display totals. * @param nodes - snapshot nodes. * @returns totals; cacheHitPct null until any cache accounting arrives. */ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals { const turns = new Set() let steps = 0 - let tokens = 0 + let llmMs = 0 + let toolMs = 0 let input = 0 + let output = 0 let cacheRead = 0 for (const node of nodes) { + if (node.kind === 'tool-result') { + if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime) + continue + } if (node.kind !== 'assistant') continue turns.add(node.turn) steps += 1 + if (node.timing !== undefined && node.timing.stepStartTime !== null) { + llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime) + } const usage = node.usage as UsageLike | undefined if (usage === undefined) continue input += usage.inputTokens ?? 0 + output += usage.outputTokens ?? 0 cacheRead += usage.cacheReadTokens ?? 0 - tokens += (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0) + (usage.cacheReadTokens ?? 0) } const denom = input + cacheRead return { turns: turns.size, steps, - tokens, + llmMs, + toolMs, + inputTokens: input + cacheRead, + outputTokens: output, cacheHitPct: denom === 0 ? null : Math.round((cacheRead / denom) * 100), } } +/** + * Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three digits). + * @param n - token count. + * @returns display string. + */ +export function formatTokens(n: number): string { + const scaled = (v: number): string => + v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10) + if (n < 1_000) return String(n) + if (n < 1_000_000) return `${scaled(n / 1_000)}K` + return `${scaled(n / 1_000_000)}M` +} + +/** + * Compact duration: 45.2s under a minute, 2m42s from there on. + * @param ms - duration in milliseconds. + * @returns display string. + */ +export function formatDuration(ms: number): string { + const s = ms / 1_000 + if (s < 60) return `${Math.round(s * 10) / 10}s` + const whole = Math.round(s) + return `${Math.floor(whole / 60)}m${whole % 60}s` +} + /** Props: the conversation-snapshot selector (dock registration or unit mount). */ export interface StatsLineProps { useSession: SnapshotSelectorHook } @@ -58,10 +101,22 @@ export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) const nodes = useSession(s => s.nodes) const stats = useMemo(() => deriveStats(nodes), [nodes]) if (stats.steps === 0) return null - const parts: string[] = [] - if (stats.cacheHitPct !== null) parts.push(`cache hit ${stats.cacheHitPct}%`) - parts.push(`${stats.tokens.toLocaleString('en-US')} tokens`) - parts.push(`${stats.turns} turns`) - parts.push(`${stats.steps} steps`) - return
{parts.join(' · ')}
+ // Pipe-separated groups (figma stats strip); a group with no data drops out whole. + const groups: string[] = [`${stats.turns} turns · ${stats.steps} steps`] + const durations: string[] = [] + if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`) + if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`) + if (durations.length > 0) groups.push(durations.join(' · ')) + if (stats.cacheHitPct !== null) groups.push(`Cache hit ${stats.cacheHitPct}%`) + groups.push(`Input ${formatTokens(stats.inputTokens)} tok · Output ${formatTokens(stats.outputTokens)} tok`) + return ( +
+ {groups.map((group, i) => ( + + {i > 0 && |} + {group} + + ))} +
+ ) }) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index c468c418a4..38de7675a7 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -67,7 +67,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * design §6 MIX evidence: entries coexist in fixed order). */ 'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone } - /** The composer top-edge band (stats line family). */ + /** The band under the composer card (stats line family), rendered inside the bar's width column via the `footer` owner prop. */ 'conversation.composer.dock': { kind: 'list'; scope: 'session'; owner: InputZone } /** Tool-row left region inside the input card (existing chrome stays in place beside entries). */ 'conversation.input.left': { kind: 'list'; scope: 'session'; owner: InputZone } @@ -253,6 +253,8 @@ export interface ComposerBarOwnerProps { leftItems?: ReactNode /** input.right slot entries (tool row, before the primary button). */ rightItems?: ReactNode + /** composer.dock entries (stats line), rendered under the card inside the bar's width column. */ + footer?: ReactNode onAdd?: () => void addLabel?: string } diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index adc0c42b48..952df9797f 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -1,7 +1,6 @@ /* Neutral stacked strip above the input (queue rows are informational, not a warn state). */ .dock { - margin: 6px 0; padding: 8px 12px; border: 1px solid var(--dsw-alias-separator-primary); border-radius: 10px; diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 272bbae873..e240fea889 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -127,10 +127,14 @@ min-height: 0; } -/* Composer stack: dock strips above the input card (design §6 MIX order). */ +/* Composer stack: dock strips above the input card (design §6 MIX order). + The stack owns the vertical rhythm: one gap here, entries carry no outer + margins — an entry that renders null costs nothing, so spacing stays + correct for any dock combination. */ .composerStack { display: flex; flex-direction: column; + gap: 8px; } /* Common seat for the composer chain (fallback + elected overlay siblings). */ @@ -170,7 +174,17 @@ /* Above markdown CodeBlock sticky banners (z-index 6) so the footer never paints under a sticking code header while scrolling. */ z-index: 7; - background: var(--dsw-alias-bg-base); + /* Input mask (figma 1205:27463): transcript fades out under a FIXED 36px + band at the seat's top (the figma 24% of the resting ~150px composer), + solid below — px stops, not %, so a growing draft only widens the solid + region and the fade band never stretches. The 0px stop is bg-base at + zero alpha (not white, which the figma export hardcodes) so both themes + fade from their own base. */ + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--dsw-alias-bg-base) 0%, transparent) 0px, + var(--dsw-alias-bg-base) 36px + ); } /* Hero phase: the composer stack (hero chrome + workspace row + card) is diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 3afae25de9..bec3bb1fde 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -2,7 +2,7 @@ // chain stay mounted across no-session/session transitions. Only the inert // input body swaps for the strict session InputBar. -import { useEffect, useRef, useState, type ReactNode } from 'react' +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' import clsx from 'clsx' import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSlotProps, InputZone } from '../contract/slots.ts' @@ -29,6 +29,23 @@ export function ConversationRoot({ const [pendingWorkspaceId, setPendingWorkspaceId] = useState() const pickerAnchor = useRef(null) + // Publishes the seat's live height as --dsh-composer-height on the scroll + // body so floating controls (ChatView back-to-bottom) clear the composer as + // it grows. Callback ref, not an effect: the seat remounts when the tree + // moves between the no-session and session paths. Stable identity so React + // reattaches only on those remounts, not on every render. + const seatObserver = useRef(null) + const seatResizeRef = useCallback((seat: HTMLDivElement | null): void => { + seatObserver.current?.disconnect() + seatObserver.current = null + const scroller = seat?.parentElement ?? null + if (seat === null || scroller === null) return + seatObserver.current = new ResizeObserver(() => { + scroller.style.setProperty('--dsh-composer-height', `${seat.offsetHeight}px`) + }) + seatObserver.current.observe(seat) + }, []) + const sessionWorkspace = sessionId === undefined ? undefined : workspaces.items.find(workspace => workspace.sessionIds.includes(sessionId)) @@ -106,6 +123,9 @@ export function ConversationRoot({ overlay: renderSlot('conversation.input.overlay', {}), leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone), rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone), + // Stats band under the card, inside the bar's width column so both + // share one constraint (composer.dock = stats-line family). + footer: !hero && zone !== undefined ? renderSlot('conversation.composer.dock', zone) : null, }) const composerBar = ( @@ -113,9 +133,6 @@ export function ConversationRoot({ {hero && } {hero && } {hero && heroWorkspaceRow} - {/* Stats band above the input-dock strips so the prior ChatView footer - order (stats → todo/queue → card) is preserved under the sticky stack. */} - {!hero && zone !== undefined && renderSlot('conversation.composer.dock', zone)} {!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)} {inputBar} @@ -133,7 +150,7 @@ export function ConversationRoot({ // on the fallback alone would leave Question/Approval panels at the content // end off-screen when the user is not pinned to the floor. const composerSeat = ( -
+
{composer}
) diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 8837752830..f0a59942f7 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -20,10 +20,10 @@ display: flex; flex-direction: column; align-items: center; - /* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by - the chat scroller. Top 6 is the gap under the dock todo strip (12px todo - margin + 6px here); error/status strips still carry their own margin. */ - padding: 6px 32px 12px; + /* figma Input_Bottom: pad L32/R32/B8; the bottom gradient mask is owned by + the chat scroller. No top pad: the composer stack's gap owns the space + above; error/status strips still carry their own margin. */ + padding: 0 32px 8px; } .hero { @@ -209,12 +209,16 @@ .mirror { visibility: hidden; pointer-events: none; - /* figma min-h 52 (= ~2 × 24 line + 4pt); 14-line cap (336px). */ - min-height: 52px; max-height: 336px; overflow: hidden; } +/* Hero (centered empty-state) keeps the 2-line floor (figma min-h 52 = ~2 × 24 + line + 4pt); the docked composer collapses to the content height. */ +.hero .mirror { + min-height: 52px; +} + /* Toolbar: attach + Plan + Read-only on the left; model + send on the right (figma Input_Bottom chrome). */ .row { diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 22f645f0ce..0f922b5930 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -30,7 +30,7 @@ export type InputBarProps = ComposerBarProps export function InputBar({ useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon, useProjection, - variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment', + variant, placeholder, accessory, overlay, leftItems, rightItems, footer, onAdd, addLabel = 'Add attachment', }: InputBarProps) { const input = useInput(s => s) const notice = useNotices(s => s) @@ -417,6 +417,7 @@ export function InputBar({
+ {footer} ) } diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 7b506b5553..716f3d9419 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -1,13 +1,14 @@ /* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419): tip surface, 14px radius, status icons + secondary item labels. Column is - calc(100% - 88px) / max 776, centered; InputBar top pad supplies the gap. */ + calc(100% - 88px) / max 752 (GoalBar's column), centered; the composer + stack owns the gap. */ .root { flex: none; overflow: hidden; margin: 0 auto; width: calc(100% - 88px); - max-width: 776px; + max-width: 752px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 14px; background: var(--dsw-specific-tip); @@ -20,11 +21,13 @@ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } +/* Compact scale (GoalBar reference): collapsed header totals the goal + strip's 38px (8+8 pad + 20 line + 2 border). */ .body { display: flex; flex-direction: column; - gap: 10px; - padding: 10px 16px; + gap: 8px; + padding: 8px 14px; } .header { @@ -41,8 +44,8 @@ .title { flex: none; - font-size: 14px; - line-height: 24px; + font-size: 13px; + line-height: 20px; font-weight: 500; color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 38edc48cca..2e9aa04f92 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -12,7 +12,7 @@ import type { import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { StatsLine, deriveStats, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' +import { StatsLine, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' import { BashRow } from '../src/client/toolviews/bash-sample.tsx' afterEach(cleanup) @@ -51,7 +51,7 @@ function makeSource(init?: Partial) { } describe('deriveStats', () => { - it('folds turns/steps/tokens and cache hit percentage', () => { + it('folds turns/steps/token split and cache hit percentage', () => { const stats = deriveStats([ assistant(1, 1, { inputTokens: 100, outputTokens: 50, cacheReadTokens: 900 }), assistant(2, 1, { inputTokens: 100, outputTokens: 50 }), @@ -59,19 +59,53 @@ describe('deriveStats', () => { ]) expect(stats.turns).toBe(2) expect(stats.steps).toBe(3) - expect(stats.tokens).toBe(1200) + expect(stats.inputTokens).toBe(1100) + expect(stats.outputTokens).toBe(100) expect(stats.cacheHitPct).toBe(82) }) - it('cache hit stays null with no cache accounting; non-assistant nodes ignored', () => { + it('cache hit stays null with no cache accounting; out-of-window tool results ignored', () => { const tool: ToolResultNode = { kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [], isError: false, callView: null, resultView: null, } const stats = deriveStats([tool, assistant(1, 1)]) expect(stats.steps).toBe(1) + expect(stats.toolMs).toBe(0) expect(stats.cacheHitPct).toBeNull() }) + + it('sums LLM wall time from assistant timing and tool wall time from call/result pairs', () => { + const timed: AssistantMessageNode = { + ...assistant(1, 1), + timing: { stepStartTime: 1_000, firstTokenTime: 1_200, completedTime: 3_500 }, + } + const untimed: AssistantMessageNode = { + ...assistant(2, 1), + timing: { stepStartTime: null, firstTokenTime: null, completedTime: 9_000 }, + } + const tool: ToolResultNode = { + kind: 'tool-result', seq: 5, time: 7_000, callId: 'c', call: null, callTime: 4_000, content: [], + isError: false, callView: null, resultView: null, + } + const stats = deriveStats([timed, untimed, tool]) + expect(stats.llmMs).toBe(2_500) + expect(stats.toolMs).toBe(3_000) + }) +}) + +describe('formatters', () => { + it('formats token counts compactly', () => { + expect(formatTokens(517)).toBe('517') + expect(formatTokens(12_240)).toBe('12.2K') + expect(formatTokens(517_000)).toBe('517K') + expect(formatTokens(1_230_000)).toBe('1.2M') + }) + + it('formats durations under and over a minute', () => { + expect(formatDuration(45_230)).toBe('45.2s') + expect(formatDuration(162_000)).toBe('2m42s') + }) }) describe('StatsLine', () => { @@ -79,12 +113,13 @@ describe('StatsLine', () => { return { useSession: bindSnapshotSelector(source) } } - it('renders the joined stats row and hides with zero steps', () => { + it('renders the grouped stats row and hides with zero steps', () => { const { source } = makeSource({ nodes: [assistant(1, 1, { inputTokens: 10, outputTokens: 5, cacheReadTokens: 90 })], }) const view = render() - expect(view.getByText('cache hit 90% · 105 tokens · 1 turns · 1 steps')).toBeTruthy() + // No timing on the fixture: the duration group drops out whole. + expect(view.container.textContent).toBe('1 turns · 1 steps|Cache hit 90%|Input 100 tok · Output 5 tok') const empty = makeSource() const emptyView = render() expect(emptyView.container.textContent).toBe('') diff --git a/packages/client/ui-goal/src/client/GoalBar.module.css b/packages/client/ui-goal/src/client/GoalBar.module.css index 80c87be57b..aaf832464e 100644 --- a/packages/client/ui-goal/src/client/GoalBar.module.css +++ b/packages/client/ui-goal/src/client/GoalBar.module.css @@ -1,11 +1,12 @@ -/* GoalBar: the goal strip docked above the composer card. The dock mirrors - InputBar's horizontal geometry (32px side padding, 776px centered cap) - plus the mock's 12px inset, so the bar's edges land 12px inside the - composer card's edges in both the capped and the squeezed regimes. The - negative bottom margin eats InputBar's 8px top padding and tucks the +/* GoalBar: the goal strip docked above the composer card. The dock's 44px + side padding and the bar's 752px cap match the todo strip's column + (TodoPanel.module.css), 24px inside the composer card's edges. The + negative bottom margin cancels the composer stack's 8px gap and tucks the bar's square bottom edge 2px under the composer card's top edge (the - card, later in DOM order, paints over it). All states share one fixed - 38px height so switching between them never resizes the strip. */ + card, later in DOM order, paints over it). Surface matches the todo + strip: tip fill, l1 border — no bottom edge where it disappears under the + card. All states share one fixed 38px height so switching between them + never resizes the strip. */ .dock { padding: 0 44px; @@ -20,10 +21,10 @@ height: 38px; margin: 0 auto -10px; padding: 0 14px; + border: 1px solid var(--dsw-alias-border-l1); + border-bottom: none; border-radius: 14px 14px 0 0; - /* Translucent hover gray doubles as the mock's #F5F6F7 over the white - base and lifts the strip off the composer card in dark mode. */ - background: var(--dsw-alias-interactive-bg-hover); + background: var(--dsw-specific-tip); } .sparkle { From 4b4f7752c183b7a77dd228e82e0ce969084d2ec0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:23:05 +0800 Subject: [PATCH 06/18] ci: gate web browser snapshots --- .../2026-07-20-gui-testing-system.i18n.yaml | 6 ++-- .../process/2026-07-20-gui-testing-system.md | 7 ++-- .../2026-07-20-gui-testing-system.zh.md | 7 ++-- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +-- .../2026-07-24-web-gui-browser-e2e-lane.md | 7 ++-- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 7 ++-- ...-30-web-browser-snapshot-ci-gate.i18n.yaml | 6 ++++ ...2026-07-30-web-browser-snapshot-ci-gate.md | 33 +++++++++++++++++++ ...6-07-30-web-browser-snapshot-ci-gate.zh.md | 33 +++++++++++++++++++ .github/workflows/ci.yml | 32 +++++++++++++++++- docs/testing.i18n.yaml | 4 +-- docs/testing.md | 2 +- docs/testing.zh.md | 2 +- package.json | 3 +- packages/client/AGENTS.md | 2 +- scripts/run-gates.spec.ts | 11 +++++-- scripts/run-gates.ts | 10 ++++++ vitest.web.config.ts | 12 +++---- 18 files changed, 151 insertions(+), 37 deletions(-) create mode 100644 .agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml create mode 100644 .agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md create mode 100644 .agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml index ffa1e87103..df3de464d3 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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-20-gui-testing-system.md: 546f65f065c0c2266773acc3c28b2833a094ba9b -2026-07-20-gui-testing-system.zh.md: 6601ae0a1c2bd1671af6f02961fbda81d30ab971 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-20-gui-testing-system.md +2026-07-20-gui-testing-system.md: 12699313b6be2aa6e290916979a67364d9d07293 +2026-07-20-gui-testing-system.zh.md: 34a08feec71c472b5df829c8d93c1ed925f91b53 diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md index 546f65f065..12699313b6 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md @@ -20,7 +20,7 @@ Cut along the architecture's natural test seams into three tiers, bottom-up: |---|---|---|---| | 1 Protocol isomorphism | `AbstractApiClient` + `toFetchHandler` (bidirectional data / rpcId / zod types / SSE streams / batching / timeouts) | **The full chain at the isomorphic point**: `InProcessApiClient(toFetchHandler(脚本化 impl))` skips the network but genuinely runs the wire serialization — zero browser, pure node env | `packages/host/apiproxy/tests/client-handler.spec.ts` | | 2 Object-layer orchestration | `Session`/`SessionManager`/`ConnectionController` (state machines and timing: stitching / dedup / paging / optimistic draft clearing / pendingBuffers / reconnect / backoff) | **The "event sequence in → snapshot out" golden path**: programmable fakes + deferreds controlling timing + fake timers controlling backoff | `packages/client/{runtime,connection}/tests/` | -| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key; the keyless browser e2e lane disables the shipped model-adapter row and replays recorded session fixtures through `dsh-llm-replay` in the real in-process web assembly against conversation aria goldens ([web e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md)) | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts`, `apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | +| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key; the keyless browser e2e lane disables the shipped model-adapter row and replays recorded session fixtures through `dsh-llm-replay` in the real in-process web assembly against conversation aria goldens ([web e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md), [required CI gate](../testing/2026-07-30-web-browser-snapshot-ci-gate.md)) | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts`, `apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | Inter-tier discipline: **each tier tests its own layer, upper tiers never re-test lower ones** — an app semantic snapshot pins only user-visible projection across the assembled plugin boundary, while Playwright smoke proves browser and carrier liveness; wire semantics belong to tier 1 and data semantics to tier 2. Pure-function layers (lineage/partial/notifier/fold-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2. @@ -34,6 +34,7 @@ Inter-tier discipline: **each tier tests its own layer, upper tiers never re-tes | Baseline | `pnpm run test:gui` | Tier 1+2 vitest (`packages/client packages/host`), seconds-fast, no browser, no server | Casually, after touching any GUI source | | Semantic snapshot | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | Keyless assembled-application semantics plus the repo's transport-specific expected outputs | After a human-visible GUI change; before delivery | | Browser end-to-end | `pnpm run test:web` | Rebuilds the front-end dist first, then runs the tier-3 browser set: the two-level smoke (fixture level + real-host level self-skip) plus the keyless replayed e2e scenarios (`DSH_SNAPSHOT=record`/`refresh` re-record fixtures / rewrite goldens) | After touching the build surface/boot/carriage; before delivery | +| Browser expected-output gate | `DSH_SNAPSHOT=replay pnpm run test:web:built` | Reuses CI-built artifacts and compares every committed browser golden without writing | Every Linux pull request and default-branch push | | Gate | `pnpm run test:coverage` | The repo-wide gate (host and client GUI packages included, except annotated browser-grade exclusions) | The PR window | **Division of labor between the browser scripts and vitest**: Playwright owns browser/carrier black-box regression and long sequential user journeys; ordinary vitest owns data-layer semantics such as reference stability, timing, and wire shapes; snapshot vitest owns stable app-level semantic output through the built composition. These lanes complement each other rather than duplicating assertions. @@ -46,7 +47,7 @@ Inter-tier discipline: **each tier tests its own layer, upper tiers never re-tes ## Consequences -Each lane tests its own tier: touching any GUI source gets seconds-fast `test:gui` feedback, wire/object-layer semantics assert in milliseconds in Node, built-composition snapshots pin deterministic user-visible projection, and the browser carries wiring and carrier acceptance. The accepted cost is that inter-tier discipline is upheld by review rather than a machine gate and every new app snapshot must avoid unstable layout or clock output. +Each lane tests its own tier: touching any GUI source gets seconds-fast `test:gui` feedback, wire/object-layer semantics assert in milliseconds in Node, built-composition snapshots pin deterministic user-visible projection, and the browser carries wiring and carrier acceptance. Inter-tier discipline remains review-owned, while Linux CI mechanically enforces browser-golden freshness. Every new app snapshot must avoid unstable layout or clock output. ## Alternatives considered @@ -56,4 +57,4 @@ Each lane tests its own tier: touching any GUI source gets seconds-fast `test:gu | Migrating the verify scripts to vitest | An ordered script shares one browser session; splitting the cases either formalizes it (sequential + shared page) or re-runs the preamble × N; streaming PASS/FAIL output is exactly the agent's locating interface | | Reusing FixtureApiClient in tests | The demo script runs on a real clock, tests need deferred hand-controlled timing — orthogonal purposes; forced reuse chains the tests to the demo's rhythm | | A standalone vitest config for GUI packages (once designed as vitest.gui.config.ts) | Package-level tests/ are already scanned by the root include; `vitest run packages/client packages/host` path filtering is the tight loop — zero new config | -| Deferring hooks/component-layer unit tests (the original ruling) | Once deferred as "components are consumables, revisit after the redo"; overturned by the user on 2026-07-20 — **the jsdom mainline enters coverage** (no browser infrastructure in CI is the decisive reason, playwright demoted to a local enhancement), the RTL dependencies entered devDependencies, the first spec landed | +| Deferring hooks/component-layer unit tests | jsdom remains the coverage mainline because it gives fast per-file component behavior; the required browser replay gate complements it at the assembled tier rather than replacing it ([CI gate decision](../testing/2026-07-30-web-browser-snapshot-ci-gate.md)) | diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md index 6601ae0a1c..34a08feec7 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md @@ -20,7 +20,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 |---|---|---|---| | 1 协议同构层 | `AbstractApiClient` + `toFetchHandler`(双向数据/rpcId/ZOD类型/SSE 流/合批/超时) | **同构点全链**:`InProcessApiClient(toFetchHandler(脚本化 impl))` 不过网络但真跑 wire 序列化——零浏览器、纯 node env | `packages/host/apiproxy/tests/client-handler.spec.ts` | | 2 对象层编排 | `Session`/`SessionManager`/`ConnectionController`(状态机与时序:缝合/去重/翻页/乐观清稿/pendingBuffers/重连/退避) | **「事件序列进→快照出」黄金路径**:可编程假体 + deferred 控时序 + fake timers 控退避 | `packages/client/{runtime,connection}/tests/` | -| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以固定确定性的跨插件状态变化;独立使用 Playwright 裸库的冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过;无密钥浏览器 e2e 车道会禁用交付配置中的模型适配器行,并通过 `dsh-llm-replay` 在真实进程内 web 组装中回放录制的会话 fixture,与会话区 aria 期望输出比对([web e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md)) | `apps/web/tests/*.snapshot.ts`、`apps/web/tests/smoke-{fixture,real}.e2e.ts`、`apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | +| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以固定确定性的跨插件状态变化;独立使用 Playwright 裸库的冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过;无密钥浏览器 e2e 车道会禁用交付配置中的模型适配器行,并通过 `dsh-llm-replay` 在真实进程内 web 组装中回放录制的会话 fixture,与会话区 aria 期望输出比对([web e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md)、[必需 CI 门禁](../testing/2026-07-30-web-browser-snapshot-ci-gate.md)) | `apps/web/tests/*.snapshot.ts`、`apps/web/tests/smoke-{fixture,real}.e2e.ts`、`apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | 层间纪律:**下层各测各的,上层不重测下层**:应用语义快照只固定组装后插件边界上的用户可见投影,Playwright 冒烟测试负责验证浏览器与承载层是否存活;wire 语义归 1 层,数据语义归 2 层。纯函数层(lineage/partial/notifier/fold-adapter)随 2 层同包 tests/ 零假体直测。 @@ -34,6 +34,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 | 基础 | `pnpm run test:gui` | 1+2 层 vitest(`packages/client packages/host`),秒级、无浏览器无 server | 改 GUI 任意源码后随手跑 | | 语义快照 | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | 无需密钥的组装应用语义,以及仓库按传输形态划分的预期输出 | 用户可见的 GUI 变更后;交付前 | | 浏览器端到端 | `pnpm run test:web` | 先重建前端 dist,再跑 3 层浏览器全集:双级 smoke(fixture 级 + 真 host 级 self-skip)加上无密钥回放 e2e 场景(`DSH_SNAPSHOT=record`/`refresh` 重录 fixture / 重写期望输出) | 改构建面/boot/承载后;交付前 | +| 浏览器预期输出门禁 | `DSH_SNAPSHOT=replay pnpm run test:web:built` | 复用 CI 构建的产物,并在不写入的情况下比较每份已提交的浏览器预期输出 | 每个 Linux 拉取请求和默认分支推送 | | 门禁 | `pnpm run test:coverage` | 全仓 gate(host 与 client GUI 包均纳入,仅排除带注释的浏览器级例外) | PR 窗口 | **浏览器脚本与 vitest 的分工**:Playwright 负责浏览器/承载层黑盒回归和较长的连续用户操作流程;普通 vitest 负责引用稳定性、时序和 wire 结构等数据层语义;快照 vitest 通过构建后的组合负责稳定的应用层语义输出。这些车道彼此互补,而不重复断言。 @@ -46,7 +47,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 ## Consequences -各车道各测各层:改动任意 GUI 源码后都能获得秒级 `test:gui` 反馈,wire/对象层语义在 Node 环境中进行毫秒级断言,基于构建后组合的快照固定确定性的用户可见投影,浏览器负责接线与承载层验收。接受的代价是层间纪律由评审而非机器门禁维持,而且每个新的应用快照都必须避开不稳定的布局或时钟输出。 +各车道各测各层:改动任意 GUI 源码后都能获得秒级 `test:gui` 反馈,wire/对象层语义在 Node 环境中进行毫秒级断言,基于构建后组合的快照固定确定性的用户可见投影,浏览器负责接线与承载层验收。层间纪律仍由评审负责,而 Linux CI 通过机器门禁确保浏览器预期输出的新鲜度。每个新的应用快照都必须避开不稳定的布局或时钟输出。 ## Alternatives considered @@ -56,4 +57,4 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 | verify 脚本迁 vitest | 有序剧本共享浏览器会话,拆 case 要么形式化(sequential+共享 page)要么重走前置×N;PASS/FAIL 流式输出正是 agent 定位接口 | | 测试复用 FixtureApiClient | 演示脚本走真实时钟,测试需要 deferred 手控时序——用途正交,硬复用把测试绑死在演示节奏上 | | GUI 包独立 vitest config(曾设计 vitest.gui.config.ts) | 包级 tests/ 本就被根 include 扫到,`vitest run packages/client packages/host` 路径过滤即窄循环——零新 config | -| hooks/组件层暂缓单测(原裁决) | 曾以「组件是耗材、等重做后再议」暂缓;2026-07-20 用户改判——**jsdom 主线进覆盖率**(CI 无浏览器基建是决定性理由,playwright 降级为本地增强),RTL 依赖入 devDeps、首个 spec 已落 | +| hooks/组件层暂缓单测 | jsdom 仍是覆盖率主线,因为它能快速验证逐文件组件行为;必需的浏览器回放门禁在组装层与之互补,而非取代它([CI 门禁决策](../testing/2026-07-30-web-browser-snapshot-ci-gate.md)) | 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 0283559c9c..1b71b36ddc 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: ce59dcce270d548c91e3719eee8e9c83aea0c154 -2026-07-24-web-gui-browser-e2e-lane.zh.md: bad3dd15ed7b98cc17340666a6c1094d0de057b1 +2026-07-24-web-gui-browser-e2e-lane.md: 74522e070e9a26e61db02ea4f71c2b205f222037 +2026-07-24-web-gui-browser-e2e-lane.zh.md: 4ab1e70b3fedb1eae88be457aa0d533c1d193bb1 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 ce59dcce27..74522e070e 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 @@ -46,7 +46,7 @@ The lane covers three behavior families. Live-turn scenarios pin ordinary tool e ### CI stance -The lane ships gate-exempt inside `pnpm run test:web`, exactly as that config's header records. Adding chromium to CI would reverse the "no browser infrastructure in CI" premise in the [GUI testing note](../process/2026-07-20-gui-testing-system.md) and therefore requires its own Agent Note cross-linked from there, staged as a non-required job first with measured promotion criteria (consecutive green runs, wall time, zero-retry flake budget, runner browser-cache strategy). `TODO(ci-browser)` marks the seam. Scenarios are POSIX-oriented (the lane is not in the Windows matrix). +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 static job publishes `apps/web/dist` with the package build artifacts; the consumer job installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. CI never records or refreshes goldens. The default-branch Linux serial jobs run the same comparison and seed the hosted browser cache. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices. ## Prior art @@ -76,12 +76,11 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot ## Testing -`pnpm run test:web` runs the lane keylessly. `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` rewrites aria goldens keylessly. `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. `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. `dsh-llm-replay` unit coverage pins pacing, cancellation, consumption diagnostics, sidecar validation, indexed replacement, and the single append position. ## Deferred - **Web header-class pin**: web fixtures tokenize `{{system}}`/`{{tools}}` everywhere and no scenario pins the web composition's prompt/tool schemas (`TODO(web-header-pin)` — the scaffold `recordFixture` JSDoc marks it). Following the TUI scrub-everywhere precedent; revisit when the web assembly's header diverges from the repl composition it mirrors. -- **CI browser provisioning**: reversal of the no-browser-in-CI ruling, staged criteria above (`TODO(ci-browser)`). - **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. - **Web error surface**: the client consumes no `agent/error` frames and a pre-chunk failure freezes no partial, so a non-retryable provider failure renders no error copy — the user sees the send simply stop. The AUTH scenario pins the current contract (no crash, composer recovers, turn logged `error`) and `FIXME(web-error-surface)` marks where visible error text gets asserted once the UI grows an error rendering. - **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. @@ -89,4 +88,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 lane guards regressions only where it runs (locally, `test:web`) until the CI reversal is separately decided. +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 required Linux CI pays for Chromium provisioning and the serial browser run so the PR that changes the assembled UI owns its expected-output diff. 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 bad3dd15ed..4ab1e70b3f 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 @@ -46,7 +46,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ### CI 立场 -车道随 `pnpm run test:web` 交付、豁免门禁,与该配置头部注释所记一致。往 CI 加 chromium 会推翻 [GUI 测试笔记](../process/2026-07-20-gui-testing-system.md)中「CI 无浏览器基础设施」的前提,因此需要自己的 Agent Note 并从那里交叉链接,分阶段推进:先作为非必需任务,再以量化标准晋升(连续绿色运行次数、耗时、零重试的抖动预算、runner 浏览器缓存策略)。`TODO(ci-browser)` 标记该接缝。场景目前面向 POSIX(车道不在 Windows 矩阵中)。 +根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。static 任务会把 `apps/web/dist` 与包构建产物一同发布;消费方任务安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。CI 从不录制或刷新预期输出。默认分支上的 Linux 串行任务执行同样的比较,并填充托管浏览器缓存。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。 ## 业界先例 @@ -76,12 +76,11 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ## Testing -`pnpm run test:web` 无密钥运行该车道。`DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` 对真实模型录制一个发起提示的场景,`DSH_SNAPSHOT=refresh` 则无密钥重写 aria 预期输出。`dsh-llm-replay` 单元覆盖率钉住节奏控制、取消、消费诊断、sidecar 校验、按索引替换与唯一的追加位置。 +`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 显式选择回放模式。`dsh-llm-replay` 单元覆盖率钉住节奏控制、取消、消费诊断、sidecar 校验、按索引替换与唯一的追加位置。 ## 暂缓 - **Web 头类别钉住**:web fixture 处处 token 化 `{{system}}`/`{{tools}}`,没有场景钉住 web 组合的提示词/工具 schema(`TODO(web-header-pin)`——scaffold 的 `recordFixture` JSDoc 有标记)。沿用 TUI 处处脱敏先例;当 web 组装的请求头与其镜像的 repl 组合进一步分叉时重审。 -- **CI 浏览器供给**:推翻 CI 无浏览器裁定,分阶段标准见上(`TODO(ci-browser)`)。 - **恢复后追问场景**:真实 wire 上的历史/实时缝合路径;当该代码变更或回归时作为独立场景补充。 - **Web 错误表面**:客户端不消费任何 `agent/error` 帧,分片前的失败也没有可冻结的部分输出,因此不可重试的提供方失败不渲染任何错误文案——用户看到的只是发送就此停住。AUTH 场景钉住当前契约(不崩溃、输入框恢复可用、轮次记录为 `error`),`FIXME(web-error-surface)` 标记了待 UI 长出错误渲染后断言可见错误文本的位置。 - **输入框 steering 手势**:输入在运行期间锁定(只能停止或等待),因此 steering 场景从页面走 wire 做 steer;`TODO(web-steer-composer)` 待产品长出真实的输入框手势后,把驱动步骤升级为该手势。 @@ -89,4 +88,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 目录窗口下保持闲置;在 CI 反转被单独决策之前,车道只在其运行之处(本地,`test:web`)把守回归。 +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 目录窗口下保持闲置;必需的 Linux CI 承担 Chromium 供给与串行浏览器运行的成本,使改动组装后 UI 的 PR(Pull Request)持有相应的预期输出 diff。 diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml new file mode 100644 index 0000000000..8a8a916c21 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-30-web-browser-snapshot-ci-gate.md +2026-07-30-web-browser-snapshot-ci-gate.md: 71d6bcdbfad11032ea6f8e7d570780f780cfe071 +2026-07-30-web-browser-snapshot-ci-gate.zh.md: bb28b42c5d2684d29e7e18646d9756b40557eb11 diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md new file mode 100644 index 0000000000..71d6bcdbfa --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md @@ -0,0 +1,33 @@ +# Agent Note: Required CI gate for web browser expected outputs + +Status: implemented + +English | [中文](2026-07-30-web-browser-snapshot-ci-gate.zh.md) + +## Problem + +The [keyless web browser e2e lane](2026-07-24-web-gui-browser-e2e-lane.md) runs only under the local `pnpm run test:web` command, and PR CI does not compare `apps/web/tests/snapshots/**/*.expected.md`. A PR that changes user-visible web output can therefore remain green when its expected outputs are not refreshed; when any later branch explicitly runs `DSH_SNAPSHOT=refresh`, it backfills the earlier change and produces a diff unrelated to that branch. Ordinary local runs already default to read-only replay, so the gap is mandatory enforcement at the PR level, not a ban on writes in refresh mode. + +## Decision + +For Linux PRs, the `node 24 / snapshots and artifacts` job must run the full web browser replay/compare suite. `scripts/run-gates.ts` registers `test:web:built` as a `ci-consumers` gate and explicitly injects `DSH_SNAPSHOT=replay`; CI never runs in `record` or `refresh` mode, so when the committed goldens disagree with the currently assembled application, the tests fail directly instead of silently rewriting them on the runner and then passing. + +The static CI job already builds all publishable artifacts; it puts `apps/web/dist` and the package `lib/` directories in the built-tree artifact, which the consumer job reuses without rebuilding the entire repository. The consumer job installs Chromium and its system dependencies at the Playwright version in the lockfile, and caches the browser by operating system and the `pnpm-lock.yaml` hash. The default branch's serial Linux job runs the same compare command and populates the default-branch cache, which subsequent PRs can restore directly. + +Local `pnpm run test:web` continues to build first and then run the full browser suite; `test:web:built` is the entry point for existing build artifacts. Developers explicitly run `DSH_SNAPSHOT=refresh pnpm run test:web` only after confirming that user-visible output changed intentionally, review every expected-output diff, and then verify again in replay mode that no files are written. + +The gate remains Linux-only: these scenarios target POSIX, and the Windows and macOS serial reference jobs do not run them again. A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name. + +## Alternatives considered + +**Continue requiring only local runs.** Rejected: execution depends on developer memory, which is precisely why stale goldens drift across PRs, and cannot guarantee that the PR introducing a behavior change carries its own expected-output diff. + +**Run CI in `refresh` mode and then check the working tree.** Rejected: checking after writing turns the assertion mechanism into a generator; if the working-tree check is wired incorrectly, it can turn a regression into a passing expected-output update. Replay compares the existing goldens directly and has a smaller failure surface. + +**Create a standalone browser job and rebuild the entire repository.** Rejected: it would duplicate dependency installation and the publishable build. The existing Linux consumer job already consumes the same built-tree artifact and is part of the unified required verdict. + +**Replace real Chromium with jsdom snapshots.** Rejected: jsdom does not cover the browser, HTTP/SSE carriage, or the composition of real client plugin bundles. It remains useful for fast lower-layer feedback, but cannot replace the assembled browser chain. + +## Consequences + +Before merge, every PR proves that the current web assembly matches all committed browser expected outputs, turning a missed refresh from an “unrelated change in a later PR” into a failure in the PR that introduced it. The cost is Chromium provisioning and one serial pass through the browser scenarios in Linux CI; built-artifact reuse and the default-branch browser cache avoid duplicate builds and routine downloads. The gate still makes no claim of cross-platform browser consistency, and if a Playwright/Chromium upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn. diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md new file mode 100644 index 0000000000..bb28b42c5d --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Web 浏览器预期输出的必需 CI 门禁 + +Status: implemented + +[English](2026-07-30-web-browser-snapshot-ci-gate.md) | 中文 + +## 问题 + +[无密钥 Web 浏览器 e2e 车道](2026-07-24-web-gui-browser-e2e-lane.md)只由本地 `pnpm run test:web` 运行,PR CI 不比较 `apps/web/tests/snapshots/**/*.expected.md`。因此,改变用户可见 Web 输出的 PR 可以在漏刷预期输出时保持绿色;后来任意分支显式运行 `DSH_SNAPSHOT=refresh`,都会替前序变更补账并产生与本分支无关的 diff。普通本地运行已经默认使用只读 replay,缺口是 PR 级的强制执行,而不是禁止 refresh 写入。 + +## 决策 + +Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览器 replay/compare。`scripts/run-gates.ts` 把 `test:web:built` 作为 `ci-consumers` 的一个 gate,并显式注入 `DSH_SNAPSHOT=replay`;CI 永不以 `record` 或 `refresh` 模式运行,因此提交的 golden 与当前组装应用不一致时测试直接失败,不会在 runner 内静默改写后通过。 + +静态 CI job 已经构建全部发布产物;它把 `apps/web/dist` 连同 package `lib/` 放进 built-tree artifact,consumer job 复用该 artifact 而不重复全仓构建。consumer job 按 lockfile 中的 Playwright 版本安装 Chromium 及系统依赖,并以操作系统和 `pnpm-lock.yaml` 哈希缓存浏览器。默认分支的 Linux 串行 job 运行同一 compare 命令并产出默认分支缓存,后续 PR 可直接恢复。 + +本地 `pnpm run test:web` 仍先构建再运行浏览器全集;`test:web:built` 是已有构建产物的执行入口。开发者只在确认用户可见输出有意变化后显式运行 `DSH_SNAPSHOT=refresh pnpm run test:web`,评审每一处 expected diff,再以 replay 模式复验不再写文件。 + +门禁保持 Linux-only:这些场景面向 POSIX,Windows 与 macOS 的串行参考 job 不重复运行。PR 的 `all checks passed` 已依赖 consumer job,因此浏览器 compare 失败会阻止合并,无需新增 branch-protection check 名称。 + +## 曾考虑的替代方案 + +**继续只要求本地运行。** 已否决:执行依赖开发者记忆,正是旧 golden 跨 PR 漂移的原因,不能保证产生行为变化的 PR 自己携带 expected diff。 + +**让 CI 以 `refresh` 模式运行后检查工作树。** 已否决:写后比较把断言机制变成生成器,若工作树检查接线失效就会把回归更新成绿色;replay 直接比较已有 golden,失败面更小。 + +**新建独立 browser job 并重新构建全仓。** 已否决:它会重复依赖安装和发布构建。现有 Linux consumer job 已消费同一 built-tree artifact,并已被统一的 required verdict 聚合。 + +**用 jsdom 快照代替真实 Chromium。** 已否决:jsdom 不覆盖浏览器、HTTP/SSE 承载及真实 client plugin bundle 组合;它保留为快速的下层反馈,不能替代 assembled browser chain。 + +## 后果 + +每个 PR 都在合并前证明当前 Web 组装与所有已提交的浏览器 expected 一致,漏刷从“后续 PR 的无关变化”变成引入 PR 自己的失败。成本是 Linux CI 增加 Chromium 供给和一轮串行浏览器场景;built artifact 复用与默认分支浏览器缓存避免重复构建和常态下载。门禁仍不声称跨平台浏览器一致性,Playwright/Chromium 升级若改变 aria 格式,升级 PR 必须显式 refresh 并评审 churn。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6782024240..00e85f57ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,7 +96,7 @@ jobs: - name: Pack built tree run: >- tar -czf "$RUNNER_TEMP/node-24-built-tree.tar.gz" - apps/*/lib packages/*/*/lib vendor/*/lib + apps/*/lib apps/web/dist packages/*/*/lib vendor/*/lib - uses: actions/upload-artifact@v7 with: @@ -224,6 +224,12 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + - uses: actions/cache@v4 + if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} + - name: Install dependencies and prepare bubblewrap run: | pnpm install --frozen-lockfile & @@ -237,6 +243,9 @@ jobs: if (( install_status != 0 )); then exit "$install_status"; fi exit "$sandbox_status" + - name: Install Playwright Chromium + run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install --with-deps chromium + - name: Run compatibility, snapshot, and artifact gates run: pnpm run check:ci:consumers @@ -448,9 +457,17 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + - uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} + - name: Install (immutable) run: pnpm install --frozen-lockfile + - name: Install Playwright Chromium + run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install --with-deps chromium + - name: Prepare bubblewrap (unrestrict userns) run: bash scripts/prepare-ci-bubblewrap.sh @@ -465,6 +482,11 @@ jobs: DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci + - name: Compare web browser snapshots + env: + DSH_SNAPSHOT: replay + run: pnpm run test:web:built + # Hot-standby drill for the in-house self-hosted pool: every master move # re-runs the complete unsharded aggregate on the persistent 64-core VM, # continuously proving that environment can take over a required lane if @@ -505,6 +527,9 @@ jobs: - name: Install (immutable) run: pnpm install --frozen-lockfile + - name: Install Playwright Chromium + run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install --with-deps chromium + - name: Prepare bubblewrap (unrestrict userns) run: bash scripts/prepare-ci-bubblewrap.sh @@ -519,6 +544,11 @@ jobs: DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci + - name: Compare web browser snapshots + env: + DSH_SNAPSHOT: replay + run: pnpm run test:web:built + serial-macos: if: github.event_name == 'push' && github.ref == 'refs/heads/master' name: serial / macos diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 976f52c779..14e710f5ba 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.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 docs/testing.md -testing.md: 04bd7782fa4328b6b693f13f60f4e33b463f8a18 -testing.zh.md: 5712fd8ce7b0cd46ebeb237bdd12c6c572ebe3de +testing.md: fd4879158d7aa1f4726043b0e519f1d2827f41ec +testing.zh.md: bd78afb4fa29e8cc505bae12701f1651f7ba7b5e diff --git a/docs/testing.md b/docs/testing.md index 04bd7782fa..fd4879158d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -10,7 +10,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless pins `stream-json` through its real one-shot process. TUI journeys replay primary/child JSONL through the real loop and tools, then project ANSI into semantic terminal-state outputs; package snapshots retain transient states and a real PTY covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -- **Web browser snapshot** (gate-exempt `pnpm run test:web`): real chromium over the in-process web composition replays recorded fixtures against conversation aria goldens (`apps/web/tests/snapshots/`); record/refresh semantics and the deferred CI browser decision: [web e2e lane Agent Note](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). [Runs `build` first](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md): plugin CSS ships per plugin. +- **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares replayed browser output with `apps/web/tests/snapshots/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` [builds first](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md) for plugin CSS. Committed session-format JSONL uses the canonical packed-row layout, and the keyless snapshot gate discovers every such fixture by its `session` header. In-flight branches carrying older fixture edits merge current `master` and run the [temporary migrator](../scripts/migrate-packed-session-fixtures.ts) through `pnpm run migrate:packed-session-fixtures`; the [removal proposal](../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) retires that command and these links after all affected branches converge. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 5712fd8ce7..bd78afb4fa 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -10,7 +10,7 @@ - **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。 - **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 - **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输契约与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 通过真实单次运行进程固定 `stream-json`。TUI 旅程通过真实循环与工具回放主会话与子会话 JSONL,再将 ANSI 投影为语义化终端状态输出;包级快照保留瞬态状态,真实 PTY 覆盖进程边界([TUI 快照 Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md))。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 -- **Web 浏览器快照**(豁免门禁的 `pnpm run test:web`):真实 chromium 在进程内 web 组装之上回放已录制 fixture,与会话区 aria 预期输出比对(`apps/web/tests/snapshots/`);`DSH_SNAPSHOT=record`/`refresh` 的语义与暂缓的 CI 浏览器决策见 [web e2e 车道 Agent Note](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)。[先跑 `build`](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md):插件 CSS 按插件分别发布。 +- **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):Chromium 将回放后的浏览器输出与 `apps/web/tests/snapshots/` 比较。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md))。`test:web` 会[先构建](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)以交付插件 CSS。 签入仓库的会话格式 JSONL 使用规范打包行布局,无密钥快照门禁会通过 `session` header 发现每一份此类 fixture。仍携带旧版 fixture 改动的在途分支应合并当前 `master`,并通过 `pnpm run migrate:packed-session-fixtures` 运行[临时迁移器](../scripts/migrate-packed-session-fixtures.ts);待所有受影响分支收敛后,[移除提案](../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)会移除该命令及这些链接。 diff --git a/package.json b/package.json index ecb7215e66..465d13e18f 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", "migrate:packed-session-fixtures": "tsx scripts/migrate-packed-session-fixtures.ts", - "test:web": "npm run build && vitest run --config vitest.web.config.ts", + "test:web": "npm run build && npm run test:web:built", + "test:web:built": "vitest run --config vitest.web.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", diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index a7d80b3232..03ffbab1ab 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -76,7 +76,7 @@ The GUI test structure (three tiers, lane map) is settled in the [GUI testing sy Run the narrowest rung that covers what you touched; escalate only when the change surface demands it. 1. **Every GUI code change** — `pnpm run test:gui` (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck. -2. **Changes to the build surface, boot wiring, static serving, or the wire carriage** (`apps/web`, vite config, `dsh-host-webserver`, connection/handler/SSE) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios (`DSH_SNAPSHOT=refresh` rewrites their aria goldens after an intentional conversation-UI change; `DSH_SNAPSHOT=record` re-records fixtures with a key). +2. **Changes to the build surface, boot wiring, static serving, or the wire carriage** (`apps/web`, vite config, `dsh-host-webserver`, connection/handler/SSE) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios. Linux PR CI pins replay mode and compares the committed goldens; it never records or refreshes. Use `DSH_SNAPSHOT=refresh` locally after an intentional conversation-UI change, or `DSH_SNAPSHOT=record` with a key to re-record fixtures. 3. **Before a PR** — `pnpm run check:pre-push` (the repo-wide gate ladder). Between PR windows this rung is not expected on every commit. If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep. diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index ab80a7329b..7126316e4b 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -135,17 +135,18 @@ describe('Oxlint gate', () => { }) describe('Node 24 consumer graph', () => { - it('owns the seven-command pool and orders restored-artifact consumers', () => { + it('owns the eight-command pool and orders restored-artifact consumers', () => { const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers')) expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({ - workers: 7, + workers: 8, source: 'ci-consumers gate count', }) expect(subject.map(item => item.id)).toEqual([ 'lint-and-duplication', 'node-compat', 'snapshot', + 'web-snapshot', 'publint', 'node-next-types', 'built-package-invariants', @@ -154,10 +155,14 @@ describe('Node 24 consumer graph', () => { expect(subject.find(item => item.id === 'publint')?.needs).toBeUndefined() expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint']) expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants']) - for (const id of ['snapshot', 'node-next-types', 'built-bin-smoke']) { + for (const id of ['snapshot', 'web-snapshot', 'node-next-types', 'built-bin-smoke']) { expect(subject.find(item => item.id === id)?.needs).toEqual(['built-package-invariants']) } expect(subject.find(item => item.id === 'snapshot')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' }) + expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({ + displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built', + env: { DSH_SNAPSHOT: 'replay' }, + }) }) }) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 900b67247e..5fd444de87 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -331,6 +331,7 @@ function ciConsumerGates(): Gate[] { }), pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }), snapshotGate(restoredBuild), + webSnapshotGate(restoredBuild), pnpmScript('publint', 'publint'), pnpmScript('node-next-types', 'verify-node-next-types', { label: 'node-next types', @@ -341,6 +342,15 @@ function ciConsumerGates(): Gate[] { ] } +function webSnapshotGate(needs: string[]): Gate { + return pnpmScript('web-snapshot', 'test:web:built', { + label: 'web browser snapshot', + displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built', + env: { DSH_SNAPSHOT: 'replay' }, + needs, + }) +} + function ciWindowsBlockingGates(): Gate[] { return [ pnpmScript('windows-build', 'build', { label: 'build' }), diff --git a/vitest.web.config.ts b/vitest.web.config.ts index 485a493d55..fd9ca502bc 100644 --- a/vitest.web.config.ts +++ b/vitest.web.config.ts @@ -1,14 +1,10 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' -// Web browser lane (GUI, gate-exempt — not part of the CI sequence yet): real -// host entry points, built-client interaction snapshots, and the replayed -// keyless e2e scenarios, outside the unit/e2e includes. Real-model cases -// self-skip without DEEPSEEK_API_KEY; fixture branches and replay stay -// keyless and deterministic. -// TODO(ci-browser): running this lane in CI requires chromium provisioning -// and reverses the no-browser-in-CI ruling — staged criteria in -// .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md. +// Web browser lane: real host entry points, built-client interaction snapshots, +// and replayed keyless e2e scenarios outside the unit/e2e includes. Linux PR CI +// pins DSH_SNAPSHOT=replay and compares committed goldens; record/refresh remain +// explicit local workflows. Real-model cases self-skip without DEEPSEEK_API_KEY. try { // Node >= 21.7 native; throws when the file does not exist. process.loadEnvFile(new URL('.env', import.meta.url).pathname) From 0dbe631c3a9532fbb6931345c4cd72e7c2336064 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:23:23 +0800 Subject: [PATCH 07/18] test(web): refresh browser expected outputs --- .../tests/snapshots/code-mode-round/ui.expected.md | 9 ++------- .../snapshots/cordis-tool-round/ui.expected.md | 9 ++------- .../tests/snapshots/fresh-round-trip/ui.expected.md | 13 +++++-------- .../snapshots/lifecycle-chrome/hero.expected.md | 6 +----- .../snapshots/lifecycle-chrome/reloaded.expected.md | 9 ++------- .../snapshots/live-interactions/cancel.expected.md | 9 ++------- .../live-interactions/error-auth.expected.md | 9 ++------- .../snapshots/live-interactions/retry.expected.md | 9 ++------- .../tests/snapshots/message-actions/ui.expected.md | 8 ++------ .../question-composer/answered.expected.md | 9 ++------- .../tests/snapshots/seeded-history/ui.expected.md | 8 ++------ .../tests/snapshots/steering/mid-steer.expected.md | 3 +-- .../tests/snapshots/steering/settled.expected.md | 9 ++------- 13 files changed, 27 insertions(+), 83 deletions(-) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index ccbf531e96..ff53c0c23a 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - 'button "Think The user wants me to write a single `run_code` program that:"': - img - img @@ -38,14 +37,10 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 52% · 17,490 tokens · 1 turns · 2 steps -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 5e1a148f9e..4af132b7a2 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - button "Think The user wants me to:": - img - img @@ -62,14 +61,10 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 77% · 66,813 tokens · 1 turns · 4 steps -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index d0810ea315..b25ff5487c 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - button "Think The user wants me to run a simple bash command and reply with \"DONE\".": - img - img @@ -22,7 +21,9 @@ - img - text: {{clock}} - img -- text: Bash Echo the test string +- text: Bash Echo the test string 已完成 workspace echo WEB_E2E_OK +- button "复制" +- text: WEB_E2E_OK - button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".": - img - img @@ -33,14 +34,10 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 99% · 15,818 tokens · 1 turns · 2 steps -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index ee6c1a7475..e357ebcdd8 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -28,11 +28,7 @@ - textbox "Describe what you want to build" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 6d601aad87..8058490303 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - button "Think The user wants me to reply with a single word. Let me comply.": - img - img @@ -22,14 +21,10 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 99% · 7,810 tokens · 1 turns · 1 steps -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index a141ad147c..4b584df800 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - paragraph: partial - text: 已停止 - button "复制": @@ -19,14 +18,10 @@ - button "在新对话中分支": - img - text: {{clock}} 0 tokens · 1 turns · 1 steps -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 7fd444dc9d..30233ef5cb 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -11,15 +11,10 @@ - img - button "编辑": - img -- button "▸ 上下文注入" -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index d857b6663c..c3160c1b51 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - img @@ -22,14 +21,10 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 99% · 7,869 tokens · 1 turns · 1 steps -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index bd833b6c3c..748f0e1109 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -37,14 +37,10 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 deepseek-v4-flash": - text: deepseek-v4-flash - img diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index bd03429898..443636d80d 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": - img - img @@ -35,14 +34,10 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 95% · 8,769 tokens · 1 turns · 2 steps -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index c65bf7e712..c54c6ac3f5 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -36,14 +36,10 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 deepseek-v4-flash": - text: deepseek-v4-flash - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index b70aa38d2c..eb5d00dea4 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img - img @@ -24,7 +23,7 @@ - button: - img - img -- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} cache hit 98% · 7,946 tokens · 1 turns · 1 steps" +- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}" - region "Ready to continue?": - text: Checkpoint - heading "Ready to continue?" [level=2] diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 790bd022f2..08a230af9a 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img - img @@ -35,14 +34,10 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 98% · 15,967 tokens · 1 turns · 2 steps -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img From 72c9d40ad9026b12eb917b766e1423f098fbd6bb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:54:51 +0800 Subject: [PATCH 08/18] ci: isolate web snapshots to consumer lane --- .../2026-07-20-gui-testing-system.i18n.yaml | 4 ++-- .../process/2026-07-20-gui-testing-system.md | 2 +- .../2026-07-20-gui-testing-system.zh.md | 2 +- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 ++-- .../2026-07-24-web-gui-browser-e2e-lane.md | 4 ++-- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 4 ++-- ...-30-web-browser-snapshot-ci-gate.i18n.yaml | 4 ++-- ...2026-07-30-web-browser-snapshot-ci-gate.md | 6 +++--- ...6-07-30-web-browser-snapshot-ci-gate.zh.md | 6 +++--- .github/workflows/ci.yml | 21 ------------------- 10 files changed, 18 insertions(+), 39 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml index df3de464d3..05d448c79b 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.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/process/2026-07-20-gui-testing-system.md -2026-07-20-gui-testing-system.md: 12699313b6be2aa6e290916979a67364d9d07293 -2026-07-20-gui-testing-system.zh.md: 34a08feec71c472b5df829c8d93c1ed925f91b53 +2026-07-20-gui-testing-system.md: 8c6dafb18fc207fc4eac780ba18e108267bc28b1 +2026-07-20-gui-testing-system.zh.md: 9a0de4bfa8fa2f8de55beef53bedde51649c5d9c diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md index 12699313b6..8c6dafb18f 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md @@ -34,7 +34,7 @@ Inter-tier discipline: **each tier tests its own layer, upper tiers never re-tes | Baseline | `pnpm run test:gui` | Tier 1+2 vitest (`packages/client packages/host`), seconds-fast, no browser, no server | Casually, after touching any GUI source | | Semantic snapshot | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | Keyless assembled-application semantics plus the repo's transport-specific expected outputs | After a human-visible GUI change; before delivery | | Browser end-to-end | `pnpm run test:web` | Rebuilds the front-end dist first, then runs the tier-3 browser set: the two-level smoke (fixture level + real-host level self-skip) plus the keyless replayed e2e scenarios (`DSH_SNAPSHOT=record`/`refresh` re-record fixtures / rewrite goldens) | After touching the build surface/boot/carriage; before delivery | -| Browser expected-output gate | `DSH_SNAPSHOT=replay pnpm run test:web:built` | Reuses CI-built artifacts and compares every committed browser golden without writing | Every Linux pull request and default-branch push | +| Browser expected-output gate | `DSH_SNAPSHOT=replay pnpm run test:web:built` | Reuses CI-built artifacts and compares every committed browser golden without writing | Every Linux pull request | | Gate | `pnpm run test:coverage` | The repo-wide gate (host and client GUI packages included, except annotated browser-grade exclusions) | The PR window | **Division of labor between the browser scripts and vitest**: Playwright owns browser/carrier black-box regression and long sequential user journeys; ordinary vitest owns data-layer semantics such as reference stability, timing, and wire shapes; snapshot vitest owns stable app-level semantic output through the built composition. These lanes complement each other rather than duplicating assertions. diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md index 34a08feec7..9a0de4bfa8 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md @@ -34,7 +34,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 | 基础 | `pnpm run test:gui` | 1+2 层 vitest(`packages/client packages/host`),秒级、无浏览器无 server | 改 GUI 任意源码后随手跑 | | 语义快照 | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | 无需密钥的组装应用语义,以及仓库按传输形态划分的预期输出 | 用户可见的 GUI 变更后;交付前 | | 浏览器端到端 | `pnpm run test:web` | 先重建前端 dist,再跑 3 层浏览器全集:双级 smoke(fixture 级 + 真 host 级 self-skip)加上无密钥回放 e2e 场景(`DSH_SNAPSHOT=record`/`refresh` 重录 fixture / 重写期望输出) | 改构建面/boot/承载后;交付前 | -| 浏览器预期输出门禁 | `DSH_SNAPSHOT=replay pnpm run test:web:built` | 复用 CI 构建的产物,并在不写入的情况下比较每份已提交的浏览器预期输出 | 每个 Linux 拉取请求和默认分支推送 | +| 浏览器预期输出门禁 | `DSH_SNAPSHOT=replay pnpm run test:web:built` | 复用 CI 构建的产物,并在不写入的情况下比较每份已提交的浏览器预期输出 | 每个 Linux 拉取请求 | | 门禁 | `pnpm run test:coverage` | 全仓 gate(host 与 client GUI 包均纳入,仅排除带注释的浏览器级例外) | PR 窗口 | **浏览器脚本与 vitest 的分工**:Playwright 负责浏览器/承载层黑盒回归和较长的连续用户操作流程;普通 vitest 负责引用稳定性、时序和 wire 结构等数据层语义;快照 vitest 通过构建后的组合负责稳定的应用层语义输出。这些车道彼此互补,而不重复断言。 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 1b71b36ddc..2c9adcdc07 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: 74522e070e9a26e61db02ea4f71c2b205f222037 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 4ab1e70b3fedb1eae88be457aa0d533c1d193bb1 +2026-07-24-web-gui-browser-e2e-lane.md: 08227a83bfb45348f85e6c306e0a2cc09a876713 +2026-07-24-web-gui-browser-e2e-lane.zh.md: e1cdb6e26ac9345161031df10fc0c0a753cb2ba9 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 74522e070e..08227a83bf 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 @@ -46,7 +46,7 @@ The lane covers three behavior families. Live-turn scenarios pin ordinary tool e ### 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 static job publishes `apps/web/dist` with the package build artifacts; the consumer job installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. CI never records or refreshes goldens. The default-branch Linux serial jobs run the same comparison and seed the hosted browser cache. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices. +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 static job publishes `apps/web/dist` with the package build artifacts; the `node 24 / snapshots and artifacts` consumer job installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. CI never records or refreshes goldens. Default-branch serial jobs do not provision Chromium or run the lane. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices. ## Prior art @@ -88,4 +88,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 required Linux CI pays for Chromium provisioning and the serial 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. 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 4ab1e70b3f..e1cdb6e26a 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 @@ -46,7 +46,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ### CI 立场 -根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。static 任务会把 `apps/web/dist` 与包构建产物一同发布;消费方任务安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。CI 从不录制或刷新预期输出。默认分支上的 Linux 串行任务执行同样的比较,并填充托管浏览器缓存。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。 +根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。static 任务会把 `apps/web/dist` 与包构建产物一同发布;`node 24 / snapshots and artifacts` 消费方任务安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。CI 从不录制或刷新预期输出。默认分支串行任务不供给 Chromium,也不运行该车道。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。 ## 业界先例 @@ -88,4 +88,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 目录窗口下保持闲置;必需的 Linux CI 承担 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。 diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml index 8a8a916c21..7b3e6e7bb4 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.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-30-web-browser-snapshot-ci-gate.md -2026-07-30-web-browser-snapshot-ci-gate.md: 71d6bcdbfad11032ea6f8e7d570780f780cfe071 -2026-07-30-web-browser-snapshot-ci-gate.zh.md: bb28b42c5d2684d29e7e18646d9756b40557eb11 +2026-07-30-web-browser-snapshot-ci-gate.md: eb7d2fa159cec0c0e11adc28239cf6b3b4f0f593 +2026-07-30-web-browser-snapshot-ci-gate.zh.md: ff3d49e5b49b4f63e1b1ce52f690f80cdee70b2d diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md index 71d6bcdbfa..eb7d2fa159 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md @@ -12,11 +12,11 @@ The [keyless web browser e2e lane](2026-07-24-web-gui-browser-e2e-lane.md) runs For Linux PRs, the `node 24 / snapshots and artifacts` job must run the full web browser replay/compare suite. `scripts/run-gates.ts` registers `test:web:built` as a `ci-consumers` gate and explicitly injects `DSH_SNAPSHOT=replay`; CI never runs in `record` or `refresh` mode, so when the committed goldens disagree with the currently assembled application, the tests fail directly instead of silently rewriting them on the runner and then passing. -The static CI job already builds all publishable artifacts; it puts `apps/web/dist` and the package `lib/` directories in the built-tree artifact, which the consumer job reuses without rebuilding the entire repository. The consumer job installs Chromium and its system dependencies at the Playwright version in the lockfile, and caches the browser by operating system and the `pnpm-lock.yaml` hash. The default branch's serial Linux job runs the same compare command and populates the default-branch cache, which subsequent PRs can restore directly. +The static CI job already builds all publishable artifacts; it puts `apps/web/dist` and the package `lib/` directories in the built-tree artifact, which the consumer job reuses without rebuilding the entire repository. The `node 24 / snapshots and artifacts` consumer job installs Chromium and its system dependencies at the Playwright version in the lockfile, and caches the browser by operating system and the `pnpm-lock.yaml` hash. Default-branch serial jobs do not provision Chromium or run the suite. Local `pnpm run test:web` continues to build first and then run the full browser suite; `test:web:built` is the entry point for existing build artifacts. Developers explicitly run `DSH_SNAPSHOT=refresh pnpm run test:web` only after confirming that user-visible output changed intentionally, review every expected-output diff, and then verify again in replay mode that no files are written. -The gate remains Linux-only: these scenarios target POSIX, and the Windows and macOS serial reference jobs do not run them again. A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name. +The gate runs only in the Linux PR consumer job: these scenarios target POSIX, and serial reference jobs do not provision Chromium or run the suite. A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name. ## Alternatives considered @@ -30,4 +30,4 @@ The gate remains Linux-only: these scenarios target POSIX, and the Windows and m ## Consequences -Before merge, every PR proves that the current web assembly matches all committed browser expected outputs, turning a missed refresh from an “unrelated change in a later PR” into a failure in the PR that introduced it. The cost is Chromium provisioning and one serial pass through the browser scenarios in Linux CI; built-artifact reuse and the default-branch browser cache avoid duplicate builds and routine downloads. The gate still makes no claim of cross-platform browser consistency, and if a Playwright/Chromium upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn. +Before merge, every PR proves that the current web assembly matches all committed browser expected outputs, turning a missed refresh from an “unrelated change in a later PR” into a failure in the PR that introduced it. The cost is Chromium provisioning and one serial pass through the browser scenarios in the consumer job; built-artifact reuse and the browser cache avoid duplicate builds and downloads on reruns. The gate still makes no claim of cross-platform browser consistency, and if a Playwright/Chromium upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn. diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md index bb28b42c5d..ff3d49e5b4 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md @@ -12,11 +12,11 @@ Status: implemented Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览器 replay/compare。`scripts/run-gates.ts` 把 `test:web:built` 作为 `ci-consumers` 的一个 gate,并显式注入 `DSH_SNAPSHOT=replay`;CI 永不以 `record` 或 `refresh` 模式运行,因此提交的 golden 与当前组装应用不一致时测试直接失败,不会在 runner 内静默改写后通过。 -静态 CI job 已经构建全部发布产物;它把 `apps/web/dist` 连同 package `lib/` 放进 built-tree artifact,consumer job 复用该 artifact 而不重复全仓构建。consumer job 按 lockfile 中的 Playwright 版本安装 Chromium 及系统依赖,并以操作系统和 `pnpm-lock.yaml` 哈希缓存浏览器。默认分支的 Linux 串行 job 运行同一 compare 命令并产出默认分支缓存,后续 PR 可直接恢复。 +静态 CI job 已经构建全部发布产物;它把 `apps/web/dist` 连同 package `lib/` 放进 built-tree artifact,consumer job 复用该 artifact 而不重复全仓构建。`node 24 / snapshots and artifacts` 消费方 job 按 lockfile 中的 Playwright 版本安装 Chromium 及系统依赖,并以操作系统和 `pnpm-lock.yaml` 哈希缓存浏览器。默认分支串行 job 不供给 Chromium,也不运行该套件。 本地 `pnpm run test:web` 仍先构建再运行浏览器全集;`test:web:built` 是已有构建产物的执行入口。开发者只在确认用户可见输出有意变化后显式运行 `DSH_SNAPSHOT=refresh pnpm run test:web`,评审每一处 expected diff,再以 replay 模式复验不再写文件。 -门禁保持 Linux-only:这些场景面向 POSIX,Windows 与 macOS 的串行参考 job 不重复运行。PR 的 `all checks passed` 已依赖 consumer job,因此浏览器 compare 失败会阻止合并,无需新增 branch-protection check 名称。 +门禁仅在 Linux PR 消费方 job 中运行:这些场景面向 POSIX,串行参考 job 不供给 Chromium,也不运行该套件。PR 的 `all checks passed` 已依赖 consumer job,因此浏览器 compare 失败会阻止合并,无需新增 branch-protection check 名称。 ## 曾考虑的替代方案 @@ -30,4 +30,4 @@ Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览 ## 后果 -每个 PR 都在合并前证明当前 Web 组装与所有已提交的浏览器 expected 一致,漏刷从“后续 PR 的无关变化”变成引入 PR 自己的失败。成本是 Linux CI 增加 Chromium 供给和一轮串行浏览器场景;built artifact 复用与默认分支浏览器缓存避免重复构建和常态下载。门禁仍不声称跨平台浏览器一致性,Playwright/Chromium 升级若改变 aria 格式,升级 PR 必须显式 refresh 并评审 churn。 +每个 PR 都在合并前证明当前 Web 组装与所有已提交的浏览器 expected 一致,漏刷从“后续 PR 的无关变化”变成引入 PR 自己的失败。成本是消费方 job 需要供给 Chromium,并串行运行一轮浏览器场景;built artifact 复用与浏览器缓存避免重跑时重复构建和下载。门禁仍不声称跨平台浏览器一致性,Playwright/Chromium 升级若改变 aria 格式,升级 PR 必须显式 refresh 并评审 churn。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00e85f57ed..ec7fbf34c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -457,17 +457,9 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - uses: actions/cache@v4 - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} - - name: Install (immutable) run: pnpm install --frozen-lockfile - - name: Install Playwright Chromium - run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install --with-deps chromium - - name: Prepare bubblewrap (unrestrict userns) run: bash scripts/prepare-ci-bubblewrap.sh @@ -482,11 +474,6 @@ jobs: DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci - - name: Compare web browser snapshots - env: - DSH_SNAPSHOT: replay - run: pnpm run test:web:built - # Hot-standby drill for the in-house self-hosted pool: every master move # re-runs the complete unsharded aggregate on the persistent 64-core VM, # continuously proving that environment can take over a required lane if @@ -527,9 +514,6 @@ jobs: - name: Install (immutable) run: pnpm install --frozen-lockfile - - name: Install Playwright Chromium - run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install --with-deps chromium - - name: Prepare bubblewrap (unrestrict userns) run: bash scripts/prepare-ci-bubblewrap.sh @@ -544,11 +528,6 @@ jobs: DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci - - name: Compare web browser snapshots - env: - DSH_SNAPSHOT: replay - run: pnpm run test:web:built - serial-macos: if: github.event_name == 'push' && github.ref == 'refs/heads/master' name: serial / macos From 60620ec36c41864b07efcca5aba5d84857f95536 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:09:03 +0800 Subject: [PATCH 09/18] Green the jsdom lane for the composer-seat ResizeObserver and new StatsLine format The seat's height publisher needs a ResizeObserver stub in every spec that renders ConversationRoot (jsdom has none), and the two branch-tail StatsLine assertions move to the grouped detail-row output. --- .../tests/assembly-surfaces.spec.tsx | 13 ++++++++++++- .../tests/chat-branch-tails.spec.tsx | 2 +- .../tests/chat-code-subcalls.spec.tsx | 13 ++++++++++++- .../tests/chat-toolview-slot.spec.tsx | 13 ++++++++++++- .../tests/gate-branch-tails.spec.tsx | 2 +- .../ui-conversation/tests/skeleton.spec.tsx | 17 +++++++++++++++-- 6 files changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index 5d7f4c05e7..4322ae508b 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -29,9 +29,20 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' const SID = 's1' as SessionId -afterEach(cleanup) +/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */ +class ResizeObserverStub { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) beforeEach(() => { localStorage.clear() + vi.stubGlobal('ResizeObserver', ResizeObserverStub) }) const TODOS: TodoItem[] = [ diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 9bb6ba539a..c3a5543627 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -216,6 +216,6 @@ describe('small branch tails', () => { const view = render( , ) - expect(view.getByText('10 tokens · 1 turns · 1 steps')).toBeTruthy() + expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 10 tok') }) }) diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 6b75f940d4..bd1ac63f1f 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -23,9 +23,20 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' const SID = 's1' as SessionId -afterEach(cleanup) +/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */ +class ResizeObserverStub { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) beforeEach(() => { localStorage.clear() + vi.stubGlobal('ResizeObserver', ResizeObserverStub) }) const PROGRAM = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\nreturn listing' diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 02bb6b92dc..ccf940d430 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -21,10 +21,21 @@ import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/clien const SID = 's1' as SessionId -afterEach(cleanup) +/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */ +class ResizeObserverStub { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) // The chat store persists under its declared key; clear between cases. beforeEach(() => { localStorage.clear() + vi.stubGlobal('ResizeObserver', ResizeObserverStub) }) const toolResult = (seq: number, callId: string, name: string, args = '{"command":"make build","description":"Build"}'): ToolResultNode => ({ diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 6d58932ece..08190d917d 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -49,7 +49,7 @@ describe('render branch tails', () => { const view = render( } />, ) - expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy() + expect(view.container.textContent).toBe('2 turns · 3 steps|Cache hit 0%|Input 9 tok · Output 6 tok') }) it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => { diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index b174bace53..3ed459b5a9 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -28,8 +28,21 @@ function fakeWiring() { return { wiring: shell, sink, shell } } -afterEach(cleanup) -beforeEach(() => { localStorage.clear() }) +/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */ +class ResizeObserverStub { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) +beforeEach(() => { + localStorage.clear() + vi.stubGlobal('ResizeObserver', ResizeObserverStub) +}) const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId From 6303b22487f6961377405a34de9ed28491135005 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:16:37 +0800 Subject: [PATCH 10/18] ci: run web replay on master linux --- .../2026-07-26-ci-failover-runbook.i18n.yaml | 4 +-- .../process/2026-07-26-ci-failover-runbook.md | 2 +- .../2026-07-26-ci-failover-runbook.zh.md | 2 +- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +-- .../2026-07-24-web-gui-browser-e2e-lane.md | 2 +- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 2 +- ...-30-web-browser-snapshot-ci-gate.i18n.yaml | 4 +-- ...2026-07-30-web-browser-snapshot-ci-gate.md | 4 +-- ...6-07-30-web-browser-snapshot-ci-gate.zh.md | 4 +-- .github/workflows/ci.yml | 35 ++++++++++++++++--- package.json | 1 + packages/client/AGENTS.md | 6 ++-- scripts/run-gates.spec.ts | 14 ++++++++ scripts/run-gates.ts | 6 +++- 14 files changed, 68 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index a7b9e827fa..5fb7527463 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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/process/2026-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: 4e4f8ea7fc60cf76fd8308147bbf7cc0bac74798 -2026-07-26-ci-failover-runbook.zh.md: bb7e43fe55c9cced51f042de6503978ec9349d6b +2026-07-26-ci-failover-runbook.md: 72261f95ea74b61e3915a1a6419b2c2e616efbd9 +2026-07-26-ci-failover-runbook.zh.md: fdce40ffac5036cb4caf8eb86d20b7bb5bae4fc8 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index 4e4f8ea7fc..72261f95ea 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -14,7 +14,7 @@ Each of the three required Linux worker jobs — and the `all checks passed` ver ### What the in-house pool is -`vm-backup`: one 64-core VM, six always-on systemd-managed runner instances. Check the latest `serial / linux (self-hosted standby)` run before switching: a green standby is verified-yesterday capacity. +`vm-backup`: one 64-core VM, six always-on systemd-managed runner instances. Its image must preinstall Playwright Chromium's Linux system packages; CI downloads the lockfile-selected browser but never runs `apt` on this persistent shared host. Check the latest `serial / linux (self-hosted standby)` run before switching: its aggregate includes browser replay, so a green standby verifies both ordinary capacity and this browser prerequisite. ### Switch (any repository writer, ~1 minute, no merge) diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index bb7e43fe55..fdce40ffac 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -14,7 +14,7 @@ Status: implemented ### 自有池是什么 -`vm-backup`:一台 64 核虚拟机,6 个常驻 systemd 管理的运行器实例。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:绿色 = 这套环境昨天刚被全量验证过。 +`vm-backup`:一台 64 核虚拟机,6 个常驻 systemd 管理的运行器实例。其镜像必须预装 Playwright Chromium 的 Linux 系统软件包;CI 会下载锁文件选定的浏览器,但绝不在这台持久化共享主机上运行 `apt`。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:其聚合流程包含浏览器回放,因此绿色热备同时验证常规容量和这项浏览器先决条件。 ### 切换步骤(任何具备写权限的协作者,约 1 分钟,无需合并) 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 2c9adcdc07..0b1f79f342 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: 08227a83bfb45348f85e6c306e0a2cc09a876713 -2026-07-24-web-gui-browser-e2e-lane.zh.md: e1cdb6e26ac9345161031df10fc0c0a753cb2ba9 +2026-07-24-web-gui-browser-e2e-lane.md: b57273c1c7bb957eeb4de2cf786370c18e6e735c +2026-07-24-web-gui-browser-e2e-lane.zh.md: 53e6550b9b1ac7612908e6b464eadd40704ee05f 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 08227a83bf..b57273c1c7 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 @@ -46,7 +46,7 @@ The lane covers three behavior families. Live-turn scenarios pin ordinary tool e ### 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 static job publishes `apps/web/dist` with the package build artifacts; the `node 24 / snapshots and artifacts` consumer job installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. CI never records or refreshes goldens. Default-branch serial jobs do not provision Chromium or run the lane. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices. +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 static job publishes `apps/web/dist` with the package build artifacts; the `node 24 / snapshots and artifacts` consumer job 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. ## Prior art 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 e1cdb6e26a..53e6550b9b 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 @@ -46,7 +46,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ### CI 立场 -根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。static 任务会把 `apps/web/dist` 与包构建产物一同发布;`node 24 / snapshots and artifacts` 消费方任务安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。CI 从不录制或刷新预期输出。默认分支串行任务不供给 Chromium,也不运行该车道。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。 +根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。static 任务会把 `apps/web/dist` 与包构建产物一同发布;`node 24 / snapshots and artifacts` 消费方任务安装锁文件选定的 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 矩阵之外。 ## 业界先例 diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml index 7b3e6e7bb4..dec7f26e7f 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.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-30-web-browser-snapshot-ci-gate.md -2026-07-30-web-browser-snapshot-ci-gate.md: eb7d2fa159cec0c0e11adc28239cf6b3b4f0f593 -2026-07-30-web-browser-snapshot-ci-gate.zh.md: ff3d49e5b49b4f63e1b1ce52f690f80cdee70b2d +2026-07-30-web-browser-snapshot-ci-gate.md: b4e78e72d6eadacb18fb0e22d9c6d28a01211717 +2026-07-30-web-browser-snapshot-ci-gate.zh.md: d2383ca9c7a074bffb2f34f736541baaa9e04df9 diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md index eb7d2fa159..b4e78e72d6 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md @@ -12,11 +12,11 @@ The [keyless web browser e2e lane](2026-07-24-web-gui-browser-e2e-lane.md) runs For Linux PRs, the `node 24 / snapshots and artifacts` job must run the full web browser replay/compare suite. `scripts/run-gates.ts` registers `test:web:built` as a `ci-consumers` gate and explicitly injects `DSH_SNAPSHOT=replay`; CI never runs in `record` or `refresh` mode, so when the committed goldens disagree with the currently assembled application, the tests fail directly instead of silently rewriting them on the runner and then passing. -The static CI job already builds all publishable artifacts; it puts `apps/web/dist` and the package `lib/` directories in the built-tree artifact, which the consumer job reuses without rebuilding the entire repository. The `node 24 / snapshots and artifacts` consumer job installs Chromium and its system dependencies at the Playwright version in the lockfile, and caches the browser by operating system and the `pnpm-lock.yaml` hash. Default-branch serial jobs do not provision Chromium or run the suite. +The static CI job already builds all publishable artifacts; it puts `apps/web/dist` and the package `lib/` directories in the built-tree artifact, which the consumer job reuses without rebuilding the entire repository. On hosted runners, CI installs Chromium and its system dependencies at the Playwright version in the lockfile. On the persistent failover VM, the image owns the Linux system packages and CI installs only Chromium, avoiding per-run `apt` mutation. The hosted default-branch Linux serial job runs the suite and produces the operating-system-and-lockfile-keyed browser cache; pull requests restore it without paying compression and upload on the required path, with an operating-system prefix fallback across lockfile changes. The self-hosted standby runs the same comparison without hosted cache actions. Local `pnpm run test:web` continues to build first and then run the full browser suite; `test:web:built` is the entry point for existing build artifacts. Developers explicitly run `DSH_SNAPSHOT=refresh pnpm run test:web` only after confirming that user-visible output changed intentionally, review every expected-output diff, and then verify again in replay mode that no files are written. -The gate runs only in the Linux PR consumer job: these scenarios target POSIX, and serial reference jobs do not provision Chromium or run the suite. A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name. +For pull requests, the gate runs only in the Linux consumer job: these scenarios target POSIX, and the other PR jobs do not provision Chromium. The hosted and self-hosted default-branch Linux serial aggregates also include the comparison, while the macOS and Windows serial jobs remain browser-free. A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name. ## Alternatives considered diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md index ff3d49e5b4..d2383ca9c7 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md @@ -12,11 +12,11 @@ Status: implemented Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览器 replay/compare。`scripts/run-gates.ts` 把 `test:web:built` 作为 `ci-consumers` 的一个 gate,并显式注入 `DSH_SNAPSHOT=replay`;CI 永不以 `record` 或 `refresh` 模式运行,因此提交的 golden 与当前组装应用不一致时测试直接失败,不会在 runner 内静默改写后通过。 -静态 CI job 已经构建全部发布产物;它把 `apps/web/dist` 连同 package `lib/` 放进 built-tree artifact,consumer job 复用该 artifact 而不重复全仓构建。`node 24 / snapshots and artifacts` 消费方 job 按 lockfile 中的 Playwright 版本安装 Chromium 及系统依赖,并以操作系统和 `pnpm-lock.yaml` 哈希缓存浏览器。默认分支串行 job 不供给 Chromium,也不运行该套件。 +静态 CI job 已经构建全部发布产物;它把 `apps/web/dist` 和包的 `lib/` 目录放进 built-tree 产物,消费方 job 复用该产物而不重复全仓构建。在托管运行器上,CI 按锁文件中的 Playwright 版本安装 Chromium 及其系统依赖。在持久化故障切换 VM 上,镜像负责预装 Linux 系统软件包,CI 只安装 Chromium,避免每次运行都通过 `apt` 改动系统。托管的默认分支 Linux 串行 job 运行该套件,并生成以操作系统和锁文件为键的浏览器缓存;PR 恢复该缓存,使必需路径无需承担压缩和上传开销,并可在锁文件变化时按操作系统前缀回退。自托管热备运行相同的比较,但不执行托管缓存操作。 本地 `pnpm run test:web` 仍先构建再运行浏览器全集;`test:web:built` 是已有构建产物的执行入口。开发者只在确认用户可见输出有意变化后显式运行 `DSH_SNAPSHOT=refresh pnpm run test:web`,评审每一处 expected diff,再以 replay 模式复验不再写文件。 -门禁仅在 Linux PR 消费方 job 中运行:这些场景面向 POSIX,串行参考 job 不供给 Chromium,也不运行该套件。PR 的 `all checks passed` 已依赖 consumer job,因此浏览器 compare 失败会阻止合并,无需新增 branch-protection check 名称。 +对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX,其他 PR job 不供给 Chromium。托管和自托管的默认分支 Linux 串行聚合作业也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器。PR 的 `all checks passed` 已依赖消费方 job,因此浏览器比较失败会阻止合并,无需新增 branch-protection check 名称。 ## 曾考虑的替代方案 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec7fbf34c1..c9800a0d62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -224,11 +224,15 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - uses: actions/cache@v4 + # Pull requests restore the cache produced by serial-linux on master; + # they do not pay compression and upload on the required path. + - uses: actions/cache/restore@v4 if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-playwright- - name: Install dependencies and prepare bubblewrap run: | @@ -243,9 +247,16 @@ jobs: if (( install_status != 0 )); then exit "$install_status"; fi exit "$sandbox_status" - - name: Install Playwright Chromium + - name: Install Playwright Chromium and hosted dependencies + if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install --with-deps chromium + # The persistent VM image owns Playwright's Linux system packages; do + # not mutate the shared host with apt on every failover run. + - name: Install Playwright Chromium on the failover VM + if: vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' + run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install chromium + - name: Run compatibility, snapshot, and artifact gates run: pnpm run check:ci:consumers @@ -457,9 +468,20 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + # Master produces the hosted Chromium cache restored by pull requests. + - uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-playwright- + - name: Install (immutable) run: pnpm install --frozen-lockfile + - name: Install Playwright Chromium and system dependencies + run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install --with-deps chromium + - name: Prepare bubblewrap (unrestrict userns) run: bash scripts/prepare-ci-bubblewrap.sh @@ -472,7 +494,7 @@ jobs: DSH_OXLINT_THREADS: '1' DSH_PUBLINT_CONCURRENCY: '1' DSH_SNAPSHOT_MAX_CONCURRENCY: '1' - run: pnpm run check:ci + run: pnpm run check:ci:linux-primary # Hot-standby drill for the in-house self-hosted pool: every master move # re-runs the complete unsharded aggregate on the persistent 64-core VM, @@ -514,6 +536,11 @@ jobs: - name: Install (immutable) run: pnpm install --frozen-lockfile + # The persistent VM image owns Playwright's Linux system packages; this + # step also proves that browser provisioning remains usable for failover. + - name: Install Playwright Chromium + run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install chromium + - name: Prepare bubblewrap (unrestrict userns) run: bash scripts/prepare-ci-bubblewrap.sh @@ -526,7 +553,7 @@ jobs: DSH_OXLINT_THREADS: '1' DSH_PUBLINT_CONCURRENCY: '1' DSH_SNAPSHOT_MAX_CONCURRENCY: '1' - run: pnpm run check:ci + run: pnpm run check:ci:linux-primary serial-macos: if: github.event_name == 'push' && github.ref == 'refs/heads/master' diff --git a/package.json b/package.json index 465d13e18f..fc315cb146 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "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", + "check:ci:linux-primary": "tsx scripts/run-gates.ts ci-linux-primary", "check:ci:static": "tsx scripts/run-gates.ts ci-static", "check:ci:lint": "tsx scripts/run-gates.ts ci-lint", "check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage", diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 03ffbab1ab..2b09179410 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -76,8 +76,8 @@ The GUI test structure (three tiers, lane map) is settled in the [GUI testing sy Run the narrowest rung that covers what you touched; escalate only when the change surface demands it. 1. **Every GUI code change** — `pnpm run test:gui` (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck. -2. **Changes to the build surface, boot wiring, static serving, or the wire carriage** (`apps/web`, vite config, `dsh-host-webserver`, connection/handler/SSE) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios. Linux PR CI pins replay mode and compares the committed goldens; it never records or refreshes. Use `DSH_SNAPSHOT=refresh` locally after an intentional conversation-UI change, or `DSH_SNAPSHOT=record` with a key to re-record fixtures. -3. **Before a PR** — `pnpm run check:pre-push` (the repo-wide gate ladder). Between PR windows this rung is not expected on every commit. +2. **Any change that can alter the assembled browser or visible conversation/UI output** (client components or copy, `apps/web`, Vite, `dsh-host-webserver`, connection/handler/SSE) — additionally `DSH_SNAPSHOT=replay pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios. Linux PR CI uses the same read-only replay mode. Use `DSH_SNAPSHOT=refresh` only after confirming an intentional output change, or `DSH_SNAPSHOT=record` with a key to re-record fixtures. +3. **Before a PR** — use [dsh-pre-push-checks](../../.agents/skills/dsh-pre-push-checks/SKILL.md) to select the narrow checks for the outgoing diff; there is no repo-wide pre-push aggregate. If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep. @@ -97,5 +97,5 @@ Bringing up a new `packages/client/` plugin package (ui-workspace is the l 2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local. 3. Component tests feed props directly (`createXXXStore().create()` for the store share; plain stubs for framework hooks) — behavior-shaped assertions, no render machinery. 4. Tokens only in CSS; Chinese product copy; English comments. -5. `pnpm run test:gui` green (plus `test:web` if you touched the build surface). +5. `pnpm run test:gui` green; if the component changes visible assembled output, also run `DSH_SNAPSHOT=replay pnpm run test:web`. 6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend. diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 7126316e4b..9a67e64929 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -57,6 +57,7 @@ function withEnv(name: string, value: string | undefined, action: () => T): T describe('gate graph validation', () => { it.each([ 'ci-primary', + 'ci-linux-primary', 'ci-static', 'ci-lint', 'ci-coverage', @@ -166,6 +167,19 @@ describe('Node 24 consumer graph', () => { }) }) +describe('Linux primary graph', () => { + it('adds the same compare-only web gate after built client artifacts', () => { + const subject = withPnpmEntrypoint(() => gatesForMode('ci-linux-primary')) + const web = subject.find(item => item.id === 'web-snapshot') + + expect(web).toMatchObject({ + displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built', + env: { DSH_SNAPSHOT: 'replay' }, + needs: ['built-package-invariants'], + }) + }) +}) + describe('gate process outcomes', () => { it.skipIf(process.platform === 'win32')('reports signal termination independently from exit status', async () => { const result = await runGate(gate('terminated', { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 5fd444de87..d4cc5cb557 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -13,6 +13,7 @@ import { performance } from 'node:perf_hooks' /** A named aggregate exposed by the gate runner. */ export type Mode = | 'ci-primary' + | 'ci-linux-primary' | 'ci-static' | 'ci-lint' | 'ci-coverage' @@ -97,6 +98,7 @@ async function main(args: string[]): Promise { function parseMode(raw: string | undefined): Mode { switch (raw) { case 'ci-primary': + case 'ci-linux-primary': case 'ci-static': case 'ci-lint': case 'ci-coverage': @@ -112,7 +114,7 @@ function parseMode(raw: string | undefined): Mode { return raw default: throw new Error( - `run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | check-all | doc-sync, got ${JSON.stringify(raw)}.`, + `run-gates: expected mode ci-primary | ci-linux-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | check-all | doc-sync, got ${JSON.stringify(raw)}.`, ) } } @@ -190,6 +192,8 @@ export function gatesForMode(selected: Mode): Gate[] { switch (selected) { case 'ci-primary': return ciPrimaryGates() + case 'ci-linux-primary': + return [...ciPrimaryGates(), webSnapshotGate(['built-package-invariants'])] case 'ci-static': return ciStaticGates() case 'ci-lint': From d9fa79a1e1d52d872ce4a4436d919205c02c5a4b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:39:11 +0800 Subject: [PATCH 11/18] test(web): pin browser replay locale --- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +-- .../2026-07-24-web-gui-browser-e2e-lane.md | 2 +- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 2 +- apps/web/tests/code-mode-round.e2e.ts | 4 +-- apps/web/tests/cordis-tool-round.e2e.ts | 4 +-- .../tests/details-session-lifecycle.e2e.ts | 4 +-- apps/web/tests/lifecycle-chrome.e2e.ts | 4 +-- apps/web/tests/live-interactions.e2e.ts | 4 +-- apps/web/tests/message-actions.e2e.ts | 6 ++--- apps/web/tests/navigation-panes.e2e.ts | 4 +-- apps/web/tests/question-composer.e2e.ts | 4 +-- apps/web/tests/replay-round-trip.e2e.ts | 6 ++--- apps/web/tests/seeded-history.e2e.ts | 6 ++--- apps/web/tests/sidebar-scrollbar.e2e.ts | 4 +-- apps/web/tests/skill-invocation-policy.e2e.ts | 4 +-- apps/web/tests/smoke-real.e2e.ts | 4 +-- apps/web/tests/steering.e2e.ts | 4 +-- apps/web/tests/support.ts | 17 +++++++++++- apps/web/tests/workspace-management.e2e.ts | 26 +++++++++---------- 19 files changed, 64 insertions(+), 49 deletions(-) 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 0b1f79f342..530296f0ff 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: b57273c1c7bb957eeb4de2cf786370c18e6e735c -2026-07-24-web-gui-browser-e2e-lane.zh.md: 53e6550b9b1ac7612908e6b464eadd40704ee05f +2026-07-24-web-gui-browser-e2e-lane.md: 97be6d5d70d12f783e2c80b1bcd546cfa4582ca8 +2026-07-24-web-gui-browser-e2e-lane.zh.md: f55b4f8011cc42aa4e0f8c11d9900525a565e1a7 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 b57273c1c7..97be6d5d70 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 @@ -28,7 +28,7 @@ The barrier stack for replay-mode browser assertions is, in order: (1) host-side 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. -Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. +Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios set `dsh.locale=en` before client boot so localized role locators and goldens use one explicit language; `settings-chrome.e2e.ts` alone leaves storage unset to cover the default Chinese state and both switch directions. ### Expected outputs 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 53e6550b9b..f55b4f8011 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 @@ -28,7 +28,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu 不做单次瞬态 DOM 断言:从回放产出到 React 提交的每一跳都可能合并分片,采样 `[data-streaming]` 天然就是竞态。流式输出的增量性由持久化的 `assistant/chunk` 事件断言(模型可见 ⟺ 已记录,使日志成为权威证据)。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = 突发)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且节奏等待期间中止会即时取消。 -每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。 +每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景在客户端启动前设置 `dsh.locale=en`,使本地化的 role 定位器和预期输出统一采用明确指定的语言;只有 `settings-chrome.e2e.ts` 不预设该存储项,以覆盖默认中文状态及双向切换。 ### 预期输出 diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/code-mode-round.e2e.ts index 9826d35f7c..f772f7eedf 100644 --- a/apps/web/tests/code-mode-round.e2e.ts +++ b/apps/web/tests/code-mode-round.e2e.ts @@ -18,7 +18,7 @@ import { captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('./snapshots/code-mode-round/session.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('./snapshots/code-mode-round/ui.expected.md', import.meta.url)) @@ -44,7 +44,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { }) scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts index 558fa1bfc3..e0ad2d26e1 100644 --- a/apps/web/tests/cordis-tool-round.e2e.ts +++ b/apps/web/tests/cordis-tool-round.e2e.ts @@ -12,7 +12,7 @@ import { captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('./snapshots/cordis-tool-round/session.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('./snapshots/cordis-tool-round/ui.expected.md', import.meta.url)) @@ -62,7 +62,7 @@ describe('web e2e: Cordis tools use the generic row variants', () => { }) scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index c4d6483245..bcdf510111 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -8,7 +8,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('./snapshots/lifecycle-chrome/session.jsonl', import.meta.url)) const SEED_FIXTURE = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) @@ -40,7 +40,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5 }) await seedSession(scaffold, await readFile(SEED_FIXTURE, 'utf8'), 'details-session-lifecycle-seed') browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await appFrame(page).waitFor({ timeout: 30_000 }) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index a07db275fd..d4d684b1de 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -20,7 +20,7 @@ import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') @@ -43,7 +43,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () 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 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 692210b352..ca05e46a22 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -23,7 +23,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') @@ -90,7 +90,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { }) scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index a453d53cbc..aa14308d43 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -12,7 +12,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { saveFailureShot } from './support.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-actions', import.meta.url)) // Borrowed read-only: this scenario needs any settled user+assistant pair, not @@ -40,7 +40,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT]) await seedSession(scaffold, raw, SEED_ID) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -75,7 +75,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria')) await page.getByRole('button', { - name: '选择模型,当前 deepseek-v4-flash', + name: 'Select model, current deepseek-v4-flash', }).waitFor({ timeout: 10_000 }) // Keep a footer focused so opacity-hidden actions stay in the a11y tree // as an active/focused control during the capture. diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 0f42d911aa..fadbef1e9e 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -18,7 +18,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { saveFailureShot } from './support.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', import.meta.url)) const SEED = join(SNAPSHOT_DIR, 'seed.jsonl') @@ -56,7 +56,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await seedSession(scaffold, raw, SEED_ID) } browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) slotErrors = [] page.on('console', (message) => { diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 8689a74fc2..ac4be25299 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -18,7 +18,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') @@ -44,7 +44,7 @@ describe('web e2e: resident question composer round trip', () => { 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 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 17ac5eb03b..01544ad71f 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -18,7 +18,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/fresh-round-trip', import.meta.url)) const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url)) @@ -43,7 +43,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { }) scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -106,7 +106,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { await expect(page.getByRole('textbox').first().isVisible()).resolves.toBe(true) expect(await page.getByText('WEB_E2E_OK', { exact: false }).count()).toBeGreaterThanOrEqual(1) await page.getByRole('button', { - name: '选择模型,当前 DeepSeek-V4-Flash', + name: 'Select model, current DeepSeek-V4-Flash', }).waitFor({ timeout: 10_000 }) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 80d84d7b2f..f998c974b8 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -17,7 +17,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { saveFailureShot } from './support.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/seeded-history', import.meta.url)) const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) @@ -49,7 +49,7 @@ describe('web e2e: seeded history renders through cold resume', () => { await seedSession(scaffold, raw, SEED_ID) } browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -125,7 +125,7 @@ describe('web e2e: seeded history renders through cold resume', () => { // This scenario deliberately leaves the LLM seam open to prove zero // model calls. History still restores the selected id, but no catalog // adapter exists to provide its presentation name. - name: '选择模型,当前 deepseek-v4-flash', + name: 'Select model, current deepseek-v4-flash', }).waitFor({ timeout: 10_000 }) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') diff --git a/apps/web/tests/sidebar-scrollbar.e2e.ts b/apps/web/tests/sidebar-scrollbar.e2e.ts index 763787f81f..12b9588a97 100644 --- a/apps/web/tests/sidebar-scrollbar.e2e.ts +++ b/apps/web/tests/sidebar-scrollbar.e2e.ts @@ -67,7 +67,7 @@ import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { saveFailureShot } from './support.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/sidebar-scrollbar', import.meta.url)) @@ -275,7 +275,7 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum browser = await chromium.launch() // Shorter than the other scenarios' 1000px so SEED_COUNT rows overflow // the list with room to spare. - page = await browser.newPage({ viewport: { width: 1680, height: 800 } }) + page = await newEnglishPage(browser, 800) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts index 925ff924ca..cb252c724b 100644 --- a/apps/web/tests/skill-invocation-policy.e2e.ts +++ b/apps/web/tests/skill-invocation-policy.e2e.ts @@ -17,7 +17,7 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-invocation-policy', import.meta.url)) const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md') @@ -80,7 +80,7 @@ describe('web e2e: skill invocation policy through the real host', () => { scaffold = await launchWebScaffold({}) await seedSkills(scaffold.workspaceCwd) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index aceb002d05..a3cd7f1b00 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -24,7 +24,7 @@ import { pathToFileURL } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { REPO_ROOT, connectFreshWorkspace, probeFreePort, requireDist, saveFailureShot } from './support.ts' +import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts' function waitForReadyLine(child: ChildProcess): Promise { return new Promise((resolveReady, reject) => { @@ -376,7 +376,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke ) baseUrl = (await waitForReadyLine(child)).replace('0.0.0.0', '127.0.0.1') browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) page.on('pageerror', e => pageErrors.push(String(e))) await page.goto(baseUrl, { waitUntil: 'load' }) }, 120_000) diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index dc1bc657ad..641d692714 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -23,7 +23,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/steering', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') @@ -67,7 +67,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { sessionEvents.push(event) }) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 5521d984da..aded1aa406 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -2,13 +2,28 @@ import { existsSync, mkdirSync } from 'node:fs' import { createServer } from 'node:net' import { fileURLToPath } from 'node:url' -import type { Page } from 'playwright' +import type { Browser, Page } from 'playwright' /** The built page under test; `pnpm run test:web` rebuilds it before running. */ export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url)) export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) +/** + * Open the standard browser-test page with English selected before client + * boot. This keeps role locators and goldens deterministic across localized + * component migrations; the settings locale scenario deliberately bypasses + * this helper to cover the product's default Chinese state. + * @param browser - Playwright browser owning the page. + * @param height - Viewport height; width is fixed to the lane baseline. + * @returns the initialized page. + */ +export async function newEnglishPage(browser: Browser, height = 1000): Promise { + const page = await browser.newPage({ viewport: { width: 1680, height } }) + await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') }) + return page +} + /** Fail loud on a stale checkout instead of testing yesterday's bundle. */ export function requireDist(): void { if (!existsSync(DIST_INDEX)) { diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index ff9d458280..0e58f5188c 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -16,7 +16,7 @@ import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { saveFailureShot } from './support.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workspace-management', import.meta.url)) // The seed is another scenario's committed fixture, reused read-only: this @@ -42,12 +42,12 @@ describe('web e2e: workspace management (create / rename / flat view / hover car const agentsBefore = scaffold.ctx.agents.list().length await page.getByRole('button', { name: 'Create workspace' }).click() await page.getByRole('menuitem', { name: 'Open local folder…' }).click() - const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) + const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' }) await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: '编辑路径' }).click() - await dialog.getByLabel('编辑路径').fill(path) - await dialog.getByLabel('编辑路径').press('Enter') - await dialog.getByRole('button', { name: '打开' }).click() + await dialog.getByRole('button', { name: 'Edit path' }).click() + await dialog.getByLabel('Edit path').fill(path) + await dialog.getByLabel('Edit path').press('Enter') + await dialog.getByRole('button', { name: 'Open' }).click() await dialog.waitFor({ state: 'hidden', timeout: 10_000 }) await expect.poll( () => scaffold.ctx.workspace.resolveByPath(path), @@ -72,7 +72,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car await writeFile(join(sessionCwd, 'b.txt'), 'beta\n') await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -363,15 +363,15 @@ describe('web e2e: workspace management (create / rename / flat view / hover car try { await page.getByRole('button', { name: 'Create workspace' }).click() await page.getByRole('menuitem', { name: 'Open local folder…' }).click() - const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) + const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' }) await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: '编辑路径' }).click() - await dialog.getByLabel('编辑路径').fill(staged) - await dialog.getByLabel('编辑路径').press('Enter') + await dialog.getByRole('button', { name: 'Edit path' }).click() + await dialog.getByLabel('Edit path').fill(staged) + await dialog.getByLabel('Edit path').press('Enter') await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(BROWSER_EXPECTED, snapshot, MODE) - await dialog.getByRole('button', { name: '取消' }).click() + await dialog.getByRole('button', { name: 'Cancel' }).click() await dialog.waitFor({ state: 'hidden', timeout: 10_000 }) } finally { if (realHome === undefined) delete process.env.HOME @@ -406,7 +406,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car // card; no aria role — text anchors are the stable selector). await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBeGreaterThanOrEqual(1) // Leaving the anchor closes it with no delay. - await page.getByRole('button', { name: '设置' }).hover() + await page.getByRole('button', { name: 'Settings' }).hover() await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) From 2fa26cd1402dd19cc8a7f91bd792479411b2e27d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:39:40 +0800 Subject: [PATCH 12/18] test(web): refresh localized expected outputs --- .../snapshots/code-mode-round/ui.expected.md | 4 ++-- .../snapshots/cordis-tool-round/ui.expected.md | 4 ++-- .../snapshots/fresh-round-trip/ui.expected.md | 4 ++-- .../snapshots/lifecycle-chrome/hero.expected.md | 6 +++--- .../lifecycle-chrome/reloaded.expected.md | 4 ++-- .../live-interactions/cancel.expected.md | 4 ++-- .../live-interactions/error-auth.expected.md | 4 ++-- .../live-interactions/retry.expected.md | 4 ++-- .../snapshots/message-actions/ui.expected.md | 4 ++-- .../question-composer/answered.expected.md | 4 ++-- .../snapshots/question-composer/ui.expected.md | 14 +++++++------- .../snapshots/seeded-history/ui.expected.md | 4 ++-- .../skill-invocation-policy/menu.expected.md | 2 +- .../snapshots/steering/mid-steer.expected.md | 14 +++++++------- .../tests/snapshots/steering/settled.expected.md | 4 ++-- .../directory-browser.expected.md | 16 ++++++++-------- 16 files changed, 48 insertions(+), 48 deletions(-) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index ff53c0c23a..2851460265 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -37,11 +37,11 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 52% · 17,490 tokens · 1 turns · 2 steps -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 4af132b7a2..845d66c71f 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -61,11 +61,11 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 77% · 66,813 tokens · 1 turns · 4 steps -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index b25ff5487c..551141018e 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -34,11 +34,11 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 99% · 15,818 tokens · 1 turns · 2 steps -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index e357ebcdd8..0fff304821 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -17,9 +17,9 @@ - img - text: workspace 1 session - treeitem "New Session now" [selected] -- button "设置": +- button "Settings": - img - - text: 设置 + - text: Settings - text: Let's start building - button "Choose workspace": - img @@ -29,7 +29,7 @@ - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 8058490303..1065c5de3a 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -21,11 +21,11 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 99% · 7,810 tokens · 1 turns · 1 steps -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 4b584df800..9c9f898e26 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -18,11 +18,11 @@ - button "在新对话中分支": - img - text: {{clock}} 0 tokens · 1 turns · 1 steps -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 30233ef5cb..274b2b3132 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -11,11 +11,11 @@ - img - button "编辑": - img -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index c3160c1b51..1bc7c6c3c0 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -21,11 +21,11 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 99% · 7,869 tokens · 1 turns · 1 steps -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 748f0e1109..33b2402d50 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -37,11 +37,11 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 deepseek-v4-flash": +- button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 443636d80d..e6f6d44853 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -34,11 +34,11 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 95% · 8,769 tokens · 1 turns · 2 steps -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/question-composer/ui.expected.md b/apps/web/tests/snapshots/question-composer/ui.expected.md index 934ec244d5..a1c9dfac83 100644 --- a/apps/web/tests/snapshots/question-composer/ui.expected.md +++ b/apps/web/tests/snapshots/question-composer/ui.expected.md @@ -2,11 +2,11 @@ - text: Pick one - heading "Which color do you prefer?" [level=2] - text: 1 / 1 - - button "上一题" [disabled]: + - button "Previous question" [disabled]: - img - - button "下一题" [disabled]: + - button "Next question" [disabled]: - img - - button "放弃整组问题": + - button "Dismiss all questions": - img - radiogroup: - radio "Blue": @@ -15,9 +15,9 @@ - radio "Green": - text: 2 Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. - img - - button "其他,请填写自定义答案": + - button "Other — enter a custom answer": - img - - text: 其他,请填写自定义答案 + - text: Other — enter a custom answer - status - - button "跳过本题" - - button "提交" [disabled] + - button "Skip this question" + - button "Submit" [disabled] diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index c54c6ac3f5..4da2e8c0c9 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -36,11 +36,11 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 deepseek-v4-flash": +- button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md b/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md index ba62c84f91..11acc39ad0 100644 --- a/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md +++ b/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md @@ -1,3 +1,3 @@ - listbox "Trigger suggestions": - - text: 技能 + - text: Skills - option "policy-shared Available to both model and user invocation" [selected] diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index eb5d00dea4..7a92c09b4a 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -28,11 +28,11 @@ - text: Checkpoint - heading "Ready to continue?" [level=2] - text: 1 / 1 - - button "上一题" [disabled]: + - button "Previous question" [disabled]: - img - - button "下一题" [disabled]: + - button "Next question" [disabled]: - img - - button "放弃整组问题": + - button "Dismiss all questions": - img - radiogroup: - radio "Yes": @@ -41,9 +41,9 @@ - radio "No": - text: 2 No - img - - button "其他,请填写自定义答案": + - button "Other — enter a custom answer": - img - - text: 其他,请填写自定义答案 + - text: Other — enter a custom answer - status - - button "跳过本题" - - button "提交" [disabled] + - button "Skip this question" + - button "Submit" [disabled] diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 08a230af9a..11d103cb6e 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -34,11 +34,11 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 98% · 15,967 tokens · 1 turns · 2 steps -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md index 7fbcd35d75..4bbd790afa 100644 --- a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md +++ b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md @@ -1,10 +1,10 @@ -- dialog "选择工作区目录": - - heading "选择工作区目录" [level=2] +- dialog "Select Workspace Directory": + - heading "Select Workspace Directory" [level=2] - navigation: - - button "主目录" + - button "Home" - img - button "browse-golden" - - button "编辑路径" + - button "Edit path" - list: - listitem: - button "alpha": @@ -16,8 +16,8 @@ - img - text: beta - img - - button "新建文件夹": + - button "New folder": - img - - text: 新建文件夹 - - button "取消" - - button "打开" + - text: New folder + - button "Cancel" + - button "Open" From e80d7bce170b0a5c2c7344c5ea98fe2be5ff592e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:50:12 +0800 Subject: [PATCH 13/18] test(web): refresh message action expected outputs --- .../snapshots/code-mode-round/ui.expected.md | 5 ----- .../snapshots/cordis-tool-round/ui.expected.md | 15 --------------- .../snapshots/fresh-round-trip/ui.expected.md | 5 ----- .../snapshots/message-actions/ui.expected.md | 5 ----- .../question-composer/answered.expected.md | 5 ----- .../tests/snapshots/seeded-history/ui.expected.md | 5 ----- .../snapshots/steering/mid-steer.expected.md | 5 ----- .../tests/snapshots/steering/settled.expected.md | 5 ----- 8 files changed, 50 deletions(-) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 2851460265..43ca98bacd 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -15,11 +15,6 @@ - img - img - text: "Think The user wants me to write a single `run_code` program that:" -- button "复制": - - img -- button "在新对话中分支": - - img -- text: {{clock}} - button: - img - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 845d66c71f..9a81500afa 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -15,11 +15,6 @@ - img - img - text: "Think The user wants me to:" -- button "复制": - - img -- button "在新对话中分支": - - img -- text: {{clock}} - button: - img - img @@ -28,11 +23,6 @@ - img - img - text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code." -- button "复制": - - img -- button "在新对话中分支": - - img -- text: {{clock}} - button [expanded]: - img - text: Mount temporary Plugin typescript @@ -42,11 +32,6 @@ - img - img - text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id." -- button "复制": - - img -- button "在新对话中分支": - - img -- text: {{clock}} - button: - img - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 551141018e..78c1a6563c 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -15,11 +15,6 @@ - img - img - text: Think The user wants me to run a simple bash command and reply with "DONE". -- button "复制": - - img -- button "在新对话中分支": - - img -- text: {{clock}} - img - text: Bash Echo the test string 已完成 workspace echo WEB_E2E_OK - button "复制" diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 33b2402d50..a5f1f83c72 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -16,11 +16,6 @@ - img - img - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. -- button "复制": - - img -- button "在新对话中分支": - - img -- text: {{clock}} - img - text: Read - button "a.txt" diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index e6f6d44853..c1ec5b7262 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -15,11 +15,6 @@ - img - img - text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that. -- button "复制": - - img -- button "在新对话中分支": - - img -- text: {{clock}} - button: - img - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index 4da2e8c0c9..247f6112ca 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -15,11 +15,6 @@ - img - img - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. -- button "复制": - - img -- button "在新对话中分支": - - img -- text: {{clock}} - img - text: Read - button "a.txt" diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 7a92c09b4a..59328ee1e7 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -15,11 +15,6 @@ - img - img - text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that. -- button "复制": - - img -- button "在新对话中分支": - - img -- text: {{clock}} - button: - img - img diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 11d103cb6e..3d1788582e 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -15,11 +15,6 @@ - img - img - text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that. -- button "复制": - - img -- button "在新对话中分支": - - img -- text: {{clock}} - button: - img - img From 876d1947fe671e750651c79874bb41c780bdab08 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:56:54 +0800 Subject: [PATCH 14/18] test(web): pin queue replay locale --- apps/web/tests/queue-actions.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index be5e73f9a6..1df3ffc247 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -16,7 +16,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/queue-actions', import.meta.url)) const FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url)) @@ -63,7 +63,7 @@ describe('web e2e: queue row actions', () => { scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath }) scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) const tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) From 23fb779f7fac6e0abc44a08037e863a8cce49eba Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:57:00 +0800 Subject: [PATCH 15/18] test(web): refresh queue expected outputs --- apps/web/tests/snapshots/queue-actions/editing.expected.md | 4 ++-- apps/web/tests/snapshots/queue-actions/ui.expected.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 6a17d57df5..311c961450 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -25,11 +25,11 @@ - img - button "取消编辑": - img -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Stop generating" diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 05d3e3b347..f56d4e1970 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -19,11 +19,11 @@ - img - button "删除排队消息": - img -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Stop generating" From 9035b1b47470ed0a926cf0d5f579778cc25d86e0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:01:31 +0800 Subject: [PATCH 16/18] docs(testing): record browser gate timing --- .../testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml | 4 ++-- .../testing/2026-07-30-web-browser-snapshot-ci-gate.md | 2 ++ .../testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml index dec7f26e7f..d16d412559 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.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-30-web-browser-snapshot-ci-gate.md -2026-07-30-web-browser-snapshot-ci-gate.md: b4e78e72d6eadacb18fb0e22d9c6d28a01211717 -2026-07-30-web-browser-snapshot-ci-gate.zh.md: d2383ca9c7a074bffb2f34f736541baaa9e04df9 +2026-07-30-web-browser-snapshot-ci-gate.md: 3f87bb0f3d936bcee7ba7c3d84ae808c6ede1a97 +2026-07-30-web-browser-snapshot-ci-gate.zh.md: af563f0e2a1c20f7b53d371e97e41b7ffa52a1d1 diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md index b4e78e72d6..3f87bb0f3d 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md @@ -18,6 +18,8 @@ Local `pnpm run test:web` continues to build first and then run the full browser For pull requests, the gate runs only in the Linux consumer job: these scenarios target POSIX, and the other PR jobs do not provision Chromium. The hosted and self-hosted default-branch Linux serial aggregates also include the comparison, while the macOS and Windows serial jobs remain browser-free. A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name. +An observed self-hosted consumer run measured `web-snapshot` at 112.15 seconds and the full consumer aggregate at 114.97 seconds. The gate scheduler starts it as soon as `built-package-invariants` succeeds and runs independent gates concurrently, so it needs neither a dedicated job timeout nor a manual YAML ordering rule. + ## Alternatives considered **Continue requiring only local runs.** Rejected: execution depends on developer memory, which is precisely why stale goldens drift across PRs, and cannot guarantee that the PR introducing a behavior change carries its own expected-output diff. diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md index d2383ca9c7..af563f0e2a 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md @@ -18,6 +18,8 @@ Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览 对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX,其他 PR job 不供给 Chromium。托管和自托管的默认分支 Linux 串行聚合作业也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器。PR 的 `all checks passed` 已依赖消费方 job,因此浏览器比较失败会阻止合并,无需新增 branch-protection check 名称。 +一次自托管消费方运行中,`web-snapshot` 实测耗时 112.15 秒,完整消费方聚合实测耗时 114.97 秒。gate 调度器会在 `built-package-invariants` 成功后立即启动它,并发运行彼此独立的 gate,因此既不需要专用 job 超时,也不需要手动制定 YAML 顺序规则。 + ## 曾考虑的替代方案 **继续只要求本地运行。** 已否决:执行依赖开发者记忆,正是旧 golden 跨 PR 漂移的原因,不能保证产生行为变化的 PR 自己携带 expected diff。 From 3ecd4a569aaca05e14aaf8b0ae8c6a9b80784c37 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:02:21 +0800 Subject: [PATCH 17/18] test(skill-local): cover root unlink rewatch --- .../tests/skill-local-watcher.spec.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts index 14e3c07e41..bebf4378cf 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -235,6 +235,32 @@ describe('skill-local watcher failures', () => { await settle() }) + it('replaces a retained watcher when its root emits unlinkDir', async () => { + const home = await tempDir('skill-watch-root-unlink') + const root = join(home, '.dsh/skills') + await writeSkill(root, 'removed-skill') + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchPollIntervalMs: 10, + watchStabilityThresholdMs: 20, + }) + + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['removed-skill']) + const original = watcherHarness.watchers[0] + if (original === undefined) throw new Error('expected a root watcher') + + await rm(root, { recursive: true }) + original.emitter.emit('unlinkDir', root) + await vi.waitFor(() => { expect(original.closeCalls).toBeGreaterThan(0) }) + expect(watcherHarness.watchFiles.some(control => control.path === root)).toBe(true) + + await fiber.dispose() + }) + it('re-probes a retained root after child unlink and observes immediate recreation', async () => { const home = await tempDir('skill-watch-root-reprobe') const root = join(home, '.dsh/skills') From 291fe1e823fadc91b23d0c8901b3fa2aaa322be5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:15:57 +0800 Subject: [PATCH 18/18] test(web): refresh composer stats expected outputs --- apps/web/tests/snapshots/code-mode-round/ui.expected.md | 3 ++- apps/web/tests/snapshots/cordis-tool-round/ui.expected.md | 3 ++- apps/web/tests/snapshots/fresh-round-trip/ui.expected.md | 3 ++- apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md | 3 ++- apps/web/tests/snapshots/live-interactions/cancel.expected.md | 3 ++- apps/web/tests/snapshots/live-interactions/retry.expected.md | 3 ++- apps/web/tests/snapshots/message-actions/ui.expected.md | 3 ++- .../web/tests/snapshots/question-composer/answered.expected.md | 3 ++- apps/web/tests/snapshots/seeded-history/ui.expected.md | 3 ++- apps/web/tests/snapshots/steering/settled.expected.md | 3 ++- 10 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 43ca98bacd..391d07fc2d 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -31,7 +31,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} cache hit 52% · 17,490 tokens · 1 turns · 2 steps +- text: {{clock}} - textbox "Message the agent" - button "Add attachment": - img @@ -40,3 +40,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] +- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 52% Input 17.2K tok · Output 252 tok diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 9a81500afa..faf22422a5 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -45,7 +45,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} cache hit 77% · 66,813 tokens · 1 turns · 4 steps +- text: {{clock}} - textbox "Message the agent" - button "Add attachment": - img @@ -54,3 +54,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] +- text: 1 turns · 4 steps Tool call {{duration}} Cache hit 77% Input 66.5K tok · Output 312 tok diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 78c1a6563c..d908af87af 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -28,7 +28,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} cache hit 99% · 15,818 tokens · 1 turns · 2 steps +- text: {{clock}} - textbox "Message the agent" - button "Add attachment": - img @@ -37,3 +37,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] +- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 99% Input 15.7K tok · Output 111 tok diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 1065c5de3a..e4a14a77a8 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -20,7 +20,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} cache hit 99% · 7,810 tokens · 1 turns · 1 steps +- text: {{clock}} - textbox "Message the agent" - button "Add attachment": - img @@ -29,3 +29,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] +- text: 1 turns · 1 steps Cache hit 99% Input 7.8K tok · Output 21 tok diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 9c9f898e26..ac9a1ad53c 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -17,7 +17,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} 0 tokens · 1 turns · 1 steps +- text: {{clock}} - textbox "Message the agent" - button "Add attachment": - img @@ -26,3 +26,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] +- text: 1 turns · 1 steps Input 0 tok · Output 0 tok diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 1bc7c6c3c0..48afc76970 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -20,7 +20,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} cache hit 99% · 7,869 tokens · 1 turns · 1 steps +- text: {{clock}} - textbox "Message the agent" - button "Add attachment": - img @@ -29,3 +29,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] +- text: 1 turns · 1 steps Cache hit 99% Input 7.8K tok · Output 79 tok diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index a5f1f83c72..1d14d00143 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -31,7 +31,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps +- text: {{clock}} - textbox "Message the agent" - button "Add attachment": - img @@ -40,3 +40,4 @@ - text: deepseek-v4-flash - img - button "Send message" [disabled] +- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index c1ec5b7262..5ba842114e 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -28,7 +28,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} cache hit 95% · 8,769 tokens · 1 turns · 2 steps +- text: {{clock}} - textbox "Message the agent" - button "Add attachment": - img @@ -37,3 +37,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] +- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 95% Input 8.6K tok · Output 180 tok diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index 247f6112ca..428fa5b526 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -30,7 +30,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps +- text: {{clock}} - textbox "Message the agent" - button "Add attachment": - img @@ -39,3 +39,4 @@ - text: deepseek-v4-flash - img - button "Send message" [disabled] +- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 3d1788582e..e0f6df36fb 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -28,7 +28,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} cache hit 98% · 15,967 tokens · 1 turns · 2 steps +- text: {{clock}} - textbox "Message the agent" - button "Add attachment": - img @@ -37,3 +37,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] +- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 156 tok