{
## 后果
- 支持作用域的注册表各自通过一个聚合层表达状态,并复用相同的构造、属主、回滚、通知和回收编排。各注册表仍各自保有领域特有的校验、诊断、过滤、求值和观察者策略。
-- 公开读取接口保持狭窄:直接遍历条目表可保留显式的活语义,`merge()` 是唯一共享的物化遮蔽操作。异构的 `ScopeLayer` 不具备整层 `values()` 契约。
-- helper 刻意保持同步。未来的登记若需要异步 setup 或多份分别拥有属主的 undo,必须先明确属主与 settlement 边界,再拓宽这项契约。
-- action 必须在保留贡献前抛错,或者为自己保留的一切返回 undo;helper 无法修复超出这项契约的变更。提供的条目操作是原子的,迁移后的注册表会在插入前执行可能失败的校验。
+- 公开读取接口保持狭窄:直接遍历条目表可保留显式的活语义,`merge()` 是唯一共享的物化遮蔽操作。异构的 `ScopeLayer` 不具备整层 `values()` 约定。
+- helper 刻意保持同步。未来的登记若需要异步 setup 或多份分别拥有属主的 undo,必须先明确属主与 settlement 边界,再拓宽这项约定。
+- action 必须在保留贡献前抛错,或者为自己保留的一切返回 undo;helper 无法修复超出这项约定的变更。提供的条目操作是原子的,迁移后的注册表会在插入前执行可能失败的校验。
- 专属层会一直保持已分配状态,直到其聚合内的所有表都为空。因此,销毁一个门面不会丢弃同一 scope 拥有的其他贡献。
-- 四个公开符号构成一项可复用的包契约。将 `EntryValues` 保持为内部接口,并把消费方策略留在 helper 之外,可以限制兼容性范围。
+- 四个公开符号构成一项可复用的包约定。将 `EntryValues` 保持为内部接口,并把消费方策略留在 helper 之外,可以限制兼容性范围。
- 迁移不改变任何公开注册表行为,也不改变模型、人类、协议、持久化、配置或依赖图层面的任何输出。
## 验证
- `dsh-scope` 单元测试覆盖全局构造、专属层延迟构造、非创建式读取、命名合并顺序与遮蔽、聚合回收、工厂与 action 失败清理、通知顺序与回滚、`notify: false`、effect 标签、原始 disposer 身份、幂等拆除、调用方提供的重名错误、相同匿名值的独立登记、活迭代器,以及表清空后的 generation 脱离。
-- 工具、系统提示词和命令专项测试套件覆盖 restriction、保留传输处理、已知名称与可限制名称的一致性、guard 重入与自我替换、校验顺序、精确诊断、section 先遮蔽再求值、提供方快照成员关系、variable 重入与自我替换、隔离失败的命令观察者、冻结且经过排序的视图、直接执行和生命周期销毁。
+- 工具、系统提示词和命令专项测试套件覆盖 restriction、保留传输项处理、已知名称与可限制名称的一致性、guard 重入与自我替换、校验顺序、精确诊断、section 先遮蔽再求值、提供方快照成员关系、variable 重入与自我替换、隔离失败的命令观察者、冻结且经过排序的视图、直接执行和生命周期销毁。
- 作用域核心数据的类型等价性检查将 `ScopeLayer` 文档与其源声明绑定。仓库级的文档、模块图、构建、hygiene、覆盖率与构建产物门禁会覆盖包根导出与包边界。
- 现有 ACP(Agent Client Protocol)、headless 和 TUI 无密钥快照继续作为工具 schema 与提示词组装的回归边界;人类命令由 TUI 覆盖。实现不会更新任何预期 transcript(文本记录)。
diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml
index 465a658ca8..a79104468c 100644
--- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.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/architecture/2026-07-14-provider-routed-llm-adapters.md
-2026-07-14-provider-routed-llm-adapters.md: 27277280e423553f79d5a34f512b673413f495ff
-2026-07-14-provider-routed-llm-adapters.zh.md: aeb09a500d5750ef2793bc9a7fc09834055a56a4
+2026-07-14-provider-routed-llm-adapters.md: e1eaf52f21481a7c65e85effb7607b16f9b0ffdd
+2026-07-14-provider-routed-llm-adapters.zh.md: 4e620408dabffc8368293635613afb5778b6e822
diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md
index 27277280e4..e1eaf52f21 100644
--- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md
+++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md
@@ -6,11 +6,11 @@ English | [中文](2026-07-14-provider-routed-llm-adapters.zh.md)
## Problem
-`dsh-llm` registered adapters by exact model name. A plugin supplied a model list at Cordis startup, `LlmService` stored one adapter per listed string, and `GenerateOptions.model` selected the adapter and the provider model at once. This worked while both shipping adapters targeted the same two DeepSeek models, but it conflated two independent decisions: which upstream provider owns a request, and which model that provider should run.
+`dsh-llm` registered adapters by exact model name. A plugin supplied a model list at Cordis startup, `LlmRuntime` stored one adapter per listed string, and `GenerateOptions.model` selected the adapter and the provider model at once. This worked while both shipping adapters targeted the same two DeepSeek models, but it conflated two independent decisions: which upstream provider owns a request, and which model that provider should run.
The conflation prevents a provider gateway from serving an open-ended model catalog. OpenRouter, for example, is one provider with many model ids, while a private OpenAI-compatible endpoint may add models without changing the Harness plugin tree. Every newly selected model currently needs to have been registered during plugin startup. The same model id can also exist at multiple providers, so model-only registration cannot state which provider the caller intended.
-`dsh-llm-pi-ai` exposed none of pi-ai's provider abstraction. It constructed an inline DeepSeek `openai-completions` model, applied DeepSeek-specific payload patches, and stamped every replayed assistant message as DeepSeek. pi-ai itself has a provider/model catalog, selects APIs such as `openai-responses`, `anthropic-messages`, and `google-generative-ai`, and preserves provider-specific response ids and reasoning/tool signatures for later turns. The Harness conversion dropped that provenance, so simply replacing the inline model with a catalog lookup would have made same-model replay and cross-provider handoff incomplete.
+`dsh-llm-pi-ai` exposed none of pi-ai's provider abstraction. It constructed an inline DeepSeek `openai-completions` model, applied DeepSeek-specific payload patches, and stamped every replayed assistant message as DeepSeek. pi-ai itself has a provider/model catalog, selects APIs such as `openai-responses`, `anthropic-messages`, and `google-generative-ai`, and preserves provider-specific response ids and reasoning/tool signatures for later turns. The Harness conversion dropped the provider/model route and provider response fields, so simply replacing the inline model with a catalog lookup would have made same-model replay and cross-provider handoff incomplete.
The adapter configuration also assumes one DeepSeek API key and endpoint. A generic backend needs independent credentials and endpoint overrides per provider while leaving AWS, Google ADC, OAuth, and other ambient authentication mechanisms to pi-ai.
@@ -20,7 +20,7 @@ The adapter configuration also assumes one DeepSeek API key and endpoint. A gene
`GenerateOptions` and `LlmCallConfig` carry `provider: string` beside `model: string`; `AgentOptions` carries the corresponding optional creation field. A loop request is valid only after both values are non-empty, and both values are part of the logged request header. `agent/request` may return a replacement pair on any step, so a session can switch providers and models without changing the Cordis plugin lifecycle.
-`LlmService` registers and resolves adapters by provider. `registerAdapter(providers, adapter)` checks the entire provider list before mutating the registry, rejects a duplicate with `DUPLICATE_ADAPTER`, and disposes the whole registration as one effect. Model ids are not registration keys; the selected adapter still validates or forwards them. The later [LLM catalog and ACP selection Agent Note](2026-07-15-llm-model-catalog-and-acp-selection.md) added advisory `listProviders()` / `listModels()` discovery without turning model membership into request validation.
+`LlmRuntime` registers and resolves adapters by provider. `registerAdapter(providers, adapter)` checks the entire provider list before mutating the registry, rejects a duplicate with `DUPLICATE_ADAPTER`, and disposes the whole registration as one effect. Model ids are not registration keys; the selected adapter still validates or forwards them. The later [LLM catalog and ACP selection Agent Note](2026-07-15-llm-model-catalog-and-acp-selection.md) added advisory `listProviders()` / `listModels()` discovery without turning model membership into request validation.
A provider has exactly one adapter owner in a Cordis context. `dsh-llm-deepseek` registers `deepseek`; `dsh-llm-pi-ai` may also register `deepseek`, but loading both owners is a configuration error rather than an ordering rule or fallback. A deployment that wants the hand-rolled DeepSeek implementation excludes `deepseek` from the pi-ai profiles. A deployment that wants pi-ai's DeepSeek implementation does not mount `dsh-llm-deepseek`.
@@ -28,7 +28,7 @@ A provider has exactly one adapter owner in a Cordis context. `dsh-llm-deepseek`
### Explicit pi-ai provider profiles
-`dsh-llm-pi-ai` takes one non-empty list of provider profiles. Provider names must be unique within the list and present in pi-ai's `getProviders()` result. Each profile contains the provider name plus optional `apiKey`, `baseURL`, headers, reasoning level and budgets, cache retention, transport, SDK timeouts, a Harness stream-idle timeout, and a provider-owned `retryPolicy`. The adapter forces pi-ai's `maxRetries` to zero so one `stream()` call makes one visible provider attempt, while `dsh-llm-retry` executes the resolved policy at the agent failed-step seam. Credentials are never global: an explicit key applies only to its profile, while an absent key lets pi-ai resolve its standard environment variable, OAuth token, AWS credential chain, Google ADC, or other provider-native ambient authentication. An explicitly empty key is invalid configuration rather than an environment fallback.
+`dsh-llm-pi-ai` takes one non-empty list of provider profiles. Provider names must be unique within the list and present in pi-ai's `getProviders()` result. Each profile contains the provider name plus optional `apiKey`, `baseURL`, headers, reasoning level and budgets, cache retention, transport, SDK timeouts, a Harness stream-idle timeout, and a provider-owned `retryPolicy`. The adapter forces pi-ai's `maxRetries` to zero so one `stream()` call makes one visible provider attempt, while `dsh-llm-retry` executes the resolved policy at the agent failed-step extension point. Credentials are never global: an explicit key applies only to its profile, while an absent key lets pi-ai resolve its standard environment variable, OAuth token, AWS credential chain, Google ADC, or other provider-native ambient authentication. An explicitly empty key is invalid configuration rather than an environment fallback.
The plugin registers all configured provider names against one `PiAiAdapter` in one all-or-nothing call. A request uses its provider to select the matching profile and finds its model in `getModels(provider)` to obtain the catalog descriptor. An unknown provider fails at plugin load; an unknown model fails before network I/O with `UNKNOWN_MODEL`. The catalog object is never mutated. When a profile supplies `baseURL`, the adapter clones the selected descriptor and overrides only `baseUrl`, so a private endpoint can retain pi-ai's API, capabilities, compatibility flags, context limits, and reasoning map. The private endpoint must implement the selected provider's protocol, and the model id must still exist in the installed pi-ai catalog.
@@ -36,25 +36,25 @@ The adapter calls pi-ai's `streamSimple()` so each catalog model chooses its reg
pi-ai's common stream options do not expose stop sequences. `dsh-llm-pi-ai` rejects a defined Harness `stop` option with `UNSUPPORTED_OPTION` rather than silently ignoring it or growing a second provider-specific payload implementation. `dsh-llm-deepseek` continues to support `stop` through its native request serializer.
-### Durable assistant provenance and replay state
+### Recorded assistant route and replay state
-Assistant messages carry provider-neutral provenance containing the request's `provider` and `model`, plus an optional JSON-serializable adapter replay state. A successful `assistant/message` session event records this provenance and `deriveMessages()` returns it with the assistant message. User, system, context, and tool-result messages carry no assistant provenance. The provider/model fields are authoritative loop data; an adapter owns only its opaque replay-state payload.
+Assistant messages carry the request's `provider` and `model`, plus an optional JSON-serializable adapter replay state. A successful `assistant/message` session event records those fields and `deriveMessages()` returns them with the assistant message. User, system, context, and tool-result messages carry no assistant route fields. The provider/model fields are authoritative loop data; an adapter owns only its opaque replay-state payload.
-A terminal successful `finish` chunk may carry replay state, and `BlockAssembler` retains it alongside usage and finish reason. The loop attaches that state to the assembled assistant provenance without exposing a response-rewrite hook. Error and aborted responses do not produce a normal assistant message and therefore do not enter future model history.
+A terminal successful `finish` chunk may carry replay state, and `BlockAssembler` retains it alongside usage and finish reason. The loop attaches that state to the assembled assistant message's model source without exposing a response-rewrite hook. Error and aborted responses do not produce a normal assistant message and therefore do not enter future model history.
-The pi-ai replay state is a versioned, minimal projection of its successful `AssistantMessage`: source API/provider/model, response id/model, stop reason, and index-aligned text, thinking, and tool-call signatures. It does not duplicate text or tool arguments already carried by Harness content blocks, and it omits diagnostics, timestamps, usage, and errors. On a later request, `LlmService` gives replay state to the target adapter only when the historical provider and target provider are currently owned by the same adapter instance. That adapter combines the logged Harness content with replay state when it can restore the historical response, and owns any required cross-model or cross-provider conversion. An adapter receiving replay state with an unknown version or mismatched block shape fails explicitly; a different adapter receives only provider-neutral content and provenance.
+The pi-ai replay state is a versioned, minimal projection of its successful `AssistantMessage`: source API/provider/model, response id/model, stop reason, and index-aligned text, thinking, and tool-call signatures. It does not duplicate text or tool arguments already carried by Harness content blocks, and it omits diagnostics, timestamps, usage, and errors. On a later request, `LlmRuntime` gives replay state to the target adapter only when the historical provider and target provider are currently owned by the same adapter instance. That adapter combines the logged Harness content with replay state when it can restore the historical response, and owns any required cross-model or cross-provider conversion. An adapter receiving replay state with an unknown version or mismatched block shape fails explicitly; a different adapter receives only provider-neutral content plus provider/model fields.
-This state is model-visible replay input and therefore follows the existing [reconstructable-request rule](2026-07-05-reconstructable-requests.md): it is present in both the terminal `finish` chunk and the assembled `assistant/message` provenance that drives derivation. Resume and fork preserve it verbatim. Compaction that shadows the assistant message also removes its replay state from the active surface; the summary is ordinary provider-neutral content.
+This state is model-visible replay input and therefore follows the existing [reconstructable-request rule](2026-07-05-reconstructable-requests.md): it is present in both the terminal `finish` chunk and the assembled `assistant/message` model source that drives derivation. Resume and fork preserve it verbatim. Compaction that shadows the assistant message also removes its replay state from the active surface; the summary is ordinary provider-neutral content.
### Propagate the target through every request producer
-Every model-selection surface carries provider and model together: declarative agents, ACP and stdio app config, the JSON-RPC initialize request, subagent overrides and inheritance, workflow child overrides, and direct compaction summarization. Subagents inherit both fields from their parent before applying request overrides. The system-prompt variable set gains `provider` beside `model`.
+Every model-selection path carries provider and model together: declarative agents, ACP and stdio app config, the JSON-RPC initialize request, subagent overrides and inheritance, workflow child overrides, and direct compaction summarization. Subagents inherit both fields from their parent before applying request overrides. The system-prompt variable set gains `provider` beside `model`.
-Compaction configuration gains `summarizationProvider` beside `summarizationModel`. Both are empty to inherit, or both are non-empty to select an explicit target; a half-configured pair fails load. Inheritance uses the last logged request target when one exists and falls back to the agent's creation options. `compact/summary` records both fields with the existing model-call envelope.
+Compaction configuration gains `summarizationProvider` beside `summarizationModel`. Both are empty to inherit, or both are non-empty to select an explicit target; a half-configured pair fails load. Inheritance uses the last logged request target when one exists and falls back to the agent's creation options. `compaction/summary` records both fields with the existing model-call envelope.
The JSON-RPC runtime receives provider and model explicitly. Its convenience fallback mounts `dsh-llm-deepseek` only for provider `deepseek` when that provider has no registered owner; other missing providers fail without guessing an adapter.
-The on-disk session format remains the pre-release pinned version `0`, with no compatibility promise. Seed/load validation rejects request headers lacking provider and assistant messages lacking required provenance instead of accepting an old shape that can no longer reconstruct the request.
+The on-disk session format remains the pre-release pinned version `0`, with no compatibility promise. Seed/load validation rejects request headers and assistant messages that omit required provider/model fields instead of accepting an old shape that can no longer reconstruct the request.
## Alternatives considered
@@ -66,7 +66,7 @@ The on-disk session format remains the pre-release pinned version `0`, with no c
**Let `dsh-llm-pi-ai` automatically register every pi-ai provider.** This would claim ambient credentials and provider names the deployment never intended to expose, and would conflict with native adapters such as `dsh-llm-deepseek`. Explicit profiles make capability and credential scope reviewable.
-**Mount one pi-ai plugin instance per provider.** Separate instances isolate config but repeat plugin declarations and cannot make profile registration atomic. One adapter already receives provider on every request, so a validated profile map is the smaller lifecycle surface.
+**Mount one pi-ai plugin instance per provider.** Separate instances isolate config but repeat plugin declarations and cannot make profile registration atomic. One adapter already receives provider on every request, so a validated profile map is the smaller lifecycle API.
**Accept arbitrary inline pi-ai model descriptors.** This would support catalog-external private model ids, but it exposes pi-ai's model and compatibility schema as Harness configuration and makes the adapter responsible for validating protocol-specific combinations. The first version supports custom endpoints by overriding `baseURL` on catalog models; custom descriptors require a separate decision after a real catalog-external deployment is identified.
@@ -78,13 +78,13 @@ The on-disk session format remains the pre-release pinned version `0`, with no c
- pi-ai credentials, transport knobs, SDK timeouts, and the five-minute-default `streamIdleTimeoutMs` watchdog are scoped per provider profile. Hidden provider retries are disabled; bounded retries belong to the separately composed agent recovery policy.
- `dsh-llm-pi-ai` rejects stop sequences because pi-ai's common stream API cannot express them; the native DeepSeek adapter retains its stop support.
- Replay state is portable only within the adapter instance that owns both the historical and target providers. Cross-provider and cross-model restoration is an adapter responsibility, and another adapter receives provider-neutral history without the opaque state.
-- Current pre-release session JSONL requires provider/model request headers and assistant provenance. Older shapes remain version `0` but are rejected rather than migrated.
+- Current pre-release session JSONL requires provider/model on request headers and assistant messages. Older shapes remain version `0` but are rejected rather than migrated.
## Testing
- Unit coverage exercises registry conflicts, request reconstruction, session validation, profile resolution, single-attempt option forwarding, native API selection including OpenAI Responses, conversion, replay validation, error mapping, caller cancellation, idle-timeout transport termination, content rewrites, and same-instance versus different-instance replay dispatch.
- Keyless loop/session tests and ACP snapshots exercise durable provider/model metadata, resume and fork propagation, workflow/subagent overrides, and unchanged user-visible transcripts; the key-gated DeepSeek e2e retains real provider streaming and tool follow-up coverage.
-- Public JSDoc, package READMEs, architecture and core-data-structure docs, generated catalogs, examples, session fixtures, and Python SDK pairs use provider/model targets consistently and are checked by the repository documentation and type-equivalence gates.
+- Public JSDoc, package READMEs, architecture and subsystem docs, generated catalogs, examples, session fixtures, and Python SDK pairs use provider/model targets consistently and are checked by the repository documentation and type-equivalence gates.
## Risks
diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md
index aeb09a500d..4e620408da 100644
--- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md
@@ -6,11 +6,11 @@ Status: implemented
## 问题
-`dsh-llm` 按精确模型名称注册适配器。插件在 Cordis 启动时提供模型列表,`LlmService` 为列表中的每个字符串保存一个适配器,`GenerateOptions.model` 同时选择适配器与提供方模型。两个正式适配器都只面向相同的两个 DeepSeek 模型时,这种方式可以工作,但它混淆了两个独立决策:由哪个上游提供方承接请求,以及该提供方应运行哪个模型。
+`dsh-llm` 按精确模型名称注册适配器。插件在 Cordis 启动时提供模型列表,`LlmRuntime` 为列表中的每个字符串保存一个适配器,`GenerateOptions.model` 同时选择适配器与提供方模型。两个随附的适配器都只面向相同的两个 DeepSeek 模型时,这种方式可以工作,但它混淆了两个独立决策:由哪个上游提供方承接请求,以及该提供方应运行哪个模型。
-这种混淆使提供方网关无法提供开放的模型目录。例如,OpenRouter 是一个包含大量模型 ID 的提供方,私有 OpenAI 兼容端点也可能在不修改 harness 插件树的情况下增加模型。目前,每个新选择的模型都必须在插件启动期间完成注册。同一个模型 ID 还可能存在于多个提供方中,因此仅按模型注册无法表达调用方预期使用的提供方。
+这种混淆使提供方网关无法提供开放的模型目录。例如,OpenRouter 是一个包含大量模型 ID 的提供方,私有 OpenAI 兼容端点也可能在不修改 Harness 插件树的情况下增加模型。目前,每个新选择的模型都必须在插件启动期间完成注册。同一个模型 ID 还可能存在于多个提供方中,因此仅按模型注册无法表达调用方预期使用的提供方。
-`dsh-llm-pi-ai` 没有暴露 pi-ai 的提供方抽象。它以内联方式构造 DeepSeek `openai-completions` 模型,应用 DeepSeek 专用载荷补丁,并将每条回放的助手消息标记为 DeepSeek。pi-ai 自身提供提供方/模型目录,能够选择 `openai-responses`、`anthropic-messages`、`google-generative-ai` 等 API,并保留提供方专用的响应 ID,以及后续轮次所需的推理(reasoning)和工具签名。harness 转换丢弃了这些来源信息,因此仅将内联模型替换为目录查询,会导致同模型回放与跨提供方移交不完整。
+`dsh-llm-pi-ai` 没有暴露 pi-ai 的提供方抽象。它以内联方式构造 DeepSeek `openai-completions` 模型,应用 DeepSeek 专用的 payload 补丁,并将每条回放的助手消息标记为 DeepSeek。pi-ai 自身提供提供方/模型目录,能够选择 `openai-responses`、`anthropic-messages`、`google-generative-ai` 等 API,并保留提供方专用的响应 ID,以及后续轮次所需的推理和工具签名。Harness 转换丢弃了提供方/模型路由和提供方响应字段,因此仅将内联模型替换为目录查询,会导致同模型回放与跨提供方移交不完整。
适配器配置同样假定只存在一个 DeepSeek API 密钥和端点。通用后端需要为各提供方分别配置凭据和端点覆盖,同时继续由 pi-ai 处理 AWS、Google ADC、OAuth 等环境认证机制。
@@ -18,9 +18,9 @@ Status: implemented
### 提供方作为适配器注册键
-`GenerateOptions` 与 `LlmCallConfig` 在 `model: string` 之外携带 `provider: string`,`AgentOptions` 则携带对应的可选创建字段。只有两个值都非空时,agent loop(智能体循环)请求才有效;两个值也都是已记录请求头的一部分。`agent/request` 可以在任意步骤返回替换后的字段组合,因此会话可以切换提供方与模型,无需改变 Cordis 插件生命周期。
+`GenerateOptions` 与 `LlmCallConfig` 在 `model: string` 之外携带 `provider: string`,`AgentOptions` 则携带对应的可选创建字段。只有两个值都非空时,agent loop(智能体循环)请求才有效;两个值也都会写入请求头日志。`agent/request` 可以在任意步骤返回替换后的字段组合,因此会话可以切换提供方与模型,无需改变 Cordis 插件生命周期。
-`LlmService` 按提供方注册和解析适配器。`registerAdapter(providers, adapter)` 在修改注册表前检查整个提供方列表,遇到重复项时以 `DUPLICATE_ADAPTER` 拒绝注册,并将整组注册作为一个 effect 统一 dispose(资源释放)。模型 ID 不作为注册键;仍由选中的适配器负责验证或转发。后续的 [LLM 目录与 ACP(Agent Client Protocol)模型选择 Agent Note](2026-07-15-llm-model-catalog-and-acp-selection.md) 增加了建议性的 `listProviders()` / `listModels()` 发现接口,但不会把目录成员关系变成请求校验规则。
+`LlmRuntime` 按提供方注册和解析适配器。`registerAdapter(providers, adapter)` 在修改注册表前检查整个提供方列表,遇到重复项时返回 `DUPLICATE_ADAPTER`,并以一个 effect 为单位整体 dispose(资源释放)。模型 ID 不作为注册键;仍由选中的适配器负责验证或转发。后续的 [LLM 目录与 ACP 模型选择 Agent Note](2026-07-15-llm-model-catalog-and-acp-selection.md) 增加了建议性的 `listProviders()` / `listModels()` 发现接口,但不会把目录成员关系变成请求校验规则。
在一个 Cordis 上下文中,一个提供方只能有一个适配器所有者。`dsh-llm-deepseek` 注册 `deepseek`;`dsh-llm-pi-ai` 也可以注册 `deepseek`,但同时加载两个所有者属于配置错误,不采用顺序规则或回退行为。若部署选择手写的 DeepSeek 实现,需从 pi-ai 配置中排除 `deepseek`;若部署选择 pi-ai 的 DeepSeek 实现,则不挂载 `dsh-llm-deepseek`。
@@ -28,47 +28,47 @@ Status: implemented
### 显式 pi-ai 提供方配置
-`dsh-llm-pi-ai` 接受一个非空的提供方配置列表。列表内的提供方名称必须唯一,并且存在于 pi-ai 的 `getProviders()` 结果中。每项配置包含提供方名称,以及可选的 `apiKey`、`baseURL`、headers、推理级别和预算、缓存保留设置、传输方式、SDK 超时、harness 流空闲超时,以及由提供方拥有的 `retryPolicy`。适配器强制将 pi-ai 的 `maxRetries` 设为零,使一次 `stream()` 调用只发起一次可见的提供方请求;`dsh-llm-retry` 则在 agent 失败步骤 seam 上执行解析后的策略。凭据不设全局值:显式密钥仅对所属配置生效;未提供密钥时,pi-ai 使用标准环境变量、OAuth token、AWS 凭据链、Google ADC 或其他提供方原生环境认证。显式空密钥属于无效配置,不会回退到环境认证。
+`dsh-llm-pi-ai` 接受一个非空的提供方配置列表。列表内的提供方名称必须唯一,并且存在于 pi-ai 的 `getProviders()` 结果中。每项配置包含提供方名称,以及可选的 `apiKey`、`baseURL`、headers、推理级别和预算、缓存保留设置、传输方式、SDK 超时、Harness 流空闲超时,以及由提供方拥有的 `retryPolicy`。适配器强制将 pi-ai 的 `maxRetries` 设为零,使一次 `stream()` 调用只发起一次可见的提供方请求;`dsh-llm-retry` 则在 agent 失败步骤扩展点上执行解析后的策略。凭据不设全局值:显式密钥仅对所属配置生效;未提供密钥时,pi-ai 使用标准环境变量、OAuth token、AWS 凭据链、Google ADC 或其他提供方原生环境认证。显式空密钥属于无效配置,不会回退到环境认证。
插件通过一次全有或全无调用,将所有已配置的提供方名称注册到同一个 `PiAiAdapter`。请求按 provider 选择对应配置,并在 `getModels(provider)` 中查找模型以取得目录描述符。未知提供方会在插件加载时失败;未知模型会在网络 I/O 前以 `UNKNOWN_MODEL` 失败。适配器不会修改目录对象。当配置提供 `baseURL` 时,适配器复制选中的描述符,仅覆盖 `baseUrl`,使私有端点保留 pi-ai 的 API、能力、兼容标志、上下文限制与推理映射。私有端点必须实现所选提供方的协议,模型 ID 也仍须存在于已安装的 pi-ai 目录中。
-适配器调用 pi-ai 的 `streamSimple()`,因此每个目录模型会选择其注册的 API 实现;描述符为 `openai-responses` 时使用 OpenAI Responses,而非 Chat Completions。harness 的 temperature、最大 token 数、signal、session ID,以及提供方配置中的通用流选项均直接传递。配置 headers 与 harness 强制归因 headers 合并;发生保留名称冲突时,以 harness 归因为准。适配器不再维护 DeepSeek 专用载荷重写或提供方协议矩阵。
+适配器调用 pi-ai 的 `streamSimple()`,因此每个目录模型会选择其注册的 API 实现;描述符为 `openai-responses` 时使用 OpenAI Responses,而非 Chat Completions。Harness 的 temperature、最大 token 数、signal、session ID,以及提供方配置中的通用流选项均直接传递。配置 headers 与 Harness 强制归因 headers 合并;发生保留名称冲突时,以 Harness 归因为准。适配器不再维护 DeepSeek 专用 payload 重写或提供方协议矩阵。
-pi-ai 的通用流选项不支持停止序列。若 harness `stop` 选项已定义,`dsh-llm-pi-ai` 会以 `UNSUPPORTED_OPTION` 拒绝请求,不会静默忽略,也不会增加第二套提供方专用 payload 实现。`dsh-llm-deepseek` 继续通过原生请求序列化器支持 `stop`。
+pi-ai 的通用流选项不支持停止序列。若 Harness `stop` 选项已定义,`dsh-llm-pi-ai` 会以 `UNSUPPORTED_OPTION` 拒绝请求,不会静默忽略,也不会增加第二套提供方专用 payload 实现。`dsh-llm-deepseek` 继续通过原生请求序列化器支持 `stop`。
-### 持久化助手来源信息与回放状态
+### 已记录的助手路由与回放状态
-助手消息携带提供方无关的来源信息,其中包含请求的 `provider` 和 `model`,以及可选的 JSON 可序列化适配器回放状态。成功的 `assistant/message` 会话事件记录这些来源信息,`deriveMessages()` 返回助手消息时也会包含这些信息。用户、system、context 与工具结果消息不携带助手来源信息。provider/model 字段是 agent loop 的权威数据;适配器仅拥有其不透明回放状态 payload。
+助手消息携带请求的 `provider` 和 `model`,以及可选的 JSON 可序列化适配器回放状态。成功的 `assistant/message` 会话事件记录这些字段,`deriveMessages()` 返回助手消息时也会包含它们。用户、系统、上下文与工具结果消息不携带助手路由字段。提供方/模型字段是 agent loop 的权威数据;适配器仅拥有其不透明回放状态 payload。
-成功的终止 `finish` 分片可以携带回放状态,`BlockAssembler` 会将其与 token 用量和结束原因一起保留。agent loop 会把该状态附加到组装后的助手来源信息,不再暴露响应改写 hook。错误或中止响应不会生成正常助手消息,因此不会进入后续模型历史。
+成功的终止 `finish` 分片可以携带回放状态,`BlockAssembler` 会将其与 token 用量和结束原因一起保留。agent loop 会把该状态附加到已组装助手消息的模型来源中,但不公开响应改写钩子。错误或中止响应不会生成正常助手消息,因此不会进入后续模型历史。
-pi-ai 回放状态是其成功 `AssistantMessage` 的带版本最小投影,包含源 API/provider/model、响应 ID/model、停止原因,以及按索引对齐的文本、thinking 和工具调用签名。它不会重复 harness 内容块中已有的文本或工具参数,也不包含诊断信息、时间戳、用量或错误。后续请求中,只有历史提供方和目标提供方当前归同一个适配器实例所有时,`LlmService` 才会把回放状态交给目标适配器。适配器在能够恢复历史响应时,将 harness 记录的内容与回放状态组合,并负责所需的跨模型或跨提供方转换。适配器收到未知版本或块形状不匹配的回放状态时会显式失败;其他适配器只能收到提供方无关的内容与来源信息。
+pi-ai 回放状态是其成功 `AssistantMessage` 的带版本最小投影,包含源 API/提供方/模型、响应 ID/模型、停止原因,以及按索引对齐的文本签名、thinking 签名和工具调用签名。它不会重复 Harness 内容块中已有的文本或工具参数,也不包含诊断信息、时间戳、用量或错误。后续请求中,只有历史提供方和目标提供方当前归同一个适配器实例所有时,`LlmRuntime` 才会把回放状态交给目标适配器。适配器在能够恢复历史响应时,将 Harness 记录的内容与回放状态组合,并负责所需的跨模型或跨提供方转换。适配器收到未知版本或块形状不匹配的回放状态时会显式失败;其他适配器只能收到提供方无关的内容以及提供方/模型字段。
-该状态属于模型可见的回放输入,因此遵循现有的[请求可重建规则](2026-07-05-reconstructable-requests.md):它同时存在于终止 `finish` 分片和驱动派生的已组装 `assistant/message` 来源信息中。恢复和 fork 会原样保留该状态。压缩(compaction)遮蔽助手消息时,也会从活动 surface 中移除其回放状态;摘要属于普通的提供方无关内容。
+该状态属于模型可见的回放输入,因此遵循现有的[请求可重建规则](2026-07-05-reconstructable-requests.md):它同时存在于终止 `finish` 分片和驱动派生的已组装 `assistant/message` 模型来源中。恢复和 fork 会原样保留该状态。压缩(compaction)遮蔽助手消息时,也会从活动 surface 中移除其回放状态;摘要属于普通的提供方无关内容。
### 在所有请求生产方中传播目标
-每个模型选择接口都同时携带 provider 与 model:声明式 agent、ACP 和 stdio 应用配置、JSON-RPC initialize 请求、subagent 覆盖与继承、工作流子 agent 覆盖,以及直接压缩摘要。subagent 先从父 agent 继承两个字段,再应用请求覆盖。系统提示词变量集合在 `model` 之外增加 `provider`。
+每条模型选择路径都同时携带 provider 与 model:声明式 agent、ACP(Agent Client Protocol)和 stdio 应用配置、JSON-RPC initialize 请求、subagent 覆盖与继承、工作流子 agent 覆盖,以及直接压缩摘要。subagent 先从父 agent 继承两个字段,再应用请求覆盖。系统提示词变量集合在 `model` 之外增加 `provider`。
-压缩配置在 `summarizationModel` 之外增加 `summarizationProvider`。两个值均为空时继承,均非空时选择显式目标;只配置其中一个会导致加载失败。继承优先使用最近一次记录的请求目标,没有时回退到 agent 创建选项。`compact/summary` 使用现有模型调用封装记录两个字段。
+压缩配置在 `summarizationModel` 之外增加 `summarizationProvider`。两个值均为空时继承,均非空时选择显式目标;只配置其中一个会导致加载失败。继承优先使用最近一次记录的请求目标,没有时回退到 agent 创建选项。`compaction/summary` 使用现有模型调用 envelope 记录两个字段。
-JSON-RPC 运行时显式接收 provider 与 model。仅当 `deepseek` 提供方没有注册所有者时,其便利回退才会挂载 `dsh-llm-deepseek`;其他缺失的提供方会直接失败,不会猜测适配器。
+JSON-RPC 运行时显式接收提供方与模型。仅当 `deepseek` 提供方没有注册所有者时,其便利回退才会挂载 `dsh-llm-deepseek`;其他缺失的提供方会直接失败,不会猜测适配器。
-磁盘会话格式仍使用预发布阶段固定的版本 `0`,且不承诺兼容性。seed/load 验证会拒绝缺少 provider 的请求头,以及缺少必需来源信息的助手消息,不会接受已无法重建请求的旧格式。
+磁盘会话格式仍使用预发布阶段固定的版本 `0`,且不承诺兼容性。seed/load 验证会拒绝省略必需提供方/模型字段的请求头和助手消息,不会接受已无法重建请求的旧格式。
## 考虑过的替代方案
**继续以模型名称作为注册表键,并增加通配适配器。** 通配机制会在精确注册与兜底插件之间引入回退顺序,使重复所有权取决于监听器顺序;若不再增加其他约定,仍无法区分不同提供方中相同的模型 ID。
-**将提供方与模型编码到一个字符串中。** OpenRouter 的 `openai/gpt-*` 等值已经包含类似提供方的前缀和斜杠。分隔符约定会把路由语法泄漏到每个模型选择接口,并需要转义规则;两个显式字段更清晰,也可以分别记录日志。
+**将提供方与模型编码到一个字符串中。** OpenRouter 的 `openai/gpt-*` 等值已经包含类似提供方的前缀和斜杠。分隔符约定会把路由语法泄漏到每个模型选择器,并需要转义规则;两个显式字段更清晰,也可以分别记录日志。
**增加 `backend + provider + model`。** backend 键可以让 `dsh-llm-deepseek` 与 pi-ai 的 DeepSeek 实现共存,并按请求切换。最终采用的部署规则是一个提供方对应一个适配器所有者:同一上游的不同实现属于由插件组合选定的替代项。第三个路由维度会增加每个请求与配置的负担,却没有当前消费方。
**让 `dsh-llm-pi-ai` 自动注册所有 pi-ai 提供方。** 这种方式会占用部署无意暴露的环境凭据和提供方名称,并与 `dsh-llm-deepseek` 等原生适配器冲突。显式配置可以审查能力和凭据范围。
-**每个提供方挂载一个 pi-ai 插件实例。** 独立实例可以隔离配置,但会重复插件声明,也无法实现配置注册的原子性。每个请求本就向同一个适配器提供 provider,因此经过验证的配置映射具有更小的生命周期接口。
+**每个提供方挂载一个 pi-ai 插件实例。** 独立实例可以隔离配置,但会重复插件声明,也无法实现配置注册的原子性。每个请求本就向同一个适配器提供提供方,因此经过验证的配置映射具有更小的生命周期接口。
-**接受任意内联 pi-ai 模型描述符。** 这种方式可支持目录外的私有模型 ID,但会将 pi-ai 的模型与兼容性 schema 暴露为 harness 配置,并要求适配器验证协议专用组合。当前版本通过覆盖目录模型的 `baseURL` 支持自定义端点;只有实际出现目录外部署需求后,才会另行决策是否支持自定义描述符。
+**接受任意内联 pi-ai 模型描述符。** 这种方式可支持目录外的私有模型 ID,但会将 pi-ai 的模型与兼容性 schema 暴露为 Harness 配置,并要求适配器验证协议专用组合。当前版本通过覆盖目录模型的 `baseURL` 支持自定义端点;只有实际出现目录外部署需求后,才会另行决策是否支持自定义描述符。
## 影响
@@ -78,13 +78,13 @@ JSON-RPC 运行时显式接收 provider 与 model。仅当 `deepseek` 提供方
- pi-ai 凭据、传输选项、SDK 超时,以及默认五分钟的 `streamIdleTimeoutMs` 空闲超时机制均按提供方配置隔离。系统禁用隐藏的提供方重试;有界重试由单独组合的 agent 恢复策略负责。
- pi-ai 的通用流 API 无法表达停止序列,因此 `dsh-llm-pi-ai` 会拒绝停止序列;原生 DeepSeek 适配器仍支持停止序列。
- 仅当历史提供方与目标提供方归同一个适配器实例所有时,回放状态才可移植。适配器负责跨提供方和跨模型恢复;其他适配器只接收不含不透明状态的提供方无关历史。
-- 当前预发布会话 JSONL 要求请求头包含 provider/model,助手消息包含来源信息。旧格式仍使用版本 `0`,但会被拒绝,不执行迁移。
+- 当前预发布会话 JSONL 要求请求头和助手消息都包含提供方/模型。旧格式仍使用版本 `0`,但会被拒绝,不执行迁移。
## 测试
- 单元测试覆盖注册表冲突、请求重建、会话验证、配置解析、单次请求的选项转发、包括 OpenAI Responses 在内的原生 API 选择、转换、回放验证、错误映射、调用方取消、空闲超时导致的传输终止、内容重写,以及同一实例与不同实例间的回放分发。
-- 无密钥的 agent loop/会话测试和 ACP 快照覆盖持久化 provider/model 元数据、恢复与 fork 传播、工作流/subagent 覆盖,以及不变的用户可见 transcript(文本记录);密钥门控的 DeepSeek e2e 测试保留真实提供方的流式输出与工具后续调用覆盖率。
-- 公共 JSDoc、包的 README、架构与核心数据结构文档、生成目录、示例、会话 fixture(测试前置数据)和 Python SDK 配对文档统一使用 provider/model 目标,并由仓库文档与类型等价门禁校验。
+- 无密钥的 agent loop/会话测试和 ACP 快照覆盖持久化提供方/模型元数据、恢复与 fork 传播、工作流/subagent 覆盖,以及不变的用户可见 transcript(文本记录);密钥门控的 DeepSeek e2e 测试保留真实提供方的流式输出与工具后续调用覆盖率。
+- 公共 JSDoc、package README、架构与子系统文档、生成目录、示例、会话 fixture(测试前置数据)和 Python SDK 配对文档统一使用提供方/模型目标,并由仓库文档与类型等价门禁校验。
## 风险
diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml
index 233164d4e4..458999f905 100644
--- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.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/architecture/2026-07-15-agent-initiator-scope.md
-2026-07-15-agent-initiator-scope.md: 69648100e76cfc212469854188d664357fec22f1
-2026-07-15-agent-initiator-scope.zh.md: 505d198ccd2a54af1a15fc1ad6c03b27d217eca0
+2026-07-15-agent-initiator-scope.md: 63540c0ec6b29a10613e01f1ed9ced24e8f2d277
+2026-07-15-agent-initiator-scope.zh.md: a0e3638081d875adc2412191829ab84c8dcd697c
diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md
index 69648100e7..63540c0ec6 100644
--- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md
+++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md
@@ -12,7 +12,7 @@ Deep process-local infrastructure sometimes needs a trusted initiating Agent bel
## Decision
-The mandatory `ctx.agents` service uses Node `AsyncLocalStorage` to carry the initiating Agent. It stores the exact `Agent` directly rather than introducing a one-field frame; a separate private run token records nested boundary lineage only for teardown bookkeeping and carries no identity. The [core-data catalog](../../../../docs/core-data-structures/core.md#initiating-agent) identifies the carried type.
+The mandatory `ctx.agents` service uses Node `AsyncLocalStorage` to carry the initiating Agent. It stores the exact `Agent` directly rather than introducing a one-field frame; a separate private run token records nested boundary lineage only for teardown bookkeeping and carries no identity. The [core-data catalog](../../../../docs/subsystems/core.md#initiating-agent) identifies the carried type.
`currentInitiator()` reads optionally, `requireInitiator()` throws `no initiating agent is active`, and `withInitiator(agent, operation)` preserves the operation's exact synchronous value or Promise. `withoutInitiator(operation)` establishes a clearing boundary for work that must not inherit an Agent. Session remains derived as `agent.session`; turn, step, tool call, `signal`, model, `cwd`, sandbox, and authorization stay with their existing owners.
@@ -20,7 +20,7 @@ The mandatory `ctx.agents` service uses Node `AsyncLocalStorage` to carry the in
Concurrent drivers receive independent stores. A child driver's continuations carry the child, while the caller resumes in its prior store as soon as `withInitiator()` returns; active-run tracking keeps the returned Promise in the teardown drain until it settles. Creation, persistence load, and unpublished `setup(agentCtx)` remain outside the child's driver boundary: creation initiated by a parent runs under the parent identity, while `agentCtx.agent` explicitly identifies the child.
-Ambient identity does not replace explicit contracts. `ToolExecution.agent`, `AssembleContext.agent`, `GenerateOptions.sessionId`, task ownership, parent/child requests, `ctx.agent`, `agentCtx.agent`, approval and hook subjects, `cwd` selection, cancellation, worker/process messages, persistence records, and wire identity remain explicit. A remote boundary materializes the identity it needs into its typed request because ALS is process-local.
+Ambient identity does not replace explicit contracts. `ToolExecution.agent`, `AssembleContext.agent`, `GenerateOptions.sessionId`, job ownership, parent/child requests, `ctx.agent`, `agentCtx.agent`, approval and hook subjects, `cwd` selection, cancellation, worker/process messages, persistence records, and wire identity remain explicit. A remote boundary materializes the identity it needs into its typed request because ALS is process-local.
`AgentRegistry` owns an ordered initiator lifecycle. Teardown first rejects new boundaries; removing `ctx.agents` then drains injected dependents such as AgentLoop, and the registry waits for active returned-Promise boundaries before calling `AsyncLocalStorage.disable()`. If a boundary's inherited async chain starts an owning Cordis fiber's unload, the private run-token lineage releases that nested boundary chain from the drain, which prevents teardown from waiting on itself while unrelated boundaries still drain. `currentInitiator()` and `requireInitiator()` remain usable through a retained in-flight service reference while the ordinary drain runs; after disposal, initiator methods throw `agent initiator scope is disposed`. Root Context disposal may start sibling fiber teardown concurrently, so active-boundary counting remains necessary in addition to Cordis dependency ordering.
diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md
index 505d198ccd..a0e3638081 100644
--- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md
@@ -12,7 +12,7 @@ harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负
## 决策
-必需的 `ctx.agents` 服务使用 Node `AsyncLocalStorage` 携带发起 Agent。它直接存储同一个 `Agent`,不引入只有一个字段的帧;另一个私有运行标记只记录嵌套边界的谱系,供 teardown 记账使用,不携带身份。[核心数据目录](../../../../docs/core-data-structures/core.md#initiating-agent)标明了所携带的类型。
+必需的 `ctx.agents` 服务使用 Node `AsyncLocalStorage` 携带发起 Agent。它直接存储同一个 `Agent`,不引入只有一个字段的帧;另一个私有运行标记只记录嵌套边界的谱系,供 teardown 记账使用,不携带身份。[核心数据目录](../../../../docs/subsystems/core.md#initiating-agent)标明了所携带的类型。
`currentInitiator()` 用于可选读取,`requireInitiator()` 抛出 `no initiating agent is active`,`withInitiator(agent, operation)` 保留操作返回的同步值或 Promise 本身。`withoutInitiator(operation)` 会建立清空边界,供不得继承 Agent 的工作使用。会话仍通过 `agent.session` 推导;轮次、步骤、工具调用、`signal`、模型、`cwd`、沙箱和授权继续由现有归属方管理。
@@ -20,21 +20,21 @@ harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负
因此,并发驱动使用彼此独立的存储。子驱动的异步延续携带子 Agent;`withInitiator()` 返回后,调用方立即恢复之前的存储,而活动运行计数仍持续跟踪返回的 Promise,直到其结束。创建、持久化加载和尚未发布的 `setup(agentCtx)` 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而 `agentCtx.agent` 显式标识子 Agent。
-隐式身份不会取代显式契约。`ToolExecution.agent`、`AssembleContext.agent`、`GenerateOptions.sessionId`、任务归属、父子请求、`ctx.agent`、`agentCtx.agent`、审批与 hook 主体、`cwd` 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。
+隐式身份不会取代显式约定。`ToolExecution.agent`、`AssembleContext.agent`、`GenerateOptions.sessionId`、任务归属、父子请求、`ctx.agent`、`agentCtx.agent`、审批与 hook 主体、`cwd` 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。
`AgentRegistry` 管理一个有序的发起方生命周期。teardown 会先拒绝新边界;移除 `ctx.agents` 后,AgentLoop 等注入方开始排空,注册表随后等待活动的返回 Promise 边界,最后调用 `AsyncLocalStorage.disable()`。如果某个边界继承的异步调用链启动所属 Cordis fiber 的卸载,私有运行标记谱系会从排空范围中释放该嵌套边界链,从而避免 teardown 等待自身完成,同时继续排空无关边界。在普通排空期间,进行中代码可通过保留的服务引用继续调用 `currentInitiator()` 和 `requireInitiator()`;dispose(资源释放)后,发起方方法会抛出 `agent initiator scope is disposed`。根 Context dispose 可能并发启动同级 fiber 的 teardown,因此除 Cordis 依赖顺序外仍必须统计活动边界。
-发起方作用域不负责管理脱离返回链的工作:注册表排空只跟踪 `withInitiator()` 或 `withoutInitiator()` 返回的 Promise。边界内创建的异步资源会继承其存储,直到自身结束或 ALS 被禁用;所属 seam 必须显式停止未纳入返回 Promise 的工作。Agent 所属的前台工作会把完整生命周期纳入返回值,并保留显式取消契约。无关的定时器、队列和部署基础设施在 `withoutInitiator(operation)` 下启动;队列、worker、进程和协议边界必须序列化身份,不能期待 ALS 传播。
+发起方作用域不负责管理脱离返回链的工作:注册表排空只跟踪 `withInitiator()` 或 `withoutInitiator()` 返回的 Promise。边界内创建的异步资源会继承其存储,直到自身结束或 ALS 被禁用;所属 seam 必须显式停止未纳入返回 Promise 的工作。Agent 所属的前台工作会把完整生命周期纳入返回值,并保留显式取消约定。无关的定时器、队列和部署基础设施在 `withoutInitiator(operation)` 下启动;队列、worker、进程和协议边界必须序列化身份,不能期待 ALS 传播。
宿主感知的传输层可以从 `ctx.agents.requireInitiator().session.id` 推导由部署方拥有的 `X-Harness-Session-Id` 等请求头;模型可见 schema 和参数中不包含该请求头。本决策不让现有生产 MCP 或 Web 传输层采用此请求头。测试替身传输层用于证明可信边界,而不会把宿主路由策略分配给现有的提供方无关 seam。
-本决策扩展 [Agent 注册作用域契约](2026-07-08-agent-scope-contexts.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.md),不会改变其中 `agent.ctx` 的静态含义。
+本决策扩展 [Agent 注册作用域约定](2026-07-08-agent-scope-contexts.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.md),不会改变其中 `agent.ctx` 的静态含义。
## 验证
Agent 服务测试锁定可选与必需读取、同步值及跨 realm Promise 的精确身份、内建 Promise 结束状态观察、并发、嵌套及清空边界、同步抛错或 Promise 拒绝后的恢复、普通与重入排空顺序及保留引用的错误。AgentLoop 集成测试锁定并发与嵌套驱动、无 Agent 调用、AgentRegistry 重启、根 Context 销毁,以及包内私有的循环和工具调度通过隐式查找完成。组合、模块图、构建及运行时闭包检查确保默认组合包、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 通过 `ctx.agents` 完成接线,无需其他提供方。
-测试替身形式的宿主感知传输层在内部推导 `X-Harness-Session-Id`,并验证工具 schema 与日志中记录的参数都不包含身份字段。服务有意不排空边界操作所返回 Promise 之外的异步工作;这类工作仍由所属方的显式停止契约管理。
+测试替身形式的宿主感知传输层在内部推导 `X-Harness-Session-Id`,并验证工具 schema 与日志中记录的参数都不包含身份字段。服务有意不排空边界操作所返回 Promise 之外的异步工作;这类工作仍由所属方的显式停止约定管理。
## 考虑过的替代方案
@@ -46,7 +46,7 @@ Agent 服务测试锁定可选与必需读取、同步值及跨 realm Promise
**保存命名帧或完整运行时帧。** 只有一个字段的 `{ agent }` 帧只是包装该值,而 Agent、会话、inbox、取消、轮次、步骤、工具执行和持久化已经有各自的真源。增加更多字段会产生陈旧快照和另一套生命周期;直接携带 `Agent`,由方法名标识边界,无需重复保存状态。
-**包含步骤级 `AbortSignal`、`cwd`、沙箱或授权。** 它们的生命周期及权限范围与驱动边界不一致,而且现有 seam 已经显式传递这些值。新增控制能力需要独立决策和嵌套生命周期契约。
+**包含步骤级 `AbortSignal`、`cwd`、沙箱或授权。** 它们的生命周期及权限范围与驱动边界不一致,而且现有 seam 已经显式传递这些值。新增控制能力需要独立决策和嵌套生命周期约定。
**使用进程级 `currentAgent`。** 并发 Agent 和 subagent 会在异步延续执行之间相互覆盖,因此可变全局值只在 harness 不具备的串行保证下才正确。
diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml
index faade67459..69af08605c 100644
--- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.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/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md
-2026-07-15-llm-model-catalog-and-acp-selection.md: 9edc723b0dfafeaf395eb9325373835138ddbc41
-2026-07-15-llm-model-catalog-and-acp-selection.zh.md: aefd0af3e4de8fac4f5819e0ee279d3343aec6be
+2026-07-15-llm-model-catalog-and-acp-selection.md: 3dddbe7e9fae74e4ce1ec1c8e93a40c3352b5a54
+2026-07-15-llm-model-catalog-and-acp-selection.zh.md: 145fd0bc379ff8132d09ec628b6381a18f755cfa
diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md
index 9edc723b0d..3dddbe7e9f 100644
--- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md
+++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md
@@ -8,7 +8,7 @@ English | [中文](2026-07-15-llm-model-catalog-and-acp-selection.zh.md)
## Problem
-Provider-routed adapters let every request choose `provider + model`, but `LlmService` exposed only routing and streaming. A UI could not discover which providers were registered or which models an adapter was prepared to recommend. ACP clients therefore received no `model` session config option, so Zed, JetBrains, and VS Code integrations had no model list even though the request seam already supported runtime switching.
+Provider-routed adapters let every request choose `provider + model`, but `LlmRuntime` exposed only routing and streaming. A UI could not discover which providers were registered or which models an adapter was prepared to recommend. ACP clients therefore received no `model` session config option, so Zed, JetBrains, and VS Code integrations had no model list even though the LLM service already supported runtime switching.
Model discovery cannot become request validation. The hand-written DeepSeek adapter deliberately forwards arbitrary model ids to a public or private endpoint, while pi-ai has a finite installed catalog that is authoritative for its own request resolution. Treating one shared catalog as a whitelist would remove the private-endpoint behavior that provider routing was designed to preserve.
@@ -20,23 +20,23 @@ ACP selection must also preserve the provider dimension. The same model id may a
`LlmAdapter` gains `providerInfo(provider)` and asynchronous `listModels(provider)` methods. Their provider-neutral results are `LlmProviderInfo { id, name }` and `LlmModelInfo { provider, id, name, description? }`. The defaults preserve existing adapter behavior by naming a provider after its route and advertising no models.
-`LlmService.listProviders()` returns detached metadata in registration order. `LlmService.listModels(provider)` delegates to the route owner, validates non-empty ids and names, rejects a mismatched provider or duplicate model id with `INVALID_CATALOG`, and returns detached values. Unknown providers still fail with `NO_ADAPTER`. Provider metadata is validated atomically during `registerAdapter()` so a malformed display record cannot leave a partial registration.
+`LlmRuntime.listProviders()` returns detached metadata in registration order. `LlmRuntime.listModels(provider)` delegates to the route owner, validates non-empty ids and names, rejects a mismatched provider or duplicate model id with `INVALID_CATALOG`, and returns detached values. Unknown providers still fail with `NO_ADAPTER`. Provider metadata is validated atomically during `registerAdapter()` so a malformed display record cannot leave a partial registration.
Catalog membership is advisory. It drives selectors and diagnostics but never changes `stream()` routing and never rejects an otherwise valid request. Provider ownership remains exclusive and lifecycle-bound; model ids remain request-time adapter input.
`dsh-llm-pi-ai` maps the configured provider's installed `getModels(provider)` entries into the neutral catalog. Its existing request-time catalog lookup remains authoritative and still rejects unknown models with `UNKNOWN_MODEL`. `dsh-llm-deepseek` accepts an optional `models` config containing display entries, defaulting to `deepseek-v4-flash` named `DeepSeek-V4-Flash` and `deepseek-v4-pro` named `DeepSeek-V4-Pro`. An explicit list replaces those defaults and an empty list disables discovery. The entries improve selector UX for known public or private models, while every unlisted model id continues to pass through unchanged.
-### Per-session selection in the front door
+### Per-session selection in the front end
-A selection is owned by the front door that offers it (today the TUI `/model` selector), never by `LlmService` or `AgentOptions`: those are deployment-wide or creation-wide objects, and mutating them would couple concurrent sessions. Each opaque choice carries the full provider/model pair, because the same model id may appear under multiple routes.
+A selection is owned by the front end that offers it (today the TUI `/model` selector), never by `LlmRuntime` or `AgentOptions`: those are deployment-wide or creation-wide objects, and mutating them would couple concurrent sessions. Each opaque choice carries the full provider/model pair, because the same model id may appear under multiple routes.
The ACP automation transport is not a catalog consumer. Its deployment config supplies one optional provider/model target for newly created agents, and it advertises no model selector or configuration-option interface.
### Prompt/request consistency and durability
-`installAgentLlmTarget` (in `dsh-agent`) installs scoped `system-prompt/assemble` and `agent/request` listeners for a front-door-owned target. Prompt assembly snapshots the selected pair once per step, overwrites the assembled `provider` and `model` variables after downstream prompt listeners, and the request listener applies that same snapshot after downstream request listeners. A selection during asynchronous assembly therefore starts on the next step rather than splitting prompt text from routing. Other call-config fields remain untouched.
+`installModelSelection` (in `dsh-agent`) installs scoped `system-prompt/assemble` and `agent/request` listeners for a front-end-owned selection. Prompt assembly snapshots the selected pair once per step, overwrites the assembled `provider` and `model` variables after downstream prompt listeners, and the request listener applies that same snapshot after downstream request listeners. A selection during asynchronous assembly therefore starts on the next step rather than splitting prompt text from routing. Other call-config fields remain untouched.
-The request header remains the durable source of truth. When a selected target is actually used, the existing full `request/header` snapshot records it, and a front door initializes its selection from the folded last request header before falling back to creation options. A selection that is never used by a request is intentionally in-memory only because it never became model-visible state.
+The request header remains the durable source of truth. When a selection is actually used, the existing full `request/header` snapshot records it, and a front end initializes its selection from the folded last request header before falling back to creation options. A selection that is never used by a request is intentionally in-memory only because it never became model-visible state.
## Alternatives considered
@@ -44,13 +44,13 @@ The request header remains the durable source of truth. When a selected target i
**Make catalogs mandatory whitelists.** This conflicts with the hand-written adapter's arbitrary model pass-through and private deployments. The selected adapter already owns authoritative request validation.
-**Store selection in `AgentOptions` or `LlmService`.** Those are creation-wide or deployment-wide objects. Mutating them would couple concurrent sessions and bypass the logged `agent/request` replacement path.
+**Store selection in `AgentOptions` or `LlmRuntime`.** Those are creation-wide or deployment-wide objects. Mutating them would couple concurrent sessions and bypass the logged `agent/request` replacement path.
**Persist a new model-selection session event immediately.** An unused UI selection has not affected a model request. Recording the existing request header when the target is consumed preserves the model-visible-if-and-only-if-logged rule without adding a second source of truth.
## Consequences
-- Any adapter can expose a dynamic model list without leaking provider-library types into the core seam.
+- Any adapter can expose a dynamic model list without leaking provider-library types into the LLM Service Definition.
- Catalog consumers must treat absence as “not advertised,” never “invalid request.”
- pi-ai adapters expose their installed provider catalogs; hand-written DeepSeek deployments list known choices explicitly and retain arbitrary model support.
- Human-facing catalog consumers own their selection interaction. ACP uses its fixed deployment target and does not widen the protocol with model discovery.
diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md
index aefd0af3e4..145fd0bc37 100644
--- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md
@@ -4,11 +4,11 @@ Status: implemented
[English](2026-07-15-llm-model-catalog-and-acp-selection.md) | 中文
-> 目录决策仍然有效。ACP 会话级模型选择已由 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。
+> 目录决策仍然有效。ACP(Agent Client Protocol)会话级模型选择已由 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。
## 问题
-基于提供方路由的适配器允许每次请求选择 `provider + model`,但 `LlmService` 只暴露路由和流式调用。UI 无法发现已注册的提供方,也无法知道适配器愿意推荐哪些模型。因此,ACP 客户端收不到 `model` 会话配置项;即使请求 seam 已经支持运行时切换,Zed、JetBrains 和 VS Code 集成仍没有模型列表。
+基于提供方路由的适配器允许每次请求选择 `provider + model`,但 `LlmRuntime` 只暴露路由和流式调用。UI 无法发现已注册的提供方,也无法知道适配器愿意推荐哪些模型。因此,ACP 客户端收不到 `model` 会话配置项;即使 LLM(大语言模型)服务已经支持运行时切换,Zed、JetBrains 和 VS Code 集成仍没有模型列表。
模型发现不能变成请求校验。手写 DeepSeek 适配器会把任意模型 ID 原样转发给公开或私有端点,而 pi-ai 的有限安装目录则是其自身请求解析的权威依据。将共享目录视为白名单,会破坏提供方路由需要保留的私有端点能力。
@@ -20,23 +20,23 @@ ACP 选择还必须保留提供方维度。同一个模型 ID 可能存在于多
`LlmAdapter` 增加 `providerInfo(provider)` 与异步 `listModels(provider)` 方法。其提供方无关结果分别为 `LlmProviderInfo { id, name }` 和 `LlmModelInfo { provider, id, name, description? }`。默认实现以路由名称作为提供方名称,并且不展示模型,从而保持现有适配器行为。
-`LlmService.listProviders()` 按注册顺序返回元数据副本。`LlmService.listModels(provider)` 委托给路由所有者,校验非空 ID 和名称,并在提供方不匹配或模型 ID 重复时以 `INVALID_CATALOG` 失败,最后返回值的副本。未知提供方仍以 `NO_ADAPTER` 失败。提供方元数据在 `registerAdapter()` 期间进行原子校验,错误展示记录不会留下部分注册。
+`LlmRuntime.listProviders()` 按注册顺序返回元数据副本。`LlmRuntime.listModels(provider)` 委托给路由所有者,校验非空 ID 和名称,并在提供方不匹配或模型 ID 重复时以 `INVALID_CATALOG` 失败,最后返回值的副本。未知提供方仍以 `NO_ADAPTER` 失败。提供方元数据在 `registerAdapter()` 期间进行原子校验,错误展示记录不会留下部分注册。
目录成员关系仅提供建议。它驱动选择器与诊断,但不会改变 `stream()` 路由,也不会拒绝原本有效的请求。提供方所有权仍然具有排他性并绑定生命周期;模型 ID 仍是请求时传给适配器的输入。
-`dsh-llm-pi-ai` 将已配置提供方的安装目录 `getModels(provider)` 映射为中立目录。其现有请求时目录查询仍是权威依据,未知模型仍以 `UNKNOWN_MODEL` 失败。`dsh-llm-deepseek` 接受可选的 `models` 配置作为展示条目,默认包含名为 `DeepSeek-V4-Flash` 的 `deepseek-v4-flash` 和名为 `DeepSeek-V4-Pro` 的 `deepseek-v4-pro`。显式列表会替换这些默认值,空列表则关闭发现。这些条目改善已知公开或私有模型的选择体验,而所有未列出的模型 ID 仍会原样透传。
+`dsh-llm-pi-ai` 将已配置提供方的 `getModels(provider)` 返回的已安装条目映射为提供方无关的目录。其现有请求时目录查询仍是权威依据,未知模型仍以 `UNKNOWN_MODEL` 失败。`dsh-llm-deepseek` 接受包含展示条目的可选 `models` 配置,默认包含名为 `DeepSeek-V4-Flash` 的 `deepseek-v4-flash` 和名为 `DeepSeek-V4-Pro` 的 `deepseek-v4-pro`。显式列表会替换这些默认值,空列表则关闭发现。这些条目改善已知公开或私有模型的选择体验,而所有未列出的模型 ID 仍会原样透传。
-### 前门内的会话级选择
+### 前端内的会话级选择
-选择由提供它的前门拥有(今天是 TUI 的 `/model` 选择器),而不由 `LlmService` 或 `AgentOptions` 拥有:它们是部署级或创建级对象,改动它们会把并发会话耦合在一起。每个不透明选项都携带完整的提供方/模型对,因为同一模型 ID 可能出现在多个路由下。
+选择由提供它的前端拥有(今天是 TUI 的 `/model` 选择器),而不由 `LlmRuntime` 或 `AgentOptions` 拥有:它们是部署级或创建级对象,改动它们会把并发会话耦合在一起。每个不透明选项都携带完整的提供方/模型对,因为同一模型 ID 可能出现在多个路由下。
ACP 自动化传输层不是目录消费方。它通过部署配置为新创建的 agent 提供一个可选的提供方/模型目标,不展示模型选择器或配置选项接口。
### 提示词/请求一致性与持久化
-`installAgentLlmTarget`(位于 `dsh-agent`)为前门拥有的目标安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。提示词组装在每个步骤对所选组合做一次快照,在下游提示词监听器之后覆写组装出的 `provider` 与 `model` 变量;请求监听器在下游请求监听器之后应用同一快照。因此,发生在异步组装期间的选择会从下一个步骤生效,而不会让提示词文本与路由分裂。其他调用配置字段保持不变。
+`installModelSelection`(位于 `dsh-agent`)为前端拥有的选择安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。提示词组装在每个步骤对所选组合做一次快照,在下游提示词监听器之后覆写组装出的 `provider` 与 `model` 变量;请求监听器在下游请求监听器之后应用同一快照。因此,发生在异步组装期间的选择会从下一个步骤生效,而不会让提示词文本与路由分裂。其他调用配置字段保持不变。
-请求头仍是持久化的真源。当所选目标真正被使用时,现有的完整 `request/header` 快照会记录它;前门先从折叠后的最后一个请求头初始化其选择,然后才回退到创建选项。从未被请求使用的选择有意只保留在内存中,因为它从未成为模型可见状态。
+请求头仍是持久化的真源。当某个选择真正被使用时,现有的完整 `request/header` 快照会记录它;前端先从折叠后的最后一个请求头初始化其选择,然后才回退到创建选项。从未被请求使用的选择有意只保留在内存中,因为它从未成为模型可见状态。
## 考虑过的替代方案
@@ -44,13 +44,13 @@ ACP 自动化传输层不是目录消费方。它通过部署配置为新创建
**将目录设为强制白名单。** 这与手写适配器的任意模型透传和私有部署冲突。请求的权威校验本就属于被选中的适配器。
-**把选择存进 `AgentOptions` 或 `LlmService`。** 它们是创建级或部署级对象。改动它们会把并发会话耦合在一起,并绕过有日志记录的 `agent/request` 替换路径。
+**把选择存进 `AgentOptions` 或 `LlmRuntime`。** 它们是创建级或部署级对象。改动它们会把并发会话耦合在一起,并绕过有日志记录的 `agent/request` 替换路径。
**立即持久化一个新的模型选择会话事件。** 未被使用的 UI 选择尚未影响任何模型请求。在目标被消费时记录现有请求头,既保持「模型可见当且仅当有日志」的规则,又不会引入第二个真源。
## 结果
-- 任意适配器都能暴露动态模型列表,无需把提供方库类型泄漏到核心 seam。
+- 任意适配器都能暴露动态模型列表,无需把提供方库类型泄漏到 LLM Service Definition。
- 目录消费方必须把缺失理解为「未展示」,而不是「请求无效」。
- pi-ai 适配器会暴露其已安装的提供方目录;手写 DeepSeek 部署显式列出已知选项,同时保留对任意模型的支持。
- 面向人类的目录消费方拥有各自的选择交互。ACP 使用固定部署目标,不会为模型发现扩大协议范围。
diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml
index 11073946ea..aee9c76d55 100644
--- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.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/architecture/2026-07-15-lsp-capability-seam.md
-2026-07-15-lsp-capability-seam.md: d96b3a9c5139c1455a51f4fff793293d7b5a11c0
-2026-07-15-lsp-capability-seam.zh.md: 256b293f213acb06588f3ef6c655242b1c8fd5b9
+2026-07-15-lsp-capability-seam.md: 90f9daf4b890bd53621916e492d4d82baaa3e8cc
+2026-07-15-lsp-capability-seam.zh.md: b85b478b25a375b04e1301d893e53198554641e3
diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md
index d96b3a9c51..90f9daf4b8 100644
--- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md
+++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md
@@ -17,10 +17,10 @@ Many language servers behave best when the queried document is opened with curre
Add LSP as a three-package capability seam with one read-only model tool and one generic local provider implementation:
1. `@deepseek-ai/dsh-lsp` at `packages/lsp/lsp` owns `ctx.lsp`, provider registration and selection, normalized requests/results, execution control, and structured LSP errors.
-2. `@deepseek-ai/dsh-lsp-local` at `packages/lsp/lsp-local` adapts configured stdio language servers to the seam. One plugin instance accepts a named server table and registers one isolated provider for each command and extension-to-language-id mapping.
+2. `@deepseek-ai/dsh-lsp-stdio` at `packages/lsp/lsp-stdio` adapts configured stdio language servers to the seam. One plugin instance accepts a named server table and registers one isolated provider for each command and extension-to-language-id mapping.
3. `@deepseek-ai/dsh-tool-lsp` at `packages/lsp/tool-lsp` owns the model-facing `lsp` schema, prompt guidance, argument validation, result limits and formatting, and transport-neutral UI presentation.
-`dsh-lsp-local` is a generic host, not a language-server catalog or installer. Deployments explicitly configure commands and mappings; future presets belong in composition plugins or `cordis.yml` overlays.
+`dsh-lsp-stdio` is a generic host, not a language-server catalog or installer. Deployments explicitly configure commands and mappings; future presets belong in composition plugins or `cordis.yml` overlays.
The model and seam expose exactly `goToDefinition`, `findReferences`, `goToImplementation`, and `hover`; no arbitrary JSON-RPC method escapes through `ctx.lsp`. These operation literals match Claude Code's familiar camelCase names while the tool name and `file_path` field remain harness-owned.
@@ -32,7 +32,7 @@ The prompt positions LSP as a precision aid: `Use search/read for ordinary navig
The seam exposes one `query(request, signal?)` operation because no fields need implementation defaulting: `workspaceRoot` is required, `languageId` comes from the registration, and consumers own timeouts and result limits. `query()` selects and derives without hidden `??` fallbacks, leaving no executable spec to resolve. `dsh-tool-lsp` validates model arguments and passes only `exec.signal` as a bare `AbortSignal`, matching web and keeping `dsh-lsp` independent of `dsh-tools`. Removal before selection fails as unavailable; later disposal follows the selected provider's cancellation lifecycle without rerouting.
-The intended contract shape is:
+The contract shape:
```ts
import type { Branded } from '@deepseek-ai/dsh-brand'
@@ -62,7 +62,7 @@ interface LspProviderQuery extends LspQueryRequest {
}
type LspQueryResult =
- | { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[]; readonly resolvedWorkspaceRoot: string }
+ | { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[]; readonly resolvedWorkspaceUri: string }
| { readonly kind: 'hover'; readonly hover: { readonly contents: string; readonly range?: LspRange } | null }
interface LspProvider {
@@ -77,9 +77,9 @@ interface LspService {
}
```
-Mapping keys normalize to lowercase, leading-dot extensions selected from `filePath`'s final extension; language ids only synchronize documents. Seam positions and ranges are zero-based UTF-16. `findReferences` always includes declarations: providers enforce this internally, the local mapping sets `context.includeDeclaration: true`, and callers get no flag. Closed result unions normalize navigation to locations and hover to content or `null`; navigation results carry the provider's resolved workspace root so consumers relativize file URIs in the same canonical namespace. The seam exposes no protocol types, process or document controls, or generic request escape hatch.
+Mapping keys normalize to lowercase, leading-dot extensions selected from `filePath`'s final extension; language ids only synchronize documents. Seam positions and ranges are zero-based UTF-16. `findReferences` always includes declarations: providers enforce this internally, the local mapping sets `context.includeDeclaration: true`, and callers get no flag. Closed result unions normalize navigation to locations and hover to content or `null`; navigation results carry the provider's canonical workspace URI so consumers relativize file URIs in the execution world's namespace. The seam exposes no protocol types, process or document controls, or generic request escape hatch.
-`dsh-lsp-local` owns host files, server configuration, JSON-RPC, process and transient-document state, and protocol translation; it depends on `dsh-lsp` and Node APIs, not `dsh-fs`. The server-table key is its provider id. The plugin resolves every server-local setting before registration, rolls back earlier registrations if a later mapping is invalid or conflicts, and retains an independent process pool per provider. `dsh-tool-lsp` runtime-injects only `tools`, `lsp`, and `systemPrompt`, obtains the workspace from `exec.agent?.session.header.cwd` through a package-local `sessionCwd(exec)` helper matching the filesystem tools' lookup, and imports no provider.
+`dsh-lsp-stdio` owns server configuration, JSON-RPC, process and transient-document state, and protocol translation. It reads through `ctx.fs` and launches through `ctx.subprocess`, depending on their Service Definition packages rather than concrete providers; the [portable execution-world decision](2026-07-28-portable-execution-world-consumers.md) owns that pairing. The server-table key is its provider id. The plugin resolves every server-local setting before registration, rolls back earlier registrations if a later mapping is invalid or conflicts, and retains an independent process pool per provider. `dsh-tool-lsp` runtime-injects only `tools`, `lsp`, and `systemPrompt`, obtains the workspace from `exec.agent?.session.header.cwd` through a package-local `sessionCwd(exec)` helper matching the filesystem tools' lookup, and imports no provider.
## Model-facing contract
@@ -98,56 +98,56 @@ interface LspToolInput {
The tool requires `workspaceRoot` from session `header.cwd`, with no fallback; absence fails as `LSP_WORKSPACE_REQUIRED` before querying or startup. The local provider resolves relative paths against that root and accepts absolute paths directly; both forms are canonicalized and rejected before startup when the target is outside the canonical workspace.
-Locations render as stable, file-grouped `path:line:character` entries. A `file:` URI accepted by Node `fileURLToPath()` becomes a relative path inside the workspace or an absolute path outside it; other URIs remain verbatim. `maxLocations` defaults to `100` and reports omitted items; `maxResultChars` defaults to `16_000` and bounds every complete rendered result, including its truncation metadata. Empty locations and `null` hover are successful no-result responses; missing or malformed server payloads fail with structured `LSP_MALFORMED_RESPONSE` errors.
+Locations render as stable, file-grouped `path:line:character` entries without applying harness-host path rules. A valid `file:` URI becomes a relative path inside the provider's canonical workspace URI or a URI-derived absolute path outside it; malformed and non-`file:` URIs remain verbatim. `maxLocations` defaults to `100` and reports omitted items; `maxResultChars` defaults to `16_000` and bounds every complete rendered result, including its truncation metadata. Empty locations and `null` hover are successful no-result responses; missing or malformed server payloads fail with structured `LSP_MALFORMED_RESPONSE` errors.
The transport-neutral presenter uses `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }` with an args-derived operation/cursor `title`. Because `FileLocation` has no character, follow-along focuses the input line while the title preserves the cursor; presentation remains pure.
## Timeout ownership
-`dsh-tool-lsp` attaches one configurable `timeoutMs` budget, default `60_000`, to the tool definition. `dsh-timeout-policy` enforces it and supplies `exec.signal`, which reaches `ctx.lsp.query`; the budget covers the complete queued open/query/close lifecycle and is not model-configurable.
+`dsh-tool-lsp` attaches one configurable `timeoutMs` budget, default `60_000`, to the tool definition. `dsh-tool-call-timeout-policy` enforces it and supplies `exec.signal`, which reaches `ctx.lsp.query`; the budget covers the complete queued open/query/close lifecycle and is not model-configurable.
The seam and provider add no startup or request deadline. Non-tool callers therefore receive no hidden timeout and must supply an `AbortSignal`, using `deadline()` when they need a budget.
-Provider disposal occurs outside tool execution, so `dsh-lsp-local` keeps `shutdownTimeoutMs` (default `5_000`) for `shutdown`/`exit` and `killGraceMs` (default `2_000`) for both request-cancel grace and SIGTERM-to-SIGKILL escalation; the same bounds govern failed-instance cleanup. Timer values above Node's `2_147_483_647` ms scheduling range fail at load. The provider uses `deadline()` and `timeoutOf()` but owns request cancellation, process signals, and awaiting close because timeout notification does not terminate work.
+Provider disposal occurs outside tool execution, so `dsh-lsp-stdio` keeps `shutdownTimeoutMs` (default `5_000`) for `shutdown`/`exit` and `killGraceMs` (default `2_000`) for both request-cancel grace and SIGTERM-to-SIGKILL escalation; the same bounds govern failed-instance cleanup. Timer values above Node's `2_147_483_647` ms scheduling range fail at load. The provider uses `deadline()` and `timeoutOf()` but owns request cancellation, process signals, and awaiting close because timeout notification does not terminate work.
## Workspace, filesystem, and document synchronization
-`dsh-lsp-local` canonicalizes and reads through Node APIs in the subprocess's host namespace. It rejects missing, non-regular, non-UTF-8, oversized, or canonical out-of-workspace sources and keeps one `O_NOFOLLOW | O_NONBLOCK` handle through validation and reading, so a FIFO with no writer cannot block before the regular-file check. It observes caller cancellation around each filesystem operation. It does not consume `ctx.fs` or emit `fs/observed`: only the LSP result is model-visible, so the query does not satisfy read-before-write policy.
+`dsh-lsp-stdio` canonicalizes and reads through `ctx.fs` in the language server's execution world. It requires the workspace target to be a directory, rejects out-of-workspace sources through provider-owned containment, consumes `streamText`, and enforces `maxDocumentBytes` as chunks arrive; the provider retains regular-file validation and UTF-8 decoding while the protocol consumer owns its document limit. It fuses caller cancellation with provider disposal across each filesystem operation, tracks workspace lookups before they enter a queue, and awaits those lookups during disposal. It does not emit `fs/observed`: only the LSP result is model-visible, so the query does not satisfy read-before-write policy.
The `read` tool is unsuitable source because its output is windowed, numbered, transcript-visible, and observed. Reading in `tool-lsp` would also assign provider-specific synchronization to the consumer and preclude non-local providers.
The local provider uses a compatibility-first transient-open sequence for every query. It accepts legacy `textDocumentSync` `Full` or `Incremental`, or options with `openClose: true`; omitted, `None`, or explicitly incompatible synchronization fails as unsupported before `didOpen`.
-1. Canonicalize and validate the host path, then read the current source with Node filesystem APIs.
+1. Resolve and contain the source through `ctx.fs`, then stream its current text through the same provider while enforcing the document byte limit.
2. Send `textDocument/didOpen` with version `1`, full text, and the configured language id. Its write remains abortable; failure or cancellation invalidates the instance and awaits bounded process termination before the pool can reuse it.
3. Send the requested `textDocument/definition`, `textDocument/references`, `textDocument/implementation`, or `textDocument/hover` request.
4. If `didOpen` succeeded, attempt `textDocument/didClose` in `finally` after the request settles or aborts. A close-write failure does not replace the settled result or error, but invalidates the instance and awaits bounded process termination.
Documents close after each call, so the first version needs no `didChange`, `didSave`, content cache, mutation listener, or document LRU. One abortable per-workspace provider queue serializes source-read/open/query/close lifecycles, so a waiting query reads current bytes only when its turn starts; the instance also keeps protocol lifecycles serialized. Distinct workspaces may run in parallel. The server's workspace index remains responsible for closed files reached from the source.
-The canonical workspace `realpath` must be a directory and supplies process cwd, `rootUri`, the sole `workspaceFolders` entry, and pool identity; symlink aliases therefore share an instance. Result locations may be external, but an external path cannot become a query source. Remote, virtual, or independently sandboxed filesystems require another provider.
+The canonical workspace target must be a directory. Its target key supplies pool identity, its process path supplies cwd, and its provider-owned `file:` URI supplies `rootUri` and the sole `workspaceFolders` entry; aliases share an instance when the filesystem provider resolves them to one key. Result locations may be external, but an external path cannot become a query source. A filesystem that cannot share paths with the mounted subprocess provider is a composition error, not a reason for another LSP package.
## Local server lifecycle and protocol behavior
-`dsh-lsp-local` lazily single-flights one server per `(provider id, canonical workspace realpath)`. At load it resolves the executable after credential scrubbing and environment overrides, failing before registration if unavailable; server process launch stays lazy (first query spawns it) and uses no shell. `maxMessageBytes` defaults to `16_000_000`, `maxStderrBytes` to `1_000_000`, and `maxDocumentBytes` to `4_000_000`. A crash fails the active query without replay; a later query may replace the process. Each query starts at most one process, so the MVP has no cross-request restart counter.
+`dsh-lsp-stdio` lazily single-flights one server per `(provider id, canonical workspace target)`. At load it calls `ctx.subprocess.resolveExecutable()` with the configured environment, failing before registration if unavailable; first query launches through raw protocol pipes with no shell and a bounded collected stderr tail. `maxMessageBytes` defaults to `16_000_000`, `maxStderrBytes` to `1_000_000`, and `maxDocumentBytes` to `4_000_000`. A crash fails the active query without replay; a later query may replace the process. Each query starts at most one process, so the MVP has no cross-request restart counter.
-Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. Returned operation and synchronization capabilities are authoritative. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. Configuration may supply initialization options and `workspace/configuration` responses, but the client rejects `workspace/applyEdit` and never executes commands or edits.
+Initialization uses `processId: null` because the client and server may inhabit different process namespaces. It advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. Returned operation and synchronization capabilities are authoritative. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. Configuration may supply initialization options and `workspace/configuration` responses, but the client rejects `workspace/applyEdit` and never executes commands or edits.
Navigation maps `Location` directly and `LocationLink` from `targetUri` plus `targetSelectionRange`. Positions must be nonnegative integers. Hover normalization accepts only valid `MarkupContent` and `MarkedString` shapes, preserves string values, renders language-tagged values as fenced code, and joins arrays with one blank line. The model-facing tool applies `maxResultChars` after rendering.
Abort reaches every query phase and sends `$/cancelRequest` once an id exists. An unresponsive server is terminated and awaited without collateral active work because the instance is serialized. Disposal rejects and cancels work, attempts graceful shutdown, escalates through bounded termination, and awaits quiescence.
-## Deliberately deferred surface
+## Deliberately deferred API
Symbols are deferred because they need different schemas and overlap read/search; a future workspace-symbol tool must accept a search query. Call hierarchy is deferred because support is uneven, and `prepareCallHierarchy` remains an internal prerequisite rather than a model operation.
Diagnostics need separate freshness, accumulation, and transcript rules. Mutations such as rename, code actions, and formatting require separate tools with preview, permission, and write-policy integration.
-The local provider trusts its configured server and claims no sandbox confinement. Supporting untrusted binaries requires a later process/filesystem contract for workspace reads plus private cache and temporary writes; restricted, remote, or virtual workspaces require another provider.
+The provider trusts its configured server. Its filesystem visibility and process confinement are exactly those of the mounted execution world; LSP adds no independent sandbox policy.
## Alternatives considered
-**Copy Claude Code's unified schema.** Its cursor operations validate the core use case, but symbols and call hierarchy need different arguments. Copying all nine operations would freeze speculative surface, so the proposal aligns only on the four semantic queries.
+**Copy Claude Code's unified schema.** Its cursor operations validate the core use case, but symbols and call hierarchy need different arguments. Copying all nine operations would freeze speculative surface, so the seam aligns only on the four semantic queries.
**Let providers register tools.** Loaded servers would then control model schema and prompts, preventing one stable contract across local and remote providers.
@@ -155,9 +155,9 @@ The local provider trusts its configured server and claims no sandbox confinemen
**Expose `resolve(request)` / `query(spec)`.** With no defaulted fields, resolution would only expose provider selection, and a public spec could outlive provider disposal or replacement. One operation keeps selection and invocation atomic to the registration lifetime.
-**Wrap the signal in a per-seam execution-context object.** Web passes a bare `AbortSignal`; wrapping this single field would add unexplained asymmetry. `query()` gains a context object only when another field requires it.
+**Wrap the signal in an LSP execution-context object.** Web passes a bare `AbortSignal`; wrapping this single field would add unexplained asymmetry. `query()` gains a context object only when another field requires it.
-**Read through `ctx.fs` or the `read` tool.** This could mix the document with a server index from another filesystem namespace; tool output is also windowed, numbered, and observed. The host-local provider reads unobserved full text beside its subprocess.
+**Read through the model-facing `read` tool.** Rejected because tool output is windowed, numbered, transcript-visible, and observed. The provider consumes streamed full text directly through the same `ctx.fs` execution world used by its subprocess.
**Keep documents open.** Mirroring edits requires version ownership, all-path `didChange`, HMR recovery, eviction, and stale-state rules. Transient opens avoid that MVP state machine.
@@ -178,9 +178,9 @@ The local provider trusts its configured server and claims no sandbox confinemen
- Registry tests pin atomic reservation/release, order-independent selection, and structured unavailable, disposed, conflict, and unsupported-operation errors.
- Fake-stdio tests pin exact initialization capabilities, four protocol mappings, `Location`/`LocationLink` and hover normalization, and `findReferences` mapping to `references.includeDeclaration`.
- Synchronization tests pin UTF-16 negotiation and conversion, supported and rejected `textDocumentSync` forms, blocked and failed open writes, balanced transient open/close, close-write failure, and malformed-response rejection.
-- Timeout tests pin one `TOOL_TIMEOUT` budget, unclassified upstream cancellation, no hidden seam deadline, and bounded awaited teardown.
+- Timeout tests pin one `TOOL_TIMEOUT` budget, unclassified upstream cancellation, no hidden LSP deadline, and bounded awaited teardown.
- Lifecycle tests pin startup single-flight, complete-lifecycle serialization with fresh queued source reads, cross-workspace parallelism, abortable queues, crash replacement without replay, failed-stdin teardown, and quiescent disposal.
-- Host-filesystem tests pin session-cwd requirements, relative and absolute source containment through symlinks, document validation, file/non-file URI rendering, unformatted source, and no `fs/observed` event.
+- Filesystem-host tests pin session-cwd requirements, provider-owned containment and URI rendering, bounded document reads, unformatted source, and no `fs/observed` event.
- A keyless pinned TypeScript real-server e2e exercises all four operations; runnable configuration uses the same explicit provider mapping.
- Snapshots cover model-visible schema, prompt, results, and omissions; a built-artifact smoke test covers framing and cleanup.
- Package and architecture docs cover configuration, security boundaries, and search/read guidance; the new `packages/lsp/` group is added to the AGENTS.md repository-layout block, the packages/README.md group table, and architecture.md in the same change.
@@ -195,4 +195,4 @@ Extension ownership is exclusive within one runtime. Two providers cannot both c
UTF-16 cursor columns are exact for the protocol but difficult for a model to count around non-BMP characters. Invalid or off-symbol positions may produce empty results, so error text and prompt examples must explain the coordinate convention without encouraging broad LSP use.
-Direct Node access aligns the query snapshot with the server index but bypasses `ctx.fs` and its policy. Canonical containment rejects source files outside the workspace; a trusted server may still read the workspace and use caches. The first implementation therefore requires trusted host-local deployment and provides no sandbox guarantee.
+The paired filesystem/subprocess providers align the query snapshot with the server index but do not make a trusted language server safe. Canonical containment rejects query sources outside the workspace at resolution time, but stream opening does not add stable-handle identity across a concurrent path replacement; the server itself receives the execution world's configured authority and may read other paths or use caches.
diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md
index 256b293f21..b85b478b25 100644
--- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md
@@ -8,7 +8,7 @@ Status: implemented
harness 已具备文本搜索与文件读取能力,但二者都无法识别程序符号。文本匹配无法可靠地区分同名函数、跟踪导入别名、关联接口与具体实现,也无法报告推断类型。因此,agent(智能体)在修改代码前缺少人类通过编辑器语言服务器获得的语义导航能力。
-语言服务器协议(Language Server Protocol,LSP)支持分属三个职责方:模型需要稳定的查询 schema,harness 需要提供方选择与规范化结果,本地实现则负责进程、JSON-RPC、工作区、同步与文件系统行为。将三者合并会使模型契约绑定本地子进程,并阻碍远程或沙箱原生提供方。
+语言服务器协议(Language Server Protocol,LSP)支持分属三个职责方:模型需要稳定的查询 schema,harness 需要提供方选择与规范化结果,本地实现则负责进程、JSON-RPC、工作区、同步与文件系统行为。将三者合并会使模型约定绑定本地子进程,并阻碍远程或沙箱原生提供方。
许多语言服务器在查询文档已按当前文本打开时表现最佳。兼容的 agent 客户端必须限制这项状态、定义内部读取是否算作模型观察,并确保文档快照与服务器工作区索引位于同一文件系统命名空间。
@@ -17,22 +17,22 @@ harness 已具备文本搜索与文件读取能力,但二者都无法识别程
将 LSP 建成由三个包组成的能力 seam,其中包含一个只读模型工具和一个通用本地提供方实现:
1. `packages/lsp/lsp` 下的 `@deepseek-ai/dsh-lsp` 负责 `ctx.lsp`、提供方注册与选择、标准化请求与结果、执行控制,以及结构化 LSP 错误。
-2. `packages/lsp/lsp-local` 下的 `@deepseek-ai/dsh-lsp-local` 将配置的 stdio 语言服务器适配到该服务边界。一个插件实例接收具名服务器表,并为每组命令及扩展名到语言 id 的映射注册一个隔离的提供方。
+2. `packages/lsp/lsp-stdio` 下的 `@deepseek-ai/dsh-lsp-stdio` 将配置的 stdio 语言服务器适配到该 seam。一个插件实例接收具名服务器表,并为每组命令及扩展名到语言 id 的映射注册一个隔离的提供方。
3. `packages/lsp/tool-lsp` 下的 `@deepseek-ai/dsh-tool-lsp` 负责面向模型的 `lsp` schema、提示词指导、参数校验、结果限制与格式化,以及与传输方式无关的 UI 展示。
-`dsh-lsp-local` 是通用 host,不是语言服务器目录或安装器。部署显式配置命令与映射;未来 preset 属于组合插件或 `cordis.yml` overlay。
+`dsh-lsp-stdio` 是通用 host,不是语言服务器目录或安装器。部署显式配置命令与映射;未来 preset 属于组合插件或 `cordis.yml` overlay。
-模型与服务边界仅公开 `goToDefinition`、`findReferences`、`goToImplementation` 和 `hover`;`ctx.lsp` 不提供任意 JSON-RPC 方法。这些操作字面量与 Claude Code 熟悉的 camelCase 命名一致,而工具名与 `file_path` 字段仍由 harness 自行定义。
+模型与 seam 仅公开 `goToDefinition`、`findReferences`、`goToImplementation` 和 `hover`;`ctx.lsp` 不提供任意 JSON-RPC 方法。这些操作字面量与 Claude Code 熟悉的 camelCase 命名一致,而工具名与 `file_path` 字段仍由 harness 自行定义。
提示词将 LSP 定位为精确查询手段:`Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references.`
## 包与职责边界
-`dsh-lsp` 按带品牌类型的 id 和扩展名到语言 id 的映射注册提供方。`registerProvider()` 以原子方式占用 id 与所有规范化扩展名:输入无效或存在冲突时不发布任何状态,清理函数释放全部占用。提供方插件通过 `ctx.effect()` 注册。系统按查询且不受顺序影响地选择提供方;没有匹配项时返回结构化不可用错误。第一版不提供 glob、language-id 或显式路由选择器,也不静态声明操作能力。
+`dsh-lsp` 按带品牌类型的 id 和扩展名到语言 id 的映射注册提供方。`registerProvider()` 以原子方式占用 id 与所有规范化扩展名:输入无效或存在冲突时不发布任何状态,dispose(资源释放)函数释放全部占用。提供方插件通过 `ctx.effect()` 注册。系统按查询且不受顺序影响地选择提供方;没有匹配项时返回结构化不可用错误。第一版不提供 glob、language-id 或显式路由选择器,也不静态声明操作能力。
-服务边界只公开 `query(request, signal?)`,因为没有字段需要实现层填充默认值:`workspaceRoot` 是必填项,`languageId` 来自注册映射,超时与结果限制由消费方负责。`query()` 执行选择与推导时不使用隐藏的 `??` 后备逻辑,因此没有需要 resolve 的可执行 spec。`dsh-tool-lsp` 校验模型参数,并只把 `exec.signal` 作为裸 `AbortSignal` 传递,与 web 一致,并使 `dsh-lsp` 不依赖 `dsh-tools`。提供方在选择前被移除时按不可用失败;之后的释放遵循已选提供方的取消生命周期,不改路由。
+seam 只公开 `query(request, signal?)`,因为没有字段需要实现层填充默认值:`workspaceRoot` 是必填项,`languageId` 来自注册映射,超时与结果限制由消费方负责。`query()` 执行选择与推导时不使用隐藏的 `??` 后备逻辑,因此没有需要 resolve 的可执行 spec。`dsh-tool-lsp` 校验模型参数,并只把 `exec.signal` 作为裸 `AbortSignal` 传递,与 web 一致,并使 `dsh-lsp` 不依赖 `dsh-tools`。提供方在选择前被移除时按不可用失败;之后的 dispose 遵循已选提供方的取消生命周期,不改路由。
-预期契约如下:
+约定如下:
```ts
import type { Branded } from '@deepseek-ai/dsh-brand'
@@ -62,7 +62,7 @@ interface LspProviderQuery extends LspQueryRequest {
}
type LspQueryResult =
- | { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[]; readonly resolvedWorkspaceRoot: string }
+ | { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[]; readonly resolvedWorkspaceUri: string }
| { readonly kind: 'hover'; readonly hover: { readonly contents: string; readonly range?: LspRange } | null }
interface LspProvider {
@@ -77,11 +77,11 @@ interface LspService {
}
```
-映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。服务边界中的位置和范围从零开始按 UTF-16 计数。`findReferences` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`;导航结果携带提供方解析后的工作区根目录,使消费方依据同一规范化根目录相对化文件 URI。服务边界不公开协议类型、进程或文档控制,也不提供通用请求逃生口。
+映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。seam 中的位置和范围从零开始按 UTF-16 计数。`findReferences` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`;导航结果携带提供方的规范工作区 URI,使消费方在执行世界的命名空间内相对化文件 URI。seam 不公开协议类型、进程或文档控制,也不提供通用请求逃生口。
-`dsh-lsp-local` 负责主机文件、服务器配置、JSON-RPC、进程与临时文档状态和协议转换;它依赖 `dsh-lsp` 与 Node API,不依赖 `dsh-fs`。服务器表的键是提供方 id。插件在注册前解析每个服务器的本地设置;如果后续映射无效或发生冲突,插件会撤销此前的注册,并为每个提供方保留独立进程池。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。
+`dsh-lsp-stdio` 负责服务器配置、JSON-RPC、进程与临时文档状态和协议转换。它通过 `ctx.fs` 读取,通过 `ctx.subprocess` 启动,只依赖二者的 Service Definition 包而非具体提供方;[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.md)负责定义这种配对。服务器表的键是提供方 id。插件在注册前解析每个服务器的本地设置;如果后续映射无效或发生冲突,插件会撤销此前的注册,并为每个提供方保留独立进程池。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。
-## 面向模型的契约
+## 面向模型的约定
单一 `lsp` 工具接受以下参数:
@@ -94,70 +94,70 @@ interface LspToolInput {
}
```
-`line` 和 `character` 是从一开始计数的正数 UTF-16 光标坐标;工具将其转换为服务边界中从零开始的 `LspPosition`,并将渲染位置转回。`findReferences` 包含声明,避免影响分析漏掉定义位置。提供方、语言 id、工作区根目录、限制、超时、初始化和可执行文件均不进入模型输入。
+`line` 和 `character` 是从 1 开始计数的正数 UTF-16 光标坐标;工具将其转换为 seam 中从零开始的 `LspPosition`,并将渲染位置转回。`findReferences` 包含声明,避免影响分析漏掉定义位置。提供方、语言 id、工作区根目录、限制、超时、初始化和可执行文件均不进入模型输入。
工具必须从会话 `header.cwd` 取得 `workspaceRoot`,没有后备值;缺失时在查询或启动前以 `LSP_WORKSPACE_REQUIRED` 失败。本地提供方基于根目录解析相对路径并直接接受绝对路径;两种路径都会进行规范化,如果目标位于规范工作区外,则在启动前拒绝。
-位置按文件稳定分组并渲染为 `path:line:character`。Node `fileURLToPath()` 可接受的 `file:` URI 在工作区内转换为相对路径,在工作区外转换为绝对路径;其他 URI 保持原样。`maxLocations` 默认值为 `100`,并报告省略的条目;`maxResultChars` 默认值为 `16_000`,并将每个完整渲染结果(包括截断元数据)限制在该字符数内。空位置与 `null` hover 是成功的无结果响应;服务器载荷缺失或格式错误时,以结构化 `LSP_MALFORMED_RESPONSE` 错误失败。
+位置在不应用 harness 宿主路径规则的情况下按文件稳定分组并渲染为 `path:line:character`。有效的 `file:` URI 落在提供方的规范工作区 URI 内时转换为相对路径,位于其外时转换为从 URI 派生的绝对路径;格式错误的 URI 与非 `file:` URI 保持原样。`maxLocations` 默认值为 `100`,并报告省略的条目;`maxResultChars` 默认值为 `16_000`,并限制每个完整渲染结果,其中包括截断元数据。空位置与 `null` hover 是成功的无结果响应;服务器载荷缺失或格式错误时,以结构化 `LSP_MALFORMED_RESPONSE` 错误失败。
-与传输方式无关的展示器使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }`,`title` 由参数推导并标明操作与光标。由于 `FileLocation` 没有 character,跟随位置聚焦输入行,标题保留完整光标;展示器仍为纯函数。
+与传输方式无关的展示器使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }`,`title` 由参数推导并标明操作与光标。由于 `FileLocation` 没有 character,跟随位置聚焦输入行,标题保留完整光标;展示保持纯函数。
## 超时归属
-`dsh-tool-lsp` 将一个可配置的 `timeoutMs` 预算附加到工具定义,默认值为 `60_000`。`dsh-timeout-policy` 执行预算并提供传入 `ctx.lsp.query` 的 `exec.signal`;该预算覆盖排队、打开、查询和关闭的完整生命周期,模型不可配置。
+`dsh-tool-lsp` 将一个可配置的 `timeoutMs` 预算附加到工具定义,默认值为 `60_000`。`dsh-tool-call-timeout-policy` 执行预算并提供传入 `ctx.lsp.query` 的 `exec.signal`;该预算覆盖排队、打开、查询和关闭的完整生命周期,模型不可配置。
-服务边界和提供方不增加启动或请求截止时间。非工具调用方不会获得隐藏超时,必须自行提供 `AbortSignal`,并在需要预算时使用 `deadline()`。
+seam 和提供方不增加启动或请求截止时间。非工具调用方不会获得隐藏超时,必须自行提供 `AbortSignal`,并在需要预算时使用 `deadline()`。
-提供方释放发生在工具执行之外,因此 `dsh-lsp-local` 保留 `shutdownTimeoutMs`(默认 `5_000`)限制 `shutdown`/`exit`,以及 `killGraceMs`(默认 `2_000`),同时用于限制请求取消宽限期和从 SIGTERM 升级到 SIGKILL 的宽限期;失败实例的清理也使用相同边界。定时器值超过 Node `2_147_483_647` ms 的调度范围时,插件加载失败。提供方使用 `deadline()` 和 `timeoutOf()`,但仍负责请求取消、进程信号和等待关闭,因为超时通知不会终止工作。
+提供方 dispose 发生在工具执行之外,因此 `dsh-lsp-stdio` 保留 `shutdownTimeoutMs`(默认 `5_000`)限制 `shutdown`/`exit`,以及 `killGraceMs`(默认 `2_000`),同时用于限制请求取消宽限期和从 SIGTERM 升级到 SIGKILL 的宽限期;失败实例的清理也使用相同边界。定时器值超过 Node `2_147_483_647` ms 的调度范围时,插件加载失败。提供方使用 `deadline()` 和 `timeoutOf()`,但仍负责请求取消、进程信号和等待关闭,因为超时通知不会终止工作。
## 工作区、文件系统与文档同步
-`dsh-lsp-local` 通过 Node API 在子进程所在的主机命名空间中规范化并读取文件。它拒绝缺失、非普通、非 UTF-8、超大或规范路径越出工作区的源文件,并在校验与读取期间保持同一个 `O_NOFOLLOW | O_NONBLOCK` 句柄,因此没有写入方的 FIFO 不会在普通文件校验前造成阻塞。它在每项文件系统操作前后检查调用方是否取消。它不使用 `ctx.fs` 或发送 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足写前读取策略。
+`dsh-lsp-stdio` 在语言服务器的执行环境中通过 `ctx.fs` 规范化并读取文件。它要求工作区目标是目录,使用提供方自有的 containment 拒绝工作区外的源文件,消费 `streamText`,并在分片到达时执行 `maxDocumentBytes` 上限;普通文件校验和 UTF-8 解码仍由提供方负责,文档上限则由协议消费方负责。它会针对每项文件系统操作合并调用方取消与提供方 dispose,跟踪尚未进入队列的工作区查找,并在 dispose 期间等待这些查找结算。它不发送 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足写前读取策略。
`read` 工具的输出带窗口与行号,进入 transcript(文本记录)且已被观察,不适合作为源文件。在 `tool-lsp` 内读取还会把提供方专用同步职责交给消费方,并排除非本地提供方。
本地提供方对每次查询都采用兼容优先的临时打开流程。它接受旧式 `textDocumentSync` 的 `Full` 或 `Incremental`,也接受设置了 `openClose: true` 的选项;同步能力缺失、为 `None` 或明确不兼容时,在 `didOpen` 前以不支持错误失败。
-1. 规范化并校验主机路径,再使用 Node 文件系统 API 读取当前源文件。
+1. 通过 `ctx.fs` 解析源文件并检查其位于工作区内,再通过同一提供方流式读取当前文本,同时执行文档字节上限。
2. 发送 `textDocument/didOpen`,其中包含版本 `1`、完整文本和配置的语言 id。该写入仍可取消;写入失败或遭取消会使实例失效,并等待有界进程终止完成,池才能复用它。
3. 发送所请求的 `textDocument/definition`、`textDocument/references`、`textDocument/implementation` 或 `textDocument/hover` 请求。
4. 如果 `didOpen` 成功,则在请求完成或取消后于 `finally` 中尝试发送 `textDocument/didClose`。关闭写入失败不会覆盖已经确定的结果或错误,但会使实例失效,并等待有界进程终止完成。
每次调用后都关闭文档,因此第一版不需要 `didChange`、`didSave`、内容缓存、变更监听器或文档 LRU。每个工作区的提供方队列可取消,并串行执行源文件读取、打开、查询和关闭的完整生命周期,因此等待中的查询只在轮到它时才读取当前字节;实例也会串行执行协议生命周期。不同工作区可以并行。服务器工作区索引仍负责从源文件跳转到的已关闭文件。
-规范工作区 `realpath` 必须是目录,并用于进程 cwd、`rootUri`、唯一的 `workspaceFolders` 条目和进程池 identity;符号链接别名因此共享实例。结果位置可以在工作区外,但外部路径不能成为查询源。远程、虚拟或独立沙箱化文件系统需要另一种提供方。
+规范工作区目标必须是目录。其目标键提供进程池 identity,进程路径提供 cwd,归提供方所有的 `file:` URI 则提供 `rootUri` 和唯一的 `workspaceFolders` 条目;文件系统提供方将别名解析为同一键时,它们共享实例。结果位置可以在工作区外,但外部路径不能成为查询源。无法与挂载的子进程提供方共享路径的文件系统属于组合错误,不是另建 LSP 包的理由。
## 本地服务器生命周期与协议行为
-`dsh-lsp-local` 按 `(provider id, canonical workspace realpath)` 懒启动一个服务器,并通过 single-flight 合并启动。插件加载时,它在清除凭据并应用环境变量覆盖后解析可执行文件;命令不可用时在注册前失败。服务器进程的启动保持懒执行(首次查询时才拉起),且不经过 shell。`maxMessageBytes` 默认值为 `16_000_000`,`maxStderrBytes` 默认值为 `1_000_000`,`maxDocumentBytes` 默认值为 `4_000_000`。崩溃使当前查询失败且不重放;后续查询可以替换进程。每次查询最多启动一个进程,因此 MVP 不设置跨请求重启计数器。
+`dsh-lsp-stdio` 按 `(provider id, canonical workspace target)` 懒启动一个服务器,并通过 single-flight 合并启动。插件加载时,它使用已配置的环境调用 `ctx.subprocess.resolveExecutable()`;命令不可用时在注册前失败。首次查询通过原始协议管道启动服务器,不经过 shell,并收集有界的 stderr 尾部。`maxMessageBytes` 默认值为 `16_000_000`,`maxStderrBytes` 默认值为 `1_000_000`,`maxDocumentBytes` 默认值为 `4_000_000`。崩溃使当前查询失败且不重放;后续查询可以替换进程。每次查询最多启动一个进程,因此 MVP 不设置跨请求重启计数器。
-初始化声明 `general.positionEncodings: ['utf-16']`、`workspace: { workspaceFolders: true, configuration: true }`、`textDocument.hover.contentFormat: ['markdown', 'plaintext']`,以及 definition 与 implementation 的 `linkSupport: true`,但不支持动态注册。服务器返回的操作能力与同步能力均为真源。服务器省略 `positionEncoding` 时默认为 `utf-16`;其他值均属于协议错误。配置可以提供初始化选项和 `workspace/configuration` 响应,但客户端拒绝 `workspace/applyEdit`,绝不执行命令或编辑。
+初始化使用 `processId: null`,因为客户端与服务器可能位于不同的进程命名空间。它声明 `general.positionEncodings: ['utf-16']`、`workspace: { workspaceFolders: true, configuration: true }`、`textDocument.hover.contentFormat: ['markdown', 'plaintext']`,以及 definition 与 implementation 的 `linkSupport: true`,但不支持动态注册。服务器返回的操作与同步能力均为真源。服务器省略 `positionEncoding` 时默认为 `utf-16`;其他值均属于协议错误。配置可以提供初始化选项和 `workspace/configuration` 响应,但客户端拒绝 `workspace/applyEdit`,绝不执行命令或编辑。
导航结果直接映射 `Location`,并将 `LocationLink` 的 `targetUri` 与 `targetSelectionRange` 映射为统一位置。位置必须是非负整数。`hover` 归一化只接受有效的 `MarkupContent` 和 `MarkedString` 结构,保留字符串值,把带语言标签的值渲染为围栏代码块,并以一个空行连接数组。面向模型的工具在渲染后应用 `maxResultChars`。
-取消信号传递到查询的所有阶段,请求 id 创建后还会发送 `$/cancelRequest`。无响应的服务器会被终止并等待关闭;实例串行化保证没有其他正在执行的工作被连带中断。资源释放会拒绝并取消工作、尝试优雅关闭、通过有界终止流程升级处理,并等待完全停稳。
+取消信号传递到查询的所有阶段,请求 id 创建后还会发送 `$/cancelRequest`。无响应的服务器会被终止并等待关闭;实例串行化保证没有其他正在执行的工作被连带中断。dispose 会拒绝并取消工作、尝试优雅关闭、通过有界终止流程升级处理,并等待完全停稳。
-## 明确延后的接口
+## 明确延后的 API
符号操作因需要不同 schema 且与读取或搜索重叠而延后;未来的工作区符号工具必须接收搜索词。调用层级因支持度不一而延后,`prepareCallHierarchy` 仍是内部准备步骤,不是模型操作。
诊断需要独立的新鲜度、累积与 transcript 规则。重命名、代码操作和格式化等变更能力需要单独工具,并集成预览、权限和写入策略。
-本地提供方信任配置的服务器,不声称具备沙箱隔离。支持不受信任的二进制文件需要后续补充允许读取工作区,并执行私有缓存写入与临时写入的进程/文件系统契约;受限、远程或虚拟工作区需要另一种提供方。
+提供方信任配置的服务器。其文件系统可见性与进程隔离完全取决于挂载的执行环境;LSP 不增加独立的沙箱策略。
## 备选方案
-**照搬 Claude Code 的统一 schema。** 它的光标操作验证了核心场景,但符号与调用层级需要不同参数。照搬九种操作会固化尚未验证的接口,因此本提案只对齐四种语义查询。
+**照搬 Claude Code 的统一 schema。** 它的光标操作验证了核心场景,但符号与调用层级需要不同参数。照搬九种操作会固化尚未验证的接口,因此该 seam 只对齐四种语义查询。
-**允许提供方注册工具。** 已加载服务器会控制模型 schema 和提示词,无法在本地与远程提供方之间维持统一契约。
+**允许提供方注册工具。** 已加载服务器会控制模型 schema 和提示词,无法在本地与远程提供方之间维持统一约定。
**公开任意 LSP 方法。** JSON-RPC 逃生口会泄露协议载荷,并允许未经评审的变更或命令执行;操作联合保持封闭。
-**公开 `resolve(request)` / `query(spec)`。** 没有需要填充默认值的字段时,resolve 只会暴露提供方选择,而公开 spec 可能活过提供方释放或替换。单一操作让选择与调用共用注册生命周期。
+**公开 `resolve(request)` / `query(spec)`。** 没有需要填充默认值的字段时,resolve 只会暴露提供方选择,而公开 spec 可能持续到提供方 dispose 或替换之后。单一操作让选择与调用共用注册生命周期。
-**将信号包装为服务边界专用的执行上下文对象。** Web 传递裸 `AbortSignal`;仅包装这一个字段会造成无谓的不对称。只有另一个字段确有需要时,`query()` 才引入上下文对象。
+**将信号包装为 LSP 执行上下文对象。** Web 传递裸 `AbortSignal`;仅包装这一个字段会造成无谓的不对称。只有另一个字段确有需要时,`query()` 才引入上下文对象。
-**通过 `ctx.fs` 或 `read` 工具读取。** 这可能把文档与另一文件系统命名空间中的服务器索引混合;工具输出还带窗口、行号且已被观察。host-local 提供方在子进程旁读取未观察的完整文本。
+**通过面向模型的 `read` 工具读取。**拒绝,因为工具输出带窗口与行号,会进入 transcript 且已被观察。提供方直接通过子进程所用的同一 `ctx.fs` 执行环境消费流式传输的完整文本。
**保持文档打开。** 镜像编辑需要版本归属、覆盖所有路径的 `didChange`、HMR 恢复、淘汰和陈旧状态规则。临时打开避免在 MVP 引入这套状态机。
@@ -178,21 +178,21 @@ interface LspToolInput {
- 注册表测试固定原子占用/释放、不受顺序影响的选择,以及结构化的不可用、已释放、冲突和不支持操作错误。
- 测试用 stdio server 固定精确的初始化能力、四种协议映射、`Location`/`LocationLink` 与 `hover` 归一化,以及 `findReferences` 到 `references.includeDeclaration` 的映射。
- 同步测试固定 UTF-16 协商与转换、受支持和被拒绝的 `textDocumentSync` 形式、打开写入阻塞与失败、配对的临时打开/关闭、关闭写入失败和错误响应拒绝。
-- 超时测试固定一个 `TOOL_TIMEOUT` 预算、不对上游取消错误分类、服务边界无隐藏截止时间,以及受限且等待完成的清理。
-- 生命周期测试固定启动 single-flight、完整生命周期串行化及排队查询读取最新源文件、跨工作区并行、可取消队列、崩溃后不重放的替换、stdin 失败后的进程拆除,以及释放后完全停稳。
-- 主机文件系统测试固定 session cwd 要求、符号链接下相对与绝对源路径的规范 containment、文档校验、file/non-file URI 渲染、无格式源文本和不发送 `fs/observed`。
+- 超时测试固定一个 `TOOL_TIMEOUT` 预算、不对上游取消错误分类、LSP 无隐藏截止时间,以及受限且等待完成的清理。
+- 生命周期测试固定启动 single-flight、完整生命周期串行化及排队查询读取最新源文件、跨工作区并行、可取消队列、崩溃后不重放的替换、stdin 失败后的进程拆除,以及 dispose 后完全停稳。
+- 文件系统宿主测试固定 session cwd 要求、提供方自有的 containment 与 URI 渲染、有界文档读取、无格式源文本和不发送 `fs/observed`。
- 无密钥且固定版本的 TypeScript 真实服务器 e2e 覆盖四种操作;可运行配置使用同一项显式提供方映射。
- 快照覆盖模型可见 schema、提示词、结果和省略提示;构建产物冒烟测试覆盖分帧与清理。
- 包与架构文档覆盖配置、安全边界和搜索/读取指导;同一改动中,新的 `packages/lsp/` 包组要加入 AGENTS.md 的仓库布局块、packages/README.md 的分组表和 architecture.md。
## 影响
-各语言服务器对方法支持、能力解释和索引就绪时机的处理不同;LSP 没有统一的「索引完成」信号。不具备兼容临时打开同步能力的服务器不受支持,即使它能查询已关闭文档。受支持的服务器仍可能返回空结果或不完整结果,因此工具不承诺跨服务器完整性。固定的 TypeScript e2e 只建立一条兼容性基线,不代表跨语言承诺。
+各语言服务器对方法支持、能力解释和索引就绪时机的处理不同;LSP 没有统一的“索引完成”信号。不具备兼容的临时打开同步能力的服务器不受支持,即使它能查询已关闭文档。受支持的服务器仍可能返回空结果或不完整结果,因此工具不承诺跨服务器完整性。固定的 TypeScript e2e 只建立一条兼容性基线,不代表跨语言承诺。
-临时打开会重复解析并产生通知。实例内串行会增加并发 agent 的延迟,长期运行的工作区进程则持续占用内存直到释放。
+临时打开会重复解析并产生通知。实例内串行会增加并发 agent 的延迟,长期运行的工作区进程则持续占用内存直到 dispose。
同一运行时内的扩展名所有权互斥。即使 language id 不同,两个提供方也不能同时占用 `.ts`;这是有意接受的 MVP 限制。预期扩展方式是在注册之上增加由部署配置的 selector,允许放宽互斥占用,同时不向模型输入增加提供方选择,也不改变 `LspProvider.query`。
UTF-16 光标列与协议完全一致,但模型难以在包含非 BMP 字符的文本中准确计数。无效位置或不在符号上的位置可能返回空结果,因此错误文本和提示词示例必须说明坐标约定,同时避免鼓励模型广泛使用 LSP。
-直接访问 Node 文件系统会对齐查询快照与服务器索引,但绕过 `ctx.fs` 及其策略。规范路径 containment 会拒绝工作区外的源文件;受信任的服务器仍可读取工作区并使用缓存。因此,第一版要求受信任的 host-local 部署,不提供沙箱保证。
+配对的文件系统/子进程提供方会对齐查询快照与服务器索引,但不会因此使受信任的语言服务器变得安全。规范 containment 会在解析时拒绝工作区外的查询源,但打开流不会在路径并发替换期间额外保证稳定句柄身份;服务器本身获得执行环境所配置的权限,仍可读取其他路径或使用缓存。
diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml
index 80fe86f2bc..47e7156ab8 100644
--- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.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/architecture/2026-07-15-replay-token-meter-service.md
-2026-07-15-replay-token-meter-service.md: c0f4b467ad0013dd4ac0a0301281b011ea8c261c
-2026-07-15-replay-token-meter-service.zh.md: c1d81dc0e76ee687ced5c23d26197627551e1ced
+2026-07-15-replay-token-meter-service.md: 10261189fca621b305b3ec6347b54471d81c8ba3
+2026-07-15-replay-token-meter-service.zh.md: 4e460b3c32630f205f447a16205bd4c9b1d89faf
diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md
index c0f4b467ad..10261189fc 100644
--- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md
+++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md
@@ -6,33 +6,33 @@ English | [中文](2026-07-15-replay-token-meter-service.zh.md)
## Problem
-Context pressure is useful outside compaction. A compaction backend, an overflow guard, or a future request-policy plugin can all need the same answer: how many tokens does the durable request consume? Keeping that fold inside `dsh-compact-basic` duplicates replay logic, makes measurement unavailable without compaction, and encourages callers to reuse stale accounting.
+Context pressure is useful outside compaction. A compaction backend, an overflow guard, or a future request-policy plugin can all need the same answer: how many tokens does the durable request consume? Keeping that fold inside `dsh-compaction-basic` duplicates replay logic, makes measurement unavailable without compaction, and encourages callers to reuse stale accounting.
-Provider usage is not a complete answer. It describes one successful call under one exact request envelope, while the current surface can grow, shrink, or be replaced afterward. Sessions also switch providers and models, old logs can lack chunk provenance, and usage fields separate input, cache-read, cache-write, output, and reasoning counts. A useful service therefore combines the latest exact anchor with conservative heuristic repricing and exposes the log revision consumed by each result.
+Provider usage is not a complete answer. It describes one successful call under one exact request envelope, while the current surface can grow, shrink, or be replaced afterward. Sessions also switch providers and models, old logs can omit the chunk seqs behind an assistant message, and usage fields separate input, cache-read, cache-write, output, and reasoning counts. A useful service therefore combines the latest exact anchor with conservative heuristic repricing and exposes the log revision consumed by each result.
## Decision
### One concrete LLM-family service
-`@deepseek-ai/dsh-token-meter` is one concrete package under `packages/llm/` and registers `ctx.tokenMeter`. It is not split into an interface and backend before a second implementation exists. `TokenMeterService` itself exposes `measure(session, requestHeader?)` and `estimateMessage(message)`; consumers call the singleton service directly.
+`@deepseek-ai/dsh-token-meter` is one concrete package under `packages/llm/` and registers `ctx.tokenMeter`. It is not split into an interface and backend before a second implementation exists. `TokenMeter` itself exposes `measure(session, requestHeader?)` and `estimateMessage(message)`; consumers call the singleton service directly.
The service has no configuration. Estimation uses a fixed four-characters-per-token heuristic plus structural overhead. There are no model profiles, capacity settings, density settings, tokenizer backends, or language-specific strategies. Exact provider/model capacity is a separate adapter-owned query, as specified by the [routed model context and compaction policy Agent Note](2026-07-20-routed-model-context-and-compaction-policy.md).
### Per-session replay folds
-Each session owns one isolated incremental fold. Active folds advance from `session/event`; every read catches up through the durable tail, so listener ordering, seeded sessions, and service reload do not change the answer. The fold tracks canonical full request-header snapshots, step boundaries, surface appends and replacements, assistant usage, and assistant-chunk provenance. A malformed next event fails transactionally and remains unread rather than partially mutating state.
+Each session owns one isolated incremental fold. Active folds advance from `session/event`; every read catches up through the durable tail, so listener ordering, seeded sessions, and service reload do not change the answer. The fold tracks canonical full request-header snapshots, step boundaries, surface appends and replacements, assistant usage, and the chunk seqs cited by each assistant message. A malformed next event fails transactionally and remains unread rather than partially mutating state.
`measure(session, requestHeader?)` synchronizes the fold once and returns scalar pressure together with positional per-node prices. `totalTokens` remains request-and-response pressure; `surfaceTokens` is the surface-only heuristic total and equals the sum of `nodes[].tokens`. A `requestHeader` override changes pressure pricing only, while the surface fields always describe the current session. `estimateMessage(message)` applies the fixed heuristic without session state. Each result is one detached, deeply immutable snapshot carrying one `logRevision`. Every measurement clones the current nodes and is therefore O(surface).
Provider usage is reused only when the measured canonical request envelope equals the latest successful-call anchor. Any provider, model, system, prefix, tool, or call-config change causes complete heuristic repricing. Surface changes remain a signed delta from a matching anchor, including negative values after a shrinking replacement. A later successful request replaces the earlier anchor, including across provider or model switches.
-Usage sums the disjoint input, cache-read, cache-write, and output buckets. Reasoning is not added a second time. Every successful model call records an `assistant/message`, including content-less and max-token calls, with its exact earlier chunk seqs. An explicit empty provenance list means a known empty provider stream; absent legacy provenance conservatively treats the durable assistant output as provider output.
+Usage sums the disjoint input, cache-read, cache-write, and output buckets. Reasoning is not added a second time. Every successful model call records an `assistant/message`, including content-less and max-token calls, with its exact earlier chunk seqs. An explicit empty `sourceEventSeqs` list means a known empty provider stream; an absent legacy list conservatively treats the durable assistant output as provider output.
### Compact-basic consumes, but does not own, measurement
-`dsh-compact-basic` requires `ctx.tokenMeter`; `CompactService` gains no token methods or types. Configuration, the region transaction, and summarization stay in separate modules; the service registers automatic listeners itself, while `summarize()` remains its sole subclass hook. The singleton meter consistently prices pressure, retention, shadowed content, provenance, and non-shrinking-summary rejection.
+`dsh-compaction-basic` requires `ctx.tokenMeter`; `CompactionEngine` gains no token methods or types. Configuration, the region transaction, and summarization stay in separate modules; the service registers automatic listeners itself, while `summarize()` remains its sole subclass hook. The singleton meter consistently prices pressure, retention, shadowed content, cited source events, and non-shrinking-summary rejection.
-Automatic compaction uses one unified measurement for each threshold-and-retention decision. The region transaction measures after appending its durable `compact/start` lock and again after asynchronous summarization, then compares the detached surface-node vectors. An intervening surface mutation prevents replacement; `logRevision` may advance for unrelated log-only facts without invalidating an unchanged selected span.
+Automatic compaction uses one unified measurement for each threshold-and-retention decision. The region transaction measures after appending its durable `compaction/start` lock and again after asynchronous summarization, then compares the detached surface-node vectors. An intervening surface mutation prevents replacement; `logRevision` may advance for unrelated log-only facts without invalidating an unchanged selected span.
Compact policy has service-wide defaults: threshold ratio `0.8`, retained-tail ratio `0.16`, `summarizationProvider: ''`, `summarizationModel: ''`, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 1`, and `auto: true`. Top-level fields apply to every routed target; exact provider/model entries in `modelPolicies` partially override them. Pressure scales ratios against capacity resolved from the owning adapter, and `retainTokens` may replace `retainRatio`; retention must remain below the resulting threshold. The summarization provider and model must both be set or both be empty; an empty pair resolves the latest logged request target, then the `AgentOptions` pair.
@@ -40,20 +40,20 @@ Automatic pressure runs at `agent/pre-step` before request derivation and measur
## Testing
-Unit tests cover fixed estimation, envelope invalidation and anchor replacement, replay boundaries, immutable snapshots, routed pressure, convergence, overflow generation proof, and rollback. A real Loader/Include fixture verifies the zero-config token-meter and compact-basic load path in dependency order.
+Unit tests cover fixed estimation, envelope invalidation and anchor replacement, replay boundaries, immutable snapshots, routed pressure, convergence, overflow generation proof, and rollback. A real Loader/Include fixture verifies the zero-config token-meter and compaction-basic load path in dependency order.
## Alternatives considered
-- **Keep estimation inside `CompactService`** — rejected because measurement has consumers and replay semantics independent of compaction; it would also force every compactor to expose the same unrelated API.
+- **Keep estimation inside `CompactionEngine`** — rejected because measurement has consumers and replay semantics independent of compaction; it would also force every compactor to expose the same unrelated API.
- **Split a token-meter interface from a heuristic backend immediately** — rejected because only one implementation exists. One concrete service preserves the future seam without speculative packages or configuration.
-- **Put model-keyed windows and density profiles in the meter** — rejected because replay estimation does not own model routing or capacity facts. The route-owning adapter exposes capacity, while compact-basic owns the consumer-specific threshold and retention policy.
+- **Put model-keyed windows and density profiles in the meter** — rejected because replay estimation does not own model routing or capacity facts. The route-owning adapter exposes capacity, while compaction-basic owns the consumer-specific threshold and retention policy.
- **Keep separate scalar and surface measurements** — rejected because callers would need two reads and revision matching for one decision. A scalar-only read could avoid cloning nodes below threshold, but the split API introduces a caller-side race window; the unified snapshot accepts O(surface) cloning in exchange for coherence.
- **Treat provider usage as portable between envelopes** — rejected because model, tools, prefixes, and call config are request facts. Mismatch reprices the whole current request.
## Consequences
- Token pressure has one replay-aware owner that compaction and future plugins can share.
-- The default makes the meter a zero-config composition entry; deployments configure capacity on each route-owning adapter and optional policy overrides on compact-basic.
+- The default makes the meter a zero-config composition entry; deployments configure capacity on each route-owning adapter and optional policy overrides on compaction-basic.
- Fixed heuristic pricing remains an estimate of provider behavior and is not an exact tokenizer or request serializer.
- Every measurement clones the current positional surface and therefore costs O(surface), including pressure checks that finish below threshold.
- Measurements fail loudly on malformed durable boundaries. This turns corrupted replay into a named integration failure instead of silently drifting pressure.
diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md
index c1d81dc0e7..4e460b3c32 100644
--- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md
@@ -6,55 +6,55 @@ Status: implemented
## 问题
-上下文压力并不只对压缩(compaction)有用。压缩后端、溢出保护或未来的请求策略插件都可能需要回答同一个问题:持久请求消耗了多少 token?如果把该折叠逻辑留在 `dsh-compact-basic` 内部,就会重复实现回放逻辑,使未加载压缩的调用方无法使用计量,并诱使调用方复用陈旧的核算结果。
+上下文压力并不只对压缩(compaction)有用。压缩后端、溢出保护或未来的请求策略插件都可能需要回答同一个问题:持久请求消耗了多少 token?如果把该折叠逻辑留在 `dsh-compaction-basic` 内部,就会重复实现回放逻辑,使未加载压缩的调用方无法使用计量,并诱使调用方复用陈旧的核算结果。
-提供方 usage 也不是完整答案。它只描述某个精确请求信封下的一次成功调用,而当前表层之后还可能增长、缩小或被替换。会话也可能切换提供方与模型,旧日志可能缺少分片来源,usage 字段还会分别报告输入、缓存读取、缓存写入、输出与推理计数。因此,可用的服务必须把最新精确锚点与保守的启发式重新定价结合起来,并公开每个结果已经消费的日志修订号。
+提供方 usage 也不是完整答案。它只描述某个精确请求信封下的一次成功调用,而当前表层之后还可能增长、缩小或被替换。会话也可能切换提供方与模型,旧日志可能缺少构成 assistant 消息的分片 seq,usage 字段还会分别报告输入、缓存读取、缓存写入、输出与推理计数。因此,可用的服务必须把最新精确锚点与保守的启发式重新定价结合起来,并公开每个结果已经消费的日志修订号。
## 决策
### 一个具体的 LLM(大语言模型)家族服务
-`@deepseek-ai/dsh-token-meter` 是 `packages/llm/` 下的单个具体包,并注册 `ctx.tokenMeter`。在第二种实现出现之前,它不会被拆成接口与后端。`TokenMeterService` 本身公开 `measure(session, requestHeader?)` 与 `estimateMessage(message)`;消费方直接调用这个单例服务。
+`@deepseek-ai/dsh-token-meter` 是 `packages/llm/` 下的单个具体包,并注册 `ctx.tokenMeter`。在第二种实现出现之前,它不会被拆成接口与后端。`TokenMeter` 本身公开 `measure(session, requestHeader?)` 与 `estimateMessage(message)`;消费方直接调用这个单例服务。
服务没有配置。估算采用固定的每 token 四个字符启发式规则,并加上结构开销。服务不提供模型 profile、容量设置、密度设置、分词器后端或语言专用策略。对精确提供方/模型容量的查询由适配器单独负责,具体见[路由模型上下文与压缩策略 Agent Note](2026-07-20-routed-model-context-and-compaction-policy.md)。
### 逐会话回放折叠
-每个会话都有一个隔离的增量折叠。活跃折叠通过 `session/event` 前进;每次读取都会追到持久日志尾部,因此监听器顺序、种子会话与服务重载不会改变答案。折叠跟踪规范的完整请求头快照、步骤边界、表层追加与替换、assistant usage,以及 assistant 分片来源。下一个畸形事件会以事务方式失败并保持未读,不会让状态只修改一半。
+每个会话都有一个隔离的增量折叠。活跃折叠通过 `session/event` 前进;每次读取都会追到持久日志尾部,因此监听器顺序、种子会话与服务重载不会改变答案。折叠跟踪规范的完整请求头快照、步骤边界、表层追加与替换、assistant usage,以及每条 assistant 消息引用的分片 seq。下一个畸形事件会以事务方式失败并保持未读,不会让状态只修改一半。
`measure(session, requestHeader?)` 只同步一次折叠,并在返回标量压力的同时给出逐位置节点价格。`totalTokens` 仍表示请求与响应压力;`surfaceTokens` 是仅针对表层的启发式总量,并等于 `nodes[].tokens` 之和。`requestHeader` 覆盖只改变压力定价,表层字段始终描述当前会话。`estimateMessage(message)` 不依赖会话状态,直接应用固定启发式规则。每个结果都是一个分离且深度不可变的快照,只携带一个 `logRevision`。每次计量都会复制当前节点,因此成本为 O(surface)。
只有当待计量的规范请求信封等于最近一次成功调用的锚点时,服务才复用提供方 usage。提供方、模型、系统提示词、前缀、工具或调用配置任一变化都会触发完整的启发式重新定价。表层变化相对匹配锚点保留有符号增量,包括缩小替换后的负值。后续成功请求会替换先前锚点,提供方或模型切换时也一样。
-Usage 会对互不重叠的输入、缓存读取、缓存写入与输出 bucket 求和,不会再次加入推理计数。每次成功模型调用都会记录 `assistant/message`,包括无内容调用与达到 token 上限的调用,并带上精确的更早分片 seq。显式空来源列表表示已知为空的提供方流;旧日志中缺失的来源则保守地把持久 assistant 输出视为提供方输出。
+Usage 会对互不重叠的输入、缓存读取、缓存写入与输出 bucket 求和,不会再次加入推理计数。每次成功模型调用都会记录 `assistant/message`,包括无内容调用与达到 token 上限的调用,并带上精确的更早分片 seq。显式的空 `sourceEventSeqs` 列表表示已知为空的提供方流;旧日志中缺失的列表则保守地把持久 assistant 输出视为提供方输出。
-### compact-basic 消费计量,但不拥有计量
+### compaction-basic 消费计量,但不拥有计量
-`dsh-compact-basic` 要求 `ctx.tokenMeter`;`CompactService` 不增加 token 方法或类型。配置、区域事务与摘要分别保留在独立模块中,服务自身注册自动监听器,而 `summarize()` 仍是唯一的子类钩子。单例计量器一致用于压力、保留、被遮蔽内容、来源以及非缩小摘要拒绝的定价。
+`dsh-compaction-basic` 要求 `ctx.tokenMeter`;`CompactionEngine` 不增加 token 方法或类型。配置、区域事务与摘要分别保留在独立模块中,服务自身注册自动监听器,而 `summarize()` 仍是唯一的子类钩子。单例计量器一致用于压力、保留、被遮蔽内容、引用的源事件以及非缩小摘要拒绝的定价。
-自动压缩的每次阈值与保留联合决策只使用一次统一计量。区域事务会在追加持久 `compact/start` 锁后执行计量,在异步摘要完成后再次计量,随后比较分离的表层节点向量。期间发生的表层变更会阻止替换;`logRevision` 可以因无关的纯日志事实而推进,而不会使未变的选定范围失效。
+自动压缩的每次阈值与保留联合决策只使用一次统一计量。区域事务会在追加持久 `compaction/start` 锁后执行计量,在异步摘要完成后再次计量,随后比较分离的表层节点向量。期间发生的表层变更会阻止替换;`logRevision` 可以因无关的纯日志事实而推进,而不会使未变的选定范围失效。
-压缩策略采用服务级默认值:阈值比例 `0.8`、保留尾部比例 `0.16`、`summarizationProvider: ''`、`summarizationModel: ''`、`maxTokens: 8192`、`compactionRetries: 1`、`maxOverflowRetries: 1` 与 `auto: true`。顶层字段适用于每个路由目标;`modelPolicies` 中的精确提供方/模型项可以部分覆盖这些字段。压力检查根据所属适配器解析的容量缩放比例,`retainTokens` 可以替代 `retainRatio`;保留值必须小于最终阈值。摘要提供方与模型必须同时设置或同时为空;空组合先解析最近记录的请求目标,再使用 `AgentOptions` 中的组合。
+压缩策略采用服务级默认值:阈值比例 `0.8`、保留尾部比例 `0.16`、`summarizationProvider: ''`、`summarizationModel: ''`、`maxTokens: 8192`、`compactionRetries: 1`、`maxOverflowRetries: 1` 与 `auto: true`。顶层字段适用于每个路由目标;`modelPolicies` 中的精确提供方/模型项可以部分覆盖这些字段。压力检查以所属适配器解析的容量为基准换算这些比例,`retainTokens` 可以替代 `retainRatio`;保留值必须小于最终阈值。摘要提供方与模型必须同时设置或同时为空;空组合先解析最近记录的请求目标,再使用 `AgentOptions` 中的组合。
-自动压力检查在请求派生前运行于 `agent/pre-step`,并计量前一个 `agent/request` 实际所选提供方/模型产生的规范持久信封。没有请求头的会话尚无已完成的路由请求可供判断,因此不执行工作;任意路由目标都可使用这个单例估算器。规范化溢出恢复使用同一计量结果强制选择范围,并且只有在表层替换得到证明后才重试。
+自动压力检查在请求派生前运行于 `agent/pre-step`,并计量前一个 `agent/request` 实际所选提供方/模型产生的规范持久信封。没有请求头的会话尚无已完成的路由请求可供判断,因此不执行工作;任意路由目标都可使用这个单例估算器。规范的溢出恢复流程使用同一计量结果强制选择范围,并且只有在表层替换得到证明后才重试。
## 测试
-单元测试覆盖固定估算、信封失效与锚点替换、回放边界、不可变快照、已路由压力、收敛、溢出 generation 证明与回滚。真实 Loader/Include fixture(测试前置数据)验证零配置 token-meter 与 compact-basic 按依赖顺序加载的路径。
+单元测试覆盖固定估算、信封失效与锚点替换、回放边界、不可变快照、已路由压力、收敛、溢出 generation 证明与回滚。真实 Loader/Include fixture(测试前置数据)验证零配置 token-meter 与 compaction-basic 按依赖顺序加载的路径。
## 考虑过的替代方案
-- **把估算保留在 `CompactService` 内**——不予采纳,因为计量拥有独立于压缩的消费方与回放语义;它还会强迫每个压缩器暴露同一套无关 API。
+- **把估算保留在 `CompactionEngine` 内**——不予采纳,因为计量拥有独立于压缩的消费方与回放语义;它还会强迫每个压缩器暴露同一套无关 API。
- **立即把 token meter 拆成接口与启发式后端**——不予采纳,因为目前只有一种实现。单个具体服务保留未来的 seam,同时避免推测性的包与配置。
-- **把模型键控窗口与密度 profile 放进 meter**——不予采纳,因为回放估算不拥有模型路由或容量事实。路由所属适配器公开容量,compact-basic 则拥有消费方专用的阈值与保留策略。
+- **把模型键控窗口与密度 profile 放进 meter**——不予采纳,因为回放估算不拥有模型路由或容量事实。路由所属适配器公开容量,compaction-basic 则拥有消费方专用的阈值与保留策略。
- **保留独立的标量与表层计量**——不予采纳,因为消费方必须为一次决策执行两次读取并匹配修订号。仅读取标量可以避免在低于阈值时复制节点,但拆分 API 会在消费方引入竞态窗口;统一快照接受 O(surface) 复制成本,以换取结果一致性。
- **在不同信封之间移用提供方 usage**——不予采纳,因为模型、工具、前缀与调用配置都是请求事实。不匹配时会重新定价完整当前请求。
## 后果
- Token 压力拥有一个可供压缩与未来插件共享的回放感知所有者。
-- 默认值让 meter 成为零配置组合项;部署在各个路由所属适配器上配置容量,并在 compact-basic 上配置可选策略覆盖。
+- 默认值让 meter 成为零配置组合项;部署时在各个路由所属适配器上配置容量,并在 compaction-basic 上配置可选策略覆盖。
- 固定启发式定价仍然只是提供方行为的估计,并不是精确分词器或请求序列化器。
-- 每次计量都会复制当前的位置表层,因此成本为 O(surface),低于阈值即可结束的压力检查也不例外。
+- 每次计量都会复制当前带位置信息的表层,因此成本为 O(surface),低于阈值即可结束的压力检查也不例外。
- 遇到畸形持久边界时,计量会明确失败。这会把损坏的回放转化为具名集成错误,而不是让压力静默漂移。
-- post-step 压力检查读取精确记录的路由、工具与前缀边界;对于在成功 usage 锚点出现前就被拒绝的请求,提供方溢出分类仍是由适配器维护的兜底路径。
+- 步骤后压力检查读取精确记录的路由、工具与前缀边界;对于在成功 usage 锚点出现前就被拒绝的请求,提供方溢出分类仍是由适配器维护的兜底路径。
diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml
index 9c5d00eae0..342f2073ea 100644
--- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.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/architecture/2026-07-16-explicit-turn-cancellation.md
-2026-07-16-explicit-turn-cancellation.md: cce649976c9f4f596d5306b9fe8c3fd49a0e1adc
-2026-07-16-explicit-turn-cancellation.zh.md: 6f8b83fdb42af03c97dc2e8a9345a01acc6019fc
+2026-07-16-explicit-turn-cancellation.md: 86faad9929d3eb5b00e66bb1c46a2e35b135d954
+2026-07-16-explicit-turn-cancellation.zh.md: 5397b368628c196efc2b35c00855267246ac6e85
diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md
index cce649976c..86faad9929 100644
--- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md
+++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md
@@ -12,15 +12,15 @@ The [initiating Agent scope decision](2026-07-15-agent-initiator-scope.md) inten
## Decision
-Agent owns the runtime-only `AgentCancelCause` union `{ kind: 'user' } | { kind: 'parent' }`; `agent.cancel()` defaults to `user`. TypeScript enforces that vocabulary at this typed same-process seam, with no runtime validator, fallback, or special compatibility contract for untyped callers. An active `TurnCancellation` copies the typed discriminant into a fresh frozen signal reason; idle cancellation has no holder to mutate and does not arm later work.
+Agent owns the runtime-only `AgentCancelCause` union `{ kind: 'user' } | { kind: 'parent' }`; `agent.cancel()` defaults to `user`. TypeScript enforces that vocabulary at this typed same-process boundary, with no runtime validator, fallback, or special compatibility contract for untyped callers. An active `TurnCancellation` copies the typed discriminant into a fresh frozen signal reason; idle cancellation has no holder to mutate and does not arm later work.
An interrupted live turn ends with the coarse durable `{ kind: 'aborted' }` outcome. The terminal event records what happened to the turn, while the runtime signal identifies who requested cancellation; it does not duplicate `user` or `parent` into replay. Session seed/load rejects legacy aborted records with a reason or any other extra field, so replay cannot reintroduce caller-owned cancellation detail. The process-local `agent/cancel-requested` notification is not durable; a future audit requirement uses a separate durable control-request event so a request and its eventual outcome remain distinct. Durable events contain no stack, signal, error object, free-form cancellation text, or backend-private detail.
AgentLoop privately owns one `TurnCancellation` per prospective turn. It installs the holder before notifying `agent/status = running`, retains its single `AbortController` through inbox claim, `agent/pre-step`, prompt assembly, every step, model and tool execution, and `agent/turn-stopping`, then clears the exact holder immediately before publishing `turn/end`. Terminal event observers and the following durability flush therefore cannot cancel already-completed turn work even though driver status may remain `running` until the flush settles. Every participating method, event, and request value receives that same explicit signal; the next turn receives a fresh signal.
-The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer belongs to the next turn. If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work.
+The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer is latched and runs when the aborted activity converges to idle — a `disposed` cancel leaves it parked ([cancel-convergence wake latch](../bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work.
-The explicit event signatures keep their positional form and place `signal` inside `PreStepContext` or immediately before a waterfall's final `next`. Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn.
+The explicit event signatures pass a single payload object: agent-scoped events carry `agent` and `signal` in the payload with `next` last, and the remaining APIs keep `signal` immediately before a waterfall's final `next`. `PreStepContext` and `RequestFailureContext` are retired, with their fields folded into the `agent/pre-step` and `agent/request-error` payloads ([payload-object events](2026-08-06-agent-event-payload-objects.md)). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn.
`ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a canonical frozen cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam.
@@ -40,16 +40,16 @@ Initiator-scope tests assert that every hook still observes the exact Agent and
**Persist a free-form string reason.** Strings admit spelling drift, prevent exhaustive switching, and encourage consumers to parse presentation text. The runtime uses a closed discriminated union, while the terminal record needs only the stable aborted outcome.
-**Persist the typed caller cause in `turn/end`.** No production replay, UI, ACP, telemetry, or workflow consumer distinguishes `user` from `parent`. Copying the request source into the terminal result would conflate two facts and add Session-specific validation without a consumer; a future audit surface can record a separate cancellation-request event.
+**Persist the typed caller cause in `turn/end`.** No production replay, UI, ACP, telemetry, or workflow consumer distinguishes `user` from `parent`. Copying the request source into the terminal result would conflate two facts and add Session-specific validation without a consumer; a future audit trail can record a separate cancellation-request event.
**Define speculative `superseded`, `timeout`, and `shutdown` variants now.** No current Agent cancellation producer implements those semantics. `shutdown` is already lifecycle disposal, and timeout or supersession should enter the union only with an owning policy and unique terminal meaning.
-**Expose public turn or step context wrappers.** Existing positional seams already identify Agent, turn, and step. A wrapper would widen every API, duplicate ownership, and tempt callers to treat a captured object as durable authority.
+**Expose public turn or step context wrappers.** Existing seams already identify Agent, turn, and step. A wrapper would widen every API, duplicate ownership, and tempt callers to treat a captured object as durable authority.
-**Abandon uncooperative work after a grace period.** Returning idle while same-process work still runs breaks teardown and resource-ownership guarantees. Hard termination requires a worker or process isolation boundary and is outside this control seam.
+**Abandon uncooperative work after a grace period.** Returning idle while same-process work still runs breaks teardown and resource-ownership guarantees. Hard termination requires a worker or process isolation boundary and is outside this control boundary.
## Consequences
-Cancellation has one runtime owner, one signal per live turn, and one typed runtime caller vocabulary. Session retains the coarse `aborted` outcome that its consumers actually use, rejects reason-bearing legacy forms, and stays isolated from runtime objects. Cooperative cancellation reaches every asynchronous turn seam, including work before the first step and after the last one, while terminal publication and persistence remain outside its authority.
+Cancellation has one runtime owner, one signal per live turn, and one typed runtime caller vocabulary. Session retains the coarse `aborted` outcome that its consumers actually use, rejects reason-bearing legacy forms, and stays isolated from runtime objects. Cooperative cancellation reaches every asynchronous turn extension point, including work before the first step and after the last one, while terminal publication and persistence remain outside its authority.
The explicit signal adds parameters to several public events and requires plugins to forward cancellation deliberately. This is intentional: authority is visible at the call boundary, lifetime matches the turn, and stale ambient descendants cannot acquire control. Uncooperative in-process work may delay cancellation, but the reported quiescent state remains truthful.
diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md
index 6f8b83fdb4..5397b36862 100644
--- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md
@@ -6,21 +6,21 @@ Status: implemented
## 问题
-取消是一种生命周期短于 Agent(智能体)驱动器的控制能力。自由文本字符串无法穷尽地区分调用方,步骤级控制器也无法中断提示词提交、提示词组装、继续决策或轮次终止策略。持久化 `Error`、`AbortSignal.reason` 或后端私有对象还会向持久化回放暴露不稳定的运行时细节。
+取消是一种生命周期短于 Agent(智能体)驱动器的控制能力。自由文本字符串无法完整区分所有调用方,步骤级控制器也无法中断提示词提交、提示词组装、继续决策或轮次终止策略。持久化 `Error`、`AbortSignal.reason` 或后端私有对象还会向持久化回放暴露不稳定的运行时细节。
[发起 Agent 作用域决策](2026-07-15-agent-initiator-scope.md)有意让 AsyncLocalStorage 只携带同一个 Agent。若把轮次、步骤或 signal 状态加入这个与驱动器同生命周期的边界,陈旧的异步后代就会看似仍对后续轮次拥有权限。因此,取消需要一个轮次归属方并显式传播,且不创建另一套环境上下文或公开的轮次包装层。
## 决策
-Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user' } | { kind: 'parent' }`;`agent.cancel()` 默认使用 `user`。TypeScript 在这个类型化的同进程 seam 中强制执行该词汇,不提供运行时校验器、后备行为,也不为无类型调用方提供特殊兼容性契约。活跃的 `TurnCancellation` 会把类型化判别字段复制为一个全新且已冻结的 signal 原因;空闲状态下没有可修改的持有者,也不会让后续工作预先进入取消状态。
+Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user' } | { kind: 'parent' }`;`agent.cancel()` 默认使用 `user`。TypeScript 在这个类型化的同进程边界中强制执行该词汇,不提供运行时校验器、后备行为,也不为无类型调用方提供特殊兼容性约定。活跃的 `TurnCancellation` 会把类型化判别字段复制为一个全新且已冻结的 signal 原因;空闲状态下没有可修改的持有者,也不会让后续工作预先进入取消状态。
正在运行的轮次被中断后,以粗粒度的持久化结果 `{ kind: 'aborted' }` 结束。终态事件记录轮次发生了什么,运行时 signal 标识谁请求了取消;回放不会重复保存 `user` 或 `parent`。会话 seed/load 会拒绝携带取消原因或任何其他额外字段的旧式中止记录,因此回放无法重新引入由调用方持有的取消细节。仅限进程内的 `agent/cancel-requested` 通知不会持久化;未来若有审计需求,应使用独立的持久化控制请求事件,让请求与最终结果保持为两项事实。持久化事件不包含调用栈、signal、错误对象、自由文本取消原因或后端私有细节。
AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖 inbox 领取、`agent/pre-step`、提示词组装、每个步骤、模型与工具执行以及 `agent/turn-stopping`;随后在发布 `turn/end` 前立即清除所安装的那个持有者。因此,即使驱动器状态可能在持久化刷新结算前保持 `running`,终态事件观察者及其后的持久化刷新也无法取消已完成的轮次工作。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。
-对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作属于下一个轮次。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。
+对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作会被锁存,并在被中止的活动收敛到空闲时执行——`disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../bug-fix/2026-08-07-cancel-convergence-wake-latch.md))。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。
-显式事件签名保留位置参数形式,并把 `signal` 放入 `PreStepContext`,或放在 waterfall(瀑布式事件)的最后一个参数 `next` 之前。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。
+显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 API 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.md))。进入 pre-step 时、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。
`ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。
@@ -30,7 +30,7 @@ Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时
## 验证
-契约测试验证类型化调用方联合类型、冻结且与调用方分离、默认行为与首次请求优先行为、粗粒度的会话 JSON 往返与旧式记录拒绝、ACP `user`、进程内 subagent `parent` 以及 dispose 优先级。AgentLoop 测试让协作式监听器在 pre-step、系统提示词组装、请求、模型流、请求错误恢复、工具执行和轮次停止处等待 signal;并断言同一轮次使用一个 signal,不同轮次使用全新的 signal,终态发布期间和持久化刷新受阻期间不存在取消权限。真实钩子桥接器测试会在报告空闲状态前取消并回收受阻的提示词钩子。
+约定测试验证类型化调用方联合类型、冻结且与调用方分离、默认行为与首次请求优先行为、粗粒度的会话 JSON 往返与旧式记录拒绝、ACP `user`、进程内 subagent `parent` 以及 dispose 优先级。AgentLoop 测试让协作式监听器在 pre-step、系统提示词组装、请求、模型流、请求错误恢复、工具执行和轮次停止处等待 signal;并断言同一轮次使用一个 signal,不同轮次使用全新的 signal,终态发布期间和持久化刷新受阻期间不存在取消权限。真实钩子桥接器测试会在报告空闲状态前取消并回收受阻的提示词钩子。
发起方作用域测试断言所有钩子仍观察到同一个 Agent 且没有环境中的轮次 signal,并发 Agent 保持独立的身份与 signal,嵌套子驱动只遮蔽身份。竞态测试覆盖空闲状态取消、运行前取消、从 `running` 监听器提交替代提示词、重复取消以及取消与 dispose 竞争下的完全停稳。
@@ -40,16 +40,16 @@ Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时
**持久化自由文本原因。** 字符串允许拼写漂移、阻碍穷尽分支判断,还会鼓励消费方解析展示文本。运行时使用封闭的可辨识联合类型,终态记录只需要稳定的中止结果。
-**在 `turn/end` 中持久化类型化调用方取消原因。** 当前没有任何生产环境中的回放、UI、ACP、遥测或工作流消费方区分 `user` 与 `parent`。把请求来源复制到终态结果会混淆两项事实,还会在没有消费方的情况下引入会话特有校验;未来的审计接口可以记录独立的取消请求事件。
+**在 `turn/end` 中持久化类型化调用方取消原因。** 当前没有任何生产环境中的回放、UI、ACP、遥测或工作流消费方区分 `user` 与 `parent`。把请求来源复制到终态结果会混淆两项事实,还会在没有消费方的情况下引入会话特有校验;未来的审计记录可以包含独立的取消请求事件。
**现在就定义推测性的 `superseded`、`timeout` 和 `shutdown` 变体。** 当前没有 Agent 取消生产方实现这些语义。`shutdown` 已经属于生命周期 dispose;超时或替代只有在拥有明确归属策略和唯一终态含义时才应进入联合类型。
-**公开轮次或步骤上下文包装类型。** 现有位置参数 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。
+**公开轮次或步骤上下文包装类型。** 现有 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。
-**在宽限期后放弃不协作的工作。** 同进程工作仍在运行时就报告空闲状态,会破坏资源清理与资源归属保证。硬终止需要 worker 或进程隔离边界,不属于该控制 seam。
+**在宽限期后放弃不协作的工作。** 同进程工作仍在运行时就报告空闲状态,会破坏资源清理与资源归属保证。硬终止需要 worker 或进程隔离边界,不属于该控制边界。
## 后果
-取消拥有一个运行时归属方、每个活跃轮次一个 signal,以及一套类型化的运行时调用方词汇。会话保留其消费方实际使用的粗粒度 `aborted` 结果,拒绝携带原因的旧式形式,并与运行时对象保持隔离。协作式取消覆盖每个异步轮次 seam,包括第一个步骤之前和最后一个步骤之后的工作,而终态发布和持久化仍在其权限范围之外。
+取消拥有一个运行时归属方、每个活跃轮次一个 signal,以及一套类型化的运行时调用方词汇。会话保留其消费方实际使用的粗粒度 `aborted` 结果,拒绝携带原因的旧式形式,并与运行时对象保持隔离。协作式取消覆盖每个异步轮次扩展点,包括第一个步骤之前和最后一个步骤之后的工作,而终态发布和持久化仍在其权限范围之外。
显式 signal 会给多个公开事件增加参数,并要求插件有意识地转发取消。这是有意设计:权限在调用边界可见,生命周期与轮次匹配,陈旧的环境异步后代无法获得控制能力。不协作的进程内工作可能延迟取消,但所报告的完全停稳仍然真实。
diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml
index 2f56298d57..e772ff9036 100644
--- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.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/architecture/2026-07-19-cooperative-tool-cancellation.md
-2026-07-19-cooperative-tool-cancellation.md: be237f6ca9475699bb4af76896772a1a7409033d
-2026-07-19-cooperative-tool-cancellation.zh.md: dbc74588931a2ae75678b0026df7a8175b0d20b6
+2026-07-19-cooperative-tool-cancellation.md: 781202688a5cbcd7076ee694fc7dd9489683d8e8
+2026-07-19-cooperative-tool-cancellation.zh.md: a5ce2e671e92f4758ddeb3d3556d8af574467f7f
diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md
index be237f6ca9..781202688a 100644
--- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md
+++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md
@@ -42,11 +42,11 @@ The registry first creates the call token, snapshots the visible definition's op
Once a tool body starts, the registry awaits it. Cancellation reaches the body through the fused signal but never races or abandons its promise. A cooperative implementation stops or forwards cancellation and settles after its owned work reaches quiescence; an uncooperative same-process implementation can keep the registry pending indefinitely. Process, worker, network, and provider layers retain responsibility for their own termination mechanisms.
-This decision requires cancellation at the tool invocation seam only. Making signals required on asynchronous capabilities reachable from tool bodies is a separate migration proposed in [Required cancellation through tool-reachable capability seams](../../proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md).
+This decision requires cancellation at the tool invocation boundary only. Making signals required on asynchronous capabilities reachable from tool bodies is a separate migration proposed in [Required cancellation through tool-reachable capability seams](../../proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md).
## Verification
-[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`code-mode.spec.ts`](../../../../packages/core/tools/tests/code-mode.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/timeout/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership.
+[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`code-mode.spec.ts`](../../../../packages/core/tools/tests/code-mode.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership.
No registry test can prove that arbitrary third-party same-process code observes the signal or stops in bounded time. Capability tests continue to prove cancellation and quiescence at the boundary that owns each side effect.
@@ -54,7 +54,7 @@ No registry test can prove that arbitrary third-party same-process code observes
**Keep the signal optional and synthesize a fallback.** Rejected because a registry-owned fallback has no caller lifetime to represent and preserves the exact omission the type should prevent.
-**Validate `AbortSignal` at runtime.** Rejected because this is a typed same-process seam, not a serialization boundary. Runtime checks would duplicate the static contract without making cooperative use enforceable.
+**Validate `AbortSignal` at runtime.** Rejected because this is a typed same-process boundary, not a serialization boundary. Runtime checks would duplicate the static contract without making cooperative use enforceable.
**Add `supportsCancellation` metadata, callback-arity checks, or signal-use linting.** Rejected because none proves that asynchronous work observes or correctly forwards cancellation. Availability is a type contract; behavior remains a tool and capability responsibility.
diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md
index dbc7458893..a5ce2e671e 100644
--- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md
@@ -18,7 +18,7 @@ Status: implemented
`ToolDefinition.execute(args, exec)` 保持现有签名。`defineTool()` 会把 `exec.signal` 上下文推断为必填的 `AbortSignal`,因此每个已注册的 TypeScript 工具都能在无需类型断言的情况下观察或转发取消。所有第一方直接调用方和 Code Mode 嵌套调度都会显式传入当前操作的信号。
-注册表信任这份类型化同进程契约。它不在运行时校验 `AbortSignal`,也不为缺失或畸形信号添加敌意输入测试。校验仍位于解析器与配置、模型与工具 JSON、持久化与文件、worker、进程和协议边界;违反 TypeScript 接口的无类型 JavaScript 不享有兼容性契约。
+注册表信任这份类型化同进程约定。它不在运行时校验 `AbortSignal`,也不为缺失或畸形信号添加敌意输入测试。校验仍位于解析器与配置、模型与工具 JSON、持久化与文件、worker、进程和协议边界;违反 TypeScript 接口的无类型 JavaScript 不享有兼容性约定。
### 可变性由流水线阶段决定
@@ -42,11 +42,11 @@ Status: implemented
工具主体一旦启动,注册表就会等待它完成。取消通过融合信号到达工具主体,但注册表不会与其 promise 竞速或丢弃该 promise。协作式实现会停止自身工作或继续转发取消,并在所持有的工作完全停稳后完成;不协作的同进程实现可能让注册表无限期保持等待。进程、worker、网络和提供方层仍负责各自的终止机制。
-这项决策只要求工具调用 seam 携带取消信号。让工具主体可达的异步能力也必须接收信号,属于另一项迁移,见提议中的[工具可达能力 seam 中的必填取消](../../proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md)。
+这项决策只要求工具调用边界携带取消信号。让工具主体可达的异步能力也必须接收信号,属于另一项迁移,见提议中的[工具可达能力 seam 中的必填取消](../../proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md)。
## 验证
-[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`code-mode.spec.ts`](../../../../packages/core/tools/tests/code-mode.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/timeout/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。
+[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`code-mode.spec.ts`](../../../../packages/core/tools/tests/code-mode.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。
任何注册表测试都无法证明任意第三方同进程代码会观察信号或在有界时间内停止。各能力的测试仍需在拥有相应副作用的边界证明取消与完全停稳。
@@ -54,15 +54,15 @@ Status: implemented
**保留可选信号并生成后备值。** 不予采纳,因为注册表持有的后备信号不代表任何调用方生命周期,也会保留类型系统本应阻止的缺失情况。
-**在运行时校验 `AbortSignal`。** 不予采纳,因为这是类型化同进程 seam,不是序列化边界。运行时检查只会重复静态契约,仍无法强制实现协作式使用信号。
+**在运行时校验 `AbortSignal`。** 不予采纳,因为这是类型化同进程边界,不是序列化边界。运行时检查只会重复静态约定,仍无法强制实现协作式使用信号。
-**添加 `supportsCancellation` 元数据、回调参数数量检查或信号使用 lint。** 不予采纳,因为这些方法都无法证明异步工作会观察或正确转发取消。信号可用性属于类型契约;具体行为仍由工具和能力负责。
+**添加 `supportsCancellation` 元数据、回调参数数量检查或信号使用 lint。** 不予采纳,因为这些方法都无法证明异步工作会观察或正确转发取消。信号可用性属于类型约定;具体行为仍由工具和能力负责。
**向所有阶段公开同一个可变执行类型。** 不予采纳,因为观察者和工具实现只需要借用信号。按阶段划分类型可以把替换权限限制在流水线拥有该操作的位置。
**禁止环绕包装层替换信号。** 不予采纳,因为截止时间和嵌套操作作用域需要词法派生信号。捕获并融合调用方信号既保留组合能力,也不允许切断调用方取消。
-**让工具 promise 与取消竞速。** 不予采纳,因为这种方式会在副作用仍可能存活时报告完成,违反[资源释放必须完全停稳的规则](../../../../docs/defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it)。
+**让工具 promise 与取消竞速。** 不予采纳,因为这种方式会在副作用仍可能存活时报告完成,违反[dispose(资源释放)必须完全停稳的规则](../../../../docs/defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it)。
## 后果
diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml
index bdb07d5f8a..1423e0f404 100644
--- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.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/architecture/2026-07-19-gui-layering-and-rpc-protocol.md
-2026-07-19-gui-layering-and-rpc-protocol.md: 34077302c53081f6ee9171d64dce9af342710d71
-2026-07-19-gui-layering-and-rpc-protocol.zh.md: bc51542ac8159ee7cba234b4ee8b4db47a7f9b58
+2026-07-19-gui-layering-and-rpc-protocol.md: 22bebb0951ca3ffa0a0679396c4c54ef32eca279
+2026-07-19-gui-layering-and-rpc-protocol.zh.md: c38328cc2e692e8eed44a3dbf4149207820265c9
diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md
index 34077302c5..22bebb0951 100644
--- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md
+++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md
@@ -4,16 +4,16 @@ Status: implemented
English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md)
-> Division of labor: this document = the layering model + the channel-independent RPC protocol; the protocol's Web implementation combines HTTP uplink with the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md), while the browser object layer is in the [web client architecture RFC](2026-07-19-gui-web-client-architecture.md).
+> Division of labor: this document = the layering model + the channel-independent RPC protocol; the protocol's Web implementation combines HTTP uplink with the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md), while the browser object layer is in the [web client architecture note](2026-07-19-gui-web-client-architecture.md).
## Problem
-We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product UI shapes are coming — Web (server), Electron, and others. We call these shapes Clients, uniformly, and want the following capabilities:
+We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product clients are coming — Web (server), Electron, and others. We call them Clients and want the following capabilities:
-- One `dsh` process supporting both `dsh web` (serve) and `dsh -p` (headless) — one process, two modes (a design reservation)
-- Launching inside Electron with the same Web technology shape as `dsh web`
+- One `dsh` process supporting both `dsh web` (serve) and `dsh --profile headless` (headless) — one process, two modes (a design reservation)
+- Launching inside Electron with the same Web technologies as `dsh web`
-That demands a stable layered responsibility model in the engineering codebase, so future client shapes plug in cleanly.
+That demands a stable layered responsibility model in the engineering codebase, so future clients plug in cleanly.
At the same time the physical channels differ per consumer (browser HTTP/WebSocket, in-process fetch/SSE, IPC later), so we also need a channel-independent message model and a single contract source of truth — "adding a method" and "swapping a carrier" must not entangle each other, and every message on the wire must be type-validatable, observable, and reconcilable.
@@ -25,23 +25,23 @@ Directories layer as follows:
- `packages/host/*`: packages provide host-side capability only (representing the Node.js engineering core built on the existing harness plugin system), and additionally
- the unified backend protocol (fetch, HTTP, streaming interfaces…) — definitions and support, see the "Message protocol" sections below
-- `packages/client/*`: packages provide client-side capability only; every package stays single-sided. Three kinds live here (the axes are owned by the [client plugin loading RFC](2026-07-23-client-plugin-loading-model.md)):
+- `packages/client/*`: packages provide client-side capability only; every package stays single-sided. Three kinds live here (the axes are owned by the [client plugin loading note](2026-07-23-client-plugin-loading-model.md)):
- **Pure libraries** (`ui-slots`, `web-react`, `ui-primitives`, plus the `loader` kernel package): ordinary root-index packages, statically bundled into the shell; the first three are seeded into the module table.
- - **Static-arrival entry packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `hmr`): no `dshClient` key and no browser bundle — the shell bundles their `src/client/` half and registers it with `ctx.modules`; they are governed as entries of the host-authored graph like everything else.
- - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services.
-- `apps/` holds the externally exported application shapes, assembled from Client / Host mixtures.
- - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`.
- - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = startHost + webserver + the built `dsh-frontend` dist; `dsh -p` = headless in-process calls, zero HTTP.
- - A future Electron shape reuses the same web client packages over an IPC fetch carrier.
+ - **Static-arrival entry packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `hmr`): no `dsh.client` key and no browser bundle — the shell bundles their `src/client/` half and registers it with `ctx.modules`; they are governed as entries of the host-authored graph like everything else.
+ - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dsh.client` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services.
+- `apps/` holds the externally exported applications, assembled from Client / Host mixtures.
+ - `apps/web` (`dsh-web-frontend`) is the vite application: a thin `main.ts` over the shell API exported by `dsh-client-web`.
+ - `apps/cli` (`@deepseek-ai/dsh`) dispatches commands: `dsh web` = Host + webserver + the built `dsh-web-frontend` dist; `dsh --profile headless` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer.
+ - A future Electron application reuses the same web client packages over an IPC fetch carrier.
```
-apps/* (application shapes: apps/web = vite app, apps/cli = bin dispatch)
+apps/* (applications: apps/web = vite app, apps/cli = bin dispatch)
│ consume
▼
packages/host/* packages/client/*
apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives
- runtime assembly / host entity dshClient plugins ×8 (node half = empty apply,
- webserver web-shape HTTP carriage client half = src/client/)
+ runtime assembly / host entity dsh.client plugins ×8 (node half = empty apply,
+ webserver Web HTTP carriage client half = src/client/)
│ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths
▼ │ (type-only + the client base class)
harness core packages ──────────────────┘ (types reach the browser via import type)
@@ -51,35 +51,35 @@ Direction discipline (every rule auditable from package deps):
- `runtime → apiproxy` is one-way; apiproxy depends only on type definitions.
- Client-side packages **never import** host-side package runtime (they consume only the two browser-safe subpaths `/api` and `/client`).
-- `webserver` does not depend on `runtime`: it provides a `{ fetch }`-shaped implementation — "webserver ← runtime" is a runtime injection relationship, not a package dependency.
-- Cross-package client imports use the `/client` subpath for plugin packages, and between plugin packages they are type-only — a cross-plugin value import is a build error at the tsdown purity gate (value cooperation goes through cordis services; the [client plugin loading RFC](2026-07-23-client-plugin-loading-model.md) owns the edge rules).
+- `webserver` does not depend on `runtime`: it provides an implementation of the `{ fetch }` interface — "webserver ← runtime" is a runtime injection relationship, not a package dependency.
+- Cross-package client imports use the `/client` subpath for plugin packages, and between plugin packages they are type-only — a cross-plugin value import is a build error at the tsdown purity gate (value cooperation goes through cordis services; the [client plugin loading note](2026-07-23-client-plugin-loading-model.md) owns the edge rules).
TypeScript checks in **two aggregate programs** referenced by a solution root (`tsconfig.json` = solution; `tsconfig.host.json` = host side + tests, excluding `packages/client`; `tsconfig.client.json` = client packages and their tests): both sides merge the cordis `Context` interface under the same keys (`sessions`, `loader`) with different services, so one program would see both declaration merges and report a collision. Shared leaves (session/llm/tools/apiproxy…) build once and are referenced by both programs ([topology](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md)).
-On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Node dependencies, browser-importable); wire messages unify under a **bidirectional model** — each logical message is shaped by "who initiates × request/response" (two axes, four cells, called the four quadrants below), decoupled from the physical channel; clients all inherit `AbstractApiClient` (protocol invariants live entirely in the base class, platform differences are just the `doFetch` transport aspect).
+On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Node dependencies, browser-importable); wire messages unify under a **bidirectional model** — each logical message is classified by "who initiates × request/response" (two axes, four cells, called the four quadrants below), decoupled from the physical channel; clients all inherit `AbstractApiClient` (protocol invariants live entirely in the base class, platform differences are just the `doFetch` transport aspect).
#### Layer roles
| Layer | Package | Responsibility | Key discipline |
|---|---|---|---|
| Front layer | `dsh-host-apiproxy` | TS/zod definitions (api/) + the fetch abstraction (fetch/: handler + client base class) | Keep it simple — every consumer needs it; importable from Node and browser alike; protocol content in the "Message protocol" sections below; clients must not bypass api through ctx |
-| Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dshClient packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly |
-| Carrier layer | `dsh-host-webserver` | Web-shape HTTP and upgrade: static serving + `/api/*`→handler forwarding + WebSocket upgrade route + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it |
+| Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dsh.client packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly |
+| Carrier layer | `dsh-host-webserver` | Web HTTP and upgrade: static serving + `/api/*`→handler forwarding + WebSocket upgrade route + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it |
| Client libraries | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | Slot registry core / ctx↔React glue / pure React atoms | Zero cordis runtime dependency in components; seeded into the loader module table by the shell |
-| Client plugins | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | Browser-side cordis plugin tree (wire consumer, core services, theme, i18n, layout, sidebar, conversation, trajectory) — see the web client architecture RFC | Dual entry (node half = empty apply; implementation in `src/client/`); the consumption face goes exclusively through ApiProxy |
-| Application shape | `@deepseek-ai/dsh` (apps/cli) + `dsh-frontend` (apps/web, the vite application) | Coarse bin dispatch + one assembly module per shape (web.ts / headless.ts); the vite app is a thin main over the `dsh-client-web` shell surface | Shapes dynamic-import so they never load each other; workspace knowledge like dist location stays in the app |
+| Client plugins | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | Browser-side cordis plugin tree (wire consumer, core services, theme, i18n, layout, sidebar, conversation, trajectory) — see the web client architecture note | Dual entry (node half = empty apply; implementation in `src/client/`); the consumption face goes exclusively through ApiProxy |
+| Application | `@deepseek-ai/dsh` (apps/cli) + `dsh-web-frontend` (apps/web, the vite application) | Coarse bin dispatch + one assembly module per application (web.ts / headless.ts); the vite app is a thin main over the `dsh-client-web` shell surface | Applications use dynamic imports so they never load each other; workspace knowledge like dist location stays in the app |
#### Naming rule
Packages under `packages/host/*` and `packages/client/*` **must carry the directory-group prefix in the package name**: host/runtime → `dsh-host-runtime`, client/runtime → `dsh-client-runtime`. The directory name does not repeat the group prefix (host/ already expresses it). The package-name tail therefore ≠ the directory name, so the `dsh-*` wildcard in tsconfig.base.json (which resolves by directory name) misses them — **each package in these two groups needs an explicit paths entry**, including separate entries for the client packages' `/client` subpaths so source-level resolution matches the exports map.
-#### How to integrate a new shape (operational checklist)
+#### How to integrate a new application (operational checklist)
1. **Pick a fetch impersonation**: browser same-origin HTTP / in-process `host.handler.fetch` injection / your own transport-aspect subclass (e.g. future Electron IPC, see the "Subclass table" below).
-2. **Write an assembly module under `apps/`**: `startHost()` + a client subclass + the shape's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app.
+2. **Write an assembly module under `apps/`**: `startHost()` + a client subclass + the application's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app.
3. **Import `dsh-host-webserver` only if you need HTTP carriage**, otherwise zero ports.
-The two existing shapes are the template: `apps/cli/src/web.ts` (startHost + dist location + startWebServer + signal shutdown) and `headless.ts` (startHost + InProcessApiClient isomorphic direct calls, zero HTTP zero ports). ACP-class protocol bridges do not follow this checklist: they expose core to the external ecosystem, mount via `ctx.plugin(front-door plugin)` directly, and wear no fetch.
+The two existing applications preserve the division: the Web application mounts Host, carrier, and browser composition, while `dsh --profile headless` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem and mount directly via `ctx.plugin(entry-point plugin)` without fetch.
## Message protocol
@@ -116,7 +116,7 @@ Domain interface signatures perceive only the narrow forms: `RpcRequest = { r
### RpcReceipt: the carrier receipt
-The HTTP response body of a `ClientResponse` is `RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }` — a carrier-layer receipt, **not** an RpcMessage (a response has no response); late/duplicate answers get `not-pending`, and the logical convergence surface is the `*/resolved` frames.
+The HTTP response body of a `ClientResponse` is `RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }` — a carrier-layer receipt, **not** an RpcMessage (a response has no response); late/duplicate answers get `not-pending`, and the logical convergence point is the `*/resolved` frames.
## The type system: signatures are the source of truth
@@ -169,13 +169,13 @@ The remaining methods (`session.create`/`session.history`/`session.rename`/`sess
### Frames (server→client, named unions)
-Two logical streams: the mux stream (`/api/events.mux`, all-session aggregate) and the host stream (`/api/events.host`, host-level events). The browser consumes one downlink WebSocket per stream, while the in-process fetch carrier retains SSE to preserve the same shape; see the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) for the physical boundary. One example frame row:
+Two logical streams: the mux stream (`/api/events.mux`, all-session aggregate) and the host stream (`/api/events.host`, host-level events). The browser consumes one downlink WebSocket per stream, while the in-process fetch carrier retains SSE with the same event framing; see the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) for the physical boundary. One example frame row:
| frame type | payload | when |
|---|---|---|
| `session/event` | `{ sessionId; event: SessionEvent }` | core passthrough: core events pass verbatim, `assistant/chunk` IS the token stream, no separate delta frame |
-The remaining frame types are not re-copied here; the full unions are `MuxFrame`/`HostFrame` in `api/events.ts`. Three semantic points to know: `session/subscribed` carries lastSeq for history seam-race detection; the `approval/question` requested frames are answerable (stable rpcId) and the resolved frames are the convergence surface; `host/agent-error` is the only outlet for live failures with no turn position.
+The remaining frame types are not re-copied here; the full unions are `MuxFrame`/`HostFrame` in `api/events.ts`. Three semantic points to know: `session/subscribed` carries lastSeq for history-race detection; the `approval/question` requested frames are answerable (stable rpcId) and the resolved frames are the convergence surface; `host/agent-error` is the only outlet for live failures with no turn position.
**Passthrough discipline**: events/messages/content blocks on the wire ARE the core types (`SessionEvent`/`ContentBlock`) — no second DTO set; types reach the browser through the `import type` dependency chain. `SessionEventMap` is merge-extensible: the client applies its documented default (ignore) to unknown types, and the event schema keeps a "valid envelope + unknown type" branch — the envelope stays strict; this is not field-level passthrough.
@@ -183,11 +183,11 @@ The remaining frame types are not re-copied here; the full unions are `MuxFrame`
- **History = event replay**: one fold (client side); history pagination and live increments share one code path; the server maintains no second materialized-snapshot system. History **page boundaries align to message boundaries** (never cut mid-message; chunks group with their finalized message), and the tail page includes the in-flight partial's chunks.
- **Prompt correlation**: the prompt's rpcId rides MessageSource (`'user-rpc'`) into the `user/message` event; the client uses it to promote the optimistic echo.
-- **Reconnect = rebuild**: no resume cursor (`mux`'s `since` signature is a reserved seat, ignored if passed); on disconnect reopen the stream + refetch history; compare `subscribed.lastSeq` with the history tail seq and backfill once if there is a seam.
+- **Reconnect = rebuild**: no resume cursor (`mux`'s `since` signature is a reserved seat, ignored if passed); on disconnect reopen the stream + refetch history; compare `subscribed.lastSeq` with the history tail seq and backfill once if there is a gap.
- **Cold session handling follows ownership**: `session.history` and the source read for `session.fork` inspect persistence without an Agent, while Agent-bound ordinary-session methods such as `prompt` resume through a deduplicated in-flight table. Session-backed subagents reject that generic resume path, and attachment status is not exposed to clients (`running` already covers it).
- **Approvals/questions**: the requested frame mints a stable rpcId on acceptance; first answer wins, and the host's in-memory pending table (keyed by rpcId) is the only referee; after a mux reopen, still-pending requested frames replay after the subscribed frame (rpcId reused verbatim — refresh recovery). The audit events `approval/asked`/`decided` continue through the durable log — frames = the live control plane, events = the durable audit. **Status**: the contract and frame types are shipped; the host-side pending table/wire answerer is unimplemented (`respond` in `api-proxy.ts` is a stub, always `not-pending`); PendingCard v1 is display-only.
- **No protocol version**: client and host release bound together; `host.describe` has no protocolVersion field; introduce one when an independently released client appears.
-- **Reserved-seam discipline**: the map holds only implemented methods; an unknown method fails loud at envelope parse (`bad-request`) — no not-implemented fallback code. The reservation list (implementing = copy the signature into the domain interface + add the map row + add the schema pair): `session.fork`, `prompt.mode` gaining `'inject'`, `task.list`, `host.listModels`, describe gaining `hostInstanceId`. (`session.rename` graduated from this list: it appends a user-source `session/title` event.)
+- **Reserved-method discipline**: the map holds only implemented methods; an unknown method fails loud at envelope parse (`bad-request`) — no not-implemented fallback code. The reservation list (implementing = copy the signature into the domain interface + add the map row + add the schema pair): `session.fork`, `prompt.mode` gaining `'inject'`, `task.list`, `host.listModels`, describe gaining `hostInstanceId`. (`session.rename` graduated from this list: it appends a user-source `session/title` event.)
## The client carrier: the AbstractApiClient class family (`fetch/client.ts`)
@@ -215,14 +215,14 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation
| Subclass | Package | doFetch | Purpose |
|---|---|---|---|
-| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing — `dsh -p` headless is the protocol's second real consumer |
-| `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser shape; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) |
+| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing; carrier tests and callers can exercise the protocol without opening a port, while product `dsh --profile headless` drives core directly |
+| `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser client; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) |
| `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) |
-| (future) IPC bridge subclass | apps/electron | IPC serialization round trip | swaps only doFetch; contract and base class unchanged |
+| IPC bridge subclass (hypothetical example — no such shell exists) | an Electron shell | IPC serialization round trip | would swap only doFetch; contract and base class unchanged |
## How to extend (operational checklists)
-**Add a unary method (5 steps)**: ① add the method signature to the domain interface (parameters/return inline — this is the single source of truth); ② add one `RpcMethodMap` row; ③ add the request/value schema pair in `.schema.ts` (anchored `Wire>`); ④ add one handler `UNARY_ROUTES` row (the handler's Web carriage is in the web client architecture RFC); ⑤ implement in the impl (echo `request.rpcId`). On the client side, add the passthrough row to the `IApiClient`/`AbstractApiClient` domain method tables.
+**Add a unary method (5 steps)**: ① add the method signature to the domain interface (parameters/return inline — this is the single source of truth); ② add one `RpcMethodMap` row; ③ add the request/value schema pair in `.schema.ts` (anchored `Wire>`); ④ add one handler `UNARY_ROUTES` row (the handler's Web carriage is in the web client architecture note); ⑤ implement in the impl (echo `request.rpcId`). On the client side, add the passthrough row to the `IApiClient`/`AbstractApiClient` domain method tables.
**Add a frame type (3 steps)**: ① add a branch to the `MuxFrame`/`HostFrame` union (answerable frames must note the stable-rpcId semantics); ② add a frame-schema branch; ③ the consumers' fold/routing documented-default already covers unknown types — add an explicit branch as needed.
@@ -230,22 +230,22 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation
**Plug in a new carrier**: subclass `AbstractApiClient` implementing only `doFetch`; to intercept at the protocol layer (like the fixture), override the `callUnary`/`openMux`/`openHost` virtuals instead. Contract and base class stay unchanged.
-**Promote a reserved seam**: copy the reserved signature into the domain interface → add the map row → add the schema pair → add the UNARY_ROUTES row → implement.
+**Promote a reserved method**: copy the reserved signature into the domain interface → add the map row → add the schema pair → add the UNARY_ROUTES row → implement.
## Consequences
-Every client shape consumes one contract: adding a unary method is a five-step mechanical change radiating from a single signature, swapping a carrier touches only a `doFetch` subclass, and every wire message is zod-validated, observable through the envelope tap, and reconcilable by rpcId. Ordinary unary calls remain bounded, while `host.pickDirectory` and `command.execute` may stay pending until the operation finishes or caller/connection cancellation arrives; this accepts that a non-cooperative user-paced operation can hang its request rather than treating valid operation duration as transport failure. The other accepted costs: two groups of packages need explicit tsconfig paths entries, and the reserved seams (fork/inject/task.list/listModels/hostInstanceId) stay dormant until a real consumer arrives.
+Every client consumes one contract: adding a unary method is a five-step mechanical change from a single signature, swapping a carrier touches only a `doFetch` subclass, and every wire message is zod-validated, observable through the envelope tap, and reconcilable by rpcId. Ordinary unary calls remain bounded, while `host.pickDirectory` and `command.execute` may stay pending until the operation finishes or caller/connection cancellation arrives; this accepts that a non-cooperative user-paced operation can hang its request rather than treating valid operation duration as transport failure. The other accepted costs: two groups of packages need explicit tsconfig paths entries, and the reserved methods (fork/inject/task.list/listModels/hostInstanceId) stay dormant until a real consumer arrives.
## Alternatives considered
| Rejected | One-line reason |
|---|---|
-| Packaging by "product shape" (a web family, an electron family) | What shapes share is host/client capability, not the shape itself; capability-provider layering means a new shape needs zero new packages |
+| Packaging by product (a web family, an electron family) | Products share host/client capabilities rather than an application implementation; capability-provider layering means a new application needs zero new packages |
| A package per mixture (e.g. a standalone headless package) | A mixture has exactly one consumer (its own app); packaging it is ownerless abstraction, while assembly in the app is readable and disposable |
-| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | A second command plane bypasses the contract, losing wire validation/observability/multi-client consistency; ctx keeps exactly two formal uses — front doors and headless event subscription |
+| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | Clients require wire validation, observability, and multi-client consistency. Direct headless is a local entry point with no client boundary and uses the public Agent/Session seams rather than a client command plane |
| webserver depending on runtime (saving the handler injection) | Structural-typing injection keeps webserver reusable by sidecars/tests with zero workspace deps; a package dependency would drag assembly knowledge into the carrier layer |
| Package names without the group prefix (continuing dsh-) | `dsh-runtime`/`dsh-web-ui` lose their belonging in the flat npm namespace; the cost is one explicit paths entry per package |
-| Reusing the in-repo JSON-RPC 2.0 (dsh-jsonrpc) | Numeric error codes degrade to a single fallback code, contracts get aligned by hand in two copies, and naming drifts without a convention |
+| Reusing the in-repo JSON-RPC 2.0 (dsh-sdk-jsonrpc-server) | Numeric error codes degrade to a single fallback code, contracts get aligned by hand in two copies, and naming drifts without a convention |
| A three-envelope model (Request/Response/Frame envelopes, signatures direction-blind) | rpcId correlation is logical-layer; frame and response direction semantics inferred from the channel break the moment the carrier changes |
| Named Request/Response type pairs as the source of truth (map registering type pairs) | Flat named types are a second name for the same fact; signature inference makes adding a method a one-place change |
| REST-style paths | The consumer is our own client with no third-party REST expectations; RPC mapping straight onto the method table is more mechanical |
diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md
index bc51542ac8..c38328cc2e 100644
--- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md
@@ -1,45 +1,45 @@
-# RFC: GUI 分层与 RPC 协议——host/client 按能力支持方分层、四象限消息模型与 fetch 载体
+# Agent Note: GUI 分层与 RPC 协议——host/client 按能力提供方分层、四象限消息模型与 fetch 载体
Status: implemented
[English](2026-07-19-gui-layering-and-rpc-protocol.md) | 中文
-> 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现由 HTTP 上行加 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md)组成,浏览器对象层见 [Web 客户端架构 RFC](2026-07-19-gui-web-client-architecture.md)。
+> 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现由 HTTP 上行加 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md)组成,浏览器对象层见 [Web 客户端架构笔记](2026-07-19-gui-web-client-architecture.md)。
## Problem
-需要提供 UI 对接层,除已有 ACP/stdio基础版本外,还需要 Web(server) 、 Electron 、等其他产品 UI 形态。我们把这些形态统一称为 Client。希望有如下能力支持:
-- 以 `dsh` 进程,同时支持 `dsh web`(启动) 和 `dsh -p`(headless) ,一个进程两种模式(设计预留)
-- 以与 `dsh web` 同构的 Web 技术形态,在 Electron 中启动
+需要提供 UI 对接层,除已有 ACP(Agent Client Protocol)/stdio 基线外,还需要 Web(server)、Electron 等其他产品客户端。我们把它们统一称为 Client。希望具备以下能力:
+- 一个 `dsh` 进程同时支持 `dsh web`(启动)和 `dsh --profile headless`(headless),一个进程两种模式(设计预留)
+- 在 Electron 中使用与 `dsh web` 相同的 Web 技术启动
-那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client 形态。
+那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client。
-同时各消费端的物理通道不同(浏览器 HTTP/WebSocket、进程内 fetch/SSE、将来 IPC),还需要一个通道无关的消息模型和单一契约事实源,让「加一个方法」「换一种载体」互不牵连,且 wire 上的每条消息可类型校验、可观测、可对账。
+同时各消费方的物理通道不同(浏览器 HTTP/WebSocket、进程内 fetch/SSE、将来 IPC),还需要一个通道无关的消息模型和单一约定真源,让「加一个方法」「换一种载体」互不牵连,且 wire 上的每条消息可类型校验、可观测、可对账。
## Decision
### 分层
-目录按照如下分层:
-- `packages/host/*`: 包只提供 Host 侧能力(代表了以现在 Harness 实体插件系统为主体的 Node.js 代码核心工程),除此之外,还包含
+目录按照如下分层:
+- `packages/host/*`:包只提供 Host 侧能力(代表了以现在 Harness 实体插件系统为主体的 Node.js 代码核心工程),除此之外,还包含
- 统一后端协议(fetch、HTTP、流式接口等)定义和支持,见本篇「消息协议」起各节
-- `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住三类包(两条轴归 [client 插件装载 RFC](2026-07-23-client-plugin-loading-model.md) 所有):
+- `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住三类包(两条轴归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有):
- **纯库**(`ui-slots`、`web-react`、`ui-primitives`,外加内核包 `loader`):普通根入口包,静态打包进壳;前三者播种进模块表。
- - **静态到达 entry 包**(`connection`、`runtime`、`ui-theme`、`i18n`、`hmr`):无 `dshClient` 键、无浏览器 bundle——壳把它们的 `src/client/` 半边打进自己的 bundle 并向 `ctx.modules` 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。
- - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。
-- `apps/` 作为对外导出的应用形态入口,可以由 Client / Host 混合组装。
- - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。
- - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = startHost + webserver + 构建出的 `dsh-frontend` dist;`dsh -p` = headless 进程内直调,零 HTTP。
- - 将来的 Electron 形态经由 IPC fetch 载体复用同一套 web client 包。
+ - **静态到达 entry 包**(`connection`、`runtime`、`ui-theme`、`i18n`、`hmr`):无 `dsh.client` 键、无浏览器 bundle——壳把它们的 `src/client/` 半边打进自己的 bundle 并向 `ctx.modules` 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。
+ - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dsh.client` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。
+- `apps/` 作为对外导出的应用入口,可以由 Client / Host 混合组装。
+ - `apps/web`(`dsh-web-frontend`)是 vite 应用:`dsh-client-web` 导出的壳 API 之上的一层薄 `main.ts`。
+ - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-web-frontend` dist;`dsh --profile headless` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。
+ - 将来的 Electron 应用经由 IPC fetch 载体复用同一套 web client 包。
```
-apps/* (application shapes: apps/web = vite app, apps/cli = bin dispatch)
+apps/* (applications: apps/web = vite app, apps/cli = bin dispatch)
│ consume
▼
packages/host/* packages/client/*
apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives
- runtime assembly / host entity dshClient plugins ×8 (node half = empty apply,
- webserver web-shape HTTP carriage client half = src/client/)
+ runtime assembly / host entity dsh.client plugins ×8 (node half = empty apply,
+ webserver Web HTTP carriage client half = src/client/)
│ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths
▼ │ (type-only + the client base class)
harness core packages ──────────────────┘ (types reach the browser via import type)
@@ -50,34 +50,34 @@ harness core packages ──────────────────┘
- `runtime → apiproxy` 单向;apiproxy 仅依赖类型定义。
- client 侧包**永不 import** host 侧包的运行时(只吃 `/api`、`/client` 两个浏览器安全子路径)。
- `webserver` 不依赖 `runtime`:它提供 `{ fetch }` 特定实现 ——「webserver ← runtime」只是运行时注入关系,不是包依赖。
-- client 侧跨包 import 插件包一律走 `/client` 子路径,且插件包之间只限类型 import——跨插件值 import 在 tsdown 纯度门禁处即构建错误(值层面的协作走 cordis 服务;边规则归 [client 插件装载 RFC](2026-07-23-client-plugin-loading-model.md) 所有)。
+- client 侧跨包 import 插件包一律走 `/client` 子路径,且插件包之间只限类型 import——跨插件值 import 在 tsdown 纯度门禁处即构建错误(值层面的协作走 cordis 服务;边规则归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有)。
TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.json` = solution;`tsconfig.host.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用([拓扑](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md))。
-协议侧:TS interface(`packages/host/apiproxy/src/api/`,零 Node 依赖,浏览器可 import);wire 消息统一为**双向模型**——每条逻辑消息由「谁发起 × request/response」定形(两轴四格,后文称四象限),与物理通道解耦;客户端统一继承 `AbstractApiClient`(协议不变量全在基类,平台差异只是 `doFetch` 传输切面)。
+协议侧:TS interface(`packages/host/apiproxy/src/api/`,零 Node 依赖,浏览器可 import);wire 消息统一为**双向模型**——每条逻辑消息按「谁发起 × request/response」分类(两轴四格,后文称四象限),与物理通道解耦;客户端统一继承 `AbstractApiClient`(协议不变量全在基类,平台差异只是 `doFetch` 传输切面)。
#### 分层角色
| 层 | 包 | 职责 | 关键纪律 |
|---|---|---|---|
-| 前置层 | `dsh-host-apiproxy` | TS/zod 定义 (api/)+ fetch 抽象 (fetch/:handler + 客户端基类) | 做简单、所有接入方都要;Node/浏览器皆可 import;协议内容见下文「消息协议」起各节;client 不得经 ctx 绕开 api |
-| 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dshClient 包的内存 Loader 树);host 级配置归属地(defaults/persistenceRoot,将来用户 profile) | 装什么插件、给什么默认值只在这里定;壳不得改装配 |
-| 承载层 | `dsh-host-webserver` | Web 形态 HTTP 与 upgrade:静态服务 + `/api/*`→handler 转发 + WebSocket upgrade route + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 |
+| 前置层 | `dsh-host-apiproxy` | TS/zod 定义 (api/)+ fetch 抽象 (fetch/:handler + 客户端基类) | 做简单、每个消费方都要;Node/浏览器皆可 import;协议内容见下文「消息协议」起各节;client 不得经 ctx 绕开 api |
+| 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dsh.client 包的内存 Loader 树);host 级配置归属地(defaults/persistenceRoot,将来用户 profile) | 装什么插件、给什么默认值只在这里定;壳不得改装配 |
+| 承载层 | `dsh-host-webserver` | Web HTTP 与 upgrade:静态服务 + `/api/*`→handler 转发 + WebSocket upgrade route + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 |
| client 库 | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | slot 注册表核心 / ctx↔React 胶合 / 纯 React 原子组件 | 组件零 cordis 运行时依赖;由壳播种进 loader 模块表 |
-| client 插件 | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | 浏览器侧 cordis 插件树(wire 消费者、核心服务、主题、i18n、布局、侧栏、对话、轨迹)——见 Web 客户端架构 RFC | 双入口(node 半边=空 apply;实现在 `src/client/`);消费面唯一经 ApiProxy |
-| 应用态 | `@deepseek-ai/dsh`(apps/cli)+ `dsh-frontend`(apps/web,vite 应用) | bin 粗分发 + 每形态一个拼装模块(web.ts / headless.ts);vite 应用是 `dsh-client-web` 壳表面之上的薄 main | 形态间动态 import 互不加载;dist 定位等 workspace 知识留在 app |
+| client 插件 | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | 浏览器侧 cordis 插件树(wire 消费方、核心服务、主题、i18n、布局、侧栏、对话、轨迹)——见 Web 客户端架构笔记 | 双入口(node 半边=空 apply;实现在 `src/client/`);消费面唯一经 ApiProxy |
+| 应用 | `@deepseek-ai/dsh`(apps/cli)+ `dsh-web-frontend`(apps/web,vite 应用) | bin 粗分发 + 每个应用一个拼装模块(web.ts / headless.ts);vite 应用是 `dsh-client-web` 壳表面之上的薄 main | 各应用使用动态 import,因此不会互相加载;dist 定位等 workspace 知识留在 app |
#### 命名规则
`packages/host/*` 与 `packages/client/*` 下的包名**必须含目录组前缀**:host/runtime → `dsh-host-runtime`、client/runtime → `dsh-client-runtime`。目录名不重复组前缀(host/ 已表达)。因此包名尾段 ≠ 目录名,tsconfig.base.json 的 `dsh-*` 通配(按目录名解析)命不中——**这两组的每包需显式 paths 条目**,且 client 各包的 `/client` 子路径要单列条目,使源码级解析与 exports map 一致。
-#### 怎么接入一个新形态(操作清单)
+#### 怎么接入一个新应用(操作清单)
1. **选 fetch 伪造方式**:浏览器同源 HTTP / 进程内 `host.handler.fetch` 注入 / 自写传输切面子类(如将来 Electron IPC,见下文「子类表」)。
-2. **在 `apps/` 下写拼装模块**:`startHost()` + 客户端子类 + 该形态私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。
+2. **在 `apps/` 下写拼装模块**:`startHost()` + 客户端子类 + 该应用私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。
3. **需要 HTTP 承载才 import `dsh-host-webserver`**,否则零端口。
-现有两形态即模板:`apps/cli/src/web.ts`(startHost + dist 定位 + startWebServer + 信号停机)与 `headless.ts`(startHost + InProcessApiClient 同构直调,零 HTTP 零端口)。ACP 类协议桥不走本清单:它把 core 暴露给外部生态,直接 `ctx.plugin(前门插件)` 挂载、不套 fetch。
+现有两个应用保持这一区分:Web 应用挂载 Host、载体与浏览器组合,而 `dsh --profile headless` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(入口插件)` 挂载,不使用 fetch。
## 消息协议
@@ -105,7 +105,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.
**rpcId 纪律**(`RpcId` 是 branded string,构造函数 `RpcId()`):
- 谁发起谁 mint;应答一律回填对应 request 的 rpcId,**绝不 mint 新 id**。
-- server-request 分两类,静态按 `method`(=帧 type)区分,**不设第三种 kind**:可应答帧(`approval/requested`、`question/requested`)的 rpcId 是稳定逻辑请求 id(受理时 mint 一次、基线重放原样复用、client 以它回填应答);纯推送帧(`session/event` 等)的 rpcId 标识该次推送(每次新 mint)。
+- server-request 分两类,静态按 `method`(=帧 type)区分,**不设第三种 kind**:可应答帧(`approval/requested`、`question/requested`)的 rpcId 是稳定逻辑请求 id(受理时 mint 一次、基线回放原样复用、client 以它回填应答);纯推送帧(`session/event` 等)的 rpcId 标识该次推送(每次新 mint)。
- 业务代码不 mint:unary 的 mint 收口在客户端基类 `callUnary`,帧的 mint 收口在 host 侧。
### 签名窄形与载体补全
@@ -114,9 +114,9 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.
### RpcReceipt:载体回执
-`ClientResponse` 的 HTTP 应答体是 `RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }`——载体层回执,**不是** RpcMessage(response 不再有 response);迟到/重复应答收 `not-pending`,逻辑收敛面是 `*/resolved` 帧。
+`ClientResponse` 的 HTTP 应答体是 `RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }`——载体层回执,**不是** RpcMessage(response 不再有 response);迟到/重复应答收 `not-pending`,逻辑收敛点是 `*/resolved` 帧。
-## 类型体系:函数签名即事实源
+## 类型体系:函数签名即真源
### RpcMethodMap 与派生泛型(`api/rpc-map.ts`)
@@ -151,7 +151,7 @@ export type ResponseValue =
- **锚定**:schema 统一 `satisfies z.ZodType>`(`api/rpc.schema.ts`)。`Wire` 是深度「| undefined」宽化——仓库开 `exactOptionalPropertyTypes` 而 zod `.optional()` 输出 `T | undefined`,直接锚原类型全线不可用;JSON wire 上缺席与 undefined 同形,宽化不损失校验语义。透传宽分支(`SessionEvent`/`ContentBlock`/帧 union/`RpcError`)与 brand id schema 用显式 cast + 注释。
- brand cast 单点:每个 schema 文件的 id cast 收口一处(`rpcIdSchema` 是 rpc.schema.ts 唯一 cast 点)。
-## 契约面(ApiProxy)
+## 约定面(ApiProxy)
根接口 `ApiProxy = { sessions, host, events, respond }`(`api/index.ts`)。新 client-request 域 = 新的一对文件(`<域>.ts` + `<域>.schema.ts`)+ 根接口一个字段 + map 加行。
@@ -163,7 +163,7 @@ export type ResponseValue =
|---|---|---|---|
| `session.list` | `{ cursor?: string }`(cursor 留座不实现) | `{ items: SessionSummary[] }` | 已持久化 session,updatedAt 倒序;v1 不建索引 |
-其余方法(`session.create`/`session.history`/`session.rename`/`session.prompt`/`session.cancel`/`host.describe`)的参数与返回不在此复写——签名即事实源,见 `api/sessions.ts`、`api/host.ts` 与 `RpcMethodMap`。
+其余方法(`session.create`/`session.history`/`session.rename`/`session.prompt`/`session.cancel`/`host.describe`)的参数与返回不在此复写——签名即真源,见 `api/sessions.ts`、`api/host.ts` 与 `RpcMethodMap`。
### 帧(server→client,具名 union)
@@ -173,19 +173,19 @@ export type ResponseValue =
|---|---|---|
| `session/event` | `{ sessionId; event: SessionEvent }` | 核心透传:core 事件原样过,`assistant/chunk` 即 token 流,无独立 delta 帧 |
-其余帧型不在此复写,union 全集见 `api/events.ts` 的 `MuxFrame`/`HostFrame`。语义上须知三点:`session/subscribed` 的 lastSeq 供 history 补缝竞态检测;`approval/question` 的 requested 帧可应答(rpcId 稳定)、resolved 帧是收敛面;`host/agent-error` 是无 turn 位置 live 失败的唯一出口。
+其余帧型不在此复写,union 全集见 `api/events.ts` 的 `MuxFrame`/`HostFrame`。语义上须知三点:`session/subscribed` 的 lastSeq 供 history 竞态检测;`approval/question` 的 requested 帧可应答(rpcId 稳定)、resolved 帧是收敛面;`host/agent-error` 是无 turn 位置 live 失败的唯一出口。
**透传纪律**:wire 上的事件/消息/内容块就是 core 类型(`SessionEvent`/`ContentBlock`),不造第二套 DTO;类型经 `import type` 依赖链直达浏览器。`SessionEventMap` merge-extensible:client 对未知 type documented-default(忽略),事件 schema 留「合法信封+未知类型」分支——信封仍严格,不是字段级 passthrough。
### 会话语义(impl 侧承诺)
-- **历史 = 事件重放**:一套 fold(client 侧),历史分页与 live 增量同一条代码路径;server 不做物化快照第二套。history **页边界对齐消息边界**(绝不从消息中间截断;chunk 随定稿消息归组),尾页含进行中 partial 的 chunk。
-- **prompt 关联**:prompt 的 rpcId 经 MessageSource(`'user-rpc'`)透传进 `user/message` 事件,client 以此把乐观回显转正。
+- **历史 = 事件回放**:一套 fold(client 侧),历史分页与 live 增量同一条代码路径;server 不做物化快照第二套。history **页边界对齐消息边界**(绝不从消息中间截断;分片随定稿消息归组),尾页含进行中 partial 的分片。
+- **提示词关联**:提示词的 rpcId 经 MessageSource(`'user-rpc'`)透传进 `user/message` 事件,client 以此把乐观回显转正。
- **重连 = 重建**:不做续传 cursor(`mux` 的 `since` 签名留座、传了忽略);断线重开流 + 重拉 history;`subscribed.lastSeq` 与 history 尾 seq 比对,有缝再补拉一次。
- **冷会话处理遵循所有权**:`session.history` 与 `session.fork` 的源端读取会在不获取 Agent 的情况下检查持久化存储,而绑定到 Agent 的普通会话方法(如 `prompt`)则通过在途表去重后恢复会话。由会话支撑的 subagent 会拒绝这条通用恢复路径,且附加状态不对客户端暴露(`running` 已经覆盖)。
-- **审批/问答**:requested 帧受理时 mint 稳定 rpcId;先到先赢,host 内存 pending 表(keyed by rpcId)是唯一裁判;mux 重开后在 subscribed 帧后重放仍 pending 的 requested 帧(rpcId 原样复用,刷新恢复)。审计事件 `approval/asked`/`decided` 照旧走 durable 日志——帧=live 控制面,事件=durable 审计。**现状**:契约与帧类型已 shipped,host 侧 pending 表/wire answerer 未实现(`api-proxy.ts` 的 `respond` 是 stub,恒回 `not-pending`);PendingCard v1 只展示。
+- **审批/问答**:requested 帧受理时 mint 稳定 rpcId;先到先赢,host 内存 pending 表(keyed by rpcId)是唯一裁判;mux 重开后在 subscribed 帧后回放仍 pending 的 requested 帧(rpcId 原样复用,刷新恢复)。审计事件 `approval/asked`/`decided` 照旧走 durable 日志——帧=live 控制面,事件=durable 审计。**现状**:约定与帧类型已 shipped,host 侧 pending 表/wire answerer 未实现(`api-proxy.ts` 的 `respond` 是 stub,恒回 `not-pending`);PendingCard v1 只展示。
- **不设协议版本**:client 与 host 绑定发布,`host.describe` 无 protocolVersion 字段;出现独立发布的 client 时再引入。
-- **预留接缝纪律**:map 只含已实现方法,未知 method 在信封 parse 即 fail loud(`bad-request`),不设 not-implemented 兜底码。预留清单(实现时把签名抄进域接口+map 加行+schema 加对即升格):`session.fork`、`prompt.mode` 加 `'inject'`、`task.list`、`host.listModels`、describe 加 `hostInstanceId`。(`session.rename` 已从本清单毕业:追加 user 来源的 `session/title` 事件。)
+- **预留方法纪律**:map 只含已实现方法,未知 method 在信封 parse 即 fail loud(`bad-request`),不设 not-implemented 兜底码。预留清单(实现时把签名抄进域接口+map 加行+schema 加对即升格):`session.fork`、`prompt.mode` 加 `'inject'`、`task.list`、`host.listModels`、describe 加 `hostInstanceId`。(`session.rename` 已从本清单毕业:追加 user 来源的 `session/title` 事件。)
## 客户端载体:AbstractApiClient 类体系(`fetch/client.ts`)
@@ -193,7 +193,7 @@ export type ResponseValue =
### IApiClient:caller 视图
-与 `ApiProxy` 同域树,但 unary 方法**收业务 payload 直传**——载体 mint rpcId 并包信封,业务代码永不 mint;需要本次调用 rpcId 的从返回的 `RpcResponse` 回显里读。`ApiProxy` 是 impl 侧实现的窄形签名契约,`IApiClient` 是 client 侧消费的 payload 直传视图,`AbstractApiClient` 桥接两者。方法逐 key 从 `RpcMethodMap` 派生——map 加行即机械更新。
+与 `ApiProxy` 同域树,但 unary 方法**收业务 payload 直传**——载体 mint rpcId 并包信封,业务代码永不 mint;需要本次调用 rpcId 的从返回的 `RpcResponse` 回显里读。`ApiProxy` 是 impl 侧实现的窄形签名约定,`IApiClient` 是 client 侧消费的 payload 直传视图,`AbstractApiClient` 桥接两者。方法逐 key 从 `RpcMethodMap` 派生——map 加行即机械更新。
### 基类持有的协议路径
@@ -207,47 +207,47 @@ export type ResponseValue =
### 实例级 envelope 观测切面
-四象限全形均过 `onEnvelope`;基类实现是**实例持有的微任务合批缓冲**(帧风暴不逐帧惊扰消费者;模块级状态会跨实例/测试泄漏,故实例持有)。观测者经 `subscribeEnvelopes(listener)` 订阅(收整批 `readonly RpcMessage[]`,返回退订函数);listener 抛异常被隔离(观测不得反噬载体)。无订阅者时零缓冲成本。当前没有任何现役消费者订阅——该切面是 wire 诊断的预留位(已退役的 RPC 调试面板是它的首个消费者,将来的诊断消费者接入时不动载体)。
+四象限全形均过 `onEnvelope`;基类实现是**实例持有的微任务合批缓冲**(帧风暴不逐帧惊扰消费方;模块级状态会跨实例/测试泄漏,故实例持有)。观测者经 `subscribeEnvelopes(listener)` 订阅(收整批 `readonly RpcMessage[]`,返回退订函数);listener 抛异常被隔离(观测不得反噬载体)。无订阅者时零缓冲成本。当前没有任何现役消费方订阅——该切面是 wire 诊断的预留位(已退役的 RPC 调试面板是它的首个消费方,将来的诊断消费方接入时不动载体)。
### 子类表(传输承载)
| 子类 | 所在包 | doFetch | 用途 |
|---|---|---|---|
-| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧——`dsh -p` headless 即协议第二真实消费者 |
-| `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器形态;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) |
+| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 `dsh --profile headless` 直接驱动 core |
+| `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器客户端;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) |
| `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) |
-| (将来)IPC 桥子类 | apps/electron | IPC 序列化往返 | 仅换 doFetch,契约/基类零改 |
+| IPC 桥子类(假想示例——尚无此形态) | Electron 壳 | IPC 序列化往返 | 只需换 doFetch,约定/基类零改 |
## 怎么扩展(操作清单)
-**加一个 unary 方法(5 步)**:①域接口加方法签名(参数/返回内联,这是唯一事实源);②`RpcMethodMap` 加一行;③`<域>.schema.ts` 加 request/value schema 对(锚 `Wire>`);④handler `UNARY_ROUTES` 加一行(handler 的 Web 承载见 Web 客户端架构 RFC);⑤impl 实现(回显 `request.rpcId`)。client 侧 `IApiClient`/`AbstractApiClient` 的域方法表同步加一行透传。
+**加一个 unary 方法(5 步)**:①域接口加方法签名(参数/返回内联,这是唯一真源);②`RpcMethodMap` 加一行;③`<域>.schema.ts` 加 request/value schema 对(锚 `Wire>`);④handler `UNARY_ROUTES` 加一行(handler 的 Web 承载见 Web 客户端架构笔记);⑤impl 实现(回显 `request.rpcId`)。client 侧 `IApiClient`/`AbstractApiClient` 的域方法表同步加一行透传。
-**加一个帧型(3 步)**:①`MuxFrame`/`HostFrame` union 加一支(可应答帧须注明 rpcId 稳定语义);②帧 schema 加一支;③消费端 fold/路由的 documented-default 已兜底未知型,按需加显式分支。
+**加一个帧型(3 步)**:①`MuxFrame`/`HostFrame` union 加一支(可应答帧须注明 rpcId 稳定语义);②帧 schema 加一支;③消费方的 fold/路由 documented-default 已兜底未知型,按需加显式分支。
**加一个错误码(2 步)**:①`RpcErrorDetailsMap` 加一行(details 必填);②`rpcErrorSchema` discriminatedUnion 加一支。
-**接一种新载体**:继承 `AbstractApiClient` 只实现 `doFetch`;需要拦截协议层(如 fixture)再覆写 `callUnary`/`openMux`/`openHost` 虚方法。契约与基类零改。
+**接一种新载体**:继承 `AbstractApiClient` 只实现 `doFetch`;需要拦截协议层(如 fixture(测试前置数据))再覆写 `callUnary`/`openMux`/`openHost` 虚方法。约定与基类零改。
-**升格一个预留接缝**:把预留签名抄进域接口 → map 加行 → schema 加对 → UNARY_ROUTES 加行 → impl 实现。
+**升格一个预留方法**:把预留签名抄进域接口 → map 加行 → schema 加对 → UNARY_ROUTES 加行 → impl 实现。
## Consequences
-所有 client 形态消费同一契约:加一个 unary 方法是从单一签名辐射的五步机械改动,换载体只动一个 `doFetch` 子类,wire 上每条消息可 zod 校验、可经 envelope tap 观测、可按 rpcId 对账。普通 unary 调用仍受时限约束,而 `host.pickDirectory` 与 `command.execute` 可保持挂起,直到操作完成或调用方/连接取消到来;若由用户掌控节奏的操作不自行结束,请求可能一直挂起,这是为避免把合理的操作时长视为传输失败而接受的代价。其余接受的代价:两组包需要显式 tsconfig paths 条目;预留接缝(fork/inject/task.list/listModels/hostInstanceId)在真实消费者出现前保持休眠。
+所有 client 使用同一约定:加一个 unary 方法是从单一签名出发的五步机械改动,换载体只动一个 `doFetch` 子类,wire 上每条消息可 zod 校验、可经 envelope tap 观测、可按 rpcId 对账。普通 unary 调用仍受时限约束,而 `host.pickDirectory` 与 `command.execute` 可保持挂起,直到操作完成或调用方/连接取消到来;若由用户掌控节奏的操作不自行结束,请求可能一直挂起,这是为避免把合理的操作时长视为传输失败而接受的代价。其余接受的代价:两组包需要显式 tsconfig paths 条目;预留方法(fork/inject/task.list/listModels/hostInstanceId)在真实消费方出现前保持休眠。
## Alternatives considered
| 放弃项 | 一句话理由 |
|---|---|
-| 按「产品形态」分包(web 一族、electron 一族) | 形态间共享的是 host/client 两侧能力而非形态本身;能力支持方分层让新形态零新包 |
-| 混合体建包(如 headless 独立包) | 混合体只有一个消费者(它自己的 app),建包是无主抽象;拼装写在 app 里可读可弃 |
-| 消费型 client 直连 ctx(省 apiproxy 一层) | 第二命令面绕开契约,wire 校验/观测/多端一致性全失;ctx 只留给前门与 headless 事件订阅两个正式用途 |
+| 按产品分包(web 一族、electron 一族) | 产品共享的是 host/client 两侧能力,而不是某个应用实现;能力提供方分层让新应用零新包 |
+| 混合体建包(如 headless 独立包) | 混合体只有一个消费方(它自己的 app),建包是无主抽象;拼装写在 app 里可读可弃 |
+| 消费型 client 直连 ctx(省 apiproxy 一层) | client 需要 wire 校验、观测与多 client 一致性。直接 headless 是没有 client 边界的本地入口,使用公开的 Agent/Session seam,而不是 client 命令面 |
| webserver 依赖 runtime(省 handler 注入) | 结构 typing 注入让 webserver 可被 sidecar/测试复用且零 workspace 依赖;包依赖会把装配知识拖进承载层 |
| 包名不带组前缀(沿用 dsh-<尾段>) | `dsh-runtime`/`dsh-web-ui` 在扁平 npm 命名空间里失去归属信息;代价只是每包一条显式 paths |
-| 复用仓内 JSON-RPC 2.0(dsh-jsonrpc) | 数字错误码退化成单码兜底、契约双份人肉对齐、命名无 convention 自然漂移 |
+| 复用仓内 JSON-RPC 2.0(dsh-sdk-jsonrpc-server) | 数字错误码退化成单码兜底、约定双份人肉对齐、命名无 convention 自然漂移 |
| 三信封模型(Request/Response/Frame 各一信封,签名不感知方向) | rpcId 是逻辑层关联,帧与应答的方向语义靠通道推断在换载体时即失效 |
-| 具名 Request/Response 类型对为事实源(map 登记类型对) | 平铺具名类型是同一事实的第二个名字;签名 infer 反推让加方法只改一处 |
-| REST 风格路径 | 消费者是自家 client,无第三方 REST 体验诉求;RPC 直映方法表更机械 |
+| 具名 Request/Response 类型对为真源(map 登记类型对) | 平铺具名类型是同一事实的第二个名字;签名 infer 反推让加方法只改一处 |
+| REST 风格路径 | 消费方是自家 client,无第三方 REST 体验诉求;RPC 直映方法表更机械 |
| DTO 层(wire 专用第二套结构) | core 类型 type-only 直达浏览器零成本;DTO 是永久的双向同步税 |
-| cursor 续传(mux since 实装) | 重连=重建(opencode 同款)覆盖 v1 全部需求;签名留座,实装等真实消费者 |
+| cursor 续传(mux since 实装) | 重连=重建(opencode 同款)覆盖 v1 全部需求;签名留座,实装等真实消费方 |
| createApiClient 工厂函数(原实现) | 平台差异(传输/观测)是继承切面不是参数;类体系让 fixture 在协议层替换而不是包一层假信封 |
| 对 `command.execute` 应用 30 秒传输时限 | 命令耗时属于操作本身,而非传输健康预算;该时限会终止本应继续运行的长时处理器,调用方/连接取消已提供所需的停止路径 |
diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml
index 5376a626e6..c48579cb0d 100644
--- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.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/architecture/2026-07-19-gui-web-client-architecture.md
-2026-07-19-gui-web-client-architecture.md: 1a91d88818c374a1637b546fb3ddf6647af68570
-2026-07-19-gui-web-client-architecture.zh.md: 5c0bacde9836d45812895f5d9c89a0e8974ed7a1
+2026-07-19-gui-web-client-architecture.md: 070b857f14007f429826ab83b17b5c8fbd3d3d0b
+2026-07-19-gui-web-client-architecture.zh.md: 37e082985b3e6dbf3effd33a5aaeb54fce96d713
diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
index 1a91d88818..070b857f14 100644
--- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
+++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
@@ -4,7 +4,7 @@ Status: implemented
English | [中文](2026-07-19-gui-web-client-architecture.zh.md)
-> Division of labor: the channel-independent layering model and RPC protocol (message model / type system / contract face / client base class) are in the [layering and RPC protocol RFC](2026-07-19-gui-layering-and-rpc-protocol.md); this document = the browser side: how the client cordis tree loads, how UI plugins compose through slots and services, and how the React-free object layer feeds React through immutable snapshots.
+> Division of labor: the channel-independent layering model and RPC protocol (message model / type system / contract face / client base class) are in the [layering and RPC protocol note](2026-07-19-gui-layering-and-rpc-protocol.md); this document = the browser side: how the client cordis tree loads, how UI plugins compose through slots and services, and how the React-free object layer feeds React through immutable snapshots.
## Problem
@@ -30,50 +30,50 @@ Both ends run cordis. The host is a cordis plugin tree; the browser runs a secon
## The client cordis tree and the loading chain
-The loading chain — the two package kinds (plain vs dshClient plugin), the module-system/plugin-governor split, the two-phase boot over the host-authored entry graph with revisions, and hot reload — is owned by the [client plugin loading RFC](2026-07-23-client-plugin-loading-model.md). The load-bearing facts for this document: the browser boots the same vendored `@cordisjs/plugin-loader` as the host with a client module system (`ctx.modules`, `packages/client/modules`) filling its `internal` seam; every unit with product behavior is an entry in the host-authored `__DSH_BOOT__` graph — every production plugin package (infrastructure included) carries the `dshClient` declaration and arrives as a fetched `./client` tsdown closure bundle, `immediately` rows differing only in boot phase-one prefetch, while plain packages (react family, cordis, the not-yet-promoted libraries) stay shell-bundled, seeded, and invisible to the graph; bundles execute `window.__ModuleLoader__.load({ id, factory })` and their `require` is answered from the lazy CJS module table (seed words + registered factories, materialized and memoized on first require — cross-plugin value imports are a build error, cooperation goes through cordis services); plugin CSS is inlined in the bundle and injected as `
-
\ No newline at end of file
+
diff --git a/apps/web/public/manifest.webmanifest b/apps/web/public/manifest.webmanifest
new file mode 100644
index 0000000000..20a428fee6
--- /dev/null
+++ b/apps/web/public/manifest.webmanifest
@@ -0,0 +1,16 @@
+{
+ "id": "/",
+ "name": "DeepSeek Harness",
+ "short_name": "DSH",
+ "start_url": "/",
+ "scope": "/",
+ "display": "fullscreen",
+ "icons": [
+ {
+ "src": "/favicon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any"
+ }
+ ]
+}
diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml
new file mode 100644
index 0000000000..b366a7ff52
--- /dev/null
+++ b/apps/web/tests/README.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 apps/web/tests/README.md
+README.md: acb0c300bafe221f6a92f0168908bebf965377b9
+README.zh.md: fd3d950a106375bb038b205415407b4a31bd2b32
diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md
new file mode 100644
index 0000000000..acb0c300ba
--- /dev/null
+++ b/apps/web/tests/README.md
@@ -0,0 +1,46 @@
+# apps/web browser e2e
+
+English | [中文](README.zh.md)
+
+These tests boot the real web composition in-process and drive it with a real
+Chromium over real HTTP. The lane's mechanics — modes, fixtures, goldens, and
+the deliberate composition divergences from `dsh web` — are documented in
+[`scaffold.ts`](scaffold.ts) and the
+[browser e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
+
+## These are Host-face tests
+
+They type-check in the root `tsconfig.host.json`, not in the Client aggregate,
+because they read Host services directly: `ctx.apiProxy`, the Host
+`SessionStore`, `ctx.sessionProjectionCache`. Driving a browser at runtime does
+not make a file part of the Client program — the two faces merge cordis
+`Context` under the same keys with different services, so one program cannot see
+both. Moving these files into the Client aggregate makes every Host-service
+access fail to compile.
+
+## Do not import `@deepseek-ai/dsh-client-*` here
+
+Importing a Client package — a value or a type — pulls its whole TypeScript
+project, and every project it references, into the **Host build graph**. That has
+bitten this lane once already: four Client consumer packages reference
+`api/remotes`' Client face, which cannot compile until Host tsdown has generated
+`@deepseek-ai/dsh-goal/remote`, so the Host build phase ended up waiting on an
+artifact it produces itself.
+
+When a scenario needs a Client-owned constant or pure function, mirror it here
+instead, next to the commented-out import that names the source module. A drift
+then surfaces as a missed selector or a stale mirrored value — a loud failure,
+never a silent pass. `scaffold.ts` follows this rule for the welcome-notice
+namespace, acknowledgement field, version, and asserted Chinese copy.
+
+Two kinds of Client import stand. `assembled-boot.ts` drives the shell itself, so
+it imports `AppWebEntry` from `@deepseek-ai/dsh-client-web` and the boot-manifest
+type from `@deepseek-ai/dsh-client-modules/client`: booting the real shell is what
+that harness is for, and both packages are already in the Host graph. Separately,
+the chat scenarios import `conversationContextKey` from
+`@deepseek-ai/dsh-client-runtime/client` because `client/runtime` is reachable
+through the unsplit `directory-picker` packages and pulls nothing further in.
+That reachability is incidental, not a guarantee — if it ever leaves the graph,
+mirror the helper like the rest.
+
+Nothing mechanically enforces this rule; keep it in review.
diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md
new file mode 100644
index 0000000000..fd3d950a10
--- /dev/null
+++ b/apps/web/tests/README.zh.md
@@ -0,0 +1,37 @@
+# apps/web 浏览器 e2e
+
+[English](README.md) | 中文
+
+这些测试在进程内启动真实的 web 组合,并用真实 Chromium 通过真实 HTTP 驱动它。该 lane
+的运行机制——模式、fixture、golden,以及与 `dsh web` 之间刻意保留的组合差异——记录在
+[`scaffold.ts`](scaffold.ts) 和
+[浏览器 e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)中。
+
+## 这些是 Host 面的测试
+
+它们在根 `tsconfig.host.json` 中做类型检查,而不在 Client aggregate 中,因为它们直接读取
+Host 服务:`ctx.apiProxy`、Host 侧 `SessionStore`、`ctx.sessionProjectionCache`。运行时驱动
+浏览器并不使一个文件成为 Client 程序的一部分——两个 face 在相同的键上以不同服务合并 cordis
+`Context`,因此单个程序无法同时看见两者。把这些文件挪进 Client aggregate 会让每一处
+Host 服务访问都无法编译。
+
+## 不要在此 import `@deepseek-ai/dsh-client-*`
+
+import 一个 Client 包——无论值还是类型——都会把它整个 TypeScript 工程、以及它引用的每个工程
+拉进 **Host 构建图**。这已经坑过本 lane 一次:四个 Client 消费方包引用了 `api/remotes` 的
+Client face,而该 face 必须等 Host tsdown 生成 `@deepseek-ai/dsh-goal/remote` 之后才能编译,
+于是 Host 构建阶段变成在等一个由它自己产出的产物。
+
+当某个场景需要 Client 持有的常量或纯函数时,改为在此处镜像一份,并紧挨着一条注释掉的
+import 点明源模块。这样漂移会表现为选择器未命中或镜像值过期——是响亮的失败,绝不会是静默
+通过。`scaffold.ts` 按此规则镜像欢迎声明的 namespace、确认字段、版本和被断言的中文文案。
+
+有两类 Client import 是长期成立的。`assembled-boot.ts` 驱动 shell 本身,因此它从
+`@deepseek-ai/dsh-client-web` import `AppWebEntry`、从
+`@deepseek-ai/dsh-client-modules/client` import boot manifest 类型:启动真实 shell 正是该
+harness 的用途,且这两个包本来就在 Host 图中。另外,chat 场景从
+`@deepseek-ai/dsh-client-runtime/client` import `conversationContextKey`,因为
+`client/runtime` 经未拆分的 `directory-picker` 包可达,且不会再牵入别的东西。这种可达性是
+偶然而非保证——一旦它离开该图,就像其余情形那样镜像该 helper。
+
+没有任何机制强制这条规则;靠 review 守住它。
diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts
new file mode 100644
index 0000000000..1a27f96c6e
--- /dev/null
+++ b/apps/web/tests/agent-preset-authoring.e2e.ts
@@ -0,0 +1,280 @@
+// Web e2e scenario: the agent-preset settings section as copy-only authoring.
+// The browser never edits composition text — a shipped preset opens in a
+// read-only viewer, the copy dialog collects an id and an optional display
+// name, and the host copies the whole directory. The section's other job is
+// getting the user TO the files: this lane pins `nativeOpen: false` (see the
+// overlay), so the location affordance answers the preset directory as text —
+// the deterministic branch a golden can hold on every platform.
+//
+// Zero model calls: no replay fixture mounts, so a stray stream fails loud.
+import { existsSync } from 'node:fs'
+import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import type { Locator } from 'playwright'
+import {
+ captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole,
+ webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-authoring', import.meta.url))
+const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md')
+const COPY_DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'copy-dialog.expected.md')
+const CREATED_EXPECTED = join(SNAPSHOT_DIR, 'created.expected.md')
+const DAMAGED_EXPECTED = join(SNAPSHOT_DIR, 'damaged.expected.md')
+/** The shipped roster, beside the composition that names it. */
+const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url))
+const OVERLAY = fileURLToPath(new URL('./agent-preset-authoring.overlay.yml', import.meta.url))
+const MODE = webSnapshotMode()
+
+describe('web e2e: agent-preset authoring is a host-side copy', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+ let userRoot: string
+
+ /** The settings dialog, opened on the Agent-presets section. */
+ function settingsDialog(): Locator {
+ return page.getByRole('dialog', { name: '设置' })
+ }
+
+ /** Tokenize the lane-owned preset root after general aria normalization. */
+ function withPresetRoot(snapshot: string): string {
+ const rootSuffix = `/${userRoot.split('/').pop()!}`
+ return snapshot.split('\n').map((line) => {
+ const rootStart = line.indexOf(rootSuffix)
+ if (rootStart === -1) return line
+ const pathStart = line.lastIndexOf(' ', rootStart) + 1
+ return `${line.slice(0, pathStart)}{{presetRoot}}${line.slice(rootStart + rootSuffix.length)}`
+ }).join('\n')
+ }
+
+ beforeAll(async () => {
+ userRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-')))
+ scaffold = await launchWebScaffold({
+ extraOverlayPath: OVERLAY,
+ agentPresets: {
+ roots: [
+ { path: SHIPPED_PRESETS, trust: 'system' },
+ { path: userRoot, trust: 'user' },
+ ],
+ default: 'standard',
+ },
+ })
+ browser = await chromium.launch()
+ // The scenario asserts the shipped Chinese copy, so the browser asks for it.
+ page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('offers the roster with copy as the only way to create', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-section'))
+ await page.getByRole('button', { name: '设置', exact: true }).click()
+ const dialog = settingsDialog()
+ await dialog.waitFor({ timeout: 10_000 })
+ await dialog.getByRole('button', { name: 'Agent 预设' }).click()
+ await dialog.getByRole('heading', { name: 'Agent 预设' }).waitFor({ timeout: 10_000 })
+ await dialog.getByText('标准模式').first().waitFor({ timeout: 10_000 })
+
+ const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+
+ await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE)
+ // The intro carries the guidance a create button used to imply, and the
+ // shipped rows offer view/copy but never delete or a location — their
+ // install is overwritten by upgrades and is not the user's to manage.
+ expect(snapshot).toContain('或用「创造模式」让 Agent 帮你创建')
+ expect(snapshot).not.toContain('新建预设')
+ expect(snapshot).toContain('查看: 标准模式')
+ expect(snapshot).not.toContain('删除: 标准模式')
+ expect(snapshot).not.toContain('打开目录')
+ }, 60_000)
+
+ it('views a shipped composition read-only instead of editing it', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-view'))
+ const dialog = settingsDialog()
+ await dialog.getByRole('button', { name: '查看: 标准模式' }).click()
+ const viewer = page.getByRole('dialog', { name: '查看 · 标准模式' })
+ await viewer.waitFor({ timeout: 10_000 })
+
+ // The real shipped composition, not a golden: the viewer shows whatever
+ // the deployment ships, and this lane only asserts it is shown read-only.
+ const shipped = await readFile(join(SHIPPED_PRESETS, 'standard', 'agent.cordis.yml'), 'utf8')
+ expect(await viewer.locator('pre').textContent()).toBe(shipped)
+ expect(await viewer.getByRole('textbox').count()).toBe(0)
+ // The header X and the footer button share the 关闭 name; the footer one
+ // is last in the dialog.
+ await viewer.getByRole('button', { name: '关闭' }).last().click()
+ await viewer.waitFor({ state: 'detached', timeout: 10_000 })
+ }, 60_000)
+
+ it('copies 极简模式 whole under a new id and lands in its files', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-copy'))
+ const dialog = settingsDialog()
+ await dialog.getByRole('button', { name: '复制: 极简模式' }).click()
+ const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' })
+ await copyDialog.waitFor({ timeout: 10_000 })
+
+ const dialogSnapshot = await captureStableAria(
+ page, '[role="dialog"][aria-label^="复制预设"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(COPY_DIALOG_EXPECTED, dialogSnapshot, MODE)
+ // Two fields and nothing else: the id is the directory name the host
+ // needs up front; description and composition live in the files.
+ expect(dialogSnapshot).toContain('标识符')
+ expect(dialogSnapshot).not.toContain('描述')
+
+ await copyDialog.getByPlaceholder('my-agent').fill('my-agent')
+ await copyDialog.getByPlaceholder('选择器中显示的名字,缺省用标识符').fill('我的模式')
+ await copyDialog.getByRole('button', { name: '创建' }).click()
+ await copyDialog.waitFor({ state: 'detached', timeout: 10_000 })
+
+ // The new row lands in the custom group, and — with no desktop opener —
+ // its directory is revealed as text right away: landing in the files is
+ // the completion of a copy, not a follow-up.
+ await dialog.getByText('我的模式').first().waitFor({ timeout: 10_000 })
+ await dialog.getByText('预设文件:').waitFor({ timeout: 10_000 })
+ // The copy dialog is detached, so the settings dialog is the only one
+ // left (it names itself via aria-labelledby, which a CSS attribute
+ // selector cannot address).
+ const snapshot = withPresetRoot(
+ await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
+ await compareOrRefreshGolden(CREATED_EXPECTED, snapshot, MODE)
+ expect(snapshot).toContain('{{presetRoot}}/my-agent')
+
+ // The host copied the whole directory and rewrote only the display
+ // metadata: the composition is byte-identical to the shipped source, the
+ // description rides along for the user to edit in place, and neither the
+ // source's name nor its roster order survives into the copy.
+ const composition = await readFile(join(userRoot, 'my-agent', 'agent.cordis.yml'), 'utf8')
+ expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8'))
+ const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8')
+ expect(metadata).toContain('name: 我的模式')
+ expect(metadata).toContain('description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。')
+ expect(metadata).not.toContain('order:')
+ }, 60_000)
+
+ it('deletes the copy after confirmation and reclaims the roster', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-delete'))
+ const dialog = settingsDialog()
+ await dialog.getByRole('button', { name: '删除: 我的模式' }).click()
+ const confirm = page.getByRole('dialog', { name: '删除该预设?' })
+ await confirm.waitFor({ timeout: 10_000 })
+ await confirm.getByRole('button', { name: '删除', exact: true }).click()
+ await confirm.waitFor({ state: 'detached', timeout: 10_000 })
+
+ await expect.poll(async () => dialog.getByText('我的模式').count(), { timeout: 10_000 }).toBe(0)
+ expect(existsSync(join(userRoot, 'my-agent'))).toBe(false)
+ // The custom group outlives its only member: the heading stays with the
+ // creator entry so the place to author a preset never disappears.
+ expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(1)
+ expect(await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).count()).toBe(1)
+ expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0)
+ }, 60_000)
+
+ it('marks damaged presets broken and clears a ghost through delete', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-damaged'))
+ // The two hand-edit damage shapes: a composition that no longer parses,
+ // and a directory whose composition file was deleted outright.
+ await mkdir(join(userRoot, 'broken-yaml'), { recursive: true })
+ await writeFile(join(userRoot, 'broken-yaml', 'agent.cordis.yml'), '- id: x\n name: [unclosed\n')
+ await mkdir(join(userRoot, 'ghost'), { recursive: true })
+ await writeFile(join(userRoot, 'ghost', 'preset.yml'), 'name: 幽灵预设\ndescription: composition 已被手动删除。\n')
+
+ // The section reads the roster when it mounts; hop away and back.
+ const dialog = settingsDialog()
+ await dialog.getByRole('button', { name: '通用设置' }).click()
+ await dialog.getByRole('button', { name: 'Agent 预设' }).click()
+ await dialog.getByText('加载失败').first().waitFor({ timeout: 10_000 })
+
+ const snapshot = withPresetRoot(
+ await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
+ await compareOrRefreshGolden(DAMAGED_EXPECTED, snapshot, MODE)
+ // Both damage shapes surface as marked, unselectable, uncopyable cards
+ // that still carry their metadata and the discovery-reported reason.
+ expect(snapshot).toContain('加载失败: broken-yaml')
+ expect(snapshot).toContain('加载失败: 幽灵预设')
+ expect(snapshot).toContain('not valid YAML')
+ expect(snapshot).toContain('agent.cordis.yml is missing')
+ expect(await dialog.getByRole('button', { name: '加载失败: broken-yaml' }).isDisabled()).toBe(true)
+ expect(await dialog.getByRole('button', { name: '复制: 幽灵预设' }).isDisabled()).toBe(true)
+ // A broken card offers no "set default" affordance at all — the aria name
+ // IS the broken marking, so the picking name must not exist.
+ expect(await dialog.getByRole('button', { name: '设为默认: broken-yaml' }).count()).toBe(0)
+
+ // The ghost's way out is the card's own delete — and the id it blocked
+ // is claimable again immediately afterwards.
+ await dialog.getByRole('button', { name: '删除: 幽灵预设' }).click()
+ const confirm = page.getByRole('dialog', { name: '删除该预设?' })
+ await confirm.waitFor({ timeout: 10_000 })
+ await confirm.getByRole('button', { name: '删除', exact: true }).click()
+ await confirm.waitFor({ state: 'detached', timeout: 10_000 })
+ await expect.poll(async () => dialog.getByText('幽灵预设').count(), { timeout: 10_000 }).toBe(0)
+ expect(existsSync(join(userRoot, 'ghost'))).toBe(false)
+
+ await dialog.getByRole('button', { name: '复制: 极简模式' }).click()
+ const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' })
+ await copyDialog.waitFor({ timeout: 10_000 })
+ await copyDialog.getByPlaceholder('my-agent').fill('ghost')
+ await copyDialog.getByRole('button', { name: '创建' }).click()
+ await copyDialog.waitFor({ state: 'detached', timeout: 10_000 })
+ await dialog.getByRole('button', { name: '设为默认: ghost' }).waitFor({ timeout: 10_000 })
+
+ // Leave the roster as the earlier tests shaped it.
+ await dialog.getByRole('button', { name: '删除: ghost' }).click()
+ const cleanup = page.getByRole('dialog', { name: '删除该预设?' })
+ await cleanup.waitFor({ timeout: 10_000 })
+ await cleanup.getByRole('button', { name: '删除', exact: true }).click()
+ await cleanup.waitFor({ state: 'detached', timeout: 10_000 })
+ await rm(join(userRoot, 'broken-yaml'), { recursive: true, force: true })
+ }, 60_000)
+
+ it('starts a creator-mode session from the section', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-creator'))
+ // Without a workspace the flow only stages (there is no session to land
+ // in until one is connected); connect first so the gesture carries all
+ // the way to a composed host session.
+ await settingsDialog().getByRole('button', { name: '关闭' }).last().click()
+ await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
+ await page.getByRole('button', { name: '设置', exact: true }).click()
+ const dialog = settingsDialog()
+ await dialog.waitFor({ timeout: 10_000 })
+ await dialog.getByRole('button', { name: 'Agent 预设' }).click()
+ await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).click()
+
+ // Leaving settings is part of the gesture: the flow lands on the
+ // new-session screen with the self-referential preset staged, and the
+ // blank session the flow produces composes from it on the host.
+ await dialog.waitFor({ state: 'detached', timeout: 10_000 })
+ await page.getByRole('button', { name: '创造模式' }).waitFor({ timeout: 10_000 })
+ await expect.poll(async () => {
+ const response = await fetch(`${scaffold.baseUrl}/api/session.list`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ type: 'client-request', rpcId: 'creator-draft-stage', method: 'session.list', payload: {},
+ }),
+ })
+ const body = await response.json() as {
+ result: { value?: { sessions: unknown[] } }
+ }
+ return JSON.stringify(body.result.value?.sessions ?? body.result)
+ }, { timeout: 15_000 }).toContain('"agentPreset":"cordis"')
+ }, 60_000)
+
+ it('drove every surface without a page error or a stream warning', () => {
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ })
+})
diff --git a/apps/web/tests/agent-preset-authoring.overlay.yml b/apps/web/tests/agent-preset-authoring.overlay.yml
new file mode 100644
index 0000000000..6644752bc2
--- /dev/null
+++ b/apps/web/tests/agent-preset-authoring.overlay.yml
@@ -0,0 +1,12 @@
+# The authoring lane drives the location affordance. A real desktop open
+# would pop a file manager on the machine running the tests and the
+# capability itself is platform-detected (macOS yes, headless Linux CI no),
+# so the gateway is pinned headless: `hasDocument` is false everywhere and
+# `openDocument` answers the directory as text — the same branch on every
+# host, and the one whose rendering a golden can hold. A patch replaces the
+# row's complete config, so the shipped routing defaults ride along.
+- id: api-gateway
+ config:
+ provider: deepseek-official
+ model: deepseek-v4-flash
+ nativeOpen: false
diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts
new file mode 100644
index 0000000000..7a512c9b90
--- /dev/null
+++ b/apps/web/tests/agent-preset-selection.e2e.ts
@@ -0,0 +1,296 @@
+// Web e2e scenario: agent-preset selection. The roster's `roots` is an
+// assembly fact the CLI entry resolves and patches in, so every other lane
+// boots with an empty roster and no preset surface at all; this is the one
+// lane that mounts the SHIPPED presets and puts them in front of a browser.
+//
+// Two surfaces, one host rule: a session's composition is fixed when the
+// session starts. Before that, the new-session chip stages the choice beside
+// the workspace picker — the only screen where it still works. After it, the
+// session header names what the session runs and offers no control at all,
+// because the host answers `agent-preset-locked` to anything else.
+//
+// Zero model calls: no replay fixture mounts, so a stray stream fails loud.
+import { fileURLToPath } from 'node:url'
+import { mkdir, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+ SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionId,
+} from '@deepseek-ai/dsh-session'
+import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
+import {
+ captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
+ webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-selection', import.meta.url))
+const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
+const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
+const HEADER_EXPECTED = join(SNAPSHOT_DIR, 'header.expected.md')
+/** The shipped roster, beside the composition that names it. */
+const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url))
+const MODE = webSnapshotMode()
+const SEED_ID = 'agent-preset-selection-web-e2e'
+/** A project skill only a preset that mounts `skill-filesystem` can discover. */
+const SKILL_NAME = 'preset-catalog-demo'
+
+/**
+ * Seed one project skill under the connected workspace.
+ *
+ * Local skill discovery is a PRESET row, so this file is visible through
+ * `standard` and invisible through `minimal` — which makes the '/' menu's
+ * skill group a statement about the session's composition.
+ * @param workspaceCwd - the scaffold's temp project parent.
+ */
+async function seedWorkspaceSkill(workspaceCwd: string): Promise {
+ const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', SKILL_NAME)
+ await mkdir(directory, { recursive: true })
+ await writeFile(join(directory, 'SKILL.md'), [
+ '---',
+ `name: ${SKILL_NAME}`,
+ 'description: Prove the slash catalog follows the session composition',
+ '---',
+ '',
+ 'Body.',
+ '',
+ ].join('\n'))
+}
+
+/**
+ * A settled one-turn session with no model content: this lane asserts chrome
+ * around a conversation, not a conversation, and a recorded turn would tie
+ * the golden to a provider's wording for no gain.
+ * @returns a tokenized session log ending on a closed turn.
+ */
+function seedLog(): string {
+ const time = 1784974100000
+ const at = (index: number, event: Record): string =>
+ JSON.stringify({ ...event, seq: index, time: time + index })
+ return [
+ JSON.stringify({ type: 'session', version: 0, id: '{{sessionId}}', createdAt: time, cwd: '{{cwd}}/workspace' }),
+ at(0, { type: 'turn/start', data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user', rpcId: 'seed' } } } }),
+ at(1, {
+ type: 'user/message',
+ data: { content: [{ type: 'text', text: 'Seeded turn.' }], source: { kind: 'user', rpcId: 'seed' } },
+ surfaceOp: 'append',
+ }),
+ at(2, { type: 'session/title', data: { title: 'Seeded turn', messageSeqs: [1], source: { kind: 'fallback' } } }),
+ at(3, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }),
+ ].join('\n')
+}
+
+/**
+ * Persist one child so the assembled header snapshot exercises both action
+ * contributors whose relative order is the product contract under test.
+ * @param scaffold - the booted Web scaffold.
+ * @param parentId - the seeded session whose header the browser opens.
+ */
+async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise {
+ const childId = sessionId('agent-preset-selection-child')
+ const createdAt = 1784974100100
+ await scaffold.ctx.sessionPersistence.create({
+ version: SESSION_FORMAT_VERSION,
+ id: childId,
+ createdAt,
+ cwd: scaffold.workspaceCwd,
+ parentSession: parentId,
+ origin: 'subagent',
+ delegationDepth: 1,
+ agentPreset: 'minimal',
+ })
+ await scaffold.ctx.sessionPersistence.append(childId, [
+ {
+ type: 'turn/start',
+ seq: 0,
+ time: createdAt,
+ data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
+ },
+ {
+ type: 'user/message',
+ seq: 1,
+ time: createdAt + 1,
+ data: {
+ content: [{ type: 'text', text: 'Check the session-header action order.' }],
+ source: { kind: 'user' },
+ },
+ surfaceOp: 'append',
+ },
+ {
+ type: 'subagent/descriptor',
+ seq: 2,
+ time: createdAt + 2,
+ data: snapshotSubagentDescriptor({
+ mode: 'one-shot', provider: 'spawn', label: 'header order probe',
+ }),
+ },
+ {
+ type: 'turn/end',
+ seq: 3,
+ time: createdAt + 3,
+ data: { turn: 1, reason: { kind: 'completed' } },
+ },
+ ] as SessionEvent[])
+ await scaffold.ctx.sessionProjectionCache.coldSnapshot(childId)
+}
+
+/**
+ * The preset the host reports for the blank session the workspace connect
+ * produced. Addressed by id rather than by scanning the serialized list: the
+ * seeded session records `minimal` too, so a substring match over the whole
+ * list answers before the switch has landed.
+ * @param baseUrl - the scaffold's origin.
+ * @returns the live session's preset, or undefined before it is listed.
+ */
+async function livePreset(baseUrl: string): Promise {
+ const response = await fetch(`${baseUrl}/api/session.list`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ type: 'client-request', rpcId: 'agent-preset-live', method: 'session.list', payload: {},
+ }),
+ })
+ const body = await response.json() as {
+ result: { value?: { items: { sessionId: string; agentPreset?: string }[] } }
+ }
+ return body.result.value?.items.find(item => item.sessionId !== SEED_ID)?.agentPreset
+}
+
+/** Every option label the trigger menu currently lists. */
+async function menuOptions(page: Page): Promise {
+ const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
+ await menu.waitFor({ timeout: 10_000 })
+ return await menu.getByRole('option').allTextContents()
+}
+
+describe('web e2e: agent-preset selection', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold({
+ agentPresets: { roots: [{ path: SHIPPED_PRESETS, trust: 'system' }], default: 'standard' },
+ })
+ // A resumed session runs what it was created with; seeding one that
+ // records `minimal` is what makes the header label a claim about the
+ // session rather than an echo of the current default.
+ const seededId = await seedSession(scaffold, seedLog(), SEED_ID, 'minimal')
+ await seedSubagent(scaffold, seededId)
+ await seedWorkspaceSkill(scaffold.workspaceCwd)
+ browser = await chromium.launch()
+ page = await newEnglishPage(browser)
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('offers the chip on the new-session screen, beside the workspace picker', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-hero'))
+ await connectFreshWorkspace(page, scaffold.workspaceCwd)
+
+ const snapshot = await captureStableAria(page, '[class*="heroWorkspaceRow"]', scaffold.workspaceCwd)
+
+ await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE)
+ // The chip opens on the deployment default, by the name that preset
+ // publishes rather than its directory name.
+ expect(snapshot).toContain('Standard mode')
+ })
+
+ it('names every preset and what it is for', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-menu'))
+ await page.getByRole('button', { name: 'Standard mode' }).click()
+ const menu = page.getByRole('menu')
+ await menu.waitFor({ timeout: 10_000 })
+
+ const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd)
+
+ await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE)
+ // Every shipped preset, each with the sentence saying what it composes —
+ // the id alone never said what a preset does.
+ expect(snapshot).toContain('Minimal mode')
+ expect(snapshot).toContain('Creator mode')
+ await page.keyboard.press('Escape')
+ })
+
+ it('applies the staged pick to the blank session, and the host honors it', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-stage'))
+ await page.getByRole('button', { name: 'Standard mode' }).click()
+ await page.getByRole('menuitem', { name: /Minimal mode/ }).click()
+
+ // The chip stages; the blank session the workspace connect produced is
+ // what the stage lands on. The host's own answer is what comes back.
+ await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('minimal')
+ })
+
+ it('re-reads the slash catalog through the composition the switch installed', async () => {
+ // Continues the previous case: the chip has already applied `minimal` to
+ // the blank session, and this one reads the menu that switch left behind.
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-slash-catalog'))
+ const composer = page.locator('textarea:enabled').last()
+
+ // `minimal` mounts neither the compaction group nor plan mode nor local
+ // skill discovery, so the catalog the composer warmed under the
+ // deployment default must not survive the switch.
+ await composer.fill('/')
+ await expect.poll(() => menuOptions(page), { timeout: 15_000 })
+ .not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
+ const onMinimal = await menuOptions(page)
+ expect(onMinimal.some(option => option.startsWith('compact'))).toBe(false)
+ expect(onMinimal.some(option => option.startsWith('plan'))).toBe(false)
+ // The host-plane commands and the client's own contribution are the
+ // floor: they belong to no preset and never move.
+ expect(onMinimal.some(option => option.startsWith('goal'))).toBe(true)
+ expect(onMinimal.some(option => option.startsWith('model'))).toBe(true)
+ await composer.fill('')
+
+ // Switching back up reaches the host at all — the chip compares the pick
+ // against its list row, so a row that never reprojected the first switch
+ // answers "already standard" and sends nothing — and restores the catalog
+ // instead of leaving the session reading the narrower composition.
+ await page.getByRole('button', { name: 'Minimal mode' }).click()
+ await page.getByRole('menuitem', { name: /^Standard mode/ }).first().click()
+ await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('standard')
+
+ await composer.fill('/')
+ await expect.poll(() => menuOptions(page), { timeout: 15_000 })
+ .toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
+ const onStandard = await menuOptions(page)
+ expect(onStandard.some(option => option.startsWith('compact'))).toBe(true)
+ expect(onStandard.some(option => option.startsWith('plan'))).toBe(true)
+ await composer.fill('')
+ }, 90_000)
+
+ it('labels a resumed session with the preset it was created under', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-header'))
+ // The seeded session's cwd is the scaffold root rather than the connected
+ // workspace, so it lists under Ungrouped; the group collapses by default.
+ await page.getByRole('treeitem', { name: /^Ungrouped/ }).click()
+ await page.locator('[role="treeitem"]').last().click()
+ await page.getByText('Seeded turn.').waitFor({ timeout: 15_000 })
+
+ const snapshot = await captureStableAria(page, '[class*="titleRow"]', scaffold.workspaceCwd)
+
+ await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE)
+ expect(snapshot).toContain('Minimal mode')
+ expect(snapshot).toContain('button "1 subagent"')
+ expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "1 subagent"'))
+ expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('button "Session log"'))
+ // Static chrome, not a control: the header can only report a composition
+ // the host would refuse to change.
+ expect(snapshot).not.toContain('button "Minimal mode"')
+ })
+
+ it('drove every surface without a page error or a stream warning', () => {
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ })
+})
diff --git a/apps/web/tests/approval-composer.e2e.ts b/apps/web/tests/approval-composer.e2e.ts
index 5723c7a9db..e57367ee46 100644
--- a/apps/web/tests/approval-composer.e2e.ts
+++ b/apps/web/tests/approval-composer.e2e.ts
@@ -9,8 +9,8 @@
// model content as the question composer: the turn cannot complete without it).
//
// Geometry is the point of the scenario. The command is unbounded model text,
-// and before the cap a long one grew the card until the refuse/allow buttons
-// left the viewport — an approval the user could see and not answer.
+// and an uncapped card grows with it until the refuse/allow buttons leave the
+// viewport — an approval the user could see and not answer.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -35,7 +35,7 @@ const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const MODE = webSnapshotMode()
// Irreducible payload: the command has to be long enough to pass the card's
-// height cap, which is the only shape that reproduces an action row pushed off
+// height cap, which is the only command length that reproduces an action row pushed off
// screen. Unrelated tokens, not a repeated word — a repeated word is what the
// model compressed into `printf 'alpha %.0s' {1..400}` while recording, and a
// short command proves nothing here. The formula keeps the source small; the
@@ -89,7 +89,7 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
await input.fill('')
// Read-only: the mode whose denial the model escalates from. Switched
- // through the shipped access-mode chip, not a test-only seam.
+ // through the shipped access-mode chip, not a test-only override.
await page.locator('[aria-label^="Access mode"]').click()
await page.getByRole('menuitem', { name: 'Read Only' }).click()
await expect.poll(
@@ -115,9 +115,8 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
const snapshot = await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
- // The regression this scenario exists for: an uncapped card grew with
- // the command until the action row left the viewport. Measured at the
- // lane baseline and at a short viewport, on the live panel.
+ // The uncapped-card hazard the header names, measured at the lane
+ // baseline and at a short viewport, on the live panel.
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
for (const height of [1000, 700]) {
await page.setViewportSize({ width: 900, height })
diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts
new file mode 100644
index 0000000000..52c0658e4e
--- /dev/null
+++ b/apps/web/tests/assembled-boot.ts
@@ -0,0 +1,153 @@
+// Shared scaffolding for the assembled-jsdom snapshots: the real built
+// workspace `lib/client.js` artifacts booted through AppWebEntry's
+// ModuleLoader path (loadBundle) against the keyless FixtureApiClient
+// transport. Every file that mounts this graph needs the same boot entry list,
+// the same bundle map, the same jsdom globals, and the same mount call, and
+// differs only in what it asserts afterwards, so the scaffolding lives here.
+//
+// Keyless and deterministic: the fixture is the fake server, so nothing here
+// reaches a model or the network.
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { act, cleanup } from '@testing-library/react'
+import { afterEach, beforeEach, vi } from 'vitest'
+import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
+import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
+
+/** Boot entries for the minimal assembled graph, each carrying the workspace bundle it loads. */
+const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [
+ { id: '@deepseek-ai/dsh-typert-registry', bundlePath: 'packages/typert/registry/lib/client.js', url: '/plugins/typert-registry.js', rev: 'fx', inject: [], immediately: true },
+ { id: '@deepseek-ai/dsh-client-connection', bundlePath: 'packages/client/connection/lib/client.js', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
+ { id: '@deepseek-ai/dsh-api-gateway', bundlePath: 'packages/api/gateway/lib/client.js', url: '/plugins/api-gateway.js', rev: 'fx', inject: ['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'], immediately: true },
+ { id: '@deepseek-ai/dsh-api-remotes', bundlePath: 'packages/api/remotes/lib/client.js', url: '/plugins/api-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-api-gateway'], immediately: true },
+ // The settings domain base: the only provider of ctx.settingsScope, which the
+ // locale and ui-theme rows below inject for their preference rows. Without it
+ // both stay pending and ui-layout never activates, so nothing renders.
+ { id: '@deepseek-ai/dsh-client-ui-settings', bundlePath: 'packages/client/ui-settings/lib/client.js', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-api-remotes'], immediately: true },
+ { id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-api-gateway'], immediately: true },
+ { id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-api-remotes'], immediately: true },
+ { id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-api-remotes'], immediately: true },
+ { id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
+ { id: '@deepseek-ai/dsh-client-ui-sidebar', bundlePath: 'packages/client/ui-sidebar/lib/client.js', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+ { id: '@deepseek-ai/dsh-client-ui-conversation', bundlePath: 'packages/client/ui-conversation/lib/client.js', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+ { id: '@deepseek-ai/dsh-client-ui-tool', bundlePath: 'packages/client/ui-tool/lib/client.js', url: '/plugins/ui-tool.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-conversation'] },
+ { id: '@deepseek-ai/dsh-client-ui-workflow-run', bundlePath: 'packages/client/ui-workflow-run/lib/client.js', url: '/plugins/ui-workflow-run.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] },
+ {
+ id: '@deepseek-ai/dsh-client-ui-workspace',
+ bundlePath: 'packages/client/ui-workspace/lib/client.js',
+ url: '/plugins/ui-workspace.js',
+ rev: 'fx',
+ inject: [
+ '@deepseek-ai/dsh-client-runtime',
+ '@deepseek-ai/dsh-client-ui-conversation',
+ '@deepseek-ai/dsh-client-ui-sidebar',
+ ],
+ },
+ { id: '@deepseek-ai/dsh-session-log-export', bundlePath: 'packages/session-query/session-log-export/lib/client.js', url: '/plugins/session-log-download.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-commands', '@deepseek-ai/dsh-client-ui-conversation'] },
+ { id: '@deepseek-ai/dsh-client-ui-trajectory', bundlePath: 'packages/client/ui-trajectory/lib/client.js', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
+]
+
+const bundles = new Map(PLUGINS.map(plugin => [
+ plugin.url,
+ readFileSync(join(process.cwd(), plugin.bundlePath), 'utf8'),
+]))
+
+interface FixtureWindow extends Window {
+ __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
+ __ModuleLoader__?: unknown
+}
+
+class ResizeObserverStub {
+ observe(): void {}
+ disconnect(): void {}
+ unobserve(): void {}
+}
+
+const win = window as FixtureWindow
+let unmount: (() => void) | undefined
+
+/**
+ * Register the per-test jsdom setup and teardown the assembled boot needs:
+ * English pinned before boot so role/text locators stay deterministic across
+ * localized component migrations (the newEnglishPage e2e convention), the
+ * observers and frame callbacks jsdom lacks, and a full reset of the document,
+ * the boot globals, and the injected plugin styles afterwards.
+ */
+export function installAssembledBootEnv(): void {
+ beforeEach(() => {
+ localStorage.clear()
+ // The locale service derives its provisional locale from the browser and
+ // takes an explicit choice only from Host settings, which this lane's
+ // fixture transport does not serve; pinning the navigator is what selects
+ // English here.
+ Object.defineProperty(navigator, 'languages', { value: ['en-US'], configurable: true })
+ Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true })
+ document.title = 'DeepSeek Harness'
+ vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
+ setTimeout(() => { callback(0) }, 0) as unknown as number)
+ vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
+ })
+
+ afterEach(() => {
+ act(() => { unmount?.() })
+ unmount = undefined
+ cleanup()
+ delete win.__DSH_BOOT__
+ delete win.__ModuleLoader__
+ document.body.innerHTML = ''
+ document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
+ document.title = ''
+ history.replaceState(null, '', '/')
+ // Deleting the own properties uncovers jsdom's own accessors again
+ // (Navigator declares both readonly, hence the erased receiver).
+ const ownNavigator = navigator as unknown as Record
+ delete ownNavigator.languages
+ delete ownNavigator.language
+ vi.unstubAllGlobals()
+ })
+}
+
+/**
+ * Mount the assembled application on the fixture transport; the teardown
+ * registered by installAssembledBootEnv disposes it.
+ */
+export function mountAssembledApp(): void {
+ history.replaceState(null, '', '/?fixture')
+ const root = document.createElement('div')
+ root.id = 'root'
+ document.body.appendChild(root)
+ win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) }
+ act(() => {
+ const entry = new AppWebEntry(root, {
+ loadBundle: async (url) => {
+ const code = bundles.get(url)
+ if (code === undefined) throw new Error(`missing built bundle ${url}`)
+ ;(0, eval)(code)
+ },
+ })
+ void entry.run()
+ unmount = () => { entry.dispose() }
+ })
+}
+
+/**
+ * Match a CSS-module class by its logical name.
+ * Module class names carry a per-build hash in one of two schemes —
+ * ui-primitives emits `__` (name bounded by underscores),
+ * feature bundles emit `_` (name at the end) — and a longer name
+ * containing this one must not match (`line` must not hit `lineNumber`).
+ * @param el - element whose class list is inspected.
+ * @param name - logical (unhashed) module class name.
+ * @returns whether the element carries that module class.
+ */
+export function hasClass(el: Element, name: string): boolean {
+ return [...el.classList].some(cls => cls === name || cls.endsWith(`_${name}`) || cls.startsWith(`_${name}_`) || cls.includes(`_${name}_`))
+}
+
+/**
+ * Whether this run rewrites its golden instead of comparing against it, set by
+ * the snapshot gate's `DSH_SNAPSHOT` mode (`record` re-runs the scenarios from
+ * scratch, `refresh` re-derives the expected text from the existing ones).
+ */
+export const REFRESHING_GOLDEN = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh'
diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts
new file mode 100644
index 0000000000..0e0a98de93
--- /dev/null
+++ b/apps/web/tests/background-job-list.e2e.ts
@@ -0,0 +1,133 @@
+// Web e2e scenario: the session-header background-job list over the real
+// host. No model call is involved — a genuine `run_in_background` bash call
+// registers with `ctx.jobs`, and the assertion chain is the whole delivery
+// path: registry change feed → api-proxy `session/jobs` frame → the client's
+// `jobsBySession` mirror → the header action.
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import { JobId } from '@deepseek-ai/dsh-jobs'
+import {
+ assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+ launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { newEnglishPage, saveFailureShot } from './support.ts'
+
+const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url))
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/background-job-list', import.meta.url))
+const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md')
+const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
+const MODE = webSnapshotMode()
+const SEED_ID = 'background-job-list-web-e2e'
+// Long enough that the running assertions never race the process exiting on
+// their own; the test kills it explicitly to reach the settled state.
+const COMMAND = 'sleep 45'
+
+/**
+ * Wait for the Host to publish the live Agent that opening a session resumes.
+ * @param scaffold - the booted web scaffold.
+ * @param sessionId - the opened session's identity.
+ * @returns the registered Agent instance.
+ */
+async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise {
+ const deadline = Date.now() + 30_000
+ for (;;) {
+ const found = scaffold.ctx.agents.get(sessionId)
+ if (found !== undefined) return found
+ if (Date.now() > deadline) throw new Error(`opening session "${sessionId}" published no live Agent`)
+ await new Promise(resolve => setTimeout(resolve, 100))
+ }
+}
+
+describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+ let agent: Agent
+ let jobId: JobId
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold({})
+ await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID)
+ browser = await chromium.launch()
+ page = await newEnglishPage(browser)
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+
+ const groupRow = page.locator('[role="treeitem"]').first()
+ await groupRow.waitFor({ timeout: 15_000 })
+ await groupRow.click()
+ const sessionRow = page.locator('[role="treeitem"]').nth(1)
+ await sessionRow.waitFor({ timeout: 10_000 })
+ await sessionRow.click()
+
+ // Opening the session drives the Host's ordinary Agent resolution; the
+ // job owner must be that exact live instance, never a second one.
+ // `expect.poll` is test-scoped, so this hook polls by hand.
+ agent = await liveAgent(scaffold, SessionId(SEED_ID))
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('shows a running background job in the session header without a refresh', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-running'))
+ // Point assertion, not a poll: `expect.poll` retries until a predicate
+ // holds, so polling for zero passes at t=0 and proves nothing. The
+ // "renders nothing without a task" branch is owned by the component suite.
+ const trigger = page.getByRole('button', { name: '1 background job running' })
+ expect(await trigger.count()).toBe(0)
+
+ const started = await scaffold.ctx.tools.execute({
+ signal: new AbortController().signal,
+ callId: CallId('background-job-list-e2e'),
+ name: 'bash',
+ arguments: { command: COMMAND, description: 'Hold a background slot open', run_in_background: true },
+ agent,
+ })
+ const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('')
+ const matched = /\bbash-\d+\b/.exec(reported)
+ if (matched === null) throw new Error(`background bash reported no job id: ${reported}`)
+ jobId = JobId(matched[0])
+
+ await trigger.waitFor({ timeout: 15_000 })
+ await trigger.click()
+ const row = page.getByRole('list', { name: 'Background jobs' }).getByRole('listitem').first()
+ await row.waitFor({ timeout: 10_000 })
+ await expect.poll(() => row.textContent()).toContain(COMMAND)
+
+ const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(RUNNING_EXPECTED, snapshot, MODE)
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ }, 60_000)
+
+ it('flips the open list to the cancelled outcome when the registry settles it', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-settled'))
+ expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested')
+
+ // The trigger drops its live count once the task leaves running/stopping,
+ // which is also the proof that settlement reached the browser unprompted.
+ const idle = page.getByRole('button', { name: '1 background job' })
+ await idle.waitFor({ timeout: 20_000 })
+
+ const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE)
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ }, 60_000)
+
+ it('keeps its snapshot inventory closed', async () => {
+ await assertFixtureInventory(SNAPSHOT_DIR, ['running.expected.md', 'settled.expected.md'])
+ })
+})
diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts
index b102dff8b8..35a918c228 100644
--- a/apps/web/tests/built-boot.snapshot.ts
+++ b/apps/web/tests/built-boot.snapshot.ts
@@ -1,109 +1,32 @@
// @vitest-environment jsdom
-// The built-bundle boot smoke: the ONE assembled-jsdom test that loads the
-// real `packages/client/*/lib/client.js` artifacts through AppWebEntry's
-// ModuleLoader path (loadBundle) and proves the boot graph
-// assembles — staged activation across the immediately tier and the inject
-// layers, per-plugin CSS injection, and a rendered journey reaching chat
-// content from the keyless FixtureApiClient transport.
+// The built-bundle boot smoke: the assembled-jsdom test that owns the boot
+// graph itself. Other files share the same scaffolding (assembled-boot.ts) to
+// reach a surface only the built bundles expose; this one asserts that the
+// graph assembles at all — staged activation across the immediately tier and
+// the inject layers, per-plugin CSS injection, and a rendered journey reaching
+// chat content from the keyless FixtureApiClient transport.
//
// Component behavior remains owned by per-package suites (SlotTestRuntime
// benches over src). This smoke additionally pins the resident interaction
// fixture's cross-plugin projection because only the built connection/runtime/
// workspace graph can prove that transport-to-row path end to end.
-import { readFileSync } from 'node:fs'
-import { join } from 'node:path'
-import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
-import { afterEach, beforeEach, expect, it, vi } from 'vitest'
-import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
-import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
+import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { expect, it } from 'vitest'
+import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
-const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
- { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
- { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
- { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
- { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
- { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
- { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
- { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
- {
- id: '@deepseek-ai/dsh-client-ui-workspace',
- dir: 'ui-workspace',
- url: '/plugins/ui-workspace.js',
- rev: 'fx',
- inject: [
- '@deepseek-ai/dsh-client-runtime',
- '@deepseek-ai/dsh-client-ui-conversation',
- '@deepseek-ai/dsh-client-ui-sidebar',
- ],
- },
- { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
-]
-
-const bundles = new Map(PLUGINS.map(plugin => [
- plugin.url,
- readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
-]))
-
-interface FixtureWindow extends Window {
- __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
- __ModuleLoader__?: unknown
-}
-
-class ResizeObserverStub {
- observe(): void {}
- disconnect(): void {}
- unobserve(): void {}
-}
-
-const win = window as FixtureWindow
-let unmount: (() => void) | undefined
-
-beforeEach(() => {
- localStorage.clear()
- // English pinned before boot: role/text locators stay deterministic across
- // localized component migrations (the newEnglishPage e2e convention).
- localStorage.setItem('dsh.locale', 'en')
- document.title = 'DeepSeek Harness'
- vi.stubGlobal('ResizeObserver', ResizeObserverStub)
- vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
- setTimeout(() => { callback(0) }, 0) as unknown as number)
- vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
-})
-
-afterEach(() => {
- act(() => { unmount?.() })
- unmount = undefined
- cleanup()
- delete win.__DSH_BOOT__
- delete win.__ModuleLoader__
- document.body.innerHTML = ''
- document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
- document.title = ''
- history.replaceState(null, '', '/')
- vi.unstubAllGlobals()
-})
+installAssembledBootEnv()
it('boots the built plugin graph and renders a fixture session end to end', async () => {
- history.replaceState(null, '', '/?fixture')
- const root = document.createElement('div')
- root.id = 'root'
- document.body.appendChild(root)
- win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
- act(() => {
- const entry = new AppWebEntry(root, {
- loadBundle: async (url) => {
- const code = bundles.get(url)
- if (code === undefined) throw new Error(`missing built bundle ${url}`)
- ;(0, eval)(code)
- },
- })
- void entry.run()
- unmount = () => { entry.dispose() }
- })
+ mountAssembledApp()
// The sidebar renders from the boot graph: every inject layer activated.
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
- await within(tree).findByText('4 sessions')
+ // The compact layout dropped group session counts; the fixture workspace
+ // group row renders immediately with its sessions beneath it.
+ const fixtureGroup = (await within(tree).findAllByText('fixture'))
+ .map(el => el.closest('[role="treeitem"]'))
+ .find(el => el?.getAttribute('aria-expanded') !== null)
+ if (fixtureGroup === undefined) throw new Error('fixture Workspace group missing')
// The resident fixture has both a question and an approval; composer routing
// exposes the question first, and the assembled workspace plugin mirrors that
@@ -120,7 +43,6 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
await waitFor(() => {
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
}, { timeout: 10_000 })
-
// Resolve the resident approval so the ordinary composer bar (which owns
// ContextMeter) resumes without replacing the session shell. This minimal
// boot graph intentionally does not mount the separate question UI plugin.
@@ -177,7 +99,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
// Every bundle injected its plugin-owned style tag (the loader's CSS path).
const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')]
.map(style => style.getAttribute('data-plugin'))
- for (const plugin of ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-ui-conversation']) {
+ for (const plugin of ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-tool']) {
expect(styleOwners).toContain(plugin)
}
})
diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts
index 61701c1cdc..cd15f2e054 100644
--- a/apps/web/tests/chat-continuous-conversation.e2e.ts
+++ b/apps/web/tests/chat-continuous-conversation.e2e.ts
@@ -18,7 +18,7 @@ import {
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+import { connectFreshWorkspace, conversationContextKey, newEnglishPage, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode()
const TURN_COUNT = 12
@@ -160,6 +160,14 @@ function toolResultText(event: Extract):
.join('')
}
+function messageKey(event: SessionEvent<'user/message'>): string {
+ return conversationContextKey('input-message', String(event.data.id))
+}
+
+function assistantKey(event: SessionEvent<'assistant/message'>): string {
+ return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`)
+}
+
describe('web e2e: continuous conversation grown through the composer', () => {
let browser: Browser
let page: Page
@@ -222,6 +230,11 @@ describe('web e2e: continuous conversation grown through the composer', () => {
const settled = scaffold.whenTurnSettled(60_000)
await page.getByRole('button', { name: 'Send message', exact: true }).click()
await page.getByText(spec.userMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
+ await expect.poll(() => sessionEvents.slice(eventStart).some(event => (
+ event.type === 'user/message'
+ && event.data.source.kind === 'user'
+ && userText(event).includes(spec.userMarker)
+ )), { timeout: 15_000 }).toBe(true)
const echoedUser = sessionEvents.slice(eventStart).find(
(event): event is SessionEvent<'user/message'> => (
event.type === 'user/message'
@@ -230,7 +243,7 @@ describe('web e2e: continuous conversation grown through the composer', () => {
),
)
if (echoedUser === undefined) throw new Error(`turn ${String(spec.index)} has no user echo event`)
- const userRow = page.locator(`[data-chat-anchor-key="node:${String(echoedUser.seq)}"]`)
+ const userRow = page.locator(`[data-chat-anchor-key="${messageKey(echoedUser)}"]`)
await expect.poll(() => userRow.count(), { timeout: 10_000 }).toBe(1)
expect(await userRow.getAttribute('data-chat-flow-kind')).toBe('user')
expect(await userRow.textContent()).toContain(spec.userMarker)
@@ -274,9 +287,9 @@ describe('web e2e: continuous conversation grown through the composer', () => {
expect(turnEnds[0]?.data).toEqual({ turn: spec.index, reason: { kind: 'completed' } })
expect(chunks).toHaveLength(spec.deltas.length + (spec.callId === undefined ? 4 : 9))
- const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(finalAssistants[0]!.seq)}"]`)
+ const assistantRow = page.locator(`[data-chat-anchor-key="${assistantKey(finalAssistants[0]!)}"]`)
await expect.poll(() => assistantRow.count(), { timeout: 10_000 }).toBe(1)
- expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
+ expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
expect(await assistantRow.textContent()).toContain(spec.doneMarker)
const calls = turnEvents.filter((event): event is SessionEvent<'tool/call'> => event.type === 'tool/call')
diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts
index b9bebbd897..03198a23a8 100644
--- a/apps/web/tests/chat-long-interactions.e2e.ts
+++ b/apps/web/tests/chat-long-interactions.e2e.ts
@@ -1,6 +1,7 @@
-// Long-history Chat behavior contract for a future virtualized renderer. Wheel
-// input only navigates to the semantic target; assertions pin content identity
-// and interaction routing rather than scroll geometry or mounted row counts.
+// Long-history Chat behavior contract that stays valid under a virtualized
+// renderer: wheel input only navigates to the semantic target; assertions pin
+// content identity and interaction routing rather than scroll geometry or
+// mounted row counts.
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -18,7 +19,7 @@ import {
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
-import { newEnglishPage, saveFailureShot } from './support.ts'
+import { conversationContextKey, newEnglishPage, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode()
const SESSION_ID = 'chat-long-interactions-e2e'
@@ -76,8 +77,13 @@ async function nextPaint(page: Page): Promise {
}
async function openSeed(page: Page): Promise {
- await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
- const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
+ // The compact layout dropped group session counts; the seeded baseline is
+ // the Ungrouped bucket once cold summaries load.
+ await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
+ // Search collapsed into a header action; expand it before filling.
+ const searchButton = page.getByRole('button', { name: 'Search sessions' })
+ if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
+ const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
await search.fill(FIXTURE.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await results.first().waitFor({ timeout: 60_000 })
@@ -115,6 +121,18 @@ function requiredEvent(
return event
}
+function messageKey(event: SessionEvent<'user/message'>): string {
+ return conversationContextKey('input-message', String(event.data.id))
+}
+
+function assistantKey(event: SessionEvent<'assistant/message'>): string {
+ return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`)
+}
+
+function turnTailKey(turn: number): string {
+ return conversationContextKey('turn-tail', String(turn))
+}
+
describe('web e2e: long Chat interaction contract', () => {
let browser: Browser
let page: Page
@@ -172,12 +190,14 @@ describe('web e2e: long Chat interaction contract', () => {
const boundary = source.session.events.find((event): event is SessionEvent<'turn/end'> => (
event.type === 'turn/end' && event.data.turn === BRANCH_TURN
))
- if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no completed boundary`)
+ if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no turn/end event`)
const expectedUserText = textContent(branchUserEvent.data.content)
await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100)
- const toolUserRow = page.locator(`[data-chat-anchor-key="node:${String(toolUserEvent.seq)}"]`)
- const toolAssistantRow = page.locator(`[data-chat-anchor-key="node:${String(toolAssistantEvent.seq)}"]`)
+ const toolUserKey = messageKey(toolUserEvent)
+ const toolAssistantKey = assistantKey(toolAssistantEvent)
+ const toolUserRow = page.locator(`[data-chat-anchor-key="${toolUserKey}"]`)
+ const toolAssistantRow = page.locator(`[data-chat-anchor-key="${toolAssistantKey}"]`)
const call1 = page.locator(`[data-chat-call-id="${TARGET_CALL_1}"]`)
const call2 = page.locator(`[data-chat-call-id="${TARGET_CALL_2}"]`)
@@ -186,28 +206,27 @@ describe('web e2e: long Chat interaction contract', () => {
expect(await call1.count()).toBe(1)
expect(await call2.count()).toBe(1)
expect(await toolUserRow.getAttribute('data-chat-flow-kind')).toBe('user')
- expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
+ expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
expect(await toolUserRow.textContent()).toContain(toolUserMarker)
expect(await toolAssistantRow.textContent()).toContain(toolAssistantMarker)
expect(await call1.textContent()).toContain(toolMarker1)
expect(await call2.textContent()).toContain(toolMarker2)
const expectedOrder = [
- `node:${String(toolUserEvent.seq)}`,
- `call:${TARGET_CALL_1}`,
- `call:${TARGET_CALL_2}`,
- `node:${String(toolAssistantEvent.seq)}`,
+ toolUserKey,
+ conversationContextKey('tool-call', TARGET_CALL_1),
+ conversationContextKey('tool-call', TARGET_CALL_2),
+ toolAssistantKey,
]
const actualOrder = await page.locator('[data-chat-anchor-key]').evaluateAll((rows, keys) => (
rows.map(row => (row as HTMLElement).dataset.chatAnchorKey)
.filter((key): key is string => key !== undefined && keys.includes(key))
), expectedOrder)
expect(actualOrder).toEqual(expectedOrder)
- const groupKeys = await Promise.all([call1, call2].map(row => row.evaluate(element => (
- element.closest('[data-chat-flow-kind="tool-group"]')?.dataset.chatFlowKey ?? null
+ const toolKinds = await Promise.all([call1, call2].map(row => row.evaluate(element => (
+ element.closest('[data-chat-flow-kind]')?.dataset.chatFlowKind ?? null
))))
- expect(groupKeys[0]).not.toBeNull()
- expect(groupKeys[1]).toBe(groupKeys[0])
+ expect(toolKinds).toEqual(['tool-call', 'tool-call'])
const summary1 = call1.locator('[data-sample="bash"]')
const summary2 = call2.locator('[data-sample="bash"]')
@@ -219,9 +238,12 @@ describe('web e2e: long Chat interaction contract', () => {
expect(await summary1.getAttribute('aria-expanded')).toBe('false')
await call2.getByText(`${toolMarker2} output line 12`, { exact: true }).waitFor({ timeout: 10_000 })
- await wheelUntilMounted(page, `[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`, -1_100)
- const userRow = page.locator(`[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`)
- const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(branchAssistantEvent.seq)}"]`)
+ const branchUserKey = messageKey(branchUserEvent)
+ const branchAssistantKey = assistantKey(branchAssistantEvent)
+ await wheelUntilMounted(page, `[data-chat-anchor-key="${branchUserKey}"]`, -1_100)
+ const userRow = page.locator(`[data-chat-anchor-key="${branchUserKey}"]`)
+ const assistantRow = page.locator(`[data-chat-anchor-key="${branchAssistantKey}"]`)
+ const turnTailRow = page.locator(`[data-chat-anchor-key="${turnTailKey(BRANCH_TURN)}"]`)
expect(await userRow.textContent()).toContain(branchUserMarker)
expect(await assistantRow.textContent()).toContain(branchAssistantMarker)
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
@@ -230,8 +252,8 @@ describe('web e2e: long Chat interaction contract', () => {
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 5_000 })
.toBe(expectedUserText)
- await assistantRow.hover()
- await assistantRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click()
+ await turnTailRow.hover()
+ await turnTailRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click()
await expect.poll(
() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)),
{ timeout: 15_000 },
diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts
index 6509055036..2e1892f8b6 100644
--- a/apps/web/tests/chat-scroll-contract.e2e.ts
+++ b/apps/web/tests/chat-scroll-contract.e2e.ts
@@ -40,6 +40,11 @@ const LIVE_TOOL_FIRST = 'CHAT_SCROLL_TOOL_STREAM_FIRST'
const LIVE_TOOL_DONE = 'CHAT_SCROLL_TOOL_STREAM_DONE'
const TOOL_READY_FILE = '.chat-scroll-tool-ready'
const TOOL_RELEASE_FILE = '.chat-scroll-tool-release'
+const INPUTS_SESSION_ID = 'chat-scroll-inputs-e2e'
+const FLING_SESSION_ID = 'chat-scroll-fling-e2e'
+const LIVE_FLING_PROMPT = 'CHAT_SCROLL_FLING_USER Keep streaming while I fling back through older output.'
+const LIVE_FLING_FIRST = 'CHAT_SCROLL_FLING_STREAM_FIRST'
+const LIVE_FLING_DONE = 'CHAT_SCROLL_FLING_STREAM_DONE'
const HISTORY_FIXTURE = createChatScrollFixture({
markerPrefix: 'HISTORY',
@@ -58,6 +63,10 @@ const RESTORE_FIXTURE_B = createChatScrollFixture({
title: 'CHAT_SCROLL_RESTORE_B comparison session',
turns: 32,
})
+const INPUTS_FIXTURE = createChatScrollFixture({
+ markerPrefix: 'INPUTS',
+ title: 'CHAT_SCROLL_INPUTS non-wheel reader input session',
+})
interface ScrollGeometry {
readonly distanceFromBottom: number
@@ -159,8 +168,10 @@ async function launchScrollWorld(options: ScrollWorldOptions): Promise {
}))
}
-async function conversationTurns(page: Page): Promise {
- const stats = page.getByText(/\d+ turns · \d+ steps/, { exact: true }).last()
- await stats.waitFor({ timeout: 15_000 })
- const value = await stats.textContent()
- const match = value?.match(/^(\d+) turns · \d+ steps$/)
- if (match?.[1] === undefined) throw new Error(`unexpected conversation stats ${JSON.stringify(value)}`)
- return Number(match[1])
+/**
+ * Rendered transcript rows in the loaded window. The stats strip cannot serve
+ * as this probe: its turn/step counts ride the whole-log sessionStats
+ * projection and stay fixed across paging by design, while the row count is
+ * exactly what grows when an older page prepends or a live turn streams in.
+ * @param page - the scenario page.
+ * @returns the number of mounted chat flow rows.
+ */
+async function loadedFlowRows(page: Page): Promise {
+ return page.locator('[data-chat-flow-key]').count()
}
async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise {
- const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
+ // Search collapsed into a header action; expand it before filling.
+ const searchButton = page.getByRole('button', { name: 'Search sessions' })
+ if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
+ const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
// Cold summaries initially show the temporary workspace basename, so the
- // persisted first-message marker is the stable user-facing identity. The
+ // persisted first-prompt marker is the stable user-facing identity. The
// query itself triggers lazy content-index reconciliation; no transient
// empty-state paint is used as a barrier.
await search.fill(fixture.markers.user(1))
@@ -273,6 +290,34 @@ async function wheelTranscript(page: Page, deltaY: number): Promise {
await nextPaint(page)
}
+/**
+ * Touch-style momentum fling over the transcript. Headless Chromium in the
+ * test lane cannot synthesize device scrolling (Input.synthesizeScrollGesture
+ * and Input.dispatchTouchEvent both deliver DOM events without moving any
+ * scroller, and compositor scrollbars ignore synthetic mouse input), so the
+ * fling replays the signature a real pan leaves on the scrollport: per-frame
+ * decaying displacements the component never authored, carrying no wheel
+ * events. Wheel-sign semantics: positive deltaY reads downward.
+ */
+async function flingTranscript(page: Page, deltaY: number): Promise {
+ await page.locator('[data-conversation-scroll]').evaluate(async (host, delta) => {
+ const direction = Math.sign(delta)
+ let remaining = Math.abs(delta)
+ // Fast launch decaying toward a floor speed, like a released finger. The
+ // floor stays above the follow threshold so contended frames (streaming
+ // writes racing the fling) still deviate far enough to read as input.
+ let velocity = Math.max(120, remaining / 8)
+ while (remaining > 0) {
+ const step = Math.min(velocity, remaining)
+ host.scrollTop += direction * step
+ remaining -= step
+ velocity = Math.max(48, velocity * 0.9)
+ await new Promise(resolve => requestAnimationFrame(() => { resolve() }))
+ }
+ }, deltaY)
+ await nextPaint(page)
+}
+
async function wheelToHistoryStart(page: Page): Promise {
for (let attempt = 0; attempt < 12; attempt += 1) {
if ((await scrollGeometry(page)).scrollTop <= 1) break
@@ -383,9 +428,9 @@ async function loadEarlierWithAnchor(page: Page): Promise {
const older = page.getByRole('button', { name: 'Load earlier', exact: true })
await older.waitFor({ timeout: 10_000 })
const anchor = await visibleFlowAnchor(page)
- const before = await conversationTurns(page)
+ const before = await loadedFlowRows(page)
await older.click()
- await expect.poll(() => conversationTurns(page), { timeout: 30_000 }).toBeGreaterThan(before)
+ await expect.poll(() => loadedFlowRows(page), { timeout: 30_000 }).toBeGreaterThan(before)
await nextPaint(page)
await expectSameFlowTop(page, anchor)
}
@@ -456,7 +501,7 @@ describe('web e2e: long Chat scroll contract', () => {
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
await world.page.getByText(LIVE_TEXT_FIRST, { exact: false }).last().waitFor({ timeout: 15_000 })
await wheelToHistoryStart(world.page)
- const beforeTurns = await conversationTurns(world.page)
+ const beforeRows = await loadedFlowRows(world.page)
await world.page.getByRole('button', { name: 'Load earlier', exact: true }).click()
await expect.poll(() => held, { timeout: 10_000 }).toBe(true)
@@ -469,7 +514,7 @@ describe('web e2e: long Chat scroll contract', () => {
).toBeGreaterThan(chunksAfterAnchor + 5)
releaseHistory()
- await expect.poll(() => conversationTurns(world.page), { timeout: 30_000 }).toBeGreaterThan(beforeTurns)
+ await expect.poll(() => loadedFlowRows(world.page), { timeout: 30_000 }).toBeGreaterThan(beforeRows)
await nextPaint(world.page)
await expectSameFlowTop(world.page, readerAnchor)
} finally {
@@ -489,7 +534,11 @@ describe('web e2e: long Chat scroll contract', () => {
additionalPages += 1
}
expect(additionalPages).toBeGreaterThan(0)
- expect(await conversationTurns(world.page)).toBe(HISTORY_FIXTURE.turns + 1)
+ // The whole log is loaded: turn 1's unique marker renders in the
+ // transcript (scoped: the sidebar search row also carries it) and no
+ // page remains.
+ expect(await world.page.locator('[data-conversation-scroll]')
+ .getByText(HISTORY_FIXTURE.markers.user(1), { exact: false }).count()).toBe(1)
expect(await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count()).toBe(0)
assertClean(world)
})
@@ -683,4 +732,112 @@ describe('web e2e: long Chat scroll contract', () => {
assertClean(world)
})
}, 180_000)
+
+ // Keyboard is the only non-wheel device this lane's Chromium can drive for
+ // real (see flingTranscript for the probe results on touch and scrollbars),
+ // so it stands in for the whole hardware input pipeline here.
+ it.skipIf(MODE === 'record')('keyboard paging owns bottom-follow without wheel input', async () => {
+ await withScrollWorld({
+ failureShot: 'web-e2e-chat-scroll-keyboard',
+ seeds: [{ fixture: INPUTS_FIXTURE, id: INPUTS_SESSION_ID }],
+ }, async (world) => {
+ await openSeed(
+ world.page,
+ INPUTS_FIXTURE,
+ INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns),
+ )
+ await expectBottom(world.page)
+ const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
+
+ // Focus rides the last seeded tool row (a tabbable button whose keydown
+ // handler passes scrolling keys through). End first normalizes the
+ // focus-driven scrollIntoView back to the floor.
+ const lastToolRow = world.page.locator(
+ `[data-chat-call-id="chat-scroll-${String(INPUTS_FIXTURE.turns).padStart(3, '0')}-1"] [data-sample="bash"]`,
+ )
+ await lastToolRow.focus()
+ await world.page.keyboard.press('End')
+ await expectBottom(world.page)
+ await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
+ for (let press = 0; press < 3; press += 1) {
+ await world.page.keyboard.press('PageUp')
+ await nextPaint(world.page)
+ }
+ await backToBottom.waitFor({ timeout: 10_000 })
+ await expect.poll(async () => (await scrollGeometry(world.page)).distanceFromBottom, { timeout: 10_000 })
+ .toBeGreaterThan(100)
+ await world.page.keyboard.press('End')
+ await expectBottom(world.page)
+ await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
+ assertClean(world)
+ })
+ }, 180_000)
+
+ it.skipIf(MODE === 'record')('touch-style fling scrolling owns streaming bottom-follow without wheel input', async () => {
+ await withScrollWorld({
+ failureShot: 'web-e2e-chat-scroll-fling-stream',
+ replay: [
+ replayEntry(toolStream()),
+ replayEntry(textStream(LIVE_FLING_FIRST, LIVE_FLING_DONE, 240)),
+ ],
+ seeds: [{ fixture: INPUTS_FIXTURE, id: FLING_SESSION_ID }],
+ }, async (world) => {
+ const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE)
+ const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE)
+ await openSeed(world.page, INPUTS_FIXTURE, INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns))
+ const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
+ const settled = world.scaffold.whenTurnSettled(60_000)
+ let released = false
+ try {
+ const composer = world.page.locator('textarea:enabled').last()
+ await composer.fill(LIVE_FLING_PROMPT)
+ await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
+ await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true)
+ await expectBottom(world.page)
+
+ // Fling away while the turn is mid-flight: the scroll burst alone must
+ // release bottom ownership, exactly like a wheel scroll would, even
+ // while streaming keeps re-asserting the floor between frames.
+ await flingTranscript(world.page, -900)
+ await backToBottom.waitFor({ timeout: 10_000 })
+ const awayAnchor = await visibleFlowAnchor(world.page)
+ const chunksBeforeRelease = world.events.filter(event => event.type === 'assistant/chunk').length
+ await writeFile(releasePath, 'release\n')
+ released = true
+ await expect.poll(
+ () => world.events.some(event => event.type === 'tool/result'),
+ { timeout: 15_000 },
+ ).toBe(true)
+ await expect.poll(
+ () => world.events.filter(event => event.type === 'assistant/chunk').length,
+ { timeout: 15_000 },
+ ).toBeGreaterThan(chunksBeforeRelease + 5)
+ await expectSameFlowTop(world.page, awayAnchor)
+
+ // Fling back to the floor: re-pin must come from the reader's scroll
+ // itself, and follow must then own the still-streaming tail. The
+ // retry loop chases the floor that streaming keeps pushing down.
+ for (let attempt = 0; attempt < 8; attempt += 1) {
+ if ((await scrollGeometry(world.page)).distanceFromBottom <= 1) break
+ await flingTranscript(world.page, 1_600)
+ }
+ await expectBottom(world.page)
+ await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
+ const chunksAtRepin = world.events.filter(event => event.type === 'assistant/chunk').length
+ await expect.poll(
+ () => world.events.filter(event => event.type === 'assistant/chunk').length,
+ { timeout: 15_000 },
+ ).toBeGreaterThan(chunksAtRepin + 5)
+ await expectBottom(world.page)
+ } finally {
+ if (!released) await writeFile(releasePath, 'release\n').catch(() => {})
+ }
+
+ await settled
+ await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
+ await world.page.getByText(LIVE_FLING_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
+ await expectBottom(world.page)
+ assertClean(world)
+ })
+ }, 180_000)
})
diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/code-mode-round.e2e.ts
index fd103ac53d..051700dc35 100644
--- a/apps/web/tests/code-mode-round.e2e.ts
+++ b/apps/web/tests/code-mode-round.e2e.ts
@@ -26,7 +26,7 @@ const MODE = webSnapshotMode()
// The scenario's one drive prompt: elicits one program with a bash sub-call
// and a failing read the program tolerates — the sub-row set the assertions
-// (and the PR gif) need. Never asserted against model prose.
+// need. Never asserted against model prose.
const PROMPT = 'Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt '
+ 'catching its error in the program. Return an object with both outcomes. Then reply DONE and stop.'
@@ -104,7 +104,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-rows'))
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// The parent run_code row wears the code variant with the model-authored
- // description as its summary (the PR1 presentCall contract).
+ // description as its summary (the presentCall contract).
const codeRow = page.locator('[data-variant="code"]').first()
await codeRow.waitFor({ timeout: 10_000 })
// Nested rows are visible WITHOUT any expand interaction, inside the
diff --git a/apps/web/tests/cold-blank-session.e2e.ts b/apps/web/tests/cold-blank-session.e2e.ts
new file mode 100644
index 0000000000..dd79c4e8b1
--- /dev/null
+++ b/apps/web/tests/cold-blank-session.e2e.ts
@@ -0,0 +1,60 @@
+/** Cold Session list visibility through the shipped compressed JSONL backend. */
+
+import { mkdir, stat } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+ captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedBlankSession,
+ watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/cold-blank-session', import.meta.url))
+const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
+const MODE = webSnapshotMode()
+const SESSION_ID = 'cold-blank-session-web-e2e'
+const WORKSPACE_NAME = 'cold-blank-workspace'
+
+describe('web e2e: cold blank Session visibility', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold({})
+ const cwd = join(scaffold.workspaceCwd, WORKSPACE_NAME)
+ await mkdir(cwd, { recursive: true })
+ await seedBlankSession(scaffold, SESSION_ID, cwd)
+ const header = (await scaffold.ctx.sessionPersistence.list())
+ .find(candidate => candidate.id === SESSION_ID)
+ if (header === undefined) throw new Error('blank Session fixture did not materialize')
+ const location = scaffold.ctx.sessionPersistence.locate(header)
+ if (location === undefined) throw new Error('JSONL fixture has no physical artifact')
+ expect((await stat(location.path)).size).toBeLessThanOrEqual(1024)
+
+ browser = await chromium.launch()
+ page = await newEnglishPage(browser)
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('keeps the verified cold blank Session out of the sidebar', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-cold-blank-session'))
+ const tree = page.getByRole('tree', { name: 'Sessions' })
+ await tree.waitFor({ timeout: 30_000 })
+ expect(await tree.getByText(WORKSPACE_NAME, { exact: true }).count()).toBe(0)
+ const sidebar = await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
+ expect(tripwire.pageErrors).toEqual([])
+ })
+})
diff --git a/apps/web/tests/complex-history.perf.ts b/apps/web/tests/complex-history.perf.ts
index 2daed2c0f6..eeb9931473 100644
--- a/apps/web/tests/complex-history.perf.ts
+++ b/apps/web/tests/complex-history.perf.ts
@@ -1,7 +1,7 @@
// Opt-in browser benchmark for high-cardinality workspace and history
// rendering. It reports measurements without timing assertions because host
-// speed is not a correctness contract; structural assertions keep the load
-// shape from silently shrinking.
+// speed is not a correctness contract; structural assertions keep the number
+// of workspaces and history entries from silently shrinking.
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -797,12 +797,11 @@ async function stableCount(
}
async function conversationTurns(page: Page): Promise {
- const stats = page.getByText(/\d+ turns · \d+ steps/, { exact: true }).last()
- await stats.waitFor({ timeout: 15_000 })
- const value = await stats.textContent()
- const match = value?.match(/^(\d+) turns · \d+ steps$/)
- if (match?.[1] === undefined) throw new Error(`unexpected conversation stats ${JSON.stringify(value)}`)
- return Number(match[1])
+ // Loaded-window turn count: one mounted turn-tail footer per settled turn in
+ // the window (context keys are `${kind.length}:${kind}${id}`). The stats
+ // strip cannot serve as this probe: its counts ride the whole-log
+ // sessionStats projection and stay fixed across paging by design.
+ return stableCount(page.locator('[data-chat-flow-key^="9:turn-tail"]'), count => count > 0)
}
function retainedDelta(
diff --git a/apps/web/tests/composer-draft-scroll.e2e.ts b/apps/web/tests/composer-draft-scroll.e2e.ts
index 5013d2638a..ae48dcddca 100644
--- a/apps/web/tests/composer-draft-scroll.e2e.ts
+++ b/apps/web/tests/composer-draft-scroll.e2e.ts
@@ -8,7 +8,7 @@
// `[data-input-backdrop]` div underneath it, which also carries the claim-token
// highlight, the chips and the ghost hint.
//
-// Two layers can only stay together by moving together. They now do: both sit
+// Two layers can only stay together by moving together. They do: both sit
// inside `[data-input-scroll]`, the composer's single scrolling box, and are as
// tall as the whole draft — so one offset, applied by the browser, moves the
// caret and the words in the same frame. Scrolling the textarea and assigning
@@ -24,7 +24,7 @@
//
// Only a real engine can show any of this. Scrolling is layout: jsdom reports
// `scrollHeight === clientHeight` for every element and never scrolls one, so
-// the unit spec in packages/client/ui-conversation/tests/input-bar.spec.tsx can
+// the unit spec in packages/client/ui-conversation/tests/input-bar.client.spec.tsx can
// only assert that one scrollport contains both layers.
//
// Zero model calls: a fresh workspace's blank session already carries a live
@@ -64,12 +64,12 @@ const DRAFT = Array.from({ length: DRAFT_LINES }, (_unused, index) => {
}).join('\n')
/**
- * A draft ending in a newline: the shape where the two layers reserve their
+ * A draft ending in a newline, where the two layers reserve their
* final line box on different terms. A textarea keeps one for the caret after a
* final newline; `white-space: pre-wrap` collapses a text node's trailing
* newline and generates none. The hidden auto-grow mirror carries the newline
* and so decides the height for both, which is why the backdrop needs no
- * padding of its own — but only a draft of this shape can show it.
+ * padding of its own — but only a draft with a trailing newline can show it.
*/
const DRAFT_TRAILING_NEWLINE = `${DRAFT}\n`
@@ -192,7 +192,7 @@ function measureComposer(page: Page): Promise {
* Absolute glyph coordinates are deliberately absent: they depend on font
* metrics and would make the fixture fail on a machine that measures text
* differently — a golden that needs re-recording per platform documents the
- * platform, not the change. What is recorded is the cap, the caret-to-glyph
+ * platform, not the behavior. What is recorded is the cap, the caret-to-glyph
* relation, and which lines are on screen, each a comparison that survives any
* layout keeping the coupling.
* @param top - metrics with the draft scrolled to its start.
@@ -297,10 +297,10 @@ describe('web e2e: composer draft scrolling', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wrap-width'))
// A layer that breaks lines somewhere else puts the words under the wrong
// caret, and an 8px difference is worth 2 to 5 lines on a wrap-sensitive
- // draft. The three now share a containing block — the scrollport — so a
- // scrollbar that consumes layout space costs them the same width; before,
- // only the textarea scrolled, and WebKit reserved gutter space for it alone
- // (768 against 776) while chromium and firefox did not.
+ // draft. All three share a containing block — the scrollport — so a
+ // scrollbar that consumes layout space costs them the same width; with
+ // only the textarea scrolling, WebKit reserves gutter space for it alone
+ // (768 against 776) while chromium and firefox do not.
const metrics = await measureComposer(page)
expect(metrics.backdropWrapWidth).toBe(metrics.inputWrapWidth)
// The mirror decides the box height, so it belongs in the same equality —
@@ -315,7 +315,7 @@ describe('web e2e: composer draft scrolling', () => {
// The reported symptom, isolated. A scroll offset changes and the caret's
// distance to its own glyphs is re-read before the task ends — before any
// `scroll` listener could have run. With the layers on one scrollport the
- // browser moved both, so the distance is unchanged; with the glyph layer
+ // browser moves both, so the distance is unchanged; with the glyph layer
// catching up in a listener it is off by the whole delta until a later
// frame, which is a caret flying away from its text mid-gesture.
const metrics = await measureComposer(page)
@@ -348,9 +348,10 @@ describe('web e2e: composer draft scrolling', () => {
it('typing at the end of a scrolled draft brings the caret back into view', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-edit'))
// The other way the box moves, and the one that depends on the browser: the
- // textarea no longer scrolls, so revealing the caret after an edit is a
- // scroll-into-view that has to walk up to the scrollport. Scroll away from
- // the caret first, so the edit has somewhere to bring it back from.
+ // textarea holds no scroll offset of its own, so revealing the caret after
+ // an edit is a scroll-into-view that has to walk up to the scrollport.
+ // Scroll away from the caret first, so the edit has somewhere to bring it
+ // back from.
const input = page.locator('textarea:enabled').first()
await input.press('End')
await input.hover()
@@ -368,9 +369,9 @@ describe('web e2e: composer draft scrolling', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-paste'))
// The composer suppresses the native paste — the machine owns the draft and
// the undo log — and restores the caret programmatically, which reveals
- // nothing on its own: measured in chromium and WebKit, the view stayed
- // where it was while the caret sat at the end of the pasted block. The
- // restore now scrolls it into view, and this is the case that proves it.
+ // nothing on its own: in chromium and WebKit the view stays put while the
+ // caret sits at the end of the pasted block, so the restore scrolls it
+ // into view; this case pins it.
const input = page.locator('textarea:enabled').first()
await input.fill('one short line')
await input.press('End')
@@ -380,7 +381,7 @@ describe('web e2e: composer draft scrolling', () => {
const data = new DataTransfer()
data.setData('text/plain', text)
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
- // Ending in a newline is the shape the engines disagree on: the caret
+ // The engines disagree when the draft ends in a newline: the caret
// lands on a line with nothing on it, where chromium reports no client
// rects at all for the collapsed position.
}, `\n${DRAFT}\n`)
@@ -400,7 +401,7 @@ describe('web e2e: composer draft scrolling', () => {
it('a draft ending in a newline scrolls to its true end, not a line above it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-trailing-newline'))
- // The layers reserve a final line box on different terms, so this shape is
+ // The layers reserve a final line box on different terms, so the trailing-newline case is
// the one that separates a height every layer agrees on from a box measured
// one line short of the caret's own last position.
const input = page.locator('textarea:enabled').first()
@@ -452,7 +453,7 @@ describe('web e2e: composer draft scrolling', () => {
const data = new DataTransfer()
data.setData('text/plain', text)
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
- // The ordinary shape — not ending in a newline — so the collapsed branch
+ // The ordinary case, without a trailing newline, so the collapsed branch
// of the reveal keeps a real engine under it; the case above owns the
// after-newline branch.
}, `\n${DRAFT}`)
diff --git a/apps/web/tests/composer-tab-geometry.e2e.ts b/apps/web/tests/composer-tab-geometry.e2e.ts
index c6b51b02bc..0f7b01c7ff 100644
--- a/apps/web/tests/composer-tab-geometry.e2e.ts
+++ b/apps/web/tests/composer-tab-geometry.e2e.ts
@@ -11,12 +11,13 @@
// gets an absolutely positioned seat instead, laid out against the padding box,
// which the scrollbar never reduces.
//
-// So the two tabs disagreed by exactly the bar's width for as long as the
-// transcript overflowed: the card jumped sideways on every tab switch, and
-// inside Chat alone at the moment a growing transcript started to scroll. The
-// column now reserves the gutter unconditionally (`scrollbar-gutter: stable`)
-// and states the overlay branch as a scroll container on the same axes, so both
-// edges are the same edge.
+// The column handles the two edges without reserving the gutter on both: Chat
+// keeps `scrollbar-gutter: stable` so its seat's content box never jumps as the
+// transcript starts to scroll; the overlay branch does NOT reserve (the view
+// owns its own scrollers, so a reserved gutter would only narrow the view's
+// content by the bar's width), and the overlay seat instead gives back the
+// bar's width (`right: var(--dsh-scrollbar-width)`) so both seats measure the
+// same width and the card does not move.
//
// Only a real engine can show this. The seat's geometry is layout: jsdom gives
// every element a zero-sized box and reports no scrollbar at all, so a unit spec
@@ -26,19 +27,18 @@
//
// The browser is launched WITHOUT Playwright's default `--hide-scrollbars`,
// which is load-bearing rather than incidental. Under that argument a scroll
-// container's bar consumes no layout width at all, so the two tabs agree before
-// this change as much as after it and every comparison below holds vacuously —
-// measured: the pre-fix cascade leaves both tabs' bands at 0 there, against 8
-// and 0 with the argument dropped. Dropping it is also the faithful
+// container's bar consumes no layout width at all, so the two tabs agree with
+// and without the compensation and every comparison below holds vacuously —
+// measured: the uncompensated cascade leaves both tabs' bands at 0 there,
+// against 8 and 0 with the argument dropped. Dropping it is also the faithful
// configuration: ui-theme's scrollbar.css gives `::-webkit-scrollbar` a width,
// and a bar that occupies layout space is what the product actually draws.
//
-// The scenario runs that pre-fix cascade in the page — `scrollbar-gutter: auto`
-// on the scroller, `overflow: hidden` on the overlay branch — and measures the
-// same two tabs through it, which is what keeps the equal rectangles above from
-// being explained by a tab switch that never reached the layout. It is the
-// reported symptom as a number: the card moves 4px, half the 8px band, on each
-// edge.
+// The scenario runs that uncompensated cascade in the page — the overlay seat's
+// `right` compensation dropped to 0 — and measures the same two tabs through
+// it, which is what keeps the equal rectangles above from being explained by a
+// tab switch that never reached the layout. It is the reported symptom as a
+// number: the card moves 4px, half the 8px band, on each edge.
//
// Zero model calls: a seeded cold session renders from its log, and switching
// tabs asks the host for nothing. A stray stream would fail loud with NO_ADAPTER.
@@ -62,9 +62,9 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-tab-geometry',
* Absolute coordinates are deliberately absent: they depend on the sidebar's
* laid-out width and on font metrics, so committing them would produce a fixture
* that has to be re-recorded per platform. What is recorded is the distance
- * between the two tabs' rectangles, which is zero when the reservation holds and
+ * between the two tabs' rectangles, which is zero when the compensation holds and
* the bar's width when it does not — including under the control, so the golden
- * carries the difference the fix removes rather than only its absence.
+ * carries the shift the uncompensated cascade produces rather than only its absence.
*/
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
@@ -114,15 +114,15 @@ async function setMeasuredViewport(
}
/**
- * The pre-fix cascade, injected into the page: the reservation dropped and the
- * overlay branch back to a hidden box. `!important` beats the module rules
- * without a rebuild, and the id lets the control be lifted again in the same
- * session.
+ * The uncompensated cascade, injected into the page: the overlay seat's `right`
+ * compensation dropped to 0, so it measures the full padding box while Chat's
+ * seat still rides the reserved content box. `!important` beats the module
+ * rules without a rebuild, and the id lets the control be lifted again in the
+ * same session.
*/
const CONTROL_STYLE_ID = 'composer-tab-geometry-control'
const CONTROL_CSS = `
-[data-conversation-scroll] { scrollbar-gutter: auto !important; }
-[data-conversation-scroll]:has([data-conversation-composer-overlay]) { overflow: hidden !important; }
+[data-conversation-scroll]:has([data-conversation-composer-overlay]) > [data-composer-seat] { right: 0 !important; }
`
/** The column scroller and the input card as the browser lays them out, in one tab. */
@@ -221,11 +221,13 @@ async function compareTabs(page: Page): Promise {
}
/**
- * Run the pre-fix cascade in the page for one measurement, then lift it.
+ * Run the uncompensated cascade in the page for one measurement, then lift it:
+ * the overlay seat's `right` compensation dropped to 0, so it measures the
+ * full padding box while Chat's seat still rides the reserved content box.
* @param page - the page under test.
- * @returns the comparison as the column laid out before this change.
+ * @returns the comparison as the column lays out without the compensation.
*/
-async function compareTabsWithoutReservation(page: Page): Promise {
+async function compareTabsWithoutCompensation(page: Page): Promise {
await page.evaluate(({ id, css }) => {
const style = document.createElement('style')
style.id = id
@@ -249,7 +251,10 @@ async function compareTabsWithoutReservation(page: Page): Promise
* @param page - the page under test.
*/
async function openSeededSession(page: Page): Promise {
- const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
+ // Search collapsed into a header action; expand it before filling.
+ const searchButton = page.getByRole('button', { name: 'Search sessions' })
+ if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
+ const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
await search.fill(FIXTURE.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
const deadline = Date.now() + 60_000
@@ -265,7 +270,7 @@ async function openSeededSession(page: Page): Promise {
* Render the golden body.
* @param wide - comparison at the viewport where the card sits at its width cap.
* @param narrow - comparison at the viewport where the card shrinks with the column.
- * @param control - comparison at the wide viewport with the reservation removed.
+ * @param control - comparison at the wide viewport with the compensation removed.
* @returns the golden body, without a trailing newline.
*/
function renderGeometry(wide: TabComparison, narrow: TabComparison, control: TabComparison): string {
@@ -288,7 +293,7 @@ function renderGeometry(wide: TabComparison, narrow: TabComparison, control: Tab
'',
...section(`Wide viewport (${String(WIDE_VIEWPORT.width)}px, card at its cap)`, wide),
...section(`Narrow viewport (${String(NARROW_VIEWPORT.width)}px, card shrinking with the column)`, narrow),
- ...section('Wide viewport, reservation removed in the page (control)', control),
+ ...section('Wide viewport, seat compensation removed in the page (control)', control),
].join('\n').trimEnd()
}
@@ -319,22 +324,29 @@ describe('web e2e: input card position across view tabs', () => {
await scaffold?.close()
})
- it('reserves the same gutter in both tabs while the transcript scrolls', async () => {
+ it('reserves the gutter in Chat and lets Trajectory own its width', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-band'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
- // Vacuity guard, in two parts. A transcript that does not overflow gives
- // Chat no scrollbar, and a hidden or overlaid bar gives it no width; either
- // would make the tabs agree without the reservation doing anything.
+ // Vacuity guard. The scenario must be able to fail: on an engine that
+ // does not implement `scrollbar-gutter`, Chat reserves nothing and the
+ // overlay seat's fixed compensation stands alone, manufacturing an 8px
+ // deviation the equal-rectangle assertions would catch. `stable` reserves
+ // even without overflow, so a short transcript is not a vacuous case; the
+ // poll still pins the measurement to the overflowing state the product
+ // ships.
await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true)
const comparison = await compareTabs(page)
expect(comparison.chat.band).toBeGreaterThan(0)
- // The reservation reaches both states, which is the whole change: the same
- // band, on a box that scrolls and on one that only holds a view.
+ // Chat keeps the unconditional reservation so its seat's content box never
+ // jumps as the transcript starts to scroll.
expect(comparison.chat.gutter).toBe('stable')
- expect(comparison.trajectory.gutter).toBe('stable')
- expect(comparison.trajectory.band).toBe(comparison.chat.band)
+ // The overlay branch does NOT reserve: the view owns its own scrollers, so
+ // a reserved gutter would only narrow the view's content by the bar's
+ // width. The seat compensates instead, which the next test asserts.
+ expect(comparison.trajectory.gutter).toBe('auto')
+ expect(comparison.trajectory.band).toBe(0)
// Declared as a scroll container on both axes rather than left to compute:
- // `overflow: hidden` would drop the reservation in WebKit, and a `visible`
+ // `overflow: hidden` would drop any reservation in WebKit, and a `visible`
// horizontal axis computes to `auto` beside a scrolling one.
expect(comparison.trajectory.overflowY).toBe('auto')
expect(comparison.trajectory.overflowX).toBe('hidden')
@@ -348,8 +360,8 @@ describe('web e2e: input card position across view tabs', () => {
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const comparison = await compareTabs(page)
// The reported symptom as a number. At this viewport the card sits at its
- // width cap, so the pre-fix shift showed up as a centring difference — half
- // the band on each edge — rather than as a width change.
+ // width cap, so the uncompensated cascade's shift shows up as a centring
+ // difference — half the band on each edge — rather than as a width change.
expect(comparison.leftShift).toBe(0)
expect(comparison.rightShift).toBe(0)
expect(comparison.widthShift).toBe(0)
@@ -363,7 +375,7 @@ describe('web e2e: input card position across view tabs', () => {
await setMeasuredViewport(page, NARROW_VIEWPORT, true)
const comparison = await compareTabs(page)
// The other geometry, and a different failure: below the cap the card takes
- // the column's width, so an unreserved gutter changed its WIDTH by the whole
+ // the column's width, so an unreserved gutter changes its WIDTH by the whole
// band instead of shifting it by half. Asserted against the capped
// measurement rather than against the cap's pixel value, which belongs to
// the stylesheet.
@@ -375,21 +387,22 @@ describe('web e2e: input card position across view tabs', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
- it('moves the card again once the reservation is removed in the page', async () => {
+ it('moves the card again once the seat compensation is removed in the page', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
// The control: without it, equal rectangles could also mean the tab switch
- // never reached the layout. Under the pre-fix cascade the Chat scroller keeps
- // its bar and the Trajectory branch goes back to a hidden box with none, and
- // the card moves by half the band on each edge.
- const comparison = await compareTabsWithoutReservation(page)
- expect(comparison.chat.gutter).toBe('auto')
+ // never reached the layout. Under the uncompensated cascade the overlay seat
+ // loses its `right` compensation and measures the full padding box, so the
+ // card moves by half the band on each edge. Chat's own reservation is
+ // untouched — that is the side that must not change.
+ const comparison = await compareTabsWithoutCompensation(page)
+ expect(comparison.chat.gutter).toBe('stable')
expect(comparison.chat.band).toBeGreaterThan(0)
expect(comparison.trajectory.band).toBe(0)
expect(comparison.leftShift).toBe(comparison.chat.band / 2)
expect(comparison.rightShift).toBe(comparison.chat.band / 2)
- // Restoring the sheet restores the fix, so the control cannot leak into the
- // remaining measurements.
+ // Restoring the sheet restores the compensation, so the control cannot leak
+ // into the remaining measurements.
const restored = await compareTabs(page)
expect(restored.leftShift).toBe(0)
expect(tripwire.pageErrors).toEqual([])
@@ -402,7 +415,7 @@ describe('web e2e: input card position across view tabs', () => {
await setMeasuredViewport(page, NARROW_VIEWPORT, true)
const narrow = await compareTabs(page)
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
- const control = await compareTabsWithoutReservation(page)
+ const control = await compareTabsWithoutCompensation(page)
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(wide, narrow, control), MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
diff --git a/apps/web/tests/conversation-column-overflow.e2e.ts b/apps/web/tests/conversation-column-overflow.e2e.ts
new file mode 100644
index 0000000000..fc51e0da34
--- /dev/null
+++ b/apps/web/tests/conversation-column-overflow.e2e.ts
@@ -0,0 +1,344 @@
+// Web e2e scenario: the conversation column scrolls on one axis only, as the
+// browser actually lays it out. The hazard: a horizontal scrollbar appears
+// under the whole center column once the window (or the sidebar drag) narrows
+// it — the hero's decorative backdrop ellipse bleeds past the column and
+// becomes user-scrollable.
+//
+// The bleed is by construction and stays: `.heroGlow` is sized 1051/776 of the
+// hero box (ConversationRoot.module.css) so the blur scales with the input
+// card. The scroll container is where the bar comes from:
+// `[data-conversation-scroll]` scrolls vertically, and a one-axis scroller
+// computes the other axis's initial `visible` to `auto`, so the bleed becomes
+// a bar; `overflow-x: hidden` on the scroller prevents it.
+//
+// Only a real engine reports that pair — the bleed and the resulting scroll
+// range — so the scenario sweeps viewport widths that bracket the glow's
+// width and asserts both at each stop. Asserting no horizontal scroll alone
+// would go vacuous the moment the glow stopped bleeding for an unrelated
+// reason, which is why each stop also records whether it bleeds; the wide stop
+// is the control where it does not.
+//
+// Zero model calls: the hero is the boot state, so nothing is seeded and no
+// replay row mounts. A stray stream would fail loud with NO_ADAPTER.
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+ assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode,
+ type WebScaffold,
+} from './scaffold.ts'
+import { newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/conversation-column-overflow', import.meta.url))
+/**
+ * Committed golden of the one-axis relation at every stop. It records
+ * relations and booleans, never absolute coordinates: the column width follows
+ * the viewport and the sidebar, and a golden carrying pixels would document the
+ * platform instead of the behavior.
+ */
+const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
+const MODE = webSnapshotMode()
+/** Narrow sweep stop where the mutation control retains overflow across scrollbar implementations. */
+const CONTROL_VIEWPORT = 600
+/**
+ * Viewport widths bracketing the glow: the narrow stops retain the reported
+ * bleed while the widest stop proves the relation can also be false.
+ */
+const WIDTHS = [1680, 1200, 1000, 800, CONTROL_VIEWPORT]
+/** Element id of the mutation control's injected sheet, so the test can take it back out. */
+const CONTROL_STYLE_ID = 'dsh-column-overflow-control'
+/** Horizontal wheel delta per gesture; must exceed the widest bleed the sweep can produce. */
+const WHEEL_DELTA = 300
+
+/** One viewport stop: whether the glow bleeds past the column, and whether that bleed scrolls. */
+interface ColumnMetrics {
+ /** Viewport width the stop was measured at. */
+ width: number
+ /** The column's content width. Not committed to the golden — it is what settles after a resize, and what the sweep waits on. */
+ columnWidth: number
+ /** Resolved `overflow-x` on the conversation scroll container. */
+ overflowX: string
+ /**
+ * True when the glow's box reaches past the column's content edge — the
+ * condition the `overflow-x: hidden` declaration has to survive.
+ */
+ glowBleeds: boolean
+ /**
+ * `scrollWidth - clientWidth`. Deliberately NOT the assertion: `hidden` and
+ * `auto` both report the same value, because `hidden` clips the bleed rather
+ * than reflowing it away. Recorded because it is the vacuity guard in
+ * numbers — it must stay positive at the narrow stops, or the scenario has
+ * stopped reproducing the situation `overflow-x: hidden` exists for.
+ */
+ bleedRange: number
+ /** True when the column still scrolls vertically — the axis `overflow-x: hidden` must not take away. */
+ scrollsVertically: boolean
+}
+
+/**
+ * Measure the conversation column at the page's current viewport.
+ * @param page - the page under test.
+ * @param width - the viewport width already applied, recorded with the reading.
+ * @returns the stop's overflow relations.
+ */
+function measureColumn(page: Page, width: number): Promise {
+ return page.evaluate((viewportWidth) => {
+ const scroller = document.querySelector('[data-conversation-scroll]')
+ if (scroller === null) throw new Error('conversation scroll container not in the DOM')
+ const glow = scroller.querySelector('[class*="heroGlow"]')
+ if (glow === null) throw new Error('hero glow not in the DOM — the boot state is not the hero')
+ const box = scroller.getBoundingClientRect()
+ const glowBox = glow.getBoundingClientRect()
+ return {
+ width: viewportWidth,
+ columnWidth: scroller.clientWidth,
+ overflowX: getComputedStyle(scroller).overflowX,
+ // `clientWidth` is the content edge, which is what the scrollable
+ // overflow region is measured against; either side counts as a bleed,
+ // though only the right one can produce a bar in this writing mode.
+ glowBleeds: glowBox.right > box.left + scroller.clientWidth + 0.5 || glowBox.left < box.left - 0.5,
+ bleedRange: scroller.scrollWidth - scroller.clientWidth,
+ scrollsVertically: getComputedStyle(scroller).overflowY === 'auto',
+ }
+ }, width)
+}
+
+/**
+ * Scroll the column sideways the way a user would and report where it landed.
+ *
+ * This is the one signal that separates the two states, and it is why the
+ * scenario needs a real engine: `overflow-x: hidden` leaves the box
+ * programmatically scrollable and leaves `scrollWidth` untouched, so every
+ * property reading agrees across the two overflow modes. Only refusing an
+ * actual input event differs — measured at the 1200px stop, the shipped
+ * column stays at 0 while the same page with `overflow-x: auto` forced on
+ * lands at its scroll boundary.
+ * @param page - the page under test.
+ * @returns `scrollLeft` after one horizontal wheel over the column.
+ */
+async function wheelHorizontally(page: Page): Promise {
+ const origin = await page.evaluate(() => {
+ const scroller = document.querySelector('[data-conversation-scroll]')
+ if (scroller === null) throw new Error('conversation scroll container not in the DOM')
+ // Start from the origin so the reading is this gesture's own effect.
+ scroller.scrollLeft = 0
+ const box = scroller.getBoundingClientRect()
+ // Near the top of the column, clear of the centered hero card: the wheel
+ // must reach the column, not a nested scroller the composer owns.
+ return { x: box.left + box.width / 2, y: box.top + 60 }
+ })
+ await page.mouse.move(origin.x, origin.y)
+ await page.mouse.wheel(WHEEL_DELTA, 0)
+ // A fixed settle, then two frames. Polling for a settled value cannot be
+ // used here — the value under test is 0, which a poll starting at 0 accepts
+ // before the gesture has had any chance to move it — so the wait is
+ // generous enough to cover a smooth-scroll animation on any engine the lane
+ // runs on. The timing is identical on both sides of the mutation control
+ // below, which is what makes a 0 reading evidence rather than a race won.
+ await page.waitForTimeout(400)
+ return page.evaluate(() => new Promise((resolve) => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ resolve(document.querySelector('[data-conversation-scroll]')?.scrollLeft ?? -1)
+ })
+ })
+ }))
+}
+
+/**
+ * Measure the positive horizontal scroll boundary without changing the
+ * shipped overflow mode. This is distinct from `scrollWidth - clientWidth`
+ * when a stable scrollbar gutter leaves part of the overflow on the negative
+ * side of the scroll origin.
+ * @param page - the page under test.
+ * @returns the greatest positive `scrollLeft` reachable by the control gesture.
+ */
+async function horizontalScrollLimit(page: Page): Promise {
+ return page.evaluate((delta) => {
+ const scroller = document.querySelector('[data-conversation-scroll]')
+ if (scroller === null) throw new Error('conversation scroll container not in the DOM')
+ const previousScrollBehavior = scroller.style.scrollBehavior
+ scroller.style.scrollBehavior = 'auto'
+ scroller.scrollLeft = delta
+ const limit = scroller.scrollLeft
+ scroller.scrollLeft = 0
+ scroller.style.scrollBehavior = previousScrollBehavior
+ return limit
+ }, WHEEL_DELTA)
+}
+
+/** A stop's readings plus where a horizontal wheel over it landed. */
+type ColumnStop = ColumnMetrics & {
+ /** `scrollLeft` after one horizontal wheel: the user-facing claim, 0 at every stop. */
+ scrollLeftAfterWheel: number
+}
+
+/**
+ * Render the golden body: one line per stop, relations only.
+ *
+ * Absolute pixels are deliberately absent apart from `scrollLeftAfterWheel`,
+ * which the shipped overflow mode pins to 0 by construction. The bleed is
+ * recorded as a boolean rather than its width, so the golden survives any
+ * platform whose column lands a pixel off — a fixture that has to be
+ * re-recorded per platform documents the platform, not the behavior.
+ * @param stops - the measured stops, in sweep order.
+ * @returns the golden body, without a trailing newline.
+ */
+function renderGeometry(stops: ColumnStop[]): string {
+ return [
+ '# Conversation column horizontal overflow',
+ '',
+ '| viewport | overflow-x | glow bleeds past the column | scrollLeft after a horizontal wheel | scrolls vertically |',
+ '| --- | --- | --- | --- | --- |',
+ ...stops.map(stop => `| ${String(stop.width)}px | ${stop.overflowX} | ${String(stop.glowBleeds)} `
+ + `| ${String(stop.scrollLeftAfterWheel)}px | ${String(stop.scrollsVertically)} |`),
+ ].join('\n')
+}
+
+describe('web e2e: the conversation column scrolls on one axis', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold({})
+ browser = await chromium.launch()
+ page = await newEnglishPage(browser, 900)
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[data-conversation-scroll] [class*="heroGlow"]', { timeout: 30_000 })
+ }, 180_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ /**
+ * Resize to a viewport and read the column once its width stops moving.
+ *
+ * The glow rides the hero box, which rides the column, and the frame eases
+ * its column tracks over `--ds-transition-duration-slow`: reading straight
+ * after a resize can report the previous viewport's relation, or a width
+ * caught mid-transition.
+ * @param width - viewport width to settle at.
+ * @returns the column's readings at that width.
+ */
+ const settleAt = async (width: number): Promise => {
+ await page.setViewportSize({ width, height: 900 })
+ let previous = -1
+ await expect.poll(async () => {
+ const current = (await measureColumn(page, width)).columnWidth
+ const settled = current === previous
+ previous = current
+ return settled
+ }, { timeout: 10_000 }).toBe(true)
+ return measureColumn(page, width)
+ }
+
+ /**
+ * Sweep the stops once per run and hand the SAME readings to every assertion
+ * below, so the golden and the assertions describe one measurement instead of
+ * two runs that could disagree. Memoized rather than re-run per test: the
+ * gestures below move the viewport, and a second sweep would be a second
+ * chance for a resize to settle differently.
+ * @returns the stops in {@link WIDTHS} order.
+ */
+ let swept: Promise | undefined
+ const sweep = (): Promise => {
+ swept ??= (async () => {
+ const stops: ColumnStop[] = []
+ for (const width of WIDTHS) {
+ stops.push({ ...await settleAt(width), scrollLeftAfterWheel: await wheelHorizontally(page) })
+ }
+ return stops
+ })()
+ return swept
+ }
+
+ it('never scrolls horizontally, at any width the glow bleeds past', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow'))
+ const stops = await sweep()
+ // The vacuity guard, in two halves: the glow has to reach past the column
+ // at the narrow stops, and that reach has to still register as scrollable
+ // overflow. Without both, the claim below holds for free.
+ expect(stops.filter(stop => stop.glowBleeds).map(stop => stop.width)).toEqual([
+ 1200, 1000, 800, CONTROL_VIEWPORT,
+ ])
+ for (const stop of stops.filter(stop => stop.glowBleeds)) {
+ expect(stop.bleedRange, `viewport ${String(stop.width)}`).toBeGreaterThan(0)
+ }
+ for (const stop of stops) {
+ expect(stop.overflowX, `viewport ${String(stop.width)}`).toBe('hidden')
+ // The reported symptom, stated directly: a horizontal wheel over the
+ // column moves nothing, at every stop.
+ expect(stop.scrollLeftAfterWheel, `viewport ${String(stop.width)}`).toBe(0)
+ // The axis the column is a scroller for must survive `overflow-x: hidden`.
+ expect(stop.scrollsVertically, `viewport ${String(stop.width)}`).toBe(true)
+ }
+ expect(tripwire.pageErrors).toEqual([])
+ }, 120_000)
+
+ it('scrolls horizontally again once the axis is opened back up (control)', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow-control'))
+ // The mutation control, run in the page rather than against a second
+ // build: it lifts exactly the `overflow-x: hidden` declaration, so the
+ // initial `visible` that a one-axis scroller computes to `auto` takes
+ // over, and shows the same gesture, at the same timing, carrying the
+ // column to its positive scroll boundary.
+ // Without it a `scrollLeft` of 0 could equally mean the wheel never arrived.
+ // Injected with an id rather than through `addStyleTag`, so the teardown
+ // below can take the sheet out again by selector: it must not outlive this
+ // test, or the golden ends up reading the control.
+ await page.evaluate((id: string) => {
+ const sheet = document.createElement('style')
+ sheet.id = id
+ sheet.textContent = '[data-conversation-scroll] { overflow-x: auto !important; }'
+ document.head.append(sheet)
+ }, CONTROL_STYLE_ID)
+ try {
+ // Resolve the mutated layout at the narrowest sweep stop. At wider stops,
+ // a classic scrollbar can change the available box enough to remove the
+ // overflow that the control is meant to expose.
+ const before = await settleAt(CONTROL_VIEWPORT)
+ expect(before.overflowX).toBe('auto')
+ expect(before.bleedRange).toBeGreaterThan(0)
+ const scrollLimit = await horizontalScrollLimit(page)
+ // The control has a reachable horizontal range, and the gesture exceeds
+ // it so the equality below proves that the wheel reached the far edge.
+ expect(scrollLimit).toBeGreaterThan(0)
+ expect(scrollLimit).toBeLessThan(WHEEL_DELTA)
+ // Rounded: `scrollLeft` is fractional under a fractional layout while
+ // the claim is that the column reached the positive boundary, not that
+ // two engines agree on a sub-pixel.
+ expect(Math.round(await wheelHorizontally(page))).toBe(Math.round(scrollLimit))
+ } finally {
+ await page.evaluate((id: string) => {
+ document.getElementById(id)?.remove()
+ }, CONTROL_STYLE_ID)
+ }
+ // The override is gone and the shipped state is back: the later goldens
+ // read the product, not the control.
+ expect((await settleAt(CONTROL_VIEWPORT)).overflowX).toBe('hidden')
+ expect(tripwire.pageErrors).toEqual([])
+ }, 120_000)
+
+ it('matches the committed column-overflow golden', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow-golden'))
+ await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(await sweep()), MODE)
+ expect(tripwire.pageErrors).toEqual([])
+ }, 120_000)
+
+ it('commits exactly the fixtures it reads', async () => {
+ // No model calls, so no replay log: the golden is the whole inventory.
+ await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
+ })
+
+ it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
+ expect(tripwire.warnings).toEqual([])
+ expect(tripwire.pageErrors).toEqual([])
+ })
+})
diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts
index ac439bf9d8..a702a7f1ea 100644
--- a/apps/web/tests/cordis-tool-round.e2e.ts
+++ b/apps/web/tests/cordis-tool-round.e2e.ts
@@ -1,7 +1,13 @@
// Web e2e scenario for the opt-in Cordis tools. Record mode drives a real
-// model through inspect, mount, and unmount; replay pins the same shipped Web
-// composition, durable calls, generic rows, highlighted Plugin source, and
-// conversation accessibility tree.
+// model through inspect, define, run, and stop; replay pins the same shipped Web
+// composition, durable calls, Cordis-owned rows, the define card's own source view,
+// and conversation accessibility tree.
+//
+// The approval is never in the fixture. The fixture pins what the MODEL said;
+// tools execute for real, and this test answers the approval before starting the
+// stop turn. The package therefore carries a browser half whose only
+// job is to be visible (`[data-snapshot-probe]`): its absence before the answer
+// and presence after it is the v3 user gate, proven rather than described.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
@@ -17,12 +23,24 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
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))
const MODE = webSnapshotMode()
-const CORDIS_TOOLS = ['cordis_inspect', 'cordis_mount', 'cordis_unmount'] as const
-const MOUNT_CODE = 'return { name: "snapshot-noop", apply(ctx) {} }'
-const PROMPT = 'Use only Cordis tools. First call cordis_inspect with what "temporary". '
- + `Then call cordis_mount with this exact code: ${JSON.stringify(MOUNT_CODE)}. `
- + 'Read its returned id and call cordis_unmount with that exact id. '
- + 'After all three calls succeed, reply exactly CORDIS_UI_DONE and stop.'
+const CORDIS_TOOLS = ['cordis_inspect_self', 'cordis_define', 'cordis_run', 'cordis_stop'] as const
+const PACKAGE_CODE = 'return { name: "snapshot-noop", apply(ctx) {} }'
+// The browser half is the PROBE this scenario turns on: it renders a marker into
+// the frame-wide overlay, so "did the plugin actually run in this page" becomes a
+// DOM fact. A host-only package would sidestep the approval round trip entirely
+// (the host runs those immediately), which would drop the v3 user gate out of
+// coverage — the one thing this scenario exists to prove.
+const CLIENT_CODE = 'return { inject: ["slots"], apply(ctx) { ctx.slots.register('
+ + '{ name: "shell.overlay", id: "snapshot-probe" }, '
+ + '() => React.createElement("div", { "data-snapshot-probe": "loaded" })) } }'
+const PROMPT = 'Use only Cordis tools. First call cordis_inspect_self with no arguments. '
+ + 'Then call cordis_define with plugin kind "new", idPrefix "snap", name "snapshot noop", '
+ + 'purpose "does nothing, for the snapshot", '
+ + `code.host exactly ${JSON.stringify(PACKAGE_CODE)} and code.client exactly ${JSON.stringify(CLIENT_CODE)}. `
+ + 'Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode "run". '
+ + 'After the run request returns, reply exactly CORDIS_UI_READY and stop.'
+const STOP_PROMPT = 'Use only Cordis tools. Call cordis_stop with pluginId "snap-1". '
+ + 'After it succeeds, reply exactly CORDIS_UI_DONE and stop.'
function assertCompleteCordisLifecycle(events: readonly SessionEvent[]): void {
const turnEnd = events.findLast(
@@ -46,7 +64,7 @@ function assertCompleteCordisLifecycle(events: readonly SessionEvent[]): void {
expect(results.every(event => !event.data.message.content[0].isError)).toBe(true)
}
-describe('web e2e: Cordis tools use the generic row variants', () => {
+describe('web e2e: Cordis tools use their owned cards', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
@@ -75,14 +93,30 @@ describe('web e2e: Cordis tools use the generic row variants', () => {
it('drives the recorded Cordis lifecycle to a settled turn (all modes)', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-cordis-drive'))
if (MODE !== 'record') {
- expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
+ expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT, STOP_PROMPT])
}
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
- const settled = scaffold.whenTurnSettled()
+ const runTurnSettled = scaffold.whenTurnSettled()
await input.fill(PROMPT)
await input.press('Enter')
- const sessionId = await settled
+
+ // The approval is the TEST's action in every mode: the fixture pins what the
+ // model said, and the gate is a real round trip through the real panel.
+ const approve = page.locator('[data-cordis-approve]').first()
+ await approve.waitFor({ timeout: 90_000 })
+ // The one assertion this scenario cannot give up: the model asking to run is
+ // NOT the plugin running. Until a person answers, the browser half has not
+ // been fetched, evaluated, or mounted anywhere on this page.
+ expect(await page.locator('[data-snapshot-probe]').count()).toBe(0)
+ await approve.click()
+ await expect.poll(() => page.locator('[data-snapshot-probe]').count(), { timeout: 30_000 }).toBe(1)
+
+ const sessionId = await runTurnSettled
+ const stopTurnSettled = scaffold.whenTurnSettled()
+ await input.fill(STOP_PROMPT)
+ await input.press('Enter')
+ await stopTurnSettled
if (MODE === 'record') {
assertCompleteCordisLifecycle(sessionEvents)
await expect.poll(() => page.getByText('CORDIS_UI_DONE', { exact: true }).count(), { timeout: 15_000 })
@@ -95,25 +129,36 @@ describe('web e2e: Cordis tools use the generic row variants', () => {
assertCompleteCordisLifecycle(sessionEvents)
})
- it.skipIf(MODE === 'record')('renders Cordis lifecycle titles over the generic row mechanics', async () => {
+ it.skipIf(MODE === 'record')('renders localized Cordis lifecycle cards', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-cordis-rows'))
await expect.poll(() => page.getByText('CORDIS_UI_DONE', { exact: true }).count(), { timeout: 15_000 })
.toBeGreaterThanOrEqual(1)
- const inspectRow = page.locator('[data-tool="cordis_inspect"]').filter({ hasText: 'Inspect' }).first()
+ const inspectRow = page.locator('[data-tool="cordis_inspect_self"]').filter({ hasText: 'Inspect' }).first()
await inspectRow.waitFor({ timeout: 10_000 })
- const mountRow = page.locator('[data-tool="cordis_mount"]').filter({ hasText: 'Mount temporary Plugin' }).first()
- await mountRow.waitFor({ timeout: 10_000 })
+ // cordis_define does NOT go through the generic row: ui-cordis registers a
+ // keyed toolview for it, and a keyed hit replaces the generic card. So the
+ // title here is the CARD's ("Cordis Plugin"), and the expanded body is the
+ // card's own two code sections rather than a generic args dump.
+ const defineRow = page.locator('[data-tool="cordis_define"]').filter({ hasText: 'Cordis Plugin' }).first()
+ await defineRow.waitFor({ timeout: 10_000 })
// The whole summary row is the expand toggle (unified tool-row interaction).
- await mountRow.locator('[aria-expanded]').first().click()
- await expect.poll(() => mountRow.locator('pre.shiki').textContent(), { timeout: 10_000 })
- .toContain(MOUNT_CODE)
+ await defineRow.locator('[aria-expanded]').first().click()
+ await expect.poll(() => defineRow.textContent(), { timeout: 10_000 }).toContain('data-snapshot-probe')
+ await defineRow.getByRole('tab', { name: 'Host' }).click()
+ await expect.poll(() => defineRow.textContent()).toContain(PACKAGE_CODE)
- const unmountRow = page.locator('[data-tool="cordis_unmount"]').filter({ hasText: 'Unmount temporary Plugin' }).first()
- await unmountRow.waitFor({ timeout: 10_000 })
- await expect.poll(() => unmountRow.textContent()).toContain('dyn-')
- await expect(unmountRow.getAttribute('data-state')).resolves.toBe('ok')
+ const runRow = page.locator('[data-tool="cordis_run"]').filter({ hasText: 'Run Cordis Plugin' }).first()
+ await runRow.waitFor({ timeout: 10_000 })
+ await expect.poll(() => runRow.textContent()).toContain('snap-')
+
+ const stopRow = page.locator('[data-tool="cordis_stop"]').filter({ hasText: 'Stop Cordis Plugin' }).first()
+ await stopRow.waitFor({ timeout: 10_000 })
+ await expect.poll(() => stopRow.textContent()).toContain('snap-')
+ await expect(stopRow.getAttribute('data-state')).resolves.toBe('ok')
+ // Stopping withdraws the browser half from every page, probe included.
+ await expect.poll(() => page.locator('[data-snapshot-probe]').count(), { timeout: 15_000 }).toBe(0)
})
it.skipIf(MODE === 'record')('matches the conversation aria golden', async () => {
diff --git a/apps/web/tests/core-web-profile.snapshot.ts b/apps/web/tests/core-web-profile.snapshot.ts
deleted file mode 100644
index 58f2a34858..0000000000
--- a/apps/web/tests/core-web-profile.snapshot.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { writeFile } from 'node:fs/promises'
-import { join } from 'node:path'
-import { fileURLToPath } from 'node:url'
-import { afterAll, beforeAll, describe, expect, it } from 'vitest'
-import type { AgentHandle } from '@deepseek-ai/dsh-agent'
-import { CallId } from '@deepseek-ai/dsh-llm'
-import { SessionId } from '@deepseek-ai/dsh-session'
-import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
-
-const CORE_WEB_OVERLAY = fileURLToPath(new URL('../../cli/config/core-web.cordis.yml', import.meta.url))
-
-describe('core Web profile', () => {
- let scaffold: WebScaffold
- let agentHandle: AgentHandle
-
- beforeAll(async () => {
- scaffold = await launchWebScaffold({
- extraOverlayPath: CORE_WEB_OVERLAY,
- toolsMode: 'native',
- })
- agentHandle = await scaffold.ctx.agents.create({
- sessionId: SessionId('core-web-profile-smoke'),
- meta: { cwd: scaffold.workspaceCwd },
- agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
- })
- })
-
- afterAll(async () => {
- const failures: unknown[] = []
- await agentHandle?.dispose().catch((error: unknown) => failures.push(error))
- await scaffold?.close().catch((error: unknown) => failures.push(error))
- if (failures.length === 1) throw failures[0]
- if (failures.length > 1) throw new AggregateError(failures, 'core Web profile smoke teardown failed')
- })
-
- it('boots and executes both tools through the shipped Web composition', async () => {
- const seedPath = join(scaffold.workspaceCwd, 'profile-smoke.txt')
- await writeFile(seedPath, 'CORE_WEB_EDITOR_OK\n')
- const signal = new AbortController().signal
- const bash = await scaffold.ctx.tools.execute({
- signal,
- callId: CallId('core-web-bash-smoke'),
- name: 'bash',
- arguments: { command: "printf 'CORE_WEB_BASH_OK\\n'" },
- agent: agentHandle.agent,
- })
- const editor = await scaffold.ctx.tools.execute({
- signal,
- callId: CallId('core-web-editor-smoke'),
- name: 'str_replace_editor',
- arguments: { command: 'view', path: seedPath },
- agent: agentHandle.agent,
- })
-
- const text = (result: typeof bash): string => result.content
- .filter(block => block.type === 'text')
- .map(block => block.text)
- .join('')
- .replaceAll(scaffold.workspaceCwd, '{{cwd}}')
- .trimEnd()
-
- expect({
- tools: scaffold.ctx.tools.schemas().map(tool => tool.name),
- bash: text(bash),
- editor: text(editor),
- }).toMatchInlineSnapshot(`
- {
- "bash": "CORE_WEB_BASH_OK",
- "editor": "Here's the content of {{cwd}}/profile-smoke.txt with line numbers (which has a total of 2 lines):
- 1 CORE_WEB_EDITOR_OK
- 2",
- "tools": [
- "bash",
- "str_replace_editor",
- ],
- }
- `)
-
- const entries = [...scaffold.ctx.loader.entries()]
- expect(entries.find(entry => entry.options.id === 'persistent-bash')?.fiber).toBeDefined()
- expect(entries.find(entry => entry.options.id === 'pty-local')?.fiber).toBeDefined()
- expect(entries.find(entry => entry.options.id === 'str-replace-editor')?.fiber).toBeDefined()
- })
-})
diff --git a/apps/web/tests/declared-reasoning.e2e.ts b/apps/web/tests/declared-reasoning.e2e.ts
new file mode 100644
index 0000000000..f908511214
--- /dev/null
+++ b/apps/web/tests/declared-reasoning.e2e.ts
@@ -0,0 +1,95 @@
+// Web e2e scenario: a hand-declared model's `reasoningEfforts` reaches the
+// composer's effort pane — the levels a settings profile declares are exactly
+// what the picker offers, and picking one records it with the Agent default.
+// Zero model calls: declaring, describing, and switching are settings/llm
+// traffic only, so there is no fixture and a stray stream would fail loud.
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { settingsNamespace } from '@deepseek-ai/dsh-settings'
+import {
+ assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+ launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
+
+/** Starts the shipped default on this scenario's declared reasoning model. */
+const OVERLAY = fileURLToPath(new URL('./declared-reasoning.overlay.yml', import.meta.url))
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/declared-reasoning', import.meta.url))
+const UI_EXPECTED = fileURLToPath(new URL('./snapshots/declared-reasoning/ui.expected.md', import.meta.url))
+const MODE = webSnapshotMode()
+
+describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach the composer', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
+ // The whole reasoning offer is the profile: key = selectable level, value
+ // = the wire spelling dispatch would send (`max: ultra` renames; the
+ // valueless `off` means "supported, send nothing"). The route sets no
+ // deployment default, so the pane leads with the provider-default entry.
+ await scaffold.ctx.settings.update(settingsNamespace('llm-pi-ai'), {
+ providers: {
+ 'acme-gateway': {
+ displayName: 'Acme Gateway',
+ api: 'openai-completions',
+ baseURL: 'https://gateway.acme.example/v1',
+ models: [{
+ id: 'acme-think',
+ name: 'Acme Think',
+ reasoningEfforts: { off: null, high: 'high', max: 'ultra' },
+ }],
+ },
+ },
+ })
+ browser = await chromium.launch()
+ page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('offers exactly the declared levels and records the picked one', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-declared-reasoning'))
+ const trigger = page.getByRole('button', { name: /^选择模型/ })
+ await trigger.waitFor({ timeout: 15_000 })
+ await trigger.click()
+ await page.getByRole('menuitem', { name: /推理等级/ }).click()
+
+ // Declared levels, nothing else: the provider-default entry (the route
+ // configures no `reasoning`), then Off/High/Max — minimal, low, medium,
+ // and xhigh were not declared and must not be offered.
+ const levels = page.getByRole('menuitemradio')
+ await expect.poll(async () => levels.allTextContents(), { timeout: 10_000 })
+ .toEqual(['Default', 'Off', 'High', 'Max'])
+ const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+
+ // Picking a level is the same gesture that saves the default selection, so
+ // the effort lands in the Agent default Settings section beside provider/model.
+ await page.getByRole('menuitemradio', { name: 'High' }).click()
+ await expect.poll(
+ async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
+ { timeout: 10_000 },
+ ).toContain('reasoningEffort: high')
+ await expect.poll(() => trigger.getAttribute('aria-label'), { timeout: 10_000 })
+ .toBe('选择模型,当前 Acme Think,推理等级 High')
+ expect(tripwire.pageErrors).toEqual([])
+ }, 60_000)
+
+ it('keeps its snapshot inventory closed', async () => {
+ await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
+ })
+})
diff --git a/apps/web/tests/declared-reasoning.overlay.yml b/apps/web/tests/declared-reasoning.overlay.yml
new file mode 100644
index 0000000000..ff89f036b3
--- /dev/null
+++ b/apps/web/tests/declared-reasoning.overlay.yml
@@ -0,0 +1,8 @@
+# The fixture-less web scaffold registers no adapter, so the shipped
+# deepseek-official default would be a route nothing serves. This scenario
+# starts the default on its own declared reasoning model so the effort pane
+# describes that model from the first open.
+- id: agent-default-model
+ config:
+ provider: acme-gateway
+ model: acme-think
diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts
new file mode 100644
index 0000000000..791f8e98c1
--- /dev/null
+++ b/apps/web/tests/default-model.e2e.ts
@@ -0,0 +1,167 @@
+// Web e2e scenario: switching models in the composer is how this deployment's
+// default is chosen. The gesture writes the shared `agent-default-model` settings section, a
+// session created afterwards starts from it, and a session that already logged
+// a route keeps deriving from its own log — the tier order the gateway
+// resolves on every read.
+// Zero model calls: the switch is settings/llm-domain traffic only, so there
+// is no fixture and a stray stream would fail loud because the adapter registry is empty. Both
+// routes are declared host-side (not through the UI, which has its own
+// scenario) through the pi-ai adapter the shipped tree already mounts: a
+// fixture-less scaffold registers no adapter at all, so the routes the
+// picker offers — and the one the composer must start on — have to come from
+// somewhere, and settings profiles are the product's own way to add them.
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import { settingsNamespace } from '@deepseek-ai/dsh-settings'
+import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
+import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
+
+/** Points the shipped shared Agent default at this scenario's own route. */
+const OVERLAY = fileURLToPath(new URL('./default-model.overlay.yml', import.meta.url))
+
+/** The route this scenario starts on, patched over the shipped default. */
+const START_ROUTE = 'origin-gateway'
+const START_MODEL = 'origin-large'
+/** The route the switch lands on, which then becomes the saved default. */
+const ROUTE = 'acme-gateway'
+const MODEL = 'acme-large'
+
+describe('web e2e: the composer model switch is the default for later sessions', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+
+ /** Create one session and its agent through the same wire face the browser uses. */
+ const createSession = async (sessionId: string): Promise => {
+ const response = await scaffold.ctx.apiProxy.sessions.create({
+ rpcId: `default-model-create-${sessionId}` as never,
+ payload: { sessionId: SessionId(sessionId), cwd: scaffold.workspaceCwd },
+ })
+ if (!response.result.ok) throw new Error(`session.create failed: ${response.result.error.message}`)
+ return response.result.value.sessionId
+ }
+
+ /** The route the gateway reports for one session, through the real wire face. */
+ const currentOf = async (sessionId: string): Promise => {
+ const response = await scaffold.ctx.apiProxy.sessions.models({
+ rpcId: `default-model-${sessionId}` as never,
+ payload: { sessionId: SessionId(sessionId) },
+ })
+ if (!response.result.ok) throw new Error(`session.models failed: ${response.result.error.message}`)
+ return response.result.value.current
+ }
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
+ // Two routes so the picker has somewhere to start and somewhere to go.
+ // Declared through the settings seam rather than the Models page: this
+ // scenario is about the composer, and the declaring flow is covered by
+ // models-settings.e2e.
+ await scaffold.ctx.settings.update(settingsNamespace('llm-pi-ai'), {
+ providers: {
+ [START_ROUTE]: {
+ displayName: 'Origin Gateway',
+ api: 'openai-completions',
+ baseURL: 'https://gateway.origin.example/v1',
+ models: [{ id: START_MODEL, name: 'Origin Large' }],
+ },
+ [ROUTE]: {
+ displayName: 'Acme Gateway',
+ api: 'openai-completions',
+ baseURL: 'https://gateway.acme.example/v1',
+ models: [{ id: MODEL, name: 'Acme Large' }],
+ },
+ },
+ })
+ browser = await chromium.launch()
+ page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ // The composer's seats only exist once a workspace is connected: without
+ // one the input is the locked placeholder and no session scope is open.
+ await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('writes the switched model as the default and leaves a logged session alone', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model'))
+ // A session that has already run a turn, spelled as the fact a turn
+ // leaves behind: its own logged route.
+ const loggedId = await createSession('default-model-logged')
+ scaffold.ctx.sessions.get(SessionId(loggedId))?.append('request/header', {
+ header: { config: { provider: START_ROUTE, model: START_MODEL } },
+ reason: 'initial',
+ })
+
+ const trigger = page.getByRole('button', { name: /^选择模型/ })
+ await trigger.waitFor({ timeout: 15_000 })
+ await trigger.click()
+ await page.getByRole('menuitem', { name: /模型/ }).click()
+ await page.getByRole('menuitemradio', { name: 'Acme Large' }).click()
+
+ // The switch is what sets the default: the shared Agent-route settings section
+ // now names it, beside the provider profiles the Models page writes.
+ await expect.poll(
+ async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
+ { timeout: 10_000 },
+ ).toContain('agent-default-model:')
+ const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
+ expect(document).toContain(`provider: ${ROUTE}`)
+ expect(document).toContain(`model: ${MODEL}`)
+
+ // A session created after the switch starts from it...
+ expect(await currentOf(await createSession('default-model-after')))
+ .toEqual({ provider: ROUTE, model: MODEL })
+ // ...while the one holding a logged route keeps deriving from its log.
+ expect(await currentOf(loggedId)).toEqual({ provider: START_ROUTE, model: START_MODEL })
+ expect(tripwire.pageErrors).toEqual([])
+ }, 60_000)
+
+ it('goes inert when the route the default names stops being served', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model-blocked'))
+ const box = page.locator('textarea[data-input-phase], textarea').first()
+ await expect.poll(async () => box.isEnabled(), { timeout: 10_000 }).toBe(true)
+
+ // What removing the provider on the Models page leaves behind: the saved
+ // default still names the route, and nothing serves it any more.
+ // `replace`, not `update`: a merge patch of `{providers: {}}` leaves every
+ // stored profile in place.
+ await scaffold.ctx.settings.replace(settingsNamespace('llm-pi-ai'), { providers: {} })
+
+ await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(false)
+ expect(await box.getAttribute('placeholder')).toBe('当前模型不可用,请先选择模型')
+
+ // The block is an affordance; the refusal is the Host's. A client that
+ // never disabled anything still cannot start a turn on a dead route.
+ const refused = await scaffold.ctx.apiProxy.sessions.prompt({
+ rpcId: 'default-model-refused' as never,
+ payload: {
+ sessionId: SessionId(await createSession('default-model-refusal')),
+ mode: 'queue' as const,
+ content: [{ type: 'text' as const, text: 'hi' }],
+ },
+ })
+ expect(refused.result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } })
+
+ // The way out stays open. Locking the model seat with everything else
+ // would leave the composer asking for the one thing it prevents.
+ const seat = page.getByRole('button', { name: /^选择模型/ })
+ expect(await seat.isEnabled()).toBe(true)
+ await seat.click()
+ await page.getByRole('menuitem', { name: /模型/ }).click()
+ await page.getByRole('menuitemradio').first().click()
+ await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(true)
+ expect(tripwire.pageErrors).toEqual([])
+ }, 60_000)
+})
diff --git a/apps/web/tests/default-model.overlay.yml b/apps/web/tests/default-model.overlay.yml
new file mode 100644
index 0000000000..654c343bb9
--- /dev/null
+++ b/apps/web/tests/default-model.overlay.yml
@@ -0,0 +1,8 @@
+# The fixture-less web scaffold registers no adapter, so the shipped
+# deepseek-official default would be a route nothing serves — which the
+# composer refuses to type into. This scenario declares its own
+# pi-ai routes and starts the default on one of them.
+- id: agent-default-model
+ config:
+ provider: origin-gateway
+ model: origin-large
diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts
index cb6c9ba914..7f507c59d2 100644
--- a/apps/web/tests/details-session-lifecycle.e2e.ts
+++ b/apps/web/tests/details-session-lifecycle.e2e.ts
@@ -42,7 +42,7 @@ function appFrame(page: Page) {
return page.locator('[style*="grid-template-columns"]').first()
}
-/** Render the two boundary affordances without platform-dependent coordinates. */
+/** Render the two column-resize handles without platform-dependent coordinates. */
async function handleSnapshot(page: Page): Promise {
const handles = await page.locator('[class*="handle"]').evaluateAll(elements =>
elements.map(element => ({
@@ -121,7 +121,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click()
- await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 })
+ await page.getByText('Into the Unknown', { exact: false }).waitFor({ timeout: 15_000 })
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
diff --git a/apps/web/tests/feedback-command.e2e.ts b/apps/web/tests/feedback-command.e2e.ts
new file mode 100644
index 0000000000..577e77a97a
--- /dev/null
+++ b/apps/web/tests/feedback-command.e2e.ts
@@ -0,0 +1,101 @@
+// Keyless assembled-browser coverage for the /feedback command over the
+// shipped Web bundles and the real host wire. The command plane settles
+// without a model turn: the host appends the log-only command/run +
+// feedback/record + command/done lifecycle, and the transcript renders the
+// acknowledgement — the recorded session id plus the session-sharing
+// disclosure — as a persistent command row. The scaffold mounts the shipped
+// telemetry row in FULL mode against a local dead endpoint (no record leaves
+// the process), so the golden pins the shipped default sentence
+// `Session sharing is enabled.`; the per-status sentences are pinned by the
+// package and OTel unit tests.
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+ assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+ launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/feedback-command', import.meta.url))
+const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
+const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md')
+const MODE = webSnapshotMode()
+// Discard port: loopback listener never binds, so FULL telemetry discloses
+// the shipped default policy without any record reaching a collector.
+const TELEMETRY_URL = 'http://127.0.0.1:9/v1/logs'
+
+const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
+
+describe('web e2e: /feedback command acknowledgement', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold({
+ telemetryUrl: TELEMETRY_URL,
+ ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }),
+ })
+ browser = await chromium.launch()
+ page = await newEnglishPage(browser)
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ // Fresh world: connecting a workspace births the blank session whose
+ // live composer accepts the slash line.
+ await connectFreshWorkspace(page, scaffold.workspaceCwd)
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('drives the recorded prompt to a settled turn (all modes)', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-drive'))
+ if (MODE !== 'record') {
+ // Drift guard: the committed fixture must carry exactly the drive prompt.
+ expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
+ }
+ const input = page.locator('textarea').first()
+ await input.waitFor({ timeout: 10_000 })
+ // Arm the turn-boundary waiter BEFORE sending, so a burst replay cannot
+ // miss the turn/end that settles the recorded turn.
+ const settled = scaffold.whenTurnSettled()
+ await input.fill(PROMPT)
+ await input.press('Enter')
+ const sessionId = await settled
+ if (MODE === 'record') {
+ await recordFixture(scaffold, sessionId, FIXTURE)
+ }
+ }, 60_000)
+
+ it.skipIf(MODE === 'record')('records feedback and renders the acknowledgement with session id and sharing status', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-command'))
+ // The drive test settled the recorded turn: the transcript is active (a
+ // command row does not render while a fresh session is still blank) and
+ // the replayed reply is on screen.
+ await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
+ const input = page.locator('textarea').first()
+ await input.fill('/feedback the diff view is unreadable')
+ await input.press('Enter')
+ // The command plane settles without a model turn: the ack row names the
+ // recorded session and the mounted FULL backend's disclosure.
+ await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 })
+ expect(await page.getByText(/Session sharing is enabled/).count()).toBe(1)
+ const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE)
+
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ }, 60_000)
+
+ it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
+ await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ack.expected.md'])
+ })
+})
diff --git a/apps/web/tests/goal-bar.overlay.yml b/apps/web/tests/goal-bar.overlay.yml
index 2594d6a3e9..4e9e382bb8 100644
--- a/apps/web/tests/goal-bar.overlay.yml
+++ b/apps/web/tests/goal-bar.overlay.yml
@@ -1,5 +1,5 @@
-# The client-side FixtureApiClient intentionally rejects settings writes, so
-# this goal-only scenario omits the durable welcome step that would otherwise
-# cover the page. Onboarding owns separate assembled-browser coverage.
+# The client-side FixtureApiClient intentionally rejects settings traffic, so
+# this goal-only scenario omits the settings shell and the onboarding steps it
+# would mount. Onboarding owns separate assembled-browser coverage.
- id: ui-settings-general
disabled: true
diff --git a/apps/web/tests/goal-command-presentation.e2e.ts b/apps/web/tests/goal-command-presentation.e2e.ts
new file mode 100644
index 0000000000..6117fb8ace
--- /dev/null
+++ b/apps/web/tests/goal-command-presentation.e2e.ts
@@ -0,0 +1,123 @@
+// Web e2e: /goal opts its command input into the human transcript while the
+// command remains log-only. The shipped composition runs with no model adapter,
+// so an accidental turn fails loud in addition to the event-level assertions.
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
+import type {} from '@deepseek-ai/dsh-commands/types'
+import {
+ acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria,
+ compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode,
+ type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/goal-command-presentation', import.meta.url))
+const UI_EXPECTED = fileURLToPath(new URL(
+ './snapshots/goal-command-presentation/ui.expected.md', import.meta.url,
+))
+const MODE = webSnapshotMode()
+
+describe('web e2e: /goal human transcript presentation', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+ const events: SessionEvent[] = []
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold()
+ scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
+ browser = await chromium.launch()
+ page = await newEnglishPage(browser)
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ await connectFreshWorkspace(page, scaffold.workspaceCwd)
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('shows the bare input and result from a fresh session without a model turn', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-command-presentation'))
+ await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), {
+ timeout: 15_000,
+ }).toBe(1)
+ const input = page.locator('textarea').first()
+ await input.fill('/goal')
+ await input.press('Enter')
+ await expect.poll(() => input.inputValue()).toBe('/goal ')
+ await input.press('Enter')
+
+ const commandInput = page.locator('[data-command-input]')
+ await commandInput.waitFor({ timeout: 10_000 })
+ await expect.poll(() => commandInput.textContent()).toBe('/goal')
+ expect(await commandInput.getAttribute('role')).toBe('group')
+ expect(await commandInput.getAttribute('aria-label')).toBe('Command input')
+ expect(await commandInput.getByRole('button').count()).toBe(0)
+ const typography = await commandInput.evaluate((element) => {
+ const bubble = element.firstElementChild?.firstElementChild
+ if (!(bubble instanceof HTMLElement)) throw new Error('command input bubble is missing')
+ const rootStyle = getComputedStyle(element)
+ const bubbleStyle = getComputedStyle(bubble)
+ return {
+ fontFamily: bubbleStyle.fontFamily,
+ parentFontFamily: rootStyle.fontFamily,
+ fontSize: bubbleStyle.fontSize,
+ lineHeight: bubbleStyle.lineHeight,
+ }
+ })
+ expect(typography).toMatchObject({ fontSize: '14px', lineHeight: '22px' })
+ expect(typography.fontFamily).not.toBe(typography.parentFontFamily)
+ const resultRow = page.locator('[data-variant="others"]').filter({ hasText: 'No goal is currently set.' })
+ await expect.poll(() => resultRow.count(), { timeout: 10_000 }).toBe(1)
+ expect(await resultRow.getByText('goal', { exact: true }).count()).toBe(1)
+ await expect.poll(() => page.locator('[data-phase="active"]').count()).toBe(1)
+ expect(await page.getByText('Into the Unknown', { exact: false }).count()).toBe(0)
+
+ const run = events.find(event => event.type === 'command/run')
+ expect(run).toMatchObject({
+ type: 'command/run',
+ data: { name: 'goal', args: ' ', source: { kind: 'user' } },
+ })
+ expect(events.some(event => event.type === 'command/done')).toBe(true)
+ expect(events.some(event => event.type === 'user/message')).toBe(false)
+ expect(events.some(event => event.type === 'turn/start')).toBe(false)
+ expect(events.some(event => event.type === 'step/start')).toBe(false)
+ expect(events.some(event => event.type === 'request/header')).toBe(false)
+
+ const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+ }, 60_000)
+
+ it('reloads the same bubble and result from the persisted command lifecycle', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-command-presentation-reload'))
+ const warningStart = tripwire.warnings.length
+ await page.reload({ waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ acknowledgeReloadConnectionLoss(tripwire, warningStart)
+
+ await expect.poll(() => page.locator('[data-command-input]').textContent(), { timeout: 15_000 }).toBe('/goal')
+ const resultRow = page.locator('[data-variant="others"]').filter({ hasText: 'No goal is currently set.' })
+ await expect.poll(() => resultRow.count(), { timeout: 10_000 }).toBe(1)
+ await expect.poll(() => page.locator('[data-phase="active"]').count()).toBe(1)
+
+ const sessions = scaffold.ctx.sessions.list()
+ expect(sessions).toHaveLength(1)
+ const persisted = sessions[0]?.events ?? []
+ expect(persisted.filter(event => event.type === 'command/run' || event.type === 'command/done')
+ .map(event => event.type)).toEqual(['command/run', 'command/done'])
+ expect(persisted.some(event => event.type === 'user/message')).toBe(false)
+ expect(persisted.some(event => event.type === 'turn/start')).toBe(false)
+ expect(persisted.some(event => event.type === 'step/start')).toBe(false)
+ expect(persisted.some(event => event.type === 'request/header')).toBe(false)
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
+ }, 90_000)
+})
diff --git a/apps/web/tests/goal-multi-turn-actions.e2e.ts b/apps/web/tests/goal-multi-turn-actions.e2e.ts
new file mode 100644
index 0000000000..89082344a7
--- /dev/null
+++ b/apps/web/tests/goal-multi-turn-actions.e2e.ts
@@ -0,0 +1,168 @@
+// Keyless replay of a real two-round Goal run. Each autonomous round ends as
+// its own turn, so the first answer must keep its IconActions when Goal opens
+// round two and the final answer must own a second, distinct action row.
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterEach, describe, expect, it, onTestFailed } from 'vitest'
+import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
+import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-goal'
+import {
+ assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+ launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/goal-multi-turn-actions', import.meta.url))
+const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
+const OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json')
+const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
+const MODE = webSnapshotMode()
+
+const PROMPT = '做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的'
+const COMMAND = `/goal ${PROMPT}`
+
+const PACKAGE_FILES: Readonly> = {
+ 'packages/client/ui-conversation/README.md': '# UI conversation\n',
+ 'packages/client/ui-conversation/package.json': '{"name":"@deepseek-ai/dsh-client-ui-conversation"}\n',
+ 'packages/client/ui-conversation/src/client.ts': 'export {}\n',
+ 'packages/client/ui-conversation/tests/chat-view.client.spec.tsx': 'export {}\n',
+ 'packages/context/session-reference/README.md': '# Session reference\n',
+ 'packages/context/session-reference/package.json': '{"name":"@deepseek-ai/dsh-session-reference"}\n',
+ 'packages/context/session-reference/src/index.ts': 'export {}\n',
+ 'packages/context/session-reference/src/uri.ts': 'export {}\n',
+ 'packages/context/session-reference/tests/session-reference.spec.ts': 'export {}\n',
+ 'packages/llm/token-meter/README.md': '# Token meter\n',
+ 'packages/llm/token-meter/package.json': '{"name":"@deepseek-ai/dsh-token-meter"}\n',
+ 'packages/llm/token-meter/src/index.ts': 'export {}\n',
+ 'packages/llm/token-meter/tests/token-meter.spec.ts': 'export {}\n',
+ 'packages/skill/skill-filesystem/README.md': '# Local skill provider\n',
+ 'packages/skill/skill-filesystem/package.json': '{"name":"@deepseek-ai/dsh-skill-filesystem"}\n',
+ 'packages/skill/skill-filesystem/src/index.ts': 'export {}\n',
+ 'packages/skill/skill-filesystem/src/invariant.ts': 'export {}\n',
+ 'packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts': 'export {}\n',
+}
+
+/** Materialize a stable package inventory inside the isolated session workspace. */
+async function seedPackageInventory(workspaceRoot: string): Promise {
+ for (const [relativePath, content] of Object.entries(PACKAGE_FILES)) {
+ const path = join(workspaceRoot, 'workspace', relativePath)
+ await mkdir(dirname(path), { recursive: true })
+ await writeFile(path, content)
+ }
+}
+
+/** Await exactly the requested number of durable turn ends, then flush the session. */
+function whenTurnsSettled(scaffold: WebScaffold, count: number, timeoutMs: number): Promise {
+ return new Promise((resolve, reject) => {
+ let completed = 0
+ const timer = setTimeout(() => {
+ off()
+ reject(new Error(`only ${completed}/${count} Goal turns ended within ${timeoutMs}ms`))
+ }, timeoutMs)
+ const off = scaffold.ctx.on('session/event', (session, event: SessionEvent) => {
+ if (event.type !== 'turn/end') return
+ completed += 1
+ if (completed !== count) return
+ clearTimeout(timer)
+ off()
+ scaffold.ctx.sessions.flush(session).then(() => { resolve(session.id) }, reject)
+ })
+ })
+}
+
+/** Goal-owned round numbers in durable user-message order. */
+function goalRounds(events: readonly SessionEvent[]): number[] {
+ return events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'goal'
+ ? [event.data.source.round]
+ : [])
+}
+
+/** Objective written by each durable Goal creation. */
+function createdObjectives(events: readonly SessionEvent[]): string[] {
+ return events.flatMap(event => event.type === 'goal/change' && event.data.operation === 'create'
+ ? [event.data.goal.objective]
+ : [])
+}
+
+describe('web e2e: Goal keeps one assistant action row per completed turn', () => {
+ let scaffold: WebScaffold | undefined
+ let browser: Browser | undefined
+ let page: Page
+ let tripwire: ReturnType
+ let sessionEvents: SessionEvent[]
+
+ afterEach(async () => {
+ const failures: unknown[] = []
+ await browser?.close().catch((error: unknown) => failures.push(error))
+ browser = undefined
+ const closing = scaffold
+ scaffold = undefined
+ await closing?.close().catch((error: unknown) => failures.push(error))
+ if (failures.length === 1) throw failures[0]
+ if (failures.length > 1) throw new AggregateError(failures, 'goal-multi-turn-actions teardown failed')
+ })
+
+ /** Boot the real Web composition and connect a fresh package fixture workspace. */
+ async function launch(): Promise {
+ sessionEvents = []
+ scaffold = await launchWebScaffold(
+ MODE === 'record' ? {} : { replayFixture: FIXTURE, replayOverride: OVERRIDE },
+ )
+ await seedPackageInventory(scaffold.workspaceCwd)
+ scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
+ browser = await chromium.launch()
+ page = await newEnglishPage(browser)
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ await connectFreshWorkspace(page, scaffold.workspaceCwd)
+ }
+
+ /** Submit the Goal command after arming the two-turn barrier. */
+ async function runGoal(timeoutMs: number): Promise {
+ const input = page.locator('textarea').first()
+ await input.waitFor({ timeout: 10_000 })
+ const settled = whenTurnsSettled(scaffold!, 2, timeoutMs)
+ await input.fill(COMMAND)
+ await input.press('Enter')
+ return settled
+ }
+
+ it.skipIf(MODE !== 'record')('records the two-round Goal through the real model', async () => {
+ await launch()
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-multi-turn-actions-record'))
+ const sessionId = await runGoal(360_000)
+ await recordFixture(scaffold!, sessionId, FIXTURE)
+ }, 380_000)
+
+ it.skipIf(MODE === 'record')('keeps actions on both completed Goal turn tails', async () => {
+ const fixtureEvents = parseSessionLog(await readFile(FIXTURE, 'utf8'))
+ expect(createdObjectives(fixtureEvents)).toEqual([PROMPT])
+ expect(goalRounds(fixtureEvents)).toEqual([1, 2])
+
+ await launch()
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-multi-turn-actions'))
+ await runGoal(120_000)
+
+ expect(sessionEvents.flatMap(event => event.type === 'turn/end' ? [event.data.turn] : []))
+ .toEqual([1, 2])
+ expect(goalRounds(sessionEvents)).toEqual([1, 2])
+ const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' })
+ await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2)
+ expect(await branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))))
+ .toEqual([null, null])
+ await branchButtons.last().focus()
+ const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
+ await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ }, 140_000)
+
+ it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => {
+ await assertFixtureInventory(SNAPSHOT_DIR, ['replay.override.json', 'session.jsonl', 'ui.expected.md'])
+ })
+})
diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts
index df81a10402..e15f339d25 100644
--- a/apps/web/tests/hmr-live.e2e.ts
+++ b/apps/web/tests/hmr-live.e2e.ts
@@ -1,4 +1,4 @@
-/** Published dsh web --dev + pnpm dev:web → browser HMR, with no page reload. */
+/** Published dsh web + pnpm dev:web → browser HMR, with no page reload. */
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
@@ -6,9 +6,9 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { chromium } from 'playwright'
import { expect, it } from 'vitest'
-import { Context } from 'cordis'
-import type { Fiber } from 'cordis'
-import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
+import { Context } from '@deepseek-ai/cordis'
+import type { Fiber } from '@deepseek-ai/cordis'
+import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { REPO_ROOT } from './support.ts'
@@ -75,8 +75,8 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a
if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first')
const originalSource = await readFile(sourcePath)
const originalBundle = await readFile(bundlePath)
- const oldText = "Let's start building"
- const sourceNeedle = "'hero.headline': 'Let\\'s start building'"
+ const oldText = 'Into the Unknown'
+ const sourceNeedle = "'hero.headline': 'Into the Unknown'"
const newText = `HMR UPDATED ${'x'.repeat(80)}`
const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`)
if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`)
@@ -88,18 +88,18 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a
let browser: Awaited> | undefined
const failures: unknown[] = []
try {
- subprocessFiber = await subprocessCtx.plugin(LocalSubprocessService)
+ subprocessFiber = await subprocessCtx.plugin(LocalSubprocessRuntime)
watcher = subprocessCtx.subprocess.spawn(spawnSpec(['pnpm', 'run', 'dev:web'], REPO_ROOT))
await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web')
host = subprocessCtx.subprocess.spawn(spawnSpec(
- [process.execPath, binPath, 'web', '--dev', '--port', '0'],
+ [process.execPath, binPath, 'web', '--port', '0'],
world,
{
DEEPSEEK_API_KEY: 'keyless-hmr-no-call',
DSH_HOME: join(world, '.dsh'),
},
))
- const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web --dev')
+ const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web')
browser = await chromium.launch()
const page = await browser.newPage()
const pageErrors: string[] = []
diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts
new file mode 100644
index 0000000000..0929148e6e
--- /dev/null
+++ b/apps/web/tests/image-display.snapshot.ts
@@ -0,0 +1,197 @@
+// @vitest-environment jsdom
+// Multimodal image surfaces over the BUILT client graph (the code-mode-fixture
+// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
+// Opens the fixture history session whose turn 73 carries an image in BOTH a
+// user message and an assistant message, and pins the product surfaces: the
+// history ImageGallery loading real fixture bytes through the authorized
+// sessions.attachment route, the single-click ImageLightbox, and the composer
+// intake chain (paste → ordered thumbnail rail → image-only send enablement → remove).
+import { fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { expect, it } from 'vitest'
+import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
+
+installAssembledBootEnv()
+
+/** Open the fixture history session (the alpha log carrying the turn-72 image pair) and wait for its gallery. */
+async function openFixtureSession(): Promise {
+ const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
+ const group = (await within(tree).findAllByText('fixture'))
+ .map(el => el.closest('[role="treeitem"]'))
+ .find(el => el?.getAttribute('aria-expanded') !== null)
+ if (group === null || group === undefined) throw new Error('fixture Workspace group missing')
+ if (group.getAttribute('aria-expanded') === 'false') {
+ fireEvent.click(within(group).getByText('fixture'))
+ await waitFor(() => {
+ expect(group.getAttribute('aria-expanded')).toBe('true')
+ })
+ }
+ const session = await within(tree).findByText('Fixture 历史会话')
+ fireEvent.click(session)
+ await waitFor(() => {
+ expect(document.querySelectorAll('[data-align] img').length).toBeGreaterThan(0)
+ }, { timeout: 10_000 })
+}
+
+it('renders the history image pair through the authorized attachment route and opens the lightbox', async () => {
+ mountAssembledApp()
+ await openFixtureSession()
+
+ // Both the user-side (align=end) and assistant-side (align=start) galleries
+ // load real fixture bytes over sessions.attachment. jsdom provides
+ // createObjectURL, so this environment MUST take the object-URL path — a
+ // data: src here would mean the fallback ran where it should not.
+ await waitFor(() => {
+ if (document.querySelector('[data-align="end"] img') === null
+ || document.querySelector('[data-align="start"] img') === null) {
+ throw new Error('history image galleries missing')
+ }
+ }, { timeout: 10_000 })
+ const galleryShape = (align: string) => [...document.querySelectorAll(`[data-align="${align}"] img`)]
+ .map(img => ({ alt: img.getAttribute('alt'), scheme: img.getAttribute('src')?.split(':')[0] }))
+ expect({ user: galleryShape('end'), assistant: galleryShape('start') }).toMatchInlineSnapshot(`
+ {
+ "assistant": [
+ {
+ "alt": "fixture-image.png",
+ "scheme": "blob",
+ },
+ ],
+ "user": [
+ {
+ "alt": "fixture-image.png",
+ "scheme": "blob",
+ },
+ ],
+ }
+ `)
+ const userImage = document.querySelector('[data-align="end"] img')!
+
+ // A single click opens the original-size lightbox; Escape/close dismisses it.
+ const frame = userImage.closest('button')
+ if (frame === null) throw new Error('image frame button missing')
+ fireEvent.click(frame)
+ const lightbox = await screen.findByRole('dialog')
+ expect(within(lightbox).getByRole('img').getAttribute('src')?.split(':')[0]).toBe('blob')
+ fireEvent.click(within(lightbox).getByRole('button', { name: /Close/ }))
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).toBeNull()
+ })
+})
+
+it('accepts pasted images into the composer rail in order and removes them', async () => {
+ mountAssembledApp()
+
+ const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
+ const start = tree.querySelector('button[aria-label="New session in fixture"]')
+ if (start === null) throw new Error('fixture Workspace new-session action missing')
+ fireEvent.click(start)
+
+ // Image-only send arming is pinned at package level (input-bar.spec.tsx);
+ // this assembled lane pins the intake chain over the built graph.
+ const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
+ const image = new File([new Uint8Array([137, 80, 78, 71])], 'pasted.png', { type: 'image/png' })
+ fireEvent.paste(textarea, {
+ clipboardData: {
+ items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }],
+ getData: () => '',
+ },
+ })
+
+ // The rail is an accessible group holding the draft thumbnail (queried via
+ // DOM: jsdom's a11y-visibility computation hides the composer subtree).
+ const rail = await waitFor(() => {
+ const el = document.querySelector('[role="group"][aria-label="Pending images"]')
+ if (el === null) throw new Error('attachment rail missing')
+ return el
+ }, { timeout: 5_000 })
+ expect([...rail.querySelectorAll('img')].map(img => ({
+ alt: img.getAttribute('alt'), scheme: img.getAttribute('src')?.split(':')[0],
+ }))).toMatchInlineSnapshot(`
+ [
+ {
+ "alt": "pasted.png",
+ "scheme": "blob",
+ },
+ ]
+ `)
+
+ const second = new File([new Uint8Array([137, 80, 78, 71])], 'second.png', { type: 'image/png' })
+ fireEvent.paste(textarea, {
+ clipboardData: {
+ items: [{ kind: 'file', type: 'image/png', getAsFile: () => second }],
+ getData: () => '',
+ },
+ })
+ await waitFor(() => {
+ expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt')))
+ .toEqual(['pasted.png', 'second.png'])
+ })
+
+ const remove = [...rail.querySelectorAll('button[aria-label^="Remove image"]')]
+ if (remove.length !== 2) throw new Error('remove buttons missing')
+ for (const button of remove) fireEvent.click(button)
+ await waitFor(() => {
+ expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
+ })
+
+ // An unsupported file announces a transient toast (the inline strip is
+ // gone) and the banner dismisses itself after its hold-and-fade lifetime.
+ fireEvent.paste(textarea, {
+ clipboardData: {
+ items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'notes.txt', { type: 'text/plain' }) }],
+ getData: () => '',
+ },
+ })
+ const toast = await screen.findByRole('alert')
+ expect(toast.textContent).toContain('Only PNG, JPG, WebP, and GIF images are supported')
+ await waitFor(() => {
+ expect(screen.queryByRole('alert')).toBeNull()
+ }, { timeout: 6_000 })
+})
+
+it('accepts a whole-page drop under the limits-labeled overlay and refuses an over-limit batch at intake', async () => {
+ mountAssembledApp()
+
+ const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
+ const start = tree.querySelector('button[aria-label="New session in fixture"]')
+ if (start === null) throw new Error('fixture Workspace new-session action missing')
+ fireEvent.click(start)
+ const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
+
+ // A file drag anywhere over the page raises the full-viewport overlay whose
+ // desc line carries the projected limits — copy that can only render after
+ // the imageLimits projection crossed the real fixture transport.
+ const image = new File([new Uint8Array([137, 80, 78, 71])], 'dropped.png', { type: 'image/png' })
+ const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' }
+ fireEvent.dragEnter(document.body, { dataTransfer })
+ const overlay = await screen.findByRole('status')
+ expect(overlay.textContent).toContain('Drag images here to add them')
+ await waitFor(() => {
+ expect(overlay.textContent).toContain('Up to 20 images, 5MB each')
+ })
+
+ // Dropping on the transcript area (not the composer card) lands in the rail.
+ fireEvent.drop(document.body, { dataTransfer })
+ await waitFor(() => {
+ const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
+ if (rail === null) throw new Error('attachment rail missing after page drop')
+ expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['dropped.png'])
+ }, { timeout: 5_000 })
+ expect(screen.queryByRole('status')).toBeNull()
+
+ // An intake that would exceed the projected per-message count is refused as
+ // a whole batch at add time: the banner names the limit and the rail keeps
+ // only the previously accepted thumbnail — no submit-time rollback.
+ const batch = Array.from({ length: 20 }, (_, i) =>
+ new File([new Uint8Array([137, 80, 78, 71])], `bulk-${String(i)}.png`, { type: 'image/png' }))
+ fireEvent.paste(textarea, {
+ clipboardData: {
+ items: batch.map(file => ({ kind: 'file', type: 'image/png', getAsFile: () => file })),
+ getData: () => '',
+ },
+ })
+ const banner = await screen.findByRole('alert')
+ expect(banner.textContent).toContain('A message can include up to 20 images')
+ const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
+ expect([...(rail?.querySelectorAll('img') ?? [])]).toHaveLength(1)
+})
diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts
index 8c81f55810..dcfabacf93 100644
--- a/apps/web/tests/lifecycle-chrome.e2e.ts
+++ b/apps/web/tests/lifecycle-chrome.e2e.ts
@@ -6,9 +6,10 @@
// client; THIS spec pins the same flow through HTTP RPC + SSE + the host
// gateway), reload replays everything from the log (zero further model
// calls), and the theme scenario proves the shipped dark palette actually
-// cascades: attribute -> alias token flip -> painted surface change. Per the
-// lane's scope ruling there is no theme/layout golden (aria is color-blind);
-// the hero's waiting state gets the one golden here.
+// cascades: attribute -> alias token flip -> painted surface change. No
+// theme/layout golden: aria snapshots are color-blind (lane scope: the
+// browser-e2e-lane Agent Note); the hero's waiting state gets the one golden
+// here.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -26,6 +27,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', impor
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
+const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md')
const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md')
// Post-reload golden: the same settled conversation rebuilt purely from
// persistence + history — byte-equal rendering is exactly the recovery claim.
@@ -83,6 +85,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
expect(Math.abs(
launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height,
)).toBeLessThan(1)
+ await input.fill('/cpt')
+ await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual([
+ 'compactCompact older conversation history',
+ ])
+ const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(FUZZY_COMMAND_MENU_EXPECTED, fuzzySnapshot, MODE)
await input.fill('')
await expect.poll(() => menu.count()).toBe(0)
})
@@ -105,8 +113,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' })
await planButton.waitFor({ timeout: 10_000 })
// The golden encodes an empty composer, and the button arriving does not
- // mean the submitted text is gone yet: under load the capture caught a
- // textbox still holding `/plan`.
+ // mean the submitted text is gone yet: under load the capture can catch
+ // a textbox still holding `/plan`.
await expect.poll(() => input.inputValue(), { timeout: 10_000 }).toBe('')
const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd)
await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE)
@@ -152,7 +160,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
}
// The blank frame renders the hero, not the resident composer: the
// headline plus the guidance placeholder are the empty state's anchors.
- await expect.poll(() => page.getByText("Let's start building", { exact: false }).count(), { timeout: 15_000 }).toBe(1)
+ await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), { timeout: 15_000 }).toBe(1)
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
if (MODE !== 'record') {
@@ -189,8 +197,13 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
it.skipIf(MODE === 'record')('materialized a real Workspace and Session over the wire', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-materialize'))
// Browser: the sidebar tree now carries the auto-created workspace group
- // with its one session, and the opened session is the selected row.
- await expect.poll(() => page.getByText('1 session', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
+ // with its one session, and the opened session is the selected row. The
+ // compact layout dropped group session counts, so the group row itself is
+ // the barrier.
+ await expect.poll(
+ () => page.locator('[role="treeitem"][aria-expanded]').filter({ hasText: 'workspace' }).count(),
+ { timeout: 15_000 },
+ ).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Host: the session's durable header cwd is the folder the workspace
@@ -225,7 +238,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
it.skipIf(MODE === 'record')('cascades the dark theme from the body attribute to painted surfaces', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-dark'))
- // This scenario pins the ThemeService's DOM contract seam directly (the
+ // This scenario pins the ThemeRuntime's DOM contract directly (the
// body[data-ds-dark-theme] attribute -> stylesheet cascade); the REAL
// user gesture above it (Settings -> Appearance cubes) is owned by
// settings-chrome.e2e.ts. Driving the attribute here keeps the cascade
@@ -258,7 +271,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [
- 'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
+ 'session.jsonl', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
])
})
})
diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts
index e3a427c446..90383f6987 100644
--- a/apps/web/tests/live-interactions.e2e.ts
+++ b/apps/web/tests/live-interactions.e2e.ts
@@ -1,6 +1,6 @@
// Web e2e scenarios: live-turn interactions — cancellation, error surfacing,
// and transient-retry recovery, all through the real composition and wire.
-// The model seam is dsh-llm-replay with override sidecars: `hang` (+ a
+// The model adapter is dsh-llm-replay with override sidecars: `hang` (+ a
// readyFile marker) makes mid-stream cancel deterministic by construction,
// `throw` entries express provider failures by stable code, and `{ patches }`
// augmentation injects a transient throw before the recorded success so
diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts
index b183fe7df5..67af32373c 100644
--- a/apps/web/tests/math-rendering.e2e.ts
+++ b/apps/web/tests/math-rendering.e2e.ts
@@ -119,6 +119,10 @@ describe('web e2e: settled Markdown math rendering', () => {
await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(6)
await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2)
expect(await page.locator('.katex-error').count()).toBe(0)
+ await expect.poll(
+ () => page.getByText('1 turns · 1 steps', { exact: false }).count(),
+ { timeout: 10_000 },
+ ).toBe(1)
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
diff --git a/apps/web/tests/max-tokens-notice.snapshot.ts b/apps/web/tests/max-tokens-notice.snapshot.ts
new file mode 100644
index 0000000000..45432e702b
--- /dev/null
+++ b/apps/web/tests/max-tokens-notice.snapshot.ts
@@ -0,0 +1,56 @@
+// @vitest-environment jsdom
+// Assembled max-tokens snapshot: boots the real built `packages/client/*/lib/
+// client.js` bundles through AppWebEntry's ModuleLoader path against the
+// keyless FixtureApiClient transport, opens the fixture session, and pins the
+// surface its max-tokens turn (72) reaches — the turn-end notice row that a
+// provider output-cap truncation must render instead of ending silently.
+//
+// The dot state is pinned beside the copy on purpose: `dot=warning` is what
+// distinguishes this notice from the error row, so a regression that routes
+// max-tokens through the turn-error presentation changes this file even when
+// its own copy still renders.
+import { mkdirSync, writeFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { hasClass, installAssembledBootEnv, mountAssembledApp, REFRESHING_GOLDEN } from './assembled-boot.ts'
+
+const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/max-tokens-notice/history-turn.expected.txt')
+
+installAssembledBootEnv()
+
+/** Normalize the notice row to stable fields: its dot state, title, and hint. */
+function noticeShape(row: Element): string {
+ const first = (name: string): string =>
+ [...row.querySelectorAll('*')].filter(el => hasClass(el, name))[0]?.textContent?.trim() ?? ''
+ return [
+ `dot=${row.querySelector('[data-state]')?.getAttribute('data-state') ?? ''}`,
+ `title=${first('maxTokensTitle')}`,
+ `hint=${first('turnErrorMessage')}`,
+ ].join('\n')
+}
+
+describe('assembled max-tokens turn-end notice', () => {
+ it('renders the localized truncation notice after the cut-off answer instead of ending silently', async () => {
+ mountAssembledApp()
+
+ const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
+ fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
+ // The truncated answer itself stays in the flow: the notice supplements the
+ // partial output, it never replaces it.
+ await screen.findByText(/条目 3:这一条写到一半被/, undefined, { timeout: 10_000 })
+ const row = await waitFor(() => {
+ const found = [...document.querySelectorAll('[role="status"]')]
+ .find(candidate => [...candidate.querySelectorAll('*')].some(el => hasClass(el, 'maxTokensTitle')))
+ expect(found).not.toBeUndefined()
+ return found!
+ }, { timeout: 10_000 })
+
+ const shape = noticeShape(row)
+ if (REFRESHING_GOLDEN) {
+ mkdirSync(dirname(EXPECTED), { recursive: true })
+ writeFileSync(EXPECTED, shape)
+ }
+ await expect(shape).toMatchFileSnapshot(EXPECTED)
+ })
+})
diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts
index aac2c4806c..4284d3d71a 100644
--- a/apps/web/tests/message-actions.e2e.ts
+++ b/apps/web/tests/message-actions.e2e.ts
@@ -107,17 +107,17 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
// Focus-reveal the footers (hover:hover keeps them opacity-hidden until
- // hover/focus-within). Every durable message footer keeps branch visible,
- // but only the final assistant at a completed transcript tail enables it.
+ // hover/focus-within). Branch renders only under assistant answers — user
+ // bubbles carry none — and only a completed transcript tail enables it.
const copyButtons = page.getByRole('button', { name: 'Copy' })
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(4)
await copyButtons.first().focus()
const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' })
- await expect.poll(() => branchButtons.count(), { timeout: 5_000 }).toBe(4)
+ await expect.poll(() => branchButtons.count(), { timeout: 5_000 }).toBe(2)
await expect.poll(
() => branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))),
{ timeout: 5_000 },
- ).toEqual(['true', 'true', 'true', null])
+ ).toEqual(['true', null])
await branchButtons.first().focus()
await expect.poll(() => page.getByRole('tooltip').textContent(), { timeout: 5_000 })
.toBe('Available only on the last message of a completed turn')
@@ -126,8 +126,9 @@ 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: 'Select model', exact: true })
+ await page.getByRole('button', { name: /^Select model, current/ })
.waitFor({ timeout: 10_000 })
+ await page.getByText(/Cache hit \d+%/u).first().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.
await page.getByRole('button', { name: 'Copy' }).first().focus()
@@ -176,6 +177,12 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
() => page.locator('[role="treeitem"][aria-selected="true"]').count(),
{ timeout: 10_000 },
).toBe(1)
+ // The child row is published before its inherited title rename settles;
+ // wait for that second RPC projection before freezing the ARIA tree.
+ await expect.poll(
+ () => page.locator('[role="treeitem"][aria-selected="true"]').textContent(),
+ { timeout: 10_000 },
+ ).toContain('Use the read tool twice (2)')
const tree = await captureStableAria(
page,
'[role="tree"][aria-label="Sessions"]',
diff --git a/apps/web/tests/message-feedback-protocol.snapshot.ts b/apps/web/tests/message-feedback-protocol.snapshot.ts
new file mode 100644
index 0000000000..9fbc556f77
--- /dev/null
+++ b/apps/web/tests/message-feedback-protocol.snapshot.ts
@@ -0,0 +1,115 @@
+import { readFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import {
+ assertFixtureInventory,
+ compareOrRefreshGolden,
+ launchWebScaffold,
+ seedSession,
+ type WebScaffold,
+} from './scaffold.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-feedback-protocol', import.meta.url))
+const SESSION_FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
+const PROTOCOL_EXPECTED = join(SNAPSHOT_DIR, 'protocol.expected.json')
+const SESSION_ID = 'message-feedback-protocol'
+const MESSAGE_ID = '11111111-1111-4111-8111-111111111111'
+
+interface ProtocolExchange {
+ readonly endpoint: string
+ readonly request: unknown
+ readonly status: number
+ readonly response: unknown
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
+}
+
+/** Extract the opaque item version while keeping every surrounding wire field snapshot-owned. */
+function createdVersion(response: unknown): string {
+ if (!isRecord(response) || !isRecord(response.result) || response.result.ok !== true
+ || !isRecord(response.result.value) || response.result.value.ok !== true
+ || !isRecord(response.result.value.value)
+ || typeof response.result.value.value.version !== 'string') {
+ throw new Error('messageFeedback.put did not return a successful versioned item')
+ }
+ return response.result.value.value.version
+}
+
+/** Replace only run-owned UUID/time values; all protocol names and business fields stay exact. */
+function normalizeProtocol(exchanges: readonly ProtocolExchange[], version: string): string {
+ return JSON.stringify(exchanges, (key, value: unknown) => {
+ if ((key === 'version' || key === 'ifVersion') && value === version) return '{{version}}'
+ if ((key === 'createdAt' || key === 'updatedAt') && typeof value === 'number') return '{{timestamp}}'
+ return value
+ }, 2)
+}
+
+describe('message feedback Host Remote protocol', () => {
+ let scaffold: WebScaffold
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold()
+ await seedSession(scaffold, await readFile(SESSION_FIXTURE, 'utf8'), SESSION_ID)
+ })
+
+ afterAll(async () => {
+ await scaffold?.close()
+ })
+
+ it('snapshots strict list, put, conflict, and delete calls through the shipped Web Host', async () => {
+ const exchanges: ProtocolExchange[] = []
+ const invoke = async (rpcId: string, endpoint: string, request: unknown): Promise => {
+ const payload = { args: { request } }
+ const response = await fetch(`${scaffold.baseUrl}/api/${endpoint}`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ type: 'client-request',
+ rpcId,
+ method: endpoint,
+ payload,
+ }),
+ })
+ const body: unknown = await response.json()
+ exchanges.push({ endpoint: `/api/${endpoint}`, request: payload, status: response.status, response: body })
+ return body
+ }
+
+ await invoke('feedback-invalid', 'messageFeedback/put', {
+ sessionId: SESSION_ID,
+ messageId: MESSAGE_ID,
+ rating: 'invalid-rating',
+ ifVersion: null,
+ })
+ await invoke('feedback-list-empty', 'messageFeedback/list', { sessionId: SESSION_ID })
+ const created = await invoke('feedback-put', 'messageFeedback/put', {
+ sessionId: SESSION_ID,
+ messageId: MESSAGE_ID,
+ rating: 'positive',
+ note: 'Useful answer',
+ ifVersion: null,
+ })
+ const version = createdVersion(created)
+ expect(version).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
+ await invoke('feedback-list-created', 'messageFeedback/list', { sessionId: SESSION_ID })
+ await invoke('feedback-conflict', 'messageFeedback/put', {
+ sessionId: SESSION_ID,
+ messageId: MESSAGE_ID,
+ rating: 'negative',
+ ifVersion: null,
+ })
+ await invoke('feedback-delete', 'messageFeedback/delete', {
+ sessionId: SESSION_ID,
+ messageId: MESSAGE_ID,
+ ifVersion: version,
+ })
+ await invoke('feedback-list-deleted', 'messageFeedback/list', { sessionId: SESSION_ID })
+
+ expect(exchanges.every(exchange => exchange.status === 200)).toBe(true)
+ await compareOrRefreshGolden(PROTOCOL_EXPECTED, normalizeProtocol(exchanges, version), scaffold.mode)
+ await assertFixtureInventory(SNAPSHOT_DIR, ['protocol.expected.json', 'session.jsonl'])
+ })
+})
diff --git a/apps/web/tests/message-feedback.e2e.ts b/apps/web/tests/message-feedback.e2e.ts
new file mode 100644
index 0000000000..69c5ad54bb
--- /dev/null
+++ b/apps/web/tests/message-feedback.e2e.ts
@@ -0,0 +1,121 @@
+// Keyless browser regression for durable per-message feedback. Cold-seeds a
+// settled two-turn transcript (zero model calls), rates one assistant message,
+// attaches a note, proves both survive a full page reload from the Host's
+// message-feedback sidecar, then retracts the rating.
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+ acknowledgeReloadConnectionLoss, launchWebScaffold,
+ seedSession, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { newEnglishPage, saveFailureShot } from './support.ts'
+
+// Borrowed read-only: this scenario needs any settled assistant message to
+// address, not a new recording (message-actions / sidebar-scrollbar pattern).
+const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
+const MODE = webSnapshotMode()
+const SEED_ID = 'message-feedback-web-e2e'
+const NOTE = 'Read both files before answering.'
+
+describe('web e2e: durable per-message feedback', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold({})
+ await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID)
+ browser = await chromium.launch()
+ page = await newEnglishPage(browser)
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ /**
+ * Open the seeded transcript. The first treeitem is the collapsible group
+ * row; the session itself is the row beneath it. The group is already
+ * expanded on a fresh load, so clicking it unconditionally would collapse it
+ * and hide the session row.
+ */
+ async function openSeededSession(): Promise {
+ const groupRow = page.locator('[role="treeitem"]').first()
+ await groupRow.waitFor({ timeout: 15_000 })
+ if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click()
+ const sessionRow = page.locator('[role="treeitem"]').nth(1)
+ await sessionRow.waitFor({ timeout: 15_000 })
+ await sessionRow.click()
+ }
+
+ it.skipIf(MODE === 'record')('persists a rating and its note across a reload, then retracts', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback'))
+ await openSeededSession()
+
+ // The controls live in the assistant message's IconActions row, which the
+ // transcript reveals on hover/focus like copy and branch. Wait for the
+ // settled closing text first: the strip mounts with that turn's tail.
+ await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 })
+ const like = page.getByRole('button', { name: 'Good response' }).first()
+ await like.waitFor({ timeout: 30_000 })
+ await like.scrollIntoViewIfNeeded()
+ await like.hover()
+ await like.click()
+ // A recorded rating relabels the button to what the next click would do,
+ // so the pressed control is addressed by the retract label from here on.
+ const rated = page.getByRole('button', { name: 'Remove rating' }).first()
+ await expect.poll(() => rated.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('true')
+
+ // A rated message offers the note editor; an unrated one does not.
+ await page.getByRole('button', { name: 'Add a note' }).first().click()
+ const editor = page.getByRole('textbox', { name: 'Feedback note' })
+ await editor.fill(NOTE)
+ await page.getByRole('button', { name: 'Save', exact: true }).click()
+ await expect.poll(() => editor.count(), { timeout: 10_000 }).toBe(0)
+ await page.getByText(NOTE, { exact: true }).waitFor({ timeout: 10_000 })
+
+ // The durable assertion: a cold browser re-reads the sidecar over the wire.
+ const warningStart = tripwire.warnings.length
+ await page.reload({ waitUntil: 'load' })
+ acknowledgeReloadConnectionLoss(tripwire, warningStart)
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ await openSeededSession()
+ await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 })
+
+ // The controller defers its list read to the first hover or focus, so a
+ // cold reload shows the unrated label until the strip is touched. Hovering
+ // the unrated control is what triggers the authoritative re-read.
+ const cold = page.getByRole('button', { name: 'Good response' }).first()
+ await cold.waitFor({ timeout: 30_000 })
+ await cold.scrollIntoViewIfNeeded()
+ await cold.hover()
+
+ const restored = page.getByRole('button', { name: 'Remove rating' }).first()
+ await restored.waitFor({ timeout: 30_000 })
+ await restored.scrollIntoViewIfNeeded()
+ await restored.hover()
+ await expect.poll(() => restored.getAttribute('aria-pressed'), { timeout: 15_000 }).toBe('true')
+ await page.getByText(NOTE, { exact: true }).waitFor({ timeout: 10_000 })
+
+ // Re-clicking the active rating retracts it, and the note goes with it.
+ await restored.click()
+ await expect.poll(
+ () => page.getByRole('button', { name: 'Good response' }).first().getAttribute('aria-pressed'),
+ { timeout: 10_000 },
+ ).toBe('false')
+ await expect.poll(() => page.getByText(NOTE, { exact: true }).count(), { timeout: 10_000 }).toBe(0)
+ }, 90_000)
+
+ it.skipIf(MODE === 'record')('kept the console clean', () => {
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ })
+})
diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts
new file mode 100644
index 0000000000..011f71ea8d
--- /dev/null
+++ b/apps/web/tests/minimal-preset.snapshot.ts
@@ -0,0 +1,122 @@
+import { mkdir, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import type { AgentHandle } from '@deepseek-ai/dsh-agent'
+import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-agent-presets'
+import type {} from '@deepseek-ai/dsh-system-prompt'
+import { assertFixtureInventory, launchWebScaffold, type WebScaffold } from './scaffold.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/minimal-preset', import.meta.url))
+const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
+const PROMPT = 'Reply exactly MINIMAL_PRESET_REQUEST_OK and stop.'
+
+describe('minimal agent preset', () => {
+ let scaffold: WebScaffold
+ let agentHandle: AgentHandle
+ let disposeInjectedPrompt: () => void
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold({ replayFixture: FIXTURE })
+ disposeInjectedPrompt = scaffold.ctx.systemPrompt.section({
+ name: 'test:injected-prompt',
+ order: 999,
+ text: 'THIS TEXT MUST NOT REACH THE MODEL.',
+ })
+ agentHandle = await scaffold.ctx.agents.create({
+ sessionId: SessionId('minimal-preset-smoke'),
+ meta: { cwd: scaffold.workspaceCwd, agentPreset: 'minimal' },
+ agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+ setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
+ })
+ })
+
+ afterAll(async () => {
+ const failures: unknown[] = []
+ await agentHandle?.dispose().catch((error: unknown) => failures.push(error))
+ try {
+ disposeInjectedPrompt?.()
+ } catch (error: unknown) {
+ failures.push(error)
+ }
+ await scaffold?.close().catch((error: unknown) => failures.push(error))
+ if (failures.length === 1) throw failures[0]
+ if (failures.length > 1) throw new AggregateError(failures, 'minimal preset smoke teardown failed')
+ })
+
+ it('sends the exact RL prompt and schemas, then executes the persistent shell and editor', async () => {
+ agentHandle.agent.followup(createUserMessage({
+ content: [{ type: 'text', text: PROMPT }],
+ source: { kind: 'user' },
+ }))
+ await agentHandle.agent.whenIdle()
+
+ const requestHeader = agentHandle.agent.session.requestHeader()
+ if (requestHeader === undefined) throw new Error('the minimal agent issued no model request')
+ expect(agentHandle.agent.session.events.some(event => event.type === 'user/message'
+ && event.data.source.kind === 'plugin'
+ && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt')).toBe(false)
+ const presetFileSystem = scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'fs')
+ expect(presetFileSystem).toBeDefined()
+ expect(presetFileSystem?.sandboxMode).toBeUndefined()
+ expect(scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'compaction')).toBeUndefined()
+
+ const stateDir = join(scaffold.workspaceCwd, 'persistent-state')
+ await mkdir(stateDir)
+ const signal = new AbortController().signal
+ await scaffold.ctx.tools.execute({
+ signal,
+ callId: CallId('minimal-bash-state-setup'),
+ name: 'bash',
+ arguments: { command: `cd ${JSON.stringify(stateDir)} && export DSH_MINIMAL_STATE=PERSISTED` },
+ agent: agentHandle.agent,
+ })
+ const bash = await scaffold.ctx.tools.execute({
+ signal,
+ callId: CallId('minimal-bash-state-read'),
+ name: 'bash',
+ arguments: { command: 'printf \'%s:%s\n\' "$DSH_MINIMAL_STATE" "$PWD"' },
+ agent: agentHandle.agent,
+ })
+ const seedPath = join(scaffold.workspaceCwd, 'preset-smoke.txt')
+ await writeFile(seedPath, 'MINIMAL_EDITOR_OK\n')
+ const editor = await scaffold.ctx.tools.execute({
+ signal,
+ callId: CallId('minimal-editor-smoke'),
+ name: 'str_replace_editor',
+ arguments: { command: 'view', path: seedPath },
+ agent: agentHandle.agent,
+ })
+
+ const text = (result: typeof bash): string => result.content
+ .filter(block => block.type === 'text')
+ .map(block => block.text)
+ .join('')
+ .replaceAll(scaffold.workspaceCwd, '{{cwd}}')
+ .trimEnd()
+
+ expect({
+ prompt: requestHeader.system,
+ tools: requestHeader.tools?.map(tool => tool.name),
+ bash: text(bash),
+ editor: text(editor),
+ }).toMatchInlineSnapshot(`
+ {
+ "bash": "PERSISTED:{{cwd}}/persistent-state",
+ "editor": "Here's the content of {{cwd}}/preset-smoke.txt with line numbers (which has a total of 2 lines):
+ 1 MINIMAL_EDITOR_OK
+ 2",
+ "prompt": "You are a helpful software engineer assistant.",
+ "tools": [
+ "bash",
+ "str_replace_editor",
+ ],
+ }
+ `)
+ expect(requestHeader.tools?.toSorted((left, right) => left.name.localeCompare(right.name)))
+ .toEqual(scaffold.ctx.tools.schemas(agentHandle.agent).toSorted((left, right) => left.name.localeCompare(right.name)))
+ await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl'])
+ })
+})
diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts
index 1d9117dc85..e07426cce1 100644
--- a/apps/web/tests/models-settings.e2e.ts
+++ b/apps/web/tests/models-settings.e2e.ts
@@ -1,15 +1,18 @@
// Web e2e scenario: the Models settings page end to end through the real
-// wire — the add card offers the dormant pi-ai catalog, typing an API key
+// wire — the add card offers the dormant pi-ai catalog, a blank key saves a
+// reference-free profile for provider-native auth, and typing an API key later
// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
-// while the settings document records only that reference; the saved row
-// appears after the route topology invalidation without presenting liveness
-// as provider status. The customized-settings fold writes the curated
-// reasoning field as a merge patch. Zero model calls: configuration is pure
+// while the settings document records only that reference. Each saved row
+// appears after route topology invalidation without presenting liveness as
+// provider status. The customized-settings fold writes its curated fields —
+// the endpoint, and a declared route's own name and protocol — as merge
+// patches against the stored profile. Zero model calls: configuration is pure
// settings/credentials/llm-domain traffic, so there is no fixture and a
-// stray stream would fail loud on the open seam. The provider under test is
+// stray stream would fail loud because the adapter registry is empty. The provider under test is
// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
-// never shadow the derived reference. Removing that row is guarded by the
-// localized provider-confirmation dialog before the unset reaches the wire.
+// never shadow the derived reference. The deletion dialog distinguishes a
+// reference-free profile from a page-managed key before the credential and
+// settings unsets reach the wire.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -25,6 +28,9 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
+const DECLARED_EXPECTED = join(SNAPSHOT_DIR, 'declared.expected.md')
+const DECLARED_EDIT_EXPECTED = join(SNAPSHOT_DIR, 'declared-edit.expected.md')
+const NATIVE_DELETE_EXPECTED = join(SNAPSHOT_DIR, 'native-delete.expected.md')
const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md')
const MODE = webSnapshotMode()
@@ -70,76 +76,201 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
expect(options).toContain('anthropic')
expect(options).toContain('minimax-cn')
await pick.selectOption('minimax-cn')
- await dialog.getByLabel('API 密钥').waitFor({ timeout: 10_000 })
+ await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE)
}, 60_000)
- it('stores the key under the derived reference and the route registers live', async () => {
- onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add'))
+ it('refuses a key no HTTP header can carry before anything is written', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-models-illegal-key'))
+ const dialog = page.getByRole('dialog', { name: '设置' })
+ const key = dialog.getByLabel('API 密钥')
+ const save = dialog.getByRole('button', { name: '保存', exact: true })
+
+ // A key no HTTP header can carry would save cleanly and fail the first
+ // turn with a ByteString TypeError; the form names the offending field
+ // instead.
+ await key.fill('sk-\u{1F600}minimax')
+ await dialog.getByText('该 API 密钥格式错误,请检查。').waitFor({ timeout: 10_000 })
+ await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false)
+
+ // Clearing it restores submit: an empty field means "keep what is stored",
+ // never a refusal, or editing any other setting would demand the key.
+ await key.fill('')
+ await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(true)
+ expect(await dialog.getByText('该 API 密钥格式错误,请检查。').count()).toBe(0)
+ }, 60_000)
+
+ it('saves a blank key as a reference-free provider-native profile', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-models-native-auth'))
const dialog = page.getByRole('dialog', { name: '设置' })
- await dialog.getByLabel('API 密钥').fill('sk-e2e-minimax')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
- // The profile lands in settings.yaml with only the derived reference, the
- // key value lands in the harness home's .env, the dormant route
- // registers, and the topology frame invalidates the page into the row.
const row = dialog.getByText('minimax-cn', { exact: true }).first()
await row.waitFor({ timeout: 10_000 })
+ await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
+ expect(await dialog.getByRole('img', { name: 'API 密钥已配置' }).count()).toBe(0)
+ expect(await dialog.getByRole('img', { name: 'API 密钥缺失' }).count()).toBe(0)
+ const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
+ expect(document).toContain('minimax-cn: {}')
+ expect(document).not.toContain('MINIMAX_CN_API_KEY')
+ }, 60_000)
+
+ it('describes reference-free deletion without claiming a credential exists', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-models-native-delete'))
+ const settingsDialog = page.getByRole('dialog', { name: '设置' })
+ await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
+ const deleteDialog = page.getByRole('dialog', { name: '删除 minimax-cn?' })
+ await deleteDialog.waitFor({ timeout: 10_000 })
+ const snapshot = await captureStableAria(
+ page,
+ '[role="dialog"][aria-label="删除 minimax-cn?"]',
+ scaffold.workspaceCwd,
+ )
+ await compareOrRefreshGolden(NATIVE_DELETE_EXPECTED, snapshot, MODE)
+ await deleteDialog.getByRole('button', { name: '取消', exact: true }).click()
+ }, 60_000)
+
+ it('stores the key under the derived reference and keeps the route live', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add'))
+ const dialog = page.getByRole('dialog', { name: '设置' })
+ await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
+ await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax')
+ await dialog.getByRole('button', { name: '保存', exact: true }).click()
+ // The profile lands in settings.yaml with only the derived reference, the
+ // key value lands in the harness home's .credentials.yaml, the dormant route
+ // registers, and the topology frame invalidates the page into the row.
+ await expect.poll(
+ async () => dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
+ { timeout: 10_000 },
+ ).toBe(0)
+ await dialog.getByRole('img', { name: 'API 密钥已配置' }).waitFor({ timeout: 10_000 })
+ await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('minimax-cn:')
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
expect(document).not.toContain('sk-e2e-minimax')
- const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8')
- expect(stored).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
+ const credentialFile = join(scaffold.harnessHome, '.credentials.yaml')
+ await expect.poll(
+ async () => readFile(credentialFile, 'utf8').catch(() => ''),
+ { timeout: 10_000 },
+ ).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax')
expect(await page.content()).not.toContain('sk-e2e-minimax')
}, 60_000)
it('applies a customized-settings field as a merge patch', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-customized'))
const dialog = page.getByRole('dialog', { name: '设置' })
- await dialog.getByRole('button', { name: '编辑' }).click()
+ await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
await dialog.getByText('自定义设置').click()
- const effort = dialog.getByLabel('推理强度')
- await effort.waitFor({ timeout: 10_000 })
- await effort.selectOption('high')
+ const url = dialog.getByLabel('API 地址')
+ await url.waitFor({ timeout: 10_000 })
+ await url.fill('https://gateway.minimax.example/v1')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
// The editor closes back to the row; the fold's write merged into the
// stored profile beside the reference.
- await expect.poll(async () => dialog.getByLabel('推理强度').count(), { timeout: 10_000 }).toBe(0)
+ await expect.poll(async () => dialog.getByLabel('API 地址').count(), { timeout: 10_000 }).toBe(0)
+ await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
- expect(document).toContain('reasoning: high')
+ expect(document).toContain('baseURL: https://gateway.minimax.example/v1')
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
- it('confirms provider deletion before removing its settings profile', async () => {
+ it('declares a route the adapter does not ship', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare'))
+ const dialog = page.getByRole('dialog', { name: '设置' })
+ const declare = dialog.getByRole('button', { name: '添加自定义提供方' })
+ await expect.poll(async () => declare.isEnabled(), { timeout: 10_000 }).toBe(true)
+ await declare.click()
+ await dialog.getByLabel('Provider ID').fill('acme-gateway')
+ await dialog.getByLabel('显示名称').fill('Acme Gateway')
+ await dialog.getByLabel('API 地址').fill('https://gateway.acme.example/v1')
+ // No reasoning effort on a provider card at all: effort is a per-model
+ // capability, the models under one provider disagree about it, and a
+ // switch in the composer already records provider+model+effort together.
+ expect(await dialog.getByLabel('推理强度').count()).toBe(0)
+ await dialog.getByRole('button', { name: '添加模型' }).click()
+ await dialog.getByLabel('模型 ID 1').fill('acme-large')
+ await dialog.getByRole('button', { name: '创建提供方', exact: true }).click()
+
+ const row = dialog.getByText('Acme Gateway', { exact: true }).first()
+ await row.waitFor({ timeout: 10_000 })
+ const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
+ expect(document).toContain('acme-gateway:')
+
+ // The tag follows the adapter's installed catalog: this route is in no
+ // catalog, while minimax-cn is — even though both now have profiles.
+ const rowCard = (name: string) => dialog.locator('li').filter({ hasText: name }).first()
+ await expect.poll(async () => rowCard('Acme Gateway').getByText('自定义').count(), { timeout: 10_000 }).toBe(1)
+ expect(await rowCard('minimax-cn').getByText('自定义').count()).toBe(0)
+
+ const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(DECLARED_EXPECTED, snapshot, MODE)
+ expect(tripwire.pageErrors).toEqual([])
+ }, 60_000)
+
+ it('reopens the name and protocol a declared route was created with', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declared-identity'))
+ const dialog = page.getByRole('dialog', { name: '设置' })
+ await dialog.getByRole('button', { name: '编辑 Acme Gateway (acme-gateway)' }).click()
+ await dialog.getByText('自定义设置').click()
+ // The create card asked this route for a name and a protocol because
+ // nothing can default them; the editor reaches the same two fields rather
+ // than sending the user to settings.yaml for what only this route names.
+ const protocol = dialog.getByLabel('API 协议')
+ await protocol.waitFor({ timeout: 10_000 })
+ expect(await protocol.inputValue()).toBe('openai-completions')
+ const name = dialog.getByLabel('显示名称', { exact: true })
+ expect(await name.inputValue()).toBe('Acme Gateway')
+ const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(DECLARED_EDIT_EXPECTED, snapshot, MODE)
+
+ await protocol.selectOption('anthropic-messages')
+ await name.fill('Acme 网关')
+ await dialog.getByRole('button', { name: '保存', exact: true }).click()
+ await expect.poll(async () => dialog.getByLabel('API 协议').count(), { timeout: 10_000 }).toBe(0)
+ // The adapter re-resolved the route under the new protocol and re-registered
+ // it under the new name: an unserviceable profile would have been refused
+ // at the write instead, and a rename that did not re-register would leave
+ // the old label on the row.
+ await dialog.getByText('Acme 网关', { exact: true }).first().waitFor({ timeout: 10_000 })
+ // The status line names the route as the refreshed directory reports it;
+ // the target captured when the card opened still carries the old name.
+ await dialog.getByText('已保存 Acme 网关 (acme-gateway)。', { exact: true }).waitFor({ timeout: 10_000 })
+ const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
+ expect(document).toContain('api: anthropic-messages')
+ expect(document).toContain('displayName: Acme 网关')
+ expect(tripwire.pageErrors).toEqual([])
+ }, 60_000)
+
+ it('confirms an identified provider deletion before removing its profile and key', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
const settingsDialog = page.getByRole('dialog', { name: '设置' })
- await settingsDialog.getByRole('button', { name: '删除', exact: true }).click()
- const deleteDialog = page.getByRole('dialog', { name: '删除模型提供方?' })
+ await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
+ const deleteDialog = page.getByRole('dialog', { name: '删除 minimax-cn?' })
await deleteDialog.waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(
page,
- '[role="dialog"][aria-label="删除模型提供方?"]',
+ '[role="dialog"][aria-label="删除 minimax-cn?"]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(DELETE_EXPECTED, snapshot, MODE)
await deleteDialog.getByRole('button', { name: '取消', exact: true }).click()
expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('minimax-cn:')
- await settingsDialog.getByRole('button', { name: '删除', exact: true }).click()
- await page.getByRole('dialog', { name: '删除模型提供方?' })
- .getByRole('button', { name: '删除提供方', exact: true }).click()
+ await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
+ await page.getByRole('dialog', { name: '删除 minimax-cn?' })
+ .getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
await expect.poll(
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
{ timeout: 10_000 },
).not.toContain('minimax-cn:')
- expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8'))
- .toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
+ expect(await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8'))
+ .not.toContain('MINIMAX_CN_API_KEY')
await expect.poll(
- async () => page.getByRole('dialog', { name: '删除模型提供方?' }).count(),
+ async () => page.getByRole('dialog', { name: '删除 minimax-cn?' }).count(),
{ timeout: 10_000 },
).toBe(0)
await page.keyboard.press('Escape')
@@ -147,6 +278,9 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
}, 60_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
- await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'delete.expected.md', 'empty.expected.md'])
+ await assertFixtureInventory(SNAPSHOT_DIR, [
+ 'configured.expected.md', 'declared-edit.expected.md', 'declared.expected.md',
+ 'delete.expected.md', 'empty.expected.md', 'native-delete.expected.md',
+ ])
})
})
diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts
index a49501584f..a0b34e53b9 100644
--- a/apps/web/tests/navigation-panes.e2e.ts
+++ b/apps/web/tests/navigation-panes.e2e.ts
@@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page, Response } from 'playwright'
import { chromium } from 'playwright'
+import { strFromU8, unzipSync } from 'fflate'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest'
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
@@ -51,8 +52,16 @@ async function assertBaselineSucceeded(response: Response, method: string): Prom
}
async function ensureSeedOpen(page: Page): Promise {
+ const welcome = page.locator('[class*="onboardingOverlay"]')
+ if (await welcome.count() > 0) {
+ await welcome.getByRole('button').click()
+ await welcome.waitFor({ state: 'detached', timeout: 15_000 })
+ }
const chat = page.getByRole('tab', { name: 'Chat', exact: true })
- const search = page.getByPlaceholder('Search name, keywords', { exact: false })
+ // Search is a collapsed header action; expand it so the input is actionable.
+ const searchButton = page.getByRole('button', { name: 'Search sessions' })
+ if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
+ const search = page.getByPlaceholder('Search sessions', { exact: false })
if (await chat.count() === 0) {
await search.fill('WATERFALL')
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
@@ -78,8 +87,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
beforeAll(async () => {
scaffold = await launchWebScaffold({})
// The workspace-aware flow runs sessions in /workspace;
- // the read targets must live in that session cwd (pre-creation is safe:
- // create-by-name adopts an existing directory).
+ // the read targets must live in that session cwd (pre-creation is safe
+ // because the picker adopts an existing directory by path).
const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
await mkdir(sessionCwd, { recursive: true })
await writeFile(join(sessionCwd, 'nav-a.md'), '# alpha nav\n')
@@ -118,8 +127,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// The frame mounts before the asynchronous session-list baseline lands.
// Search must target the settled seeded row, not the startup input that
- // the ready projection replaces.
- await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
+ // the ready projection replaces (the compact layout dropped group session
+ // counts; the Ungrouped bucket row is the barrier).
+ await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
}, 120_000)
afterEach(async () => {
@@ -164,8 +174,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
sessionId = await settled
}
await recordFixture(scaffold, sessionId!, SEED)
- // Fixture honesty: the recording must carry the shape the replay
- // scenarios assert on — three calls in turn 1 and two closed turns.
+ // Fixture honesty: the recording must contain the events the replay
+ // scenarios assert on: three calls in turn 1 and two closed turns.
const recorded = parseSessionLog(await readFile(SEED, 'utf8'))
expect(recorded.filter(e => e.type === 'turn/end')).toHaveLength(2)
const calls = recorded.filter((e): e is SessionEvent & { data: { name: string } } => e.type === 'tool/call')
@@ -175,9 +185,13 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
// The API baselines can settle before React commits their projection. The
- // seeded count is the final user-visible barrier before editing search.
- await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
- const search = page.getByPlaceholder('Search name, keywords', { exact: false })
+ // seeded Ungrouped bucket row is the final user-visible barrier before
+ // editing search (the compact layout dropped group session counts).
+ await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
+ // Search is a collapsed header action; expand it so the input is actionable.
+ const searchButton = page.getByRole('button', { name: 'Search sessions' })
+ if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
+ const search = page.getByPlaceholder('Search sessions', { exact: false })
// The cold row has not been opened, so only the persisted log can satisfy
// this query. First search lazily reconciles the SQLite content index.
await search.fill('zzzqx-no-such-session')
@@ -274,6 +288,95 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await details.getByRole('button', { name: 'Close details' }).click()
}, 60_000)
+ it.skipIf(MODE === 'record')('downloads through the Session Header and /export with one dialog', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export'))
+ await ensureSeedOpen(page)
+ const exportButton = page.getByRole('button', { name: 'Session log' })
+ expect(await exportButton.isDisabled()).toBe(false)
+ const header = exportButton.locator('xpath=ancestor::header[1]')
+ const [buttonBox, headerBox] = await Promise.all([
+ exportButton.boundingBox(), header.boundingBox(),
+ ])
+ if (buttonBox === null || headerBox === null) {
+ throw new Error('Session Header export geometry is unavailable')
+ }
+ expect(headerBox.x + headerBox.width - (buttonBox.x + buttonBox.width)).toBeLessThanOrEqual(32)
+ const responsePromise = page.waitForResponse(response =>
+ response.request().method() === 'HEAD'
+ && new URL(response.url()).pathname === '/api/session.export', { timeout: 30_000 })
+ const downloadPromise = page.waitForEvent('download', { timeout: 30_000 })
+ await exportButton.click()
+ const response = await responsePromise
+ expect(response.status()).toBe(200)
+ const download = await downloadPromise
+ expect(download.suggestedFilename()).toMatch(/^dsh-session-.+\.zip$/)
+ const dialog = page.getByRole('dialog', { name: 'Session download started' })
+ await dialog.waitFor({ timeout: 30_000 })
+ // The real host streamed the ZIP; its root entry is the persisted log
+ // text verbatim (the assembled seam: real route, real persistence read).
+ const files = unzipSync(await readFile(await download.path()))
+ expect(Object.keys(files)).toEqual(['session.jsonl'])
+ const content = strFromU8(files['session.jsonl'] as Uint8Array)
+ expect(content.split('\n')[0]).toContain(SEED_ID)
+ expect(content).toContain('FIRST_DONE')
+ await dialog.getByText('Close', { exact: true }).click()
+
+ const observer = await newEnglishPage(browser)
+ const observerTripwire = watchConsole(observer)
+ const observerSlotErrors: string[] = []
+ let observerDownloads = 0
+ observer.on('download', () => { observerDownloads += 1 })
+ observer.on('console', (message) => {
+ if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) {
+ observerSlotErrors.push(message.text())
+ }
+ })
+ const observerSessionBaseline = baselineResponse(observer, 'session.list')
+ const observerWorkspaceBaseline = baselineResponse(observer, 'workspace.list')
+ const [, observerSessionResponse, observerWorkspaceResponse] = await Promise.all([
+ observer.goto(scaffold.baseUrl, { waitUntil: 'load' }),
+ observerSessionBaseline,
+ observerWorkspaceBaseline,
+ ])
+ await Promise.all([
+ assertBaselineSucceeded(observerSessionResponse, 'observer session.list'),
+ assertBaselineSucceeded(observerWorkspaceResponse, 'observer workspace.list'),
+ ])
+ await observer.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
+ await ensureSeedOpen(observer)
+
+ try {
+ const input = page.locator('textarea').first()
+ const slashDownloadPromise = page.waitForEvent('download', { timeout: 30_000 })
+ await input.fill('/export')
+ await page.getByRole('option', { name: /export/u }).waitFor({ timeout: 10_000 })
+ await input.press('Enter')
+ const slashDownload = await slashDownloadPromise
+ expect(slashDownload.suggestedFilename()).toBe(download.suggestedFilename())
+ const slashFiles = unzipSync(await readFile(await slashDownload.path()))
+ const slashContent = strFromU8(slashFiles['session.jsonl'] as Uint8Array)
+ const slashEvents = parseSessionLog(slashContent)
+ const exportRun = slashEvents.findLast(event => event.type === 'command/run' && event.data.name === 'export')
+ if (exportRun?.type !== 'command/run') throw new Error('slash ZIP has no export command/run')
+ const exportDone = slashEvents.find(event =>
+ event.type === 'command/done' && event.data.commandId === exportRun.data.commandId)
+ expect(exportDone?.type).toBe('command/done')
+ await page.getByRole('dialog', { name: 'Session download started' }).waitFor({ timeout: 30_000 })
+ await page.getByRole('dialog', { name: 'Session download started' })
+ .getByText('Close', { exact: true }).click()
+ await observer.getByText('Session log download requested.', { exact: true }).waitFor({ timeout: 30_000 })
+ expect(observerDownloads).toBe(0)
+ expect(await observer.getByRole('dialog', { name: 'Session download started' }).count()).toBe(0)
+ expect({
+ pageErrors: observerTripwire.pageErrors,
+ slotErrors: observerSlotErrors,
+ warnings: observerTripwire.warnings,
+ }).toEqual({ pageErrors: [], slotErrors: [], warnings: [] })
+ } finally {
+ await observer.close()
+ }
+ }, 120_000)
+
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
await ensureSeedOpen(page)
@@ -331,7 +434,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
// Real layout, not jsdom's stub (which computes no geometry at all):
// squeeze the output pane below its content width and the line must keep
// its single row and overflow sideways instead of folding. Soft-wrapping
- // here is what shredded the column alignment this card exists to hold.
+ // here shreds the column alignment this card exists to hold.
const layout = await card.locator('[class*="_output_"]').first().evaluate((node) => {
const pane = node as HTMLElement
const row = pane.querySelector