Merge remote-tracking branch 'origin/master' into worktree/web-theme-settings-integration-fde706

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md
#	.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md
#	.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md
#	.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
#	apps/web/tests/scaffold.ts
#	docs/module-graph.md
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/package.json
#	packages/client/test-runtime/README.i18n.yaml
#	packages/client/test-runtime/README.zh.md
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.zh.md
#	packages/client/ui-conversation/package.json
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-theme/README.i18n.yaml
#	packages/client/ui-theme/README.md
#	packages/client/ui-theme/README.zh.md
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.zh.md
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-08-10 12:50:43 +08:00
3485 changed files with 86192 additions and 25255 deletions

View File

@@ -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-06-11-property-based-testing.md
2026-06-11-property-based-testing.md: a1bd4147a26a3d562899310e238096939fc2d01a
2026-06-11-property-based-testing.zh.md: 062b2df76597ba16a7ad0682d57b35e54a07d9bd
2026-06-11-property-based-testing.md: 7ab6f2daeb65e603d055ad6127a91f6b76ebc26e
2026-06-11-property-based-testing.zh.md: 2fae02356a84b88e750ce1fd8c3003212395b084

View File

@@ -4,7 +4,7 @@ Status: implemented
English | [中文](2026-06-11-property-based-testing.zh.md)
> Merges the original proposal and the decision record for one topic. It found a real BlockAssembler duplicate-`block-end` bug on first run.
> The property suite found a real BlockAssembler duplicate-`block-end` bug on its first run.
## Problem
@@ -12,7 +12,7 @@ Example-based tests pin the cases we thought of. The harness's core is protocol-
## Decision
Adopt `fast-check` (a root devDependency) with one `tests/properties.spec.ts` per protocol-shaped package, generators tuned for *realistic-but-adversarial* inputs (not uniform noise) and `numRuns` kept so the suite stays well under ~10s locally. Failures print a reproducible seed. (The original proposal also sketched a nightly CI job running 100× the iterations; that was not shipped — the property suite runs only in the normal `push`/`pull_request` CI, and a scheduled high-iteration job remains possible future work.)
`fast-check` (a root devDependency) powers one `tests/properties.spec.ts` per protocol-shaped package, with generators tuned for *realistic-but-adversarial* inputs (not uniform noise) and `numRuns` kept so the suite stays well under ~10s locally. Failures print a reproducible seed. (A nightly CI job running 100× the iterations is not shipped — the property suite runs only in the normal `push`/`pull_request` CI; a scheduled high-iteration job remains possible future work.)
- **dsh-llm / BlockAssembler:** arbitrary chunk streams (valid + malformed: duplicate indices, stragglers, missing block-start). Invariants: `blocks()` count ≤ distinct indices seen; re-assembly idempotent (`blocks()` is stable across repeated calls and `message().content` mirrors it); `blocks()` never throws and yields only valid content-block tags; `finish` reflects the last `finish` chunk, defaulting to `{kind:'stop'}` when none arrives.
- **dsh-session:** arbitrary event logs. Invariants: `deriveMessages` deterministic; replay-from-seed identical; seq strictly monotonic; non-message events never affect derived history; derived content is decoupled from the log.

View File

@@ -4,7 +4,7 @@ Status: implemented
[English](2026-06-11-property-based-testing.md) | 中文
> 将原始提案与同一主题的决策记录合并为一篇。首次运行即发现了 BlockAssembler 重复 `block-end` 的真实 bug。
> 属性测试套件首次运行即发现了 BlockAssembler 重复 `block-end` 的真实 bug。
## 问题
@@ -12,7 +12,7 @@ Status: implemented
## 决策
引入 `fast-check`(作为根 devDependency,在每个协议形态的包中编写一个 `tests/properties.spec.ts`。生成器调优为*逼真但对抗性*的输入(而非均匀噪声),`numRuns` 控制在本地套件总耗时远低于约 10 秒。失败时打印可复现的 seed。原始提案还草拟了一个夜间 CI job以 100 倍迭代运行;该部分未交付属性测试套件仅在常规的 `push`/`pull_request` CI 中运行定时高迭代 job 仍属可能的后续工作。)
`fast-check`(作为根 devDependency每个协议形态的包驱动一个 `tests/properties.spec.ts`。生成器调优为*逼真但对抗性*的输入(而非均匀噪声),`numRuns` 控制在本地套件总耗时远低于约 10 秒。失败时打印可复现的 seed。以 100 倍迭代运行的夜间 CI job 未交付——属性测试套件仅在常规的 `push`/`pull_request` CI 中运行定时高迭代 job 仍属可能的后续工作。)
- **dsh-llm / BlockAssembler** 任意分片流(合法 + 畸形:重复索引、滞后分片、缺少 block-start。不变式`blocks()` 计数 ≤ 已见到的不同索引数;重组幂等(`blocks()` 在重复调用间稳定,且 `message().content` 与之一致);`blocks()` 从不抛异常且仅产出合法的 content-block 标签;`finish` 反映最后一个 `finish` 分片,无此类分片时默认为 `{kind:'stop'}`
- **dsh-session** 任意事件日志。不变式:`deriveMessages` 确定性;从 seed 回放结果一致seq 严格单调递增;非消息事件不影响推导出的历史;推导出的内容与日志解耦。

View File

@@ -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-06-19-acp-snapshot-tests.md
2026-06-19-acp-snapshot-tests.md: c6e81dc4a51083ed37ad6f837fe7dbb08c212e23
2026-06-19-acp-snapshot-tests.zh.md: 5a579c3373442dc4cfc7d661ea2f2f398b804dc7
2026-06-19-acp-snapshot-tests.md: 9e03c3c5be7b15a1a34c954dca2f1d6e7a875ba2
2026-06-19-acp-snapshot-tests.zh.md: 59033a2fd85bc08bfe4abe34e8fd9aff9a67c87e

View File

@@ -8,9 +8,7 @@ English | [中文](2026-06-19-acp-snapshot-tests.zh.md)
Unit tests do not exercise the complete assembled-agent subprocess or its ACP automation wire, while real-API tests are nondeterministic and key-gated. Loader wiring, backend behavior, and protocol output can therefore regress despite green unit coverage, as the [default-export postmortem](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md) demonstrated.
The blocker for a full-transcript test is the model: the agent's output is driven by a non-deterministic LLM, and a key-gated test that hits the real API on every run is neither deterministic nor CI-runnable. We want the fidelity of a real run with the determinism of a fixture.
This Agent Note records the decision to add a third test tier — **snapshot tests** — and the design choices that make it deterministic, keyless-in-CI, and cheap to maintain.
The blocker for a full-transcript test is the model: the agent's output is driven by a non-deterministic LLM, and a key-gated test that hits the real API on every run is neither deterministic nor CI-runnable. The tier needs the fidelity of a real run with the determinism of a fixture.
## Decision

View File

@@ -8,9 +8,7 @@ Status: implemented
单元测试不会覆盖组装后的完整 agent智能体子进程及其 ACPAgent Client Protocol自动化协议格式而真实 API 测试不具确定性且受密钥门控。因此即使单元测试覆盖率检查通过Loader 接线、后端行为和协议输出仍可能回归,[默认导出事故复盘postmortem](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)已经证明了这一点。
完整 transcript文本记录测试的阻塞因素在于模型agent 的输出由非确定性的 LLM大语言模型驱动而每次运行都命中真实 API 的密钥门控测试既不确定也无法在 CI 中运行。我们需要真实运行的保真度与 fixture测试前置数据的确定性兼得。
本 Agent Note 记下了新增第三层测试——**快照测试**——的决策,以及让它具备确定性、在 CI 中无需密钥、且维护成本低廉的设计选择。
完整 transcript文本记录测试的阻塞因素在于模型agent 的输出由非确定性的 LLM大语言模型驱动而每次运行都命中真实 API 的密钥门控测试既不确定也无法在 CI 中运行。该测试层级需要真实运行的保真度与 fixture测试前置数据的确定性兼得。
## 决策
@@ -26,7 +24,7 @@ Status: implemented
`llm-replay` 短路了提供方无关的 `llm/stream` waterfall瀑布式事件`deriveReplayScript()` 在终止的 `finish` 分片处切分已记录的 `assistant/chunk` 事件,并用 `(turn, step)` 变化拒绝前一条未终止的调用。携带 `llmStreamCall: true``compact/summary` 会在其持久日志位置贡献一次调用:回放根据 `rawOutput` 重建规范块边界,保留已记录的 usage如有并提供终止的 `stop`。该标记将这次本地调用与模板摘要或远程摘要区分开;后两者即使保留了 `rawOutput`,也未使用此上下文的适配器。
### 内存中的回放条目遵守完整的 LLM
### 内存中的回放条目遵守完整的 LLM 约
`deriveReplayScript` 产出一组 `ReplayEntry`,即回放监听器按位置服务的内存单元:
@@ -52,7 +50,7 @@ Status: implemented
快照运行断言**两个**归一化后的表面,因为 harness 的外部表面是不同的:
1. **stdout transcript**——自动化客户端收到的、分帧后的 ACP JSON-RPC 响应与已提交的消息更新。它捕获传输约的回归,与已提交的 `stdout.expected.jsonl` 比较。
1. **stdout transcript**——自动化客户端收到的、分帧后的 ACP JSON-RPC 响应与已提交的消息更新。它捕获传输约的回归,与已提交的 `stdout.expected.jsonl` 比较。
2. **重新持久化的会话 JSONL**,经过规范化后与 `session.jsonl` 比较。同一 fixture 同时作为回放来源和预期日志。提示词与工具的主体内容会被清理;每种请求头类别由一个场景固定余下的请求头序列。该 pin 默认拥有可读的提示词与工具 schema 伴随文件;当完整的对应序列相同时,也可将另一个 pin 指定为其中任一来源因此每个不同的伴随文件版本只提交一次。fixture 保护会拒绝重复的伴随文件内容,录制/刷新会拒绝生成不同字节的共享引用方。最初的请求头固定理由保留在[请求头固定 Agent Note](../../archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)中。Override 场景仅从其伴随文件派生模型行为。
两个表面互补stdout 覆盖精简的自动化协议格式JSONL 覆盖协议格式有意省略的 loop、工具和 boundary 结构。

View File

@@ -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-06-19-real-api-e2e-ci.md
2026-06-19-real-api-e2e-ci.md: 935664fd01df4844ee19be7b4f2f297ebf5bd29b
2026-06-19-real-api-e2e-ci.zh.md: 4245cc9a3872cf2ab27633db5b23fc466d793934
2026-06-19-real-api-e2e-ci.md: feb5d83f087f66dfec7e1540f6b32e33656409d4
2026-06-19-real-api-e2e-ci.zh.md: c00aa6e962d9e86a9d5d5c1ccaf1001afa21ba67

View File

@@ -10,11 +10,9 @@ The harness leans hard on real-API tests by policy: [docs/testing.md](../../../.
The default gate ([.github/workflows/ci.yml](../../../../.github/workflows/ci.yml)) is deliberately keyless: it carries no secret and runs for forks. `test:e2e` self-skips without a key (`describe.skipIf(!process.env.DEEPSEEK_API_KEY)`), so adding it there would report green without exercising the real suite. A separate secret-bearing workflow is required to make real-API coverage a merge signal.
This Agent Note records the decision to add a **second, secret-consuming workflow** that runs the real-API suite in CI, and — because introducing the first CI secret into a repo that may later go public is a security/isolation decision — the threat model it relies on and what changes when the repo becomes public.
## Decision
Add a dedicated workflow, [.github/workflows/e2e.yml](../../../../.github/workflows/e2e.yml), separate from ci.yml. It runs only `pnpm run test:e2e` against the external API using a repo secret, on trusted events, with a preflight that converts a missing secret into a loud failure instead of a false green. The keyless workflow remains separate so forkable quality gates and secret-consuming real-API gates keep different trigger and credential policies.
A dedicated workflow, [.github/workflows/e2e.yml](../../../../.github/workflows/e2e.yml), separate from ci.yml, runs only `pnpm run test:e2e` against the external API using a repo secret, on trusted events, with a preflight that converts a missing secret into a loud failure instead of a false green. The keyless workflow remains separate so forkable quality gates and secret-consuming real-API gates keep different trigger and credential policies.
### A separate workflow, not a job in ci.yml

View File

@@ -10,11 +10,9 @@ Status: implemented
默认门禁([.github/workflows/ci.yml](../../../../.github/workflows/ci.yml))刻意无密钥:不携带 secret可供 fork 运行。`test:e2e` 在无密钥时自动跳过(`describe.skipIf(!process.env.DEEPSEEK_API_KEY)`),因此将其加入该工作流只会报绿而不会真正执行真实套件。要让真实 API 覆盖率成为合并信号,需要一个独立的、携带 secret 的工作流。
本 Agent Note 记下了新增**第二条消费 secret 的工作流**以在 CI 中运行真实 API 套件的决策;由于向未来可能公开的仓库引入第一个 CI secret 属于安全/隔离决策,本文也记录其依赖的威胁模型,以及仓库公开时需要做出的变更。
## 决策
添加一个专用工作流 [.github/workflows/e2e.yml](../../../../.github/workflows/e2e.yml),与 ci.yml 分离。它仅使用 repo secret 对外部 API 运行 `pnpm run test:e2e`,仅在可信事件上触发,并带有一个 preflight 检查:将缺失的 secret 转化为明确的失败而非虚假的绿色。无密钥工作流保持独立,使可 fork 的质量门禁与消费 secret 的真实 API 门禁各自拥有不同的触发和凭证策略。
一个与 ci.yml 分离的专用工作流 [.github/workflows/e2e.yml](../../../../.github/workflows/e2e.yml) 仅使用 repo secret 对外部 API 运行 `pnpm run test:e2e`,仅在可信事件上触发,并带有一个 preflight 检查:将缺失的 secret 转化为明确的失败而非虚假的绿色。无密钥工作流保持独立,使可 fork 的质量门禁与消费 secret 的真实 API 门禁各自拥有不同的触发和凭证策略。
### 独立工作流,而非 ci.yml 中的一个 job

View File

@@ -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-06-22-fork-child-replay-seed-boundary.md
2026-06-22-fork-child-replay-seed-boundary.md: ed3ec095bc14128f5ebc0a9188bc022ef97b1c8b
2026-06-22-fork-child-replay-seed-boundary.zh.md: 1d938cbf6c9c32a144d58fed48e552d85aa9c625
2026-06-22-fork-child-replay-seed-boundary.md: bcbbd279771a2ebe7eee643958ecf09cbd7ca7be
2026-06-22-fork-child-replay-seed-boundary.zh.md: ad086880d77a842be8c5dc7378018c92af95e698

View File

@@ -39,11 +39,11 @@ This closes the routing correctness gap, and two recorded fork scenarios exercis
## Alternatives considered
- **Derive the boundary heuristically in `llm-replay`** (the seeded prefix is contiguous parent events ending at the last `turn/end` before the child's first `user/message`). Rejected: a brittle heuristic in the test harness that re-derives a fact the producer already knows. Persisting the boundary at its source (the fork backend) is the "explicit > implicit at package seams" rule applied across the persistence boundary — the reader of a child fixture never has to reconstruct where the inheritance ended.
- **Derive the boundary heuristically in `llm-replay`** (the seeded prefix is contiguous parent events ending at the last `turn/end` before the child's first `user/message`). Rejected: a brittle heuristic in the test harness that re-derives a fact the producer already knows. Persisting the boundary at its source (the fork backend) is the "explicit > implicit at package boundaries" rule applied across the persistence boundary — the reader of a child fixture never has to reconstruct where the inheritance ended.
- **Pin the format version instead of bumping** (the `SESSION_FORMAT_VERSION = 0` "unstable" stance the event log uses). Rejected for the SQLite *table* layout: `SCHEMA_VERSION` is the monotonic bump-and-reject knob (a small enumerable set of revisions worth telling apart), distinct from the event-vocabulary `version`. Adding a column is precisely the breaking table change it versions, so it bumps.
## Consequences
- A new persisted header field across core + both backends; the core-data-structures catalog (`persistence.md`) is updated in the same change (its `SessionHeader` / `CreateSessionOptions` `type-equiv` blocks).
- A new persisted header field across core + both backends; the subsystems catalog (`persistence.md`) is updated in the same change (its `SessionHeader` / `CreateSessionOptions` `type-equiv` blocks).
- Existing SQLite databases at schema v2 are rejected on open (no user data pre-release).
- Spawn replay is unchanged (`seedLength` 0). Fork replay now routes a child to its own script; covered by a regression in `llm-replay`'s tests (a child fixture whose seeded prefix carries a parent chunk — the derived child script must exclude it, proven red without the slice) and a persistence round-trip test (both backends, via the shared coordinator contract).

View File

@@ -39,11 +39,11 @@ subagent 脚本由 [`deriveReplayScript`](../../../../packages/support/llm-repla
## 曾考虑的替代方案
- **在 `llm-replay` 中启发式推导边界**(播种前缀是连续的父事件,止于子会话第一条 `user/message` 之前的最后一个 `turn/end`)。否决:在测试 harness 中用脆弱的启发式重新推导一个生产者已经知道的事实。在源头fork 后端)持久化边界,是「在包 seam 处显式优于隐式」这条规则跨越持久化边界的应用——子会话 fixture测试前置数据的读取者永远不需要重建继承在哪里结束。
- **在 `llm-replay` 中启发式推导边界**(播种前缀是连续的父事件,止于子会话第一条 `user/message` 之前的最后一个 `turn/end`)。否决:在测试 harness 中用脆弱的启发式重新推导一个生产者已经知道的事实。在源头fork 后端)持久化边界,是「在包边界处显式优于隐式」这条规则跨越持久化边界的应用——子会话 fixture测试前置数据的读取者永远不需要重建继承在哪里结束。
- **固定格式版本而不递增**(事件日志使用的 `SESSION_FORMAT_VERSION = 0`「不稳定」姿态)。对 SQLite *表*布局否决:`SCHEMA_VERSION` 是单调递增并拒绝旧版的旋钮(一组小的、值得区分的修订),与事件词汇表的 `version` 不同。新增列正是它所版本化的那种破坏性表变更,因此需要递增。
## 后果
- core 与两个后端新增一个持久化 header 字段;核心数据结构目录(`persistence.md`)在同一变更中更新(其 `SessionHeader` / `CreateSessionOptions``type-equiv` 块)。
- core 与两个后端新增一个持久化 header 字段;子系统目录(`persistence.md`)在同一变更中更新(其 `SessionHeader` / `CreateSessionOptions``type-equiv` 块)。
- 既有的 schema v2 SQLite 数据库在打开时被拒绝(预发布阶段无用户数据)。
- spawn 回放不变(`seedLength` 为 0。fork 回放现在将子会话路由到自身的脚本;由 `llm-replay` 测试中的一个回归用例覆盖(一个子会话 fixture其播种前缀包含父会话的分片——推导出的子会话脚本必须排除它不做 slice 时该用例为红)以及一个持久化往返测试(两个后端,通过共享的 coordinator 约)。
- spawn 回放不变(`seedLength` 为 0。fork 回放现在将子会话路由到自身的脚本;由 `llm-replay` 测试中的一个回归用例覆盖(一个子会话 fixture其播种前缀包含父会话的分片——推导出的子会话脚本必须排除它不做 slice 时该用例为红)以及一个持久化往返测试(两个后端,通过共享的 coordinator 约)。

View File

@@ -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-06-22-subagent-snapshot-replay.md
2026-06-22-subagent-snapshot-replay.md: b8fefce5ff27b0cd3cfa2920b137e78cda0d696d
2026-06-22-subagent-snapshot-replay.zh.md: e8bd8917a02e02f2ce89105eb64794876653574d
2026-06-22-subagent-snapshot-replay.md: 187831b2d3c2f08b32289983afbbe0bc03484c82
2026-06-22-subagent-snapshot-replay.zh.md: b96be12df0ea1f1a6bb64dd787fec6880eb96243

View File

@@ -13,7 +13,7 @@ It was built for ONE session per process, and that assumption is wired into two
- **`dsh-llm-replay` keyed nothing.** It served the Nth `llm/stream` call the Nth recorded entry from a single global cursor. With a parent agent AND an in-process subagent both streaming on one context, the calls interleave and the single cursor hands the child the parent's script (and vice versa).
- **The harness harvested one log.** `findSessionLog` walked the sessions root and returned the FIRST `.jsonl` it found. A subagent runs as a second `Session` with its own log, so the child's transcript was silently dropped.
This was the `TODO(subagent-snapshots)` deferral recorded in the [subagent seam Agent Note](../feature/2026-06-21-subagent-capability-seam.md): the in-process backends (PR2) shipped with unit + e2e coverage, but the full-transcript snapshot tier could not express a nested-agent shape until this infrastructure landed. This Agent Note is that stacked follow-up.
This was the `TODO(subagent-snapshots)` deferral recorded in the [subagent seam Agent Note](../feature/2026-06-21-subagent-capability-seam.md): the in-process backends shipped with unit + e2e coverage, but the full-transcript snapshot tier could not express a nested-agent shape until this infrastructure landed.
## Decision
@@ -53,6 +53,6 @@ Both replay keyless in the default gate.
## Consequences
- The `TODO(subagent-snapshots)` deferral is resolved: nested-agent transcripts are now a first-class snapshot shape.
- `GenerateOptions.sessionId` is a small, honest core-seam addition useful beyond replay (telemetry, request routing).
- `GenerateOptions.sessionId` is a small, honest core API addition useful beyond replay (telemetry, request routing).
- The `subagent` tool is bound to a single provider, so both children in `subagent-multi` are spawn (fresh). The keying routes by session, not by backend, so it is already correct for fork. The script *derivation* was not: a fork child's log begins with the seeded parent prefix (the parent's `assistant/chunk` events), so deriving its script from the whole log would replay the parent's responses as the child's. That correctness gap is closed by persisting a seed boundary — see [Persist the seed boundary so fork-child replay routes correctly](2026-06-22-fork-child-replay-seed-boundary.md) — and recorded fork + mixed spawn+fork scenarios now exercise both transports through one transcript (see [Record fork and mixed spawn+fork snapshot scenarios](../../archived/testing/2026-06-22-fork-snapshot-scenarios.md)).
- Out-of-process (ACP) subagents are a different replay shape entirely (each child is its own PROCESS with its own replay), tracked as `TODO(acp-subagent-replay)` in the PR3 plan.
- Out-of-process (ACP) subagents are a different replay shape entirely (each child is its own PROCESS with its own replay), tracked as `TODO(acp-subagent-replay)` in `subagent-acp`.

View File

@@ -13,7 +13,7 @@ Status: implemented
- **`dsh-llm-replay` 没有做任何键控。** 它用一个全局游标,将第 N 次 `llm/stream` 调用对应到单一录制序列的第 N 条。当父 agent智能体和一个进程内 subagent 在同一个上下文上同时流式输出时,调用交错,单一游标会把子 agent 的脚本发给父 agent反之亦然
- **harness 只收集一份日志。** `findSessionLog` 遍历 sessions 根目录,返回找到的第一个 `.jsonl`。subagent 作为第二个 `Session` 运行并拥有自己的日志,因此子 agent 的 transcript文本记录被静默丢弃。
这就是 [subagent seam Agent Note](../feature/2026-06-21-subagent-capability-seam.md)中通过 `TODO(subagent-snapshots)` 推迟的工作:进程内后端PR2落地时已有单元 + e2e 覆盖,但在这套基础设施落地前,完整 transcript 快照层无法表达嵌套 agent 形状。本 Agent Note 就是该堆叠式后续工作。
这就是 [subagent seam Agent Note](../feature/2026-06-21-subagent-capability-seam.md)中通过 `TODO(subagent-snapshots)` 推迟的工作:进程内后端落地时已有单元 + e2e 覆盖,但在这套基础设施落地前,完整 transcript 快照层无法表达嵌套 agent 形状。
## 决策
@@ -53,6 +53,6 @@ Status: implemented
## 后果
- `TODO(subagent-snapshots)` 延期项已解决:嵌套 agent 的 transcript 现在是快照层的一等形态。
- `GenerateOptions.sessionId` 是一个小而诚实的 core-seam 新增,在回放之外同样有用(遥测、请求路由)。
- `GenerateOptions.sessionId` 是一个小而诚实的 core API 新增,在回放之外同样有用(遥测、请求路由)。
- `subagent` 工具绑定到单一提供方,因此 `subagent-multi` 中的两个子 agent 都是 spawn全新创建。键控按会话路由而非按后端路由因此对 fork 同样正确。但脚本*派生*逻辑此前不正确fork 子会话的日志以种子化的父前缀(父会话的 `assistant/chunk` 事件)开头,如果从完整日志派生脚本,就会把父 agent 的响应当作子 agent 的来回放。这一正确性缺口通过持久化种子边界来弥合——见[持久化 seed 边界以确保 fork 子会话回放正确路由](2026-06-22-fork-child-replay-seed-boundary.md)——录制的 fork 与混合 spawn+fork 场景现在通过一份 transcript 同时验证两种传输方式(见[记录 fork 与混合 spawn+fork 快照场景](../../archived/testing/2026-06-22-fork-snapshot-scenarios.md))。
- 进程外ACPAgent Client Protocolsubagent 是完全不同的回放形态(每个子 agent 是自己的进程、有自己的回放),作为 `TODO(acp-subagent-replay)` 记录在 PR3 计划中。
- 进程外ACPAgent Client Protocolsubagent 是完全不同的回放形态(每个子 agent 是自己的进程、有自己的回放),作为 `TODO(acp-subagent-replay)` 记录在 `subagent-acp` 中。

View File

@@ -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-22-cross-platform-test-fixtures.md
2026-07-22-cross-platform-test-fixtures.md: 6217aabfdbe8f14f869004c8dafb7e19f4b7443a
2026-07-22-cross-platform-test-fixtures.zh.md: ce9c65106b904b4c16360c9cb2545b0316bd7120
2026-07-22-cross-platform-test-fixtures.md: 9114481543d6cae1661cbed70868eddb2faa09fc
2026-07-22-cross-platform-test-fixtures.zh.md: 710ec5887f02b5c3c71d69f16ab726323501ac92

View File

@@ -30,4 +30,4 @@ Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on tha
## Consequences
Portable fixtures are slightly more explicit because expected paths derive from shared native constants and transport failures enter through a narrow writer seam. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Windows teardown depends on the host `taskkill` command after graceful protocol shutdown has failed; a successful synchronous result keeps disposal bounded and makes descendant exit observable before cleanup returns, while a failed tree kill remains visible to the disposer.
Portable fixtures are slightly more explicit because expected paths derive from shared native constants and transport failures enter through a narrow writer hook. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Windows teardown depends on the host `taskkill` command after graceful protocol shutdown has failed; a successful synchronous result keeps disposal bounded and makes descendant exit observable before cleanup returns, while a failed tree kill remains visible to the disposer.

View File

@@ -12,7 +12,7 @@ Status: implemented
## 决策
测试平台无关行为时,使用宿主的 `node:path``node:url` API 构造绝对路径与 `file:` URI再根据约要求断言原生绝对输出或稳定的工作区相对输出。无效 URI fixture 使用一种在所有受支持平台上都会被 `fileURLToPath()` 拒绝的编码形式。
测试平台无关行为时,使用宿主的 `node:path``node:url` API 构造绝对路径与 `file:` URI再根据约要求断言原生绝对输出或稳定的工作区相对输出。无效 URI fixture 使用一种在所有受支持平台上都会被 `fileURLToPath()` 拒绝的编码形式。
传输故障测试会注入连接的消息写入器,并传入与真实 Node 流相同的异步写入回调错误。生产写入器仍会把分帧消息写入子进程 stdin。这种方式让真实子进程保持存活使测试无需触及平台特有的管道句柄也能确定性地区分传输故障与进程退出。
@@ -24,10 +24,10 @@ Status: implemented
**将所有路径和 URI 归一化为 POSIX 字符串。**这会使断言保持一致,但也会改变正确的 Windows 行为外部路径是原生绝对路径UNC 文件 URI 有效,而且已配置的主目录会按照宿主路径规则解析。
**操纵子进程管道内部状态,直至写入失败。**CRT 描述符与 libuv 句柄在不同宿主和 Node 版本上的所有权不同,因此这种做法测试的是未文档化的 fixture 机制,而非连接的写入失败约。
**操纵子进程管道内部状态,直至写入失败。**CRT 描述符与 libuv 句柄在不同宿主和 Node 版本上的所有权不同,因此这种做法测试的是未文档化的 fixture 机制,而非连接的写入失败约
**在 Windows 上跳过整个测试文件或包。**过宽的排除会隐藏受支持的行为。只排除无法在 Windows 上构造相应状态的单项 fixture相关约仍保持覆盖。
**在 Windows 上跳过整个测试文件或包。**过宽的排除会隐藏受支持的行为。只排除无法在 Windows 上构造相应状态的单项 fixture相关约仍保持覆盖。
## 后果
可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器 seam 注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言以继续覆盖相应的产品行为。协议级优雅关停失败后Windows 上的资源清理依赖宿主的 `taskkill` 命令;命令同步执行成功时,可确保 dispose资源释放在有限时间内完成并确保清理返回前即可观察到后代进程退出若进程树终止失败资源释放逻辑仍能观察到该失败。
可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器钩子注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言以继续覆盖相应的产品行为。协议级优雅关停失败后Windows 上的资源清理依赖宿主的 `taskkill` 命令;命令同步执行成功时,可确保 dispose资源释放在有限时间内完成并确保清理返回前即可观察到后代进程退出若进程树终止失败资源释放逻辑仍能观察到该失败。

View File

@@ -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: 76e5f7b354632ea12a1b5f0a35240a74a238e407
2026-07-24-web-gui-browser-e2e-lane.zh.md: b139337304851954437e4e20d4bbaf3cc8dd4ef4
2026-07-24-web-gui-browser-e2e-lane.md: e572929ae6762da6adc2e77e1dba19361beaf670
2026-07-24-web-gui-browser-e2e-lane.zh.md: 99f86f40ba006c4024f367b73ce52f8679b8d2fd

View File

@@ -18,7 +18,7 @@ A plain shared-fixture module (the [testing-policy sanctioned shape](../../../..
`launchWebScaffold()` boots the real web composition from the shipped `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` through the vendored Loader's include mechanism — the same tree and mechanism `AppCLIEntry` drives for `dsh web`. Divergences ride include patches over that tree, the ACP `cordis.snapshot.yml` pattern expressed in-process: temp `persistenceRoot`; every host-level `skill-local` root (`dshHome`, `agentsHome`, and `bundledSkillDir`) pinned beneath the temp workspace with watching disabled, because ambient skill catalogs are model-visible input; `workspace-context` disabled (recorded fixtures must not embed this repo's AGENTS.md); `session-title-llm` disabled (its fire-and-forget title call would race the loop for the session's replay cursor); the webserver row pinned to port 0 with the built dist; and in keyless modes `llm-deepseek` disabled. A patch id that stops matching a row fails the boot sweep loudly instead of drifting. The boot runs `chdir`'d to the temp workspace so the api-gateway's `process.cwd()` session default, tool cwds, and fixtures agree; the `dsh web` bin's own glue (argv, profile json, AppCLIEntry) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`. Setup rollback and ordinary close both dispose the Cordis tree before removing the two owned temp roots, attempt every cleanup independently, and report cleanup failures without masking the setup failure.
Keyless model displacement is the disabled adapter row plus `installLlmReplay` filling the open seam on the settled root ctx in providers-catalog mode — never catch-all: with the adapter row disabled no adapter exists, so catch-all would leave `resolveModelInfo` unroutable and `compact-basic`'s post-step pressure check would warn every step instead of being provably inert (the published 128k `contextWindow` keeps it inert for small fixtures). The direct install rather than an inserted replay plugin row is deliberate: it returns the `ReplayHandle` the teardown consumption check needs. A scenario with no fixture leaves the seam empty, so a stray stream fails loud with NO_ADAPTER.
Keyless model displacement is the disabled adapter row plus `installLlmReplay` filling the empty adapter registry on the settled root ctx in providers-catalog mode — never catch-all: with the adapter row disabled no adapter exists, so catch-all would leave `resolveModelInfo` unroutable and `compact-basic`'s post-step pressure check would warn every step instead of being provably inert (the published 128k `contextWindow` keeps it inert for small fixtures). The direct install rather than an inserted replay plugin row is deliberate: it returns the `ReplayHandle` the teardown consumption check needs. A scenario with no fixture leaves the registry empty, so a stray stream fails loud with NO_ADAPTER.
`seedSession()` seeds cold sessions through the real persistence API — a throwaway `Context` mounting `SessionStore` + `SessionPersistenceJsonl` against the host's root, `create()` + `append()`, one `utimes` backdate for deterministic sidebar order (the `semantic-checkpoint.snapshot.ts` precedent) — never raw file writes, so the seeder knows nothing of bucket hashing, filename encoding, or compression, and the host's zstd default needs no boot knob. Seeds are validated at seed time (parseable, ending in `turn/end` — an open final turn would be mutated by resume's crash repair).
@@ -52,7 +52,7 @@ High-cardinality performance diagnostics use the separate opt-in `apps/web/tests
## Prior art
Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot + AI SDK, lobe-chat, open-webui, OpenHands, Chainlit, continue, cline, langfuse, gradio/streamlit; Playwright HAR/route, MSW, Polly/nock, WireMock, aimock). The dominant proven architecture for apps that own their backend is an in-process fake/replay model behind the real backend seam with everything downstream real (LibreChat's `LIBRECHAT_TEST_RUN_HOOK` fake model; ai-chatbot's `MockLanguageModelV3` + `simulateReadableStream`; continue's scripted mock provider classes) — which is what `dsh-llm-replay` already is. Browser-level SSE interception cannot exercise incremental rendering (`route.fulfill` delivers the whole body at once; playwright#33564) and leaves the server SSE stack untested, so projects use it only for edge cases. Chunk pacing as a fixture parameter recurs everywhere (LibreChat 10ms default with slow profiles; ai-chatbot 500ms); real models in CI rot (open-webui's suite grew 120-second timeouts, was disabled, then deleted); sessions are seeded at the persistence layer with controlled timestamps (LibreChat inserts backdated Mongo documents; langfuse seeds its DB). No surveyed project replays a recorded agent-event log through the real backend for UI tests — the closest are provider-level recorded fixtures (aimock) and frontend-level socket history emission (OpenHands MSW) — so the session-log-as-fixture design goes one step beyond prior art along the axis this repo's model-visible ⟺ logged invariant makes natural.
Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot + AI SDK, lobe-chat, open-webui, OpenHands, Chainlit, continue, cline, langfuse, gradio/streamlit; Playwright HAR/route, MSW, Polly/nock, WireMock, aimock). The dominant proven architecture for apps that own their backend is an in-process fake/replay model behind the real backend contract with everything downstream real (LibreChat's `LIBRECHAT_TEST_RUN_HOOK` fake model; ai-chatbot's `MockLanguageModelV3` + `simulateReadableStream`; continue's scripted mock provider classes) — which is what `dsh-llm-replay` already is. Browser-level SSE interception cannot exercise incremental rendering (`route.fulfill` delivers the whole body at once; playwright#33564) and leaves the server SSE stack untested, so projects use it only for edge cases. Chunk pacing as a fixture parameter recurs everywhere (LibreChat 10ms default with slow profiles; ai-chatbot 500ms); real models in CI rot (open-webui's suite grew 120-second timeouts, was disabled, then deleted); sessions are seeded at the persistence layer with controlled timestamps (LibreChat inserts backdated Mongo documents; langfuse seeds its DB). No surveyed project replays a recorded agent-event log through the real backend for UI tests — the closest are provider-level recorded fixtures (aimock) and frontend-level socket history emission (OpenHands MSW) — so the session-log-as-fixture design goes one step beyond prior art along the axis this repo's model-visible ⟺ logged invariant makes natural.
## Alternatives considered
@@ -60,7 +60,7 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot
**Mock HTTP provider at `DEEPSEEK_BASE_URL`.** Rejected as the lane's mechanism (kept for the one existing workspace-probe smoke): fixtures become hand-authored OpenAI SSE byte scripts, a second fixture format that drifts from the session-log format the rest of the repo records and replays; the adapter's real HTTP path is with-key e2e's job.
**Growing the `?fixture` client.** Rejected: tier separation — `FixtureApiClient` exists to test the client shell without a server; everything below the client API seam stays untested by construction.
**Growing the `?fixture` client.** Rejected: tier separation — `FixtureApiClient` exists to test the client shell without a server; everything below the client API boundary stays untested by construction.
**Placeholder `DEEPSEEK_API_KEY` + replay interception instead of disabling the adapter row.** Rejected despite zero composition change and two in-tree precedents: it satisfies `llm-deepseek`'s fail-loud key check with a lie and leaves a dead adapter mounted-but-intercepted; the disabled row (the ACP overlay's move) is honest keylessness and fails loud at the earliest resolvable point.
@@ -70,9 +70,9 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot
**Spawning the `dsh web` bin with a `DSH_SNAPSHOT` replay branch.** Rejected: it needs a test-only replay branch plus environment plumbing in the shipped CLI. The in-process scaffold already loads the same `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml`; only argv, profile JSON, and `AppCLIEntry` glue remain outside it, and the keyless CLI smokes cover those paths.
**Changing the wire protocol for testability.** Rejected: the contract already has a first-class keyless isomorphic seam (`InProcessApiClient(toFetchHandler(api))`), the per-event unbatched SSE is exactly what makes replay observable in a browser, and testing a wire we no longer ship would invert the tier's purpose.
**Changing the wire protocol for testability.** Rejected: the contract already has a first-class keyless in-process path (`InProcessApiClient(toFetchHandler(api))`), the per-event unbatched SSE is exactly what makes replay observable in a browser, and testing a wire we no longer ship would invert the tier's purpose.
**Real-model browser tests as the keyless lane.** Rejected: nondeterministic by construction; the surveyed cautionary case (open-webui) grew unbounded timeouts and was deleted. The with-key W5 smoke stays as the live-model complement.
**Real-model browser tests as the keyless lane.** Rejected: nondeterministic by construction; the surveyed cautionary case (open-webui) grew unbounded timeouts and was deleted. The with-key real-host smoke stays as the live-model complement.
**Running the high-cardinality performance case in the required browser gate.** Rejected: its fixture setup and full-history render add tens of seconds, while wall-clock and memory values vary with the host and cannot supply a stable correctness threshold. The required lane keeps deterministic behavior assertions; contributors run the diagnostic case when investigating or changing large-list and long-history rendering.

View File

@@ -10,7 +10,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
## 决策
`pnpm run test:web` 携带 `apps/web/tests/` 下的无密钥、确定性浏览器 e2e 车道:录制的会话日志 fixture 经 `@deepseek-ai/dsh-llm-replay` 对真实进程内 web 组合回放;用户可见状态使用规范化的 aria 预期输出,持久世界状态则使用进程内断言。配套的产品约包括 `dsh-llm-replay` 的节奏控制、消费检查与已校验的索引式覆写 patch跨包的 `dsh-llm` 失败通过自有数据属性保留经校验的提供方信息;已交付的 web 组合挂载 `llm-retry`,以处理瞬态模型失败。
`pnpm run test:web` 携带 `apps/web/tests/` 下的无密钥、确定性浏览器 e2e 车道:录制的会话日志 fixture 经 `@deepseek-ai/dsh-llm-replay` 对真实进程内 web 组合回放;用户可见状态使用规范化的 aria 预期输出,持久世界状态则使用进程内断言。配套的产品约包括 `dsh-llm-replay` 的节奏控制、消费检查与已校验的索引式覆写 patch跨包的 `dsh-llm` 失败通过自有数据属性保留经校验的提供方信息;已交付的 web 组合挂载 `llm-retry`,以处理瞬态模型失败。
### Scaffold`apps/web/tests/scaffold.ts`
@@ -18,7 +18,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
`launchWebScaffold()` 通过 vendored Loader 的 include 机制,从交付的 `apps/cli/config/base.cordis.yml``apps/cli/config/web.cordis.yml` 启动真实 web 组合——与 `AppCLIEntry``dsh web` 驱动的是同一棵树、同一套机制。差异全部经 include patch 覆盖在这棵树上,即 ACP `cordis.snapshot.yml` 模式的进程内表达:临时 `persistenceRoot`;每个主机级 `skill-local` 根目录(`dshHome``agentsHome``bundledSkillDir`)都钉在临时工作区下并禁用监听,因为环境 skill技能目录是模型可见输入禁用 `workspace-context`(录制的 fixture 不得嵌入本仓库的 AGENTS.md禁用 `session-title-llm`其发后不管的标题调用会与循环争抢会话的回放游标webserver 行钉到端口 0 加已构建 dist无密钥模式下禁用 `llm-deepseek`。patch 的 id 一旦不再匹配任何行boot 扫描会大声失败而不是漂移。boot 在临时工作区 `chdir` 下运行,使 api-gateway 的 `process.cwd()` 会话默认值、工具 cwd 与 fixture 一致;`dsh web` bin 自身的胶水argv、profile json、AppCLIEntry仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守。初始化回滚和正常关闭都会先对 Cordis 树执行 dispose资源释放再删除 scaffold 持有的两个临时根目录;每项清理都会独立尝试,并会报告清理失败而不掩盖初始化失败。
无密钥的模型替换 = 禁用适配器行的 patch 加 `installLlmReplay` 在停稳的根 ctx 上以提供方目录providers-catalog模式填充开放的 seam——绝不用 catch-all适配器行被禁用后不存在任何适配器catch-all 会让 `resolveModelInfo` 无路由可走,`compact-basic` 的步后压力检查将步步告警,而不是被可证明地闲置(发布的 128k `contextWindow` 使该路径对小 fixture 保持闲置)。选择直接安装而非插入回放插件行是刻意的:直接安装返回收尾消费检查所需的 `ReplayHandle`。没有 fixture 的场景让 seam 保持空置,任何离群的流式调用都会以 NO_ADAPTER 大声失败。
无密钥的模型替换 = 禁用适配器行的 patch 加 `installLlmReplay` 在停稳的根 ctx 上以提供方目录providers-catalog模式填充空的适配器注册表——绝不用 catch-all适配器行被禁用后不存在任何适配器catch-all 会让 `resolveModelInfo` 无路由可走,`compact-basic` 的步后压力检查将步步告警,而不是被可证明地闲置(发布的 128k `contextWindow` 使该路径对小 fixture 保持闲置)。选择直接安装而非插入回放插件行是刻意的:直接安装返回收尾消费检查所需的 `ReplayHandle`。没有 fixture 的场景让注册表保持空置,任何离群的流式调用都会以 NO_ADAPTER 大声失败。
`seedSession()` 通过真实持久化 API 播种冷会话——一次性 `Context` 挂载 `SessionStore` + `SessionPersistenceJsonl` 指向 host 的根目录,`create()` + `append()`,一次 `utimes` 回拨保证侧栏顺序确定(`semantic-checkpoint.snapshot.ts` 先例——绝不裸写文件因此播种器对桶哈希、文件名编码、压缩一无所知host 的 zstd 默认值也无需任何启动开关。种子在播种时即校验(可解析、以 `turn/end` 结尾——未闭合的最终轮次会被恢复resume的崩溃修复改写
@@ -40,19 +40,19 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
`DSH_SNAPSHOT` 选择 replay默认无密钥、record带密钥或 refresh无密钥。发起提示的 spec 将所有模式共用的驱动步骤与仅供 replay/refresh 使用的断言分开record 模式驱动真实输入框,采收内存中的会话 header 与事件,脱敏请求头,并 token 化当次运行的会话、cwd 与 RPC 标识。随后一次无密钥 refresh 重新生成 aria 预期输出。每条提示词都会与 fixture 中录制的 `user/message` 核对;每个场景目录都采用封闭清单,其中每个 JSONL 都是脱敏不动点。Web fixture 全部脱敏请求头且不钉任何 header 类别;见「暂缓」。
### 覆盖
### 覆盖约
该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局重置、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。必需车道还包含一份合成的 88 轮 Chat 滚动约,其中混合了换行 Markdown、围栏代码以及成对的 bash 调用/结果。真实 wheel、输入框、工具、tab、会话与 viewport 交互会在并发历史前插加带节奏流式输出、贴底/离底流式输出、工具 disclosure 离屏循环、扩展历史后的视图/会话重新挂载、宽度重排、贴底后立即重新挂载、输入框尺寸变化以及 textarea wheel 链场景中,断言一个具名已结算行相对 transcript scrollport 的顶部位置和到真实底部的距离;真实键盘翻页与触摸式惯性滑动模拟额外钉住不依赖 wheel 的贴底跟随所有权([读者滚动归因笔记](../bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md));它刻意不钉 DOM 基数或绝对 `scrollTop`,因此同一约可以验收虚拟化实现。另一份基于同一 fixture 的交互约钉住异构行顺序、相邻工具 disclosure 的独立状态、用户消息剪贴板内容的精确值、以轮次为边界的消息 fork、源会话/子会话隔离以及子会话中的一次真实追问轮次wheel 输入只用于导航到语义目标,不承载几何预期。一份简短的实时历史约从空白工作区开始,连续驱动输入框轮次,其中包括真实的 bash 调用/结果轮次和一段带节奏的长篇最终响应;它钉住单一会话身份、每轮事件的精确归属、浏览器回显唯一性与输入框恢复,不设置时间阈值。
该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局重置、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。必需车道还包含一份合成的 88 轮 Chat 滚动约,其中混合了换行 Markdown、围栏代码以及成对的 bash 调用/结果。真实 wheel、输入框、工具、tab、会话与 viewport 交互会在并发历史前插加带节奏流式输出、贴底/离底流式输出、工具 disclosure 离屏循环、扩展历史后的视图/会话重新挂载、宽度重排、贴底后立即重新挂载、输入框尺寸变化以及 textarea wheel 链场景中,断言一个具名已结算行相对 transcript scrollport 的顶部位置和到真实底部的距离;真实键盘翻页与触摸式惯性滑动模拟额外钉住不依赖 wheel 的贴底跟随所有权([读者滚动归因笔记](../bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md));它刻意不钉 DOM 基数或绝对 `scrollTop`,因此同一约可以验收虚拟化实现。另一份基于同一 fixture 的交互约钉住异构行顺序、相邻工具 disclosure 的独立状态、用户消息剪贴板内容的精确值、以轮次为边界的消息 fork、源会话/子会话隔离以及子会话中的一次真实追问轮次wheel 输入只用于导航到语义目标,不承载几何预期。一份简短的实时历史约从空白工作区开始,连续驱动输入框轮次,其中包括真实的 bash 调用/结果轮次和一段带节奏的长篇最终响应;它钉住单一会话身份、每轮事件的精确归属、浏览器回显唯一性与输入框恢复,不设置时间阈值。
### CI 立场
根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。`node 24 / snapshots and artifacts` 消费方任务在[消费方独立构建](../process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,安装锁文件选定的 Chromium恢复以操作系统和锁文件为键的缓存并用 `DSH_SNAPSHOT=replay` 运行该车道。这是有意的平面切分host 与 spec 使用 [tsx 源码启动](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md),浏览器则消费 `apps/web/dist` 和包的 `lib/client.js` 产物,因此门禁依赖 `built-package-invariants` 提供这些客户端产物。托管和自托管的默认分支 Linux 串行任务运行同一门禁;托管任务生成供 PR 消费的浏览器缓存持久化自托管池则不需要托管侧缓存。CI 从不录制或刷新预期输出。场景仍面向 POSIX并继续置于 Windows 和 macOS 矩阵之外。
根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。`node 24 / snapshots and artifacts` 消费方任务在[消费方独立构建](../process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,安装锁文件选定的 Chromium恢复以操作系统和锁文件为键的缓存并用 `DSH_SNAPSHOT=replay` 运行该车道。这是有意的平面切分host 与 spec 使用 [tsx 源码启动约](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md),浏览器则消费 `apps/web/dist` 和包的 `lib/client.js` 产物,因此门禁依赖 `built-package-invariants` 提供这些客户端产物。托管和自托管的默认分支 Linux 串行任务运行同一门禁;托管任务生成供 PR 消费的浏览器缓存持久化自托管池则不需要托管侧缓存。CI 从不录制或刷新预期输出。场景仍面向 POSIX并继续置于 Windows 和 macOS 矩阵之外。
高基数性能诊断使用单独按需启用的 `apps/web/tests/**/*.perf.ts` 清单,并且只由 `vitest.web.perf.config.ts` 选中。`complex-history.perf.ts` 的隔离用例复用真实 scaffold工作区用例播种 1,000 个紧凑会话以及一份包含 500 次工具调用的 500 轮次历史,在 Chat 中穷尽并重新挂载该历史,并报告 Chromium 主线程、DOM、监听器、堆内存、分页、搜索和 Trajectory 测量结果。两个续聊用例播种同一份长历史,但比较默认的 24 轮次 Chat 窗口与展开全部 500 轮次的状态然后各自通过真实输入框、agent loop、SSE wire、工具和持久化继续进行 8 个相同轮次;其中两轮执行真实 `bash` 调用并断言其持久化结果,最后一轮则填入一条包含 8,232 个字符的混合语言提示词,并回放 120 个带节奏的文本增量。一个单独的 soak 用例从空白会话开始,通过真实输入框连续驱动 100 轮,每第 10 轮执行一次 `bash` 调用并产生结果,每 10 轮强制执行一次 GC并报告每 10 轮的延迟窗口及保留的浏览器状态。随后它通过受信任的浏览器点击提交第 101 个纯文本轮次,并使用浏览器时钟分别测量发送到 transcript DOM 和发送到绘制后的延迟,排除输入框的草稿镜像,并与完整轮次完成时间分开。逐轮诊断涵盖输入框填入、点击到用户消息回显、点击到首个分片、完成、浏览器变更、持久化分片和工具事件;合成回放模型拥有足够的上下文容量,可使 fixture 基数保持稳定而不会因压缩compaction消耗脚本化调用。结构性断言钉住预期的负载、流和工具形状但时间仍不设阈值因为机器速度不属于正确性约。必需的 `vitest.web.config.ts` 清单仍仅限 `*.e2e.ts``*.snapshot.ts`,因此 `test:web:built` 及其 CI 门禁都不会收集性能用例。
高基数性能诊断使用单独按需启用的 `apps/web/tests/**/*.perf.ts` 清单,并且只由 `vitest.web.perf.config.ts` 选中。`complex-history.perf.ts` 的隔离用例复用真实 scaffold工作区用例播种 1,000 个紧凑会话以及一份包含 500 次工具调用的 500 轮次历史,在 Chat 中穷尽并重新挂载该历史,并报告 Chromium 主线程、DOM、监听器、堆内存、分页、搜索和 Trajectory 测量结果。两个续聊用例播种同一份长历史,但比较默认的 24 轮次 Chat 窗口与展开全部 500 轮次的状态然后各自通过真实输入框、agent loop、SSE wire、工具和持久化继续进行 8 个相同轮次;其中两轮执行真实 `bash` 调用并断言其持久化结果,最后一轮则填入一条包含 8,232 个字符的混合语言提示词,并回放 120 个带节奏的文本增量。一个单独的 soak 用例从空白会话开始,通过真实输入框连续驱动 100 轮,每第 10 轮执行一次 `bash` 调用并产生结果,每 10 轮强制执行一次 GC并报告每 10 轮的延迟窗口及保留的浏览器状态。随后它通过受信任的浏览器点击提交第 101 个纯文本轮次,并使用浏览器时钟分别测量发送到 transcript DOM 和发送到绘制后的延迟,排除输入框的草稿镜像,并与完整轮次完成时间分开。逐轮诊断涵盖输入框填入、点击到用户消息回显、点击到首个分片、完成、浏览器变更、持久化分片和工具事件;合成回放模型拥有足够的上下文容量,可使 fixture 基数保持稳定而不会因压缩compaction消耗脚本化调用。结构性断言钉住预期的负载、流和工具形状但时间仍不设阈值因为机器速度不属于正确性约。必需的 `vitest.web.config.ts` 清单仍仅限 `*.e2e.ts``*.snapshot.ts`,因此 `test:web:built` 及其 CI 门禁都不会收集性能用例。
## 业界先例
调研了 AI 聊天/agent web UI 与 mock 层LibreChat、vercel/ai-chatbot + AI SDK、lobe-chat、open-webui、OpenHands、Chainlit、continue、cline、langfuse、gradio/streamlitPlaywright HAR/route、MSW、Polly/nock、WireMock、aimock。自有后端的应用的主流成熟架构是真实后端 seam 后放一个进程内伪造/回放模型下游全部真实LibreChat 的 `LIBRECHAT_TEST_RUN_HOOK` 伪模型ai-chatbot 的 `MockLanguageModelV3` + `simulateReadableStream`continue 的脚本化 mock 提供方类)——这正是 `dsh-llm-replay` 已然所是。浏览器层 SSE 拦截无法检验增量渲染(`route.fulfill` 一次性交付整个响应体playwright#33564),且服务端 SSE 栈完全失测,因此各项目只把它用于边缘用例。分片节奏作为 fixture 参数反复出现LibreChat 默认 10ms 附慢速档ai-chatbot 500msCI 里的真实模型会腐烂open-webui 的套件长出 120 秒超时先被禁用后被删除会话在持久化层以受控时间戳播种LibreChat 直插回拨时间的 Mongo 文档langfuse 播种其数据库)。没有任何被调研项目为 UI 测试把录制的 agent 事件日志经真实后端回放——最接近的是提供方层录制 fixtureaimock与前端层 socket 历史发射OpenHands MSW——因此会话日志即 fixture 的设计沿着本仓库「模型可见 ⟺ 已记录」不变式所指的方向比业界先例多走了一步。
调研了 AI 聊天/agent web UI 与 mock 层LibreChat、vercel/ai-chatbot + AI SDK、lobe-chat、open-webui、OpenHands、Chainlit、continue、cline、langfuse、gradio/streamlitPlaywright HAR/route、MSW、Polly/nock、WireMock、aimock。自有后端的应用的主流成熟架构是真实后端约定后放一个进程内伪造/回放模型下游全部真实LibreChat 的 `LIBRECHAT_TEST_RUN_HOOK` 伪模型ai-chatbot 的 `MockLanguageModelV3` + `simulateReadableStream`continue 的脚本化 mock 提供方类)——这正是 `dsh-llm-replay` 已然所是。浏览器层 SSE 拦截无法检验增量渲染(`route.fulfill` 一次性交付整个响应体playwright#33564),且服务端 SSE 栈完全失测,因此各项目只把它用于边缘用例。分片节奏作为 fixture 参数反复出现LibreChat 默认 10ms 附慢速档ai-chatbot 500msCI 里的真实模型会腐烂open-webui 的套件长出 120 秒超时先被禁用后被删除会话在持久化层以受控时间戳播种LibreChat 直插回拨时间的 Mongo 文档langfuse 播种其数据库)。没有任何被调研项目为 UI 测试把录制的 agent 事件日志经真实后端回放——最接近的是提供方层录制 fixtureaimock与前端层 socket 历史发射OpenHands MSW——因此会话日志即 fixture 的设计沿着本仓库「模型可见 ⟺ 已记录」不变式所指的方向比业界先例多走了一步。
## 曾考虑的替代方案
@@ -60,19 +60,19 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
**`DEEPSEEK_BASE_URL` 处的 mock HTTP 提供方。** 作为本车道机制已否决仅保留给既有的工作区探针冒烟fixture 会变成手写的 OpenAI SSE 字节脚本,一种与仓库其余部分录制回放的会话日志格式渐行渐远的第二 fixture 格式;适配器的真实 HTTP 路径归带密钥 e2e 管。
**扩展 `?fixture` 客户端。** 已否决:分层纪律——`FixtureApiClient` 的存在意义就是脱离服务器测试客户端 shellclient API seam 以下按构造即失测。
**扩展 `?fixture` 客户端。** 已否决:分层纪律——`FixtureApiClient` 的存在意义就是脱离服务器测试客户端 shellclient API 边界以下按构造即失测。
**用占位 `DEEPSEEK_API_KEY` + 回放拦截替代禁用适配器行。** 尽管零组合改动且树内有两处先例仍被否决:它用谎言满足 `llm-deepseek` 的快速失败密钥检查还留下一个挂载却被拦截的死适配器禁用行ACP overlay 的同款做法)是诚实的无密钥,并在最早可解析点快速失败。
**`packages/support/web-snapshot` 包 + `defineWebSnapshotSuite` 工厂。** 已否决:驱动 chromium 的源码在无浏览器的覆盖率 runner 上无法诚实保持逐文件 100%,且除受门禁的包已导出的辅助工具与本地 scaffold 外,这些场景专用交互尚未形成稳定的无浏览器约。出现第二个 web 形态消费方,或被证实重复的生命周期代码确立该约后,再重新考虑。
**`packages/support/web-snapshot` 包 + `defineWebSnapshotSuite` 工厂。** 已否决:驱动 chromium 的源码在无浏览器的覆盖率 runner 上无法诚实保持逐文件 100%,且除受门禁的包已导出的辅助工具与本地 scaffold 外,这些场景专用交互尚未形成稳定的无浏览器约。出现第二个 web 形态消费方,或被证实重复的生命周期代码确立该约后,再重新考虑。
**第二份提交的规范化会话日志预期输出。** 已否决:日志表面已由 ACP/headless/TUI 套件经同一循环与持久化钉住;在此只会翻倍刷新成本并重复测试下层。内联在根上下文事件上的世界状态断言保住了验证世界的义务。
**以 `DSH_SNAPSHOT` 回放分支拉起 `dsh web` bin。** 已否决:它需要在交付的 CLI 中增加测试专用回放分支和环境变量管道。进程内 scaffold 已加载同一份 `apps/cli/config/base.cordis.yml``apps/cli/config/web.cordis.yml`;只剩 argv、profile JSON 和 `AppCLIEntry` 胶水不在其覆盖范围内,而这些路径已由无密钥 CLI 冒烟覆盖。
**为可测试性改 wire 协议。** 已否决:约已有第一等的无密钥同构 seam`InProcessApiClient(toFetchHandler(api))`),逐事件不合批的 SSE 恰是回放在浏览器中可观测的原因,测试一条不再交付的 wire 会颠倒该层的存在意义。
**为可测试性改 wire 协议。** 已否决:约已有第一等的无密钥进程内路径`InProcessApiClient(toFetchHandler(api))`),逐事件不合批的 SSE 恰是回放在浏览器中可观测的原因,测试一条不再交付的 wire 会颠倒该层的存在意义。
**以真实模型浏览器测试充当无密钥车道。** 已否决按构造即不确定被调研的前车之鉴open-webui长出无界超时后被删除。带密钥的 W5 冒烟仍是真实模型侧的补充。
**以真实模型浏览器测试充当无密钥车道。** 已否决按构造即不确定被调研的前车之鉴open-webui长出无界超时后被删除。带密钥的真实 host 冒烟仍是真实模型侧的补充。
**在必需的浏览器门禁中运行高基数性能用例。** 已否决:其 fixture 设置和完整历史渲染会增加数十秒耗时,而壁钟时间和内存值随 host 不同而变化,无法提供稳定的正确性阈值。必需车道保留确定性行为断言;贡献者在调查或更改大列表和长历史渲染时运行该诊断用例。
@@ -88,7 +88,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
- **恢复后追问场景**:真实 wire 上的历史/实时缝合路径;当该代码变更或回归时作为独立场景补充。
- **输入框 steering 手势**:输入在运行期间锁定(只能停止或等待),因此 steering 场景从页面走 wire 做 steer`TODO(web-steer-composer)` 待产品长出真实的输入框手势后,把驱动步骤升级为该手势。
- **拖拽会话重排**`workspace.insertSessionBefore` 尚无浏览器场景;它需要在同一个工作区里物化两个会话,并合成 HTML5 拖拽事件。当该表面变更或回归时再补充。无行为的会话 Rename/Fork/Delete 和工作区 Delete 菜单行待获得行为后再补充场景。
- **长历史 Chat 到 Trajectory 的 Inspect**:独立的检查数据源会在视图打开后穷尽历史,而所选记录由一个派生的表格索引定位;随着较早页面前插,该索引可能移动。短历史 Inspect 仍有覆盖;在选中项具有稳定的语义身份之前,长历史交互约不包含这项交接。
- **长历史 Chat 到 Trajectory 的 Inspect**:独立的检查数据源会在视图打开后穷尽历史,而所选记录由一个派生的表格索引定位;随着较早页面前插,该索引可能移动。短历史 Inspect 仍有覆盖;在选中项具有稳定的语义身份之前,长历史交互约不包含这项交接。
## 后果

View File

@@ -3,4 +3,4 @@
# 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-vitest-jsdom-webstorage-ownership.md
2026-07-30-vitest-jsdom-webstorage-ownership.md: 3956a7566fa1c79a767636bce9a19f16588126e2
2026-07-30-vitest-jsdom-webstorage-ownership.zh.md: 9080ee2762b74bf2efdaccd7a5905672001bc0e8
2026-07-30-vitest-jsdom-webstorage-ownership.zh.md: bd01ffdc68a45802ae403bdf44533d39126b021a

View File

@@ -6,7 +6,7 @@ Status: implemented
## 问题
受支持的 Node 版本范围包含会预留进程级 `globalThis.localStorage` 的版本。未设置 `--localstorage-file`Node 26 将该属性暴露为 `undefined`Vitest 检测到这个预留键后,不会用 jsdom 的隔离 `Storage` 对象覆盖该属性。因此,组件测试套件尚未验证产品行为便会失败,而主要的 Node 24 覆盖率分支仍能通过,因为该运行时默认不会预留此键。
受支持的 Node 版本范围包含会预留进程级 `globalThis.localStorage` 的版本。未设置 `--localstorage-file`Node 26 将该属性暴露为 `undefined`Vitest 检测到这个预留键后,不会用 jsdom 的隔离 `Storage` 对象覆盖该属性。因此,组件测试套件尚未验证产品行为便会失败,而主要的 Node 24 覆盖率通道仍能通过,因为该运行时默认不会预留此键。
## 决策
@@ -23,4 +23,4 @@ Node 兼容性汇总任务会在每条声明支持的兼容版本线上运行专
## 后果
同一条 `pnpm test` 命令在有无内置 Web Storage 的 Node 版本上均可运行。测试 worker 被有意禁止使用 Node 的进程级 Web Storage未来若产品需要该 API必须使用独立且显式的测试配置而不能削弱 jsdom 隔离。兼容性分支只增加一个专项 Vitest 进程,无需在每个 Node 版本上重复整套单元测试。
同一条 `pnpm test` 命令在有无内置 Web Storage 的 Node 版本上均可运行。测试 worker 被有意禁止使用 Node 的进程级 Web Storage未来若产品需要该 API必须使用独立且显式的测试配置而不能削弱 jsdom 隔离。兼容性通道只增加一个专项 Vitest 进程,无需在每个 Node 版本上重复整套单元测试。

View File

@@ -3,4 +3,4 @@
# 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: 14402485034cd85ec5781477ce67481165d47e62
2026-07-30-web-browser-snapshot-ci-gate.zh.md: f214c524253d2ad8a43e8543dc65ddfcfd7c065c
2026-07-30-web-browser-snapshot-ci-gate.zh.md: 28a7ef9a7046516a853b3e18a44163c01d43a318

View File

@@ -14,22 +14,22 @@ Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览
消费方 job 在[消费方独立构建](../process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,因此 `apps/web/dist` 和包的 `lib/` 目录会保留在其工作区中供浏览器套件使用。在托管运行器上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 模式复验不再写文件。
本地 `pnpm run test:web` 仍先构建再运行完整的浏览器套件`test:web:built` 是已有构建产物的执行入口。开发者只在确认用户可见输出有意变化后显式运行 `DSH_SNAPSHOT=refresh pnpm run test:web`,评审每一处预期输出 diff再以 replay 模式复验不再写文件。
对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX其他 PR job 不供给 Chromium。托管和自托管的默认分支 Linux 串行聚合作业也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器。PR 的 `all checks passed` 已依赖消费方 job因此浏览器比较失败会阻止合并无需新增 branch-protection check 名称。
对 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。
**继续只要求本地运行。** 已否决:执行依赖开发者记忆,正是旧 golden 跨 PR 漂移的原因,不能保证产生行为变化的 PR 自己携带预期输出 diff。
**让 CI 以 `refresh` 模式运行后检查工作树。** 已否决:写后比较把断言机制变成生成器,若工作树检查接线失效就会把回归更新成绿色replay 直接比较已有 golden失败面更小。
**让 CI 以 `refresh` 模式运行后检查工作树。** 已否决:写后比较把断言机制变成生成器,若工作树检查接入有误,就可能把回归变成能够通过的预期输出更新replay 直接比较已有 golden失败面更小。
**新建独立 browser job 并重新构建全仓。** 已否决:它会重复依赖安装和发布构建。现有 Linux 消费方 job 已负责该构建,并已被统一的 required verdict 聚合。
**用 jsdom 快照代替真实 Chromium。** 已否决jsdom 不覆盖浏览器、HTTP/SSE 承载及真实 client plugin bundle 组合;它保留为快速的下层反馈,不能替代 assembled browser chain
**用 jsdom 快照代替真实 Chromium。** 已否决jsdom 不覆盖浏览器、HTTP/SSE 承载及真实客户端插件包的组合;它仍可用于快速的下层反馈,不能替代组装后的浏览器链路
## 后果
每个 PR 都在合并前证明当前 Web 组装与所有已提交的浏览器 expected 一致,漏刷从“后续 PR 的无关变化”变成引入 PR 自己的失败。成本是消费方 job 需要供给 Chromium并串行运行一轮浏览器场景消费方独立构建与浏览器缓存避免重跑时重复构建和下载。门禁仍不声称跨平台浏览器一致性Playwright/Chromium 升级若改变 aria 格式,升级 PR 必须显式 refresh 并评审 churn。
每个 PR 都在合并前证明当前 Web 组装与所有已提交的浏览器预期输出一致,漏刷从“后续 PR 的无关变化”变成引入 PR 自己的失败。成本是消费方 job 需要安装 Chromium并串行运行一轮浏览器场景消费方独立构建与浏览器缓存避免重跑时重复构建和下载。门禁仍不声称跨平台浏览器一致性Playwright/Chromium 升级若改变 ARIA 格式,升级 PR 必须显式 refresh 并评审 churn。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md
2026-08-03-opt-in-reasoning-chunk-browser-stress.md: 70c200c7ade6ef995c68b68ddc21e4c85edf0da8
2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md: aa1d29bffd3dcba54925b43eab13ef4d9a4f7cff
2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md: 9fb4be27369d4489fc90a6151fcbd0861ef83f2c

View File

@@ -1,4 +1,4 @@
# Agent Note: 推理分片的逐帧累计发布与浏览器压力验证
# Agent Note: 推理reasoning分片的逐帧累计发布与浏览器压力验证
Status: implemented
@@ -12,15 +12,15 @@ Status: implemented
## 决策
`Session.acceptLiveEvent()` 立即追加每个原始事件,并同步更新 transcript、`PartialAccumulator` 及其他会话派生状态。可见的 `block-start``text-delta``reasoning-delta``tool-call-delta``block-end` 分片通过 `Notifier.markFrameDirty()` 发布:第一项变化调度一次 `requestAnimationFrame`,后续分片只继续更新累积器;帧回调从最新状态重建一个累计快照并通知订阅者一次。`usage``finish` 及未知的不可见分片保留在事件窗口中,但不触发无效的 React 通知。会话与历史检查共用同一可见分片分类。
`Session.acceptLiveEvent()` 立即追加每个原始事件,并同步更新 transcript(文本记录)`PartialAccumulator` 及其他会话派生状态。可见的 `block-start``text-delta``reasoning-delta``tool-call-delta``block-end` 分片通过 `Notifier.markFrameDirty()` 发布:第一项变化调度一次 `requestAnimationFrame`,后续分片只继续更新累积器;帧回调从最新状态重建一个累计快照并通知订阅者一次。`usage``finish` 及未知的不可见分片保留在事件窗口中,但不触发无效的 React 通知。会话与历史检查共用同一可见分片分类。
`Notifier` 用调度种类和代际标记管理待发布工作。普通结构事件继续通过 `markDirty()` 在微任务发布;如果定稿消息、工具事件或错误到达时仍有待执行的帧发布,微任务会取代它,旧帧回调因代际不匹配而失效。`notifyNow()` 同样使旧调度失效,以保留受控输入的同步回响。没有 `requestAnimationFrame` 的环境退回微任务合批。定稿事件可以跳过一次尚未显示的中间 partial但发布的定稿内容和原始事件序列保持完整。
`Notifier` 用调度种类和代际标记管理待发布工作。普通结构事件继续通过 `markDirty()` 在微任务发布;如果定稿消息、工具事件或错误到达时仍有待执行的帧发布,微任务会取代它,旧帧回调因代际不匹配而失效。`notifyNow()` 同样使旧调度失效,以保留受控输入的同步回响。没有 `requestAnimationFrame` 的环境退回微任务合批。定稿事件可以跳过一次尚未显示的中间 partial但发布的定稿内容和原始事件序列保持完整。
实时 Think 行对累计文本的横向跟尾属于纯视觉对齐,不需要在每次 React 提交中同步读取布局。组件内调度器将连续请求合并为每三帧一次,从最新 DOM 读取 `scrollWidth``clientWidth` 并将 `scrollLeft` 直接更新到最新位置;固定的视觉节奏让摘要变化可读,又不会积压浏览器平滑滚动动画。该节流只作用于 Think 的横向摘要,不延迟 Chat 正文滚动、历史 prepend 锚定或用户触发的 `scrollIntoView`
`pnpm run test:web:stress` 保留为无密钥、需显式启用的浏览器性能证据。确定性的 `?fixture` 会话以独立于绘制的节奏发出 100,000 个 `reasoning-delta`,结尾标记证明事件经过生产会话归并并到达实时 Think 行50 毫秒心跳和预先调度的 DOM 事件分别测量主线程停顿与交互延迟250 毫秒预算用于识别明显回归。`DSH_WEB_STRESS_HEADFUL=1` 允许开发者在可见浏览器中使用 Performance 面板分析同一场景。该压力车道是手动性能诊断与修复验收证据,不是默认 CI 门禁,也不替代确定性的调度单元测试。
`pnpm run test:web:stress` 保留为无密钥、需显式启用的浏览器性能证据。确定性的 `?fixture` 会话以独立于绘制的节奏发出 100,000 个 `reasoning-delta`,结尾标记证明事件经过生产会话归并并到达实时 Think 行50 毫秒心跳和预先调度的 DOM 事件分别测量主线程停顿与交互延迟250 毫秒预算用于识别明显回归。`DSH_WEB_STRESS_HEADFUL=1` 允许开发者在可见浏览器中使用 Performance 面板分析同一场景。该压力车道是手动性能诊断与修复验收证据,不是默认 CI 门禁,也不替代确定性的调度单元测试。
聚焦测试固定 `Notifier` 的逐帧合并、结构事件抢占、失效回调和无 rAF 回退,并在 `Session` 层证明一帧只发布一次最新累计文本且定稿不会被旧帧回调重复通知。fixture 的小型单元测试继续固定输入校验、外部到达节奏、并发拒绝、精确事件数和结尾标记交付,无需把 100,000 分片工作负载带入默认测试套件。
聚焦测试固定 `Notifier` 的逐帧合并、结构事件抢占、失效回调和无 rAF 回退,并在 `Session` 层证明一帧只发布一次最新累计文本且定稿不会被旧帧回调重复通知。fixture(测试前置数据)的小型单元测试继续固定输入校验、外部到达节奏、并发拒绝、精确事件数和结尾标记交付,无需把 100,000 分片工作负载带入默认测试套件。
## 曾考虑的替代方案
@@ -28,7 +28,7 @@ Status: implemented
**在接收或日志层丢弃、抽样或拼接原始分片。** 不予采纳:原始 `assistant/chunk` 是可重放的会话事实,改变它会损失诊断与 UI 保真度,并把展示频率策略混入数据权威层。
**只使用微任务合批。** 不予采纳:连续异步 `yield` 会在相邻分片间排空微任务队列,使一个微任务调度近似退化为一次分片一次通知
**只使用微任务合批。** 不予采纳:连续异步 `yield` 会在相邻分片间排空微任务队列,使微任务合批近似退化为每个分片通知一次。
**按动画帧控制测试生产方节奏。** 不予采纳:生产方会在渲染变慢时同步减速,使页面获得真实网络流不存在的隐式背压,并掩盖主线程饥饿。