merge: update the session-log-v4 integration base from master

Merge a2acc7d3eb620bb52f09f7ea3b5a3a1c23353e18 after preserving the earlier
master checkpoint and its successful CI. Carry current provider input-history
handling, UI controls, agent targeting, and runner-concurrency changes into
the integration base without changing the Session migration feature layers.

Keep durable direct-child catalog semantics alongside the upstream running /
inactive vocabulary. Resolve the snapshot description conflicts by retaining
that upstream status text and the existing descendants-only diagnostic rule;
the source and all 53 JSON tool descriptions agree. Preserve parent-session
availability handling with the upstream stale-blank and batched-history fixes.

The merged application builds, all 41 documentation gates pass, and focused
provider, tool-control, and Session Controller tests pass (98, 29, and 125).
Recorded input changes come from master: one refreshed v3 list-agents sample
and three new v3 scenarios; no migration bumps an existing snapshot generation.
This commit is contained in:
Tianyi Cui committed 2026-09-18 15:52:56 +08:00
commit c3cfe83d2e
343 files changed
+8368 -2217

No files matched your search

@@ -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-08-18-experimental-agent-teams-packages.md
2026-08-18-experimental-agent-teams-packages.md: 4a78a60c2ad7463c06b670e6ec664579ffd767b9
2026-08-18-experimental-agent-teams-packages.zh.md: 8056cd8194695e8ce41acb359e51c040d4ed3aec
2026-08-18-experimental-agent-teams-packages.md: a1d29b5e96b185a5539d664e19f2f5cc342a6aba
2026-08-18-experimental-agent-teams-packages.zh.md: f4c4eb481ae6ad6319ddac50eda82fa9b9feebf7
@@ -12,17 +12,17 @@ Moving the packages into product-role groups would remove their experimental nam
## Decision
`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, `packages/experimental/agent-team-profile`, `packages/experimental/client-ui-agent-team`, and `packages/experimental/agent-team-web-profile` are public workspace packages. They retain their existing `@deepseek-ai/dsh-experimental-*` names and join the dsh release family. The [publication denylist decision](../process/2026-09-12-experimental-publication-denylist.md) owns the public default and private exceptions; the [experimental package rules](../../../../packages/experimental/AGENTS.md) own dependency isolation and later promotion.
`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, `packages/experimental/agent-team-profile`, and `packages/experimental/client-ui-agent-team` are public workspace packages. They retain their existing `@deepseek-ai/dsh-experimental-*` names and join the dsh release family. The [publication denylist decision](../process/2026-09-12-experimental-publication-denylist.md) owns the public default and private exceptions; the [experimental package rules](../../../../packages/experimental/AGENTS.md) own dependency isolation and later promotion.
The dsh pack and publish set and the local baseline publisher include these five Agent Teams directories and the [Cua Driver providers](2026-09-12-computer-use-provider-registration.md). Workspace constraints require them to omit `private`, set `publishConfig.access` to `public`, and keep the experimental npm prefix. Release packages and apps outside the experimental group, plus the Python runtime, cannot name experimental packages in `dependencies`, `optionalDependencies`, or `peerDependencies`; experimental packages may depend on release packages and each other.
The dsh pack and publish set and the local baseline publisher include these four Agent Teams directories and the [Cua Driver providers](2026-09-12-computer-use-provider-registration.md). Workspace constraints require them to omit `private`, set `publishConfig.access` to `public`, and keep the experimental npm prefix. Release packages and apps outside the experimental group, plus the Python runtime, cannot name experimental packages in `dependencies`, `optionalDependencies`, or `peerDependencies`; experimental packages may depend on release packages and each other.
The generic caller-reserved continuable child identity and selective direct-child drain remain in the stable Subagent service. They own Subagent identity and Activation lifecycle without importing or naming Agent Teams; the experimental Team service consumes them in the permitted direction.
The published Host-side Agent Teams profile bundle depends on the Team packages and applies after `dsh-base`. It inserts the Team rows and disables the global continuable-child controls whose model-visible names overlap the Team tools. The separate published Web profile applies after `dsh-web-app` and the Host profile; it inserts the Team UI, which mounts the Remote contribution generated by the Team package. Both layers remain opt-in and leave the shipped base, CLI, Web, and Python runtime dependency graphs unchanged.
The [single-bundle decision](2026-09-18-agent-teams-single-bundle.md) supersedes the separate Host and Web bundle composition. This note retains publication, dependency isolation, and promotion rationale. The current Team profile remains opt-in and disables the global continuable-child controls whose model-visible names overlap the Team tools; the [optional-bundle decision](../process/2026-09-15-shipped-optional-bundles.md) owns its inclusion in the installation.
Profile startup resolves selected bundles before computing the [immutable profile resolution generation](2026-09-09-profile-resolution-generations.md). The generation retains installation-first precedence, traverses each explicit bundle root completely in profile order, and keeps pnpm-managed profile packages authoritative. Runtime mode enforces the result in memory; retained link and dual modes materialize the same result as shared and profile-owned projections. A private profile layer can therefore carry experimental plugin rows without adding those plugins to a release app, requiring profile users to install transitive packages directly, weakening packaged-runtime module identity, or changing another profile's resolution.
Experimental status changes compatibility and support expectations, not publication for these five packages. They retain the repository's ordinary documentation, invariant, lifecycle, security, unit, real-composition, and snapshot requirements. Promotion still requires review of the public contracts, limitations, test evidence, runtime dependents, and a named owner accepting stable-package obligations.
Experimental status changes compatibility and support expectations, not publication for these four packages. They retain the repository's ordinary documentation, invariant, lifecycle, security, unit, real-composition, and snapshot requirements. Promotion still requires review of the public contracts, limitations, test evidence, runtime dependents, and a named owner accepting stable-package obligations.
## Alternatives considered
@@ -36,6 +36,6 @@ Experimental status changes compatibility and support expectations, not publicat
## Consequences
Agent Teams publishes as five installable tarballs in the dsh release family without changing package names or enabling Team in a shipped profile. Public availability does not make the packages stable or supported by default, and stable release packages cannot take runtime dependencies on them.
Agent Teams publishes as four installable tarballs in the dsh release family without changing package names or enabling Team in a shipped profile. Public availability does not make the packages stable or supported by default, and stable release packages cannot take runtime dependencies on them.
The release family carries the experimental npm names. Promotion still creates path and npm-name churn as specified by the experimental package rules.
@@ -12,17 +12,17 @@ Agent Teams 的服务与工具约定仍在变化,但它需要使用真实 Sess
## 决策
`packages/experimental/agent-team`、`packages/experimental/tool-agent-team`、`packages/experimental/agent-team-profile`、`packages/experimental/client-ui-agent-team` 与 `packages/experimental/agent-team-web-profile` 是公开 workspace 包。它们保留现有 `@deepseek-ai/dsh-experimental-*` 名称并加入 dsh 发布系列。[发布拒绝列表决策](../process/2026-09-12-experimental-publication-denylist.zh.md)负责默认公开与私有例外;[实验性包规则](../../../../packages/experimental/AGENTS.md)负责依赖隔离与后续 promotion。
`packages/experimental/agent-team`、`packages/experimental/tool-agent-team`、`packages/experimental/agent-team-profile` 与 `packages/experimental/client-ui-agent-team` 是公开 workspace 包。它们保留现有 `@deepseek-ai/dsh-experimental-*` 名称并加入 dsh 发布系列。[发布拒绝列表决策](../process/2026-09-12-experimental-publication-denylist.zh.md)负责默认公开与私有例外;[实验性包规则](../../../../packages/experimental/AGENTS.md)负责依赖隔离与后续 promotion。
dsh 打包与发布集合以及本地基线发布器包含这五个 Agent Teams 目录和 [Cua Driver 提供方](2026-09-12-computer-use-provider-registration.zh.md)。workspace 约束要求它们省略 `private`、将 `publishConfig.access` 设为 `public`,并保留实验性 npm 前缀。实验组之外的发布包、应用和 Python 运行时不能在 `dependencies`、`optionalDependencies` 或 `peerDependencies` 中引用实验包;实验包可以依赖发布包和彼此。
dsh 打包与发布集合以及本地基线发布器包含这四个 Agent Teams 目录和 [Cua Driver 提供方](2026-09-12-computer-use-provider-registration.zh.md)。workspace 约束要求它们省略 `private`、将 `publishConfig.access` 设为 `public`,并保留实验性 npm 前缀。实验组之外的发布包、应用和 Python 运行时不能在 `dependencies`、`optionalDependencies` 或 `peerDependencies` 中引用实验包;实验包可以依赖发布包和彼此。
通用的调用方预留 continuable child 身份和精确 direct-child drain 仍属于稳定 Subagent 服务。它们负责 Subagent 身份与 Activation 生命周期,不 import 或命名 Agent Teams;实验性 Team 服务沿允许的方向消费这些能力。
公开发布的 Host 侧 Agent Teams profile bundle 依赖 Team 包,并在 `dsh-base` 之后应用。它会插入 Team 配置行,并禁用模型可见名称与 Team 工具重叠的全局 continuable-child control。独立公开发布的 Web profile 在 `dsh-web-app` 与 Host profile 之后应用;它会插入 Team UI,后者挂载 Team package 生成的 Remote contribution。两个层都保持显式启用,不改变随附 base、CLI、Web 与 Python runtime 的依赖图。
[单组合包决策](2026-09-18-agent-teams-single-bundle.zh.md)取代了独立 Host 与 Web 组合包的组合方式。本记录保留发布、依赖隔离与 promotion 的理由。当前 Team profile 仍须显式启用,并禁用模型可见名称与 Team 工具重叠的全局 continuable-child control;[可选组合包决策](../process/2026-09-15-shipped-optional-bundles.zh.md)负责其随安装交付的规则。
profile 启动会先解析所选 bundle,再计算[不可变 profile resolution generation](2026-09-09-profile-resolution-generations.zh.md)。generation 保留安装优先顺序,按 profile 顺序完整遍历每个显式 bundle 根,并让 pnpm 管理的 profile 包保持优先。runtime 模式在内存中强制该结果;保留的 link 与 dual 模式把同一结果物化为共享和 profile 自有投影。因此,私有 profile 层可以携带实验性 plugin 配置行,而无需把这些 plugin 加入发布 app、要求 profile 用户直接安装传递依赖、破坏 packaged-runtime 的模块身份,或改变其他 profile 的解析结果。
对这五个包而言,实验性状态改变兼容性与支持预期,而不阻止发布。这些包仍须满足仓库的一般文档、不变式、生命周期、安全、单元测试、真实组合测试和快照要求。promotion 前仍须评审公开约定、限制、测试证据、运行时依赖方,并由一名具名 owner 接受稳定包义务。
对这四个包而言,实验性状态改变兼容性与支持预期,而不阻止发布。这些包仍须满足仓库的一般文档、不变式、生命周期、安全、单元测试、真实组合测试和快照要求。promotion 前仍须评审公开约定、限制、测试证据、运行时依赖方,并由一名具名 owner 接受稳定包义务。
## 曾考虑的替代方案
@@ -36,6 +36,6 @@ profile 启动会先解析所选 bundle,再计算[不可变 profile resolution
## 后果
Agent Teams 会作为 dsh 发布系列中的五个可安装 tarball 发布,同时保持包名不变,也不会在随附 profile 中启用 Team。公开可用不代表这些包稳定或默认受支持,稳定发布包也不能对其建立运行时依赖。
Agent Teams 会作为 dsh 发布系列中的四个可安装 tarball 发布,同时保持包名不变,也不会在随附 profile 中启用 Team。公开可用不代表这些包稳定或默认受支持,稳定发布包也不能对其建立运行时依赖。
发布系列保留实验性 npm 名称。promotion 仍会按照实验性包规则产生路径和 npm 名改动。
@@ -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-08-23-client-derived-tool-presentation.md
2026-08-23-client-derived-tool-presentation.md: 8befe840b08006a7d2ee0fa1b7875c2c989d4779
2026-08-23-client-derived-tool-presentation.zh.md: 249366f10e6eb06941b344977169609358786413
2026-08-23-client-derived-tool-presentation.md: 931276fa6d4c0590746b6c59eeae9f998f05629c
2026-08-23-client-derived-tool-presentation.zh.md: b930f98da17a1cb6ecbf605e3a9f37255ba75fc9
@@ -22,7 +22,7 @@ The required result is one raw Session journal and one Client presentation owner
## Decision
The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../bug-fix/2026-09-05-nested-terminal-cards.md); all other presentation and ownership constraints remain.
The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../bug-fix/2026-09-05-nested-terminal-cards.md) and [compact tool details](2026-09-10-compact-tool-details.md); all other presentation and ownership constraints remain.
The Session Remote journal sends only raw, validated, persistable Session events. `session.page` and `session.follow` do not parse tool arguments, query the Tools registry, restore a presenter scope, execute `presentCall` or `presentResult`, or construct or clone any tool view.
@@ -51,7 +51,7 @@ The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCall
| Retained | the Session log format, Remote journal lifecycle, and Conversation identity/topology |
| Retained | the existing keyed slot, Generic fallback, and Chat, Details, and Trajectory structure |
| Forbidden | a new Client presenter service, parallel registry, or wire renderer id |
| Forbidden | new cards, visual redesign, interaction redesign, or PTC dispatch rich-card enhancements except the [nested terminal-card exception](../bug-fix/2026-09-05-nested-terminal-cards.md) |
| Forbidden | new cards, visual redesign, interaction redesign, or PTC dispatch rich-card enhancements except the [nested terminal-card exception](../bug-fix/2026-09-05-nested-terminal-cards.md) and [compact tool details](2026-09-10-compact-tool-details.md) |
| Forbidden | compatibility dual-writing, version negotiation, or retention of the old `view` field |
## Terminology
@@ -22,7 +22,7 @@ Host presenter 与 Client keyed renderer 分担展示会形成对同一事件的
## Decision
下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../bug-fix/2026-09-05-nested-terminal-cards.zh.md);其他展示与所有权约束全部保留。
下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)和[紧凑工具详情](2026-09-10-compact-tool-details.zh.md);其他展示与所有权约束全部保留。
Session Remote journal 只下发原始、已验证、可持久化的 Session event。`session.page` 和 `session.follow` 不解析工具参数,不查询 Tools registry,不恢复 presenter scope,不执行 `presentCall`/`presentResult`,也不构造或克隆任何 tool view。
@@ -51,7 +51,7 @@ Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCa
| 保留 | Session 日志格式、Remote journal 生命周期与 Conversation identity/topology |
| 保留 | 现有 keyed slot、Generic fallback、Chat、Details 与 Trajectory 结构 |
| 禁止 | 新 Client presenter service、平行 registry 或 wire renderer id |
| 禁止 | 新卡片、视觉改版、交互改版或 PTC dispatch rich-card 增强,[嵌套 terminal 卡片例外](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)除外 |
| 禁止 | 新卡片、视觉改版、交互改版或 PTC dispatch rich-card 增强,[嵌套 terminal 卡片例外](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)和[紧凑工具详情](2026-09-10-compact-tool-details.zh.md)除外 |
| 禁止 | 为兼容保留双写、版本协商或旧 `view` 字段 |
## 术语
@@ -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-08-25-session-observations-and-projection-owned-client-state.md
2026-08-25-session-observations-and-projection-owned-client-state.md: ff5628c3051086159d532b71ca3e98c1b41a51f8
2026-08-25-session-observations-and-projection-owned-client-state.zh.md: e433df28fec86564fe1c1e9e88686a5abe6b9549
2026-08-25-session-observations-and-projection-owned-client-state.md: 0ff8cbf50c18285092432850ce5222430f5acf0d
2026-08-25-session-observations-and-projection-owned-client-state.zh.md: f3fca078d089a9ee1e329fdbb661653053053a91
@@ -120,7 +120,7 @@ Client-local interaction state also remains local: loading and error status, an
### Domain applications
- **Title and list metadata.** Cached projection hints may render an existing title and determine blankness or recency. Missing hints leave those facts unknown; only the bounded small-log policy may resolve them during listing.
- **Title and list metadata.** Cached projection hints may render an existing title and determine blankness or recency. Missing hints leave those facts unknown; only the bounded small-log policy may resolve them during listing. The Client reconciles list rows with the current metadata projection: nonblank evidence excludes a Session from blank reuse, and the later prompt timestamp supplies recency. Projection stores outlive lazy Client Session instances, so instantiation reads retained metadata even before a list row is available. A stale list response cannot override a newer history or control projection.
- **Model selection.** `model/selection` records a complete provider, model, and optional reasoning effort. `modelSelection` distinguishes the last request's route from a later selection pending consumption by a request header.
- **Agent preset.** The projection initializes from immutable Session metadata and advances on preset-selection events. A missing or `null` value is not replaced with the deployment default for an existing Session.
- **Subagent identity.** The `subagent` unit remains the sole descriptor interpreter. Listing obtains candidates from the shared corpus and resolves values through live state, projection cache, or an observation rather than scanning events itself.
@@ -120,7 +120,7 @@ Client 本地交互状态也继续留在本地:loading 和 error 状态、打
### 领域应用
- **Title 与 list metadata。** Cached projection hints 可以渲染已有 title,并判断 blankness 或 recency。Hints 缺失时这些事实保持未知;listing 期间只有有界小日志策略可以解析它们。
- **Title 与 list metadata。** Cached projection hints 可以渲染已有 title,并判断 blankness 或 recency。Hints 缺失时这些事实保持未知;listing 期间只有有界小日志策略可以解析它们。Client 使用当前 metadata projection 对账列表行:非空白事实会将 Session 排除在空白复用之外,较晚的 prompt 时间用于最近活动排序。Projection store 的生命周期长于惰性创建的 Client Session 实例,因此实例化时即使尚无列表行,也会读取已保留的元数据。过期列表响应不能覆盖较新的 history 或 control projection。
- **Model selection。** `model/selection` 记录完整 provider、model 和可选 reasoning effort。`modelSelection` 区分上一请求使用的 route,以及等待 request header 消费的较晚 selection。
- **Agent preset。** Projection 从不可变 Session metadata 初始化,并随 preset-selection event 推进。对于现有 Session,缺失或 `null` 值不会替换成部署默认值。
- **Subagent identity。** `subagent` unit 仍是唯一 descriptor interpreter。Listing 从共享 corpus 获得 candidate,并通过 live state、projection cache 或 observation 解析值,不自行扫描 event。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md
2026-09-10-compact-tool-details.md: 6c1f29c5554c74b01fc2a86c2dee3750a529a6b5
2026-09-10-compact-tool-details.zh.md: d0822a48f100f9f21ef4706204087fcd1e627da4
@@ -0,0 +1,29 @@
# Agent Note: Compact Tool Details from Recorded Results
Status: implemented
English | [中文](2026-09-10-compact-tool-details.zh.md)
## Problem
Goal, todo, and schedule results contain a small set of user-facing facts that take more effort to read as JSON. Dedicated expanded bodies need to fit the existing conversation rows without adding a second presentation registry or live state to historical calls.
## Decision
The `ui-tool` package registers goal and schedule toolviews through the existing keyed slot and shares one package-internal fields/list component with the todo view. Successful, recognized results display recorded values; incomplete calls, errors, malformed values, and unsupported result formats retain generic input/output. Schedule domain errors, including uncertain persistence and an unknown deletion target, retain their original result.
Tool rows keep their disclosure and Inspect interaction. Checklist marks are static and have accessible status labels. Schedule times use absolute dates with the viewer's time zone; states come from the recorded result. Goal details distinguish an active goal awaiting continuation from an armed goal. These display values do not subscribe to the current goal, todo, or schedule projection.
Todo changes compare each root or nested call with its preceding durable `todo/write`, so later writes cannot alter an earlier card. The conversation assembler repairs predecessors when older history loads. Until the predecessor is known, the card shows the submitted list without inferred additions or removals.
This scoped visual change is an exception to the visual-equivalence requirement in [Client-derived tool presentation](2026-08-23-client-derived-tool-presentation.md). Its raw-event ownership, generic fallback, and single keyed registry remain authoritative.
## Alternatives considered
**A shared presenter framework:** the existing keyed slot already dispatches tools. Reusing only the detail body keeps the abstraction local until another package needs it.
**A dashboard inside each card:** interactive controls, progress graphics, and live states add visual weight and can misrepresent what a historical call returned. Compact read-only fields and lists preserve the conversation's hierarchy.
## Consequences
Registered tools gain readable details without changing Host tools, Session events, or public Client exports. Tool-specific adapters interpret recorded JSON and text; unrecognized formats retain generic output. The authored tool-details Session replay covers their assembled rendering.
@@ -0,0 +1,29 @@
# Agent Note: Compact Tool Details from Recorded Results
Status: implemented
[English](2026-09-10-compact-tool-details.md) | 中文
## 问题
目标、任务清单和定时任务的结果包含少量面向用户的事实,以 JSON 阅读时较费力。专用展开内容需要融入现有对话行,不能为历史调用新增一套呈现注册表或实时状态。
## 决策
`ui-tool` 包通过现有按键分发的插槽注册目标和定时任务视图,并与任务清单视图共用一个包内字段/列表组件。成功且可识别的结果显示记录值;未完成调用、错误、格式异常和不支持的结果格式保留通用输入/输出。定时任务领域错误(包括持久化不确定和删除目标不存在)保留原始结果。
工具行保留展开和 Inspect 交互。任务清单标记是静态的,并带有可访问的状态标签。定时任务使用查看者时区的绝对日期,状态取自记录的结果。目标详情区分等待继续的活动目标和已启动的目标。这些显示值不订阅当前目标、任务清单或定时任务投影。
任务清单的变化将每个根调用或嵌套调用与之前的持久化 `todo/write` 比较,因此后续写入不会改变较早的卡片。对话组装器在加载更早的历史时修复前序记录。前序记录尚不明确时,卡片展示提交的列表,不推断新增或删除。
这项有限的视觉调整是[客户端派生工具呈现](2026-08-23-client-derived-tool-presentation.zh.md)中视觉等价要求的例外。该决策中的原始事件所有权、通用回退和唯一按键注册表仍然有效。
## 考虑过的替代方案
**共享呈现框架:**现有按键插槽已负责工具分发。仅复用详情内容,可以在其他包出现需求前将抽象限制在包内。
**在每张卡片中嵌入仪表盘:**交互控件、进度图形和实时状态会增加视觉负担,也可能歪曲历史调用返回的事实。紧凑的只读字段和列表保留了对话的层级。
## 影响
已注册的工具获得可读详情,无需改变 Host 工具、Session 事件或公开的 Client 导出。工具专用适配器解读记录中的 JSON 和文本;未识别的格式保留通用输出。手写的 tool-details Session 回放覆盖这些工具组装后的渲染。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md
2026-09-18-agent-teams-single-bundle.md: 21878a8e481871f5b0eeebd8f1bea707411ff3b7
2026-09-18-agent-teams-single-bundle.zh.md: 39c6bfb7b365a45ff98d29f91a4d7905b26db008
@@ -0,0 +1,33 @@
# Agent Note: Enable Agent Teams with one bundle
Status: implemented
English | [中文](2026-09-18-agent-teams-single-bundle.zh.md)
## Problem
Separate Agent Teams and Agent Teams Web switches require users to discover that tools and their browser controls need both selections. The package split exposes composition details in the plugin page without helping users choose a different Team capability.
## Decision
`@deepseek-ai/dsh-experimental-agent-team-profile` carries the Team service, tools, and browser UI in one optional bundle. Its patch retains the `agent-team`, `tool-agent-team`, and `ui-agent-team` row ids, so profile patches can still configure individual rows. The UI package has an inert Host entry; its browser entry mounts only in a Web Client, and headless does not start a Web server.
The separate `@deepseek-ai/dsh-experimental-agent-team-web-profile` package is absent from the workspace and release family. The plugin page exposes one Team selection, disabled by default.
This decision supersedes the separate Host/Web composition in the [package publication note](2026-08-18-experimental-agent-teams-packages.md) and the two Team selections in the [optional-bundle note](../process/2026-09-15-shipped-optional-bundles.md). Both remain active for publication, dependency isolation, promotion, and installation ownership. The [archived Web-controls note](../../archived/feature/2026-08-06-agent-teams-web.md) records the original split; its historical text remains frozen.
## Alternatives considered
**Keep two switches and explain their dependency.** Users still need two selections for one feature, and either selection alone leaves an incomplete browser experience.
**Combine the entries in the generic profile loader or plugin manager.** Recognizing Team package names there would make shared loading or presentation code own feature-specific composition. The bundle patch already declares the required rows and dependencies.
## Consequences
Users enable tools and browser controls together. Separate installation-level selection of a UI-only Team bundle is unavailable; custom profiles still control individual patch rows. Headless installations include the UI dependency graph even though they do not mount its browser entry.
Existing profiles that select the removed Web bundle have an upgrade compatibility gap: startup fails when that package cannot be resolved. Bundle composition provides no automatic rewrite of those saved selections. Compatibility handling remains separate from this composition decision.
## Verification
The plugin-manager browser test observes the Team controls appear after one switch, reads the roster and task board through Remote, and observes their removal after disabling the bundle. The Team-panel browser test covers task creation; the built headless CLI test covers delegation without a Web server. These scenarios do not cover upgrading a profile that still selects the removed Web bundle.
@@ -0,0 +1,33 @@
# Agent Note: 通过一个组合包启用 Agent Teams
Status: implemented
[English](2026-09-18-agent-teams-single-bundle.md) | 中文
## 问题
独立的 Agent Teams 与 Agent Teams Web 开关要求用户自行发现:工具与浏览器控件需要同时选中两项。包的拆分把组合细节暴露在插件页上,却没有帮助用户选择不同的 Team 能力。
## 决策
`@deepseek-ai/dsh-experimental-agent-team-profile` 在一个可选组合包中携带 Team 服务、工具与浏览器 UI。其 patch 保留 `agent-team`、`tool-agent-team` 与 `ui-agent-team` 配置行 id,因此 profile patch 仍可单独配置各行。UI 包的 Host 入口不执行逻辑;其浏览器入口只在 Web 客户端中挂载,headless 不会启动 Web 服务。
独立的 `@deepseek-ai/dsh-experimental-agent-team-web-profile` 包不在 workspace 与发布系列中。插件页提供一个 Team 选项,默认关闭。
本决策取代[包发布记录](2026-08-18-experimental-agent-teams-packages.zh.md)中的独立 Host/Web 组合方式,以及[可选组合包记录](../process/2026-09-15-shipped-optional-bundles.zh.md)中的两个 Team 选项。两条记录仍负责发布、依赖隔离、promotion 与安装方所有权。[已归档的 Web 控件记录](../../archived/feature/2026-08-06-agent-teams-web.md)记载原始拆分方式,其历史文本保持冻结。
## 曾考虑的替代方案
**保留两个开关并解释依赖关系。** 用户仍须为一个功能选中两项,单独选中任意一项都不能提供完整的浏览器体验。
**在通用 profile loader 或插件管理器中合并入口。** 在这些位置识别 Team 包名会让共用的加载或展示代码负责功能特定的组合。组合包 patch 已能声明所需配置行与依赖。
## 后果
用户同时启用工具与浏览器控件。安装层面不再提供仅选择 Team UI 组合包的入口;自定义 profile 仍能单独控制 patch 配置行。headless 安装包含 UI 依赖图,但不会挂载其浏览器入口。
选中了已移除 Web 组合包的已有 profile 存在升级兼容性缺口:无法解析该包时,启动会失败。组合包本身不会自动改写这些已保存的选择。兼容性处理与本组合决策分开。
## 验证
插件管理器浏览器测试观察一次开关后 Team 控件出现,通过 Remote 读取成员列表与任务看板,并观察关闭组合包后控件移除。Team 面板浏览器测试覆盖任务创建;构建后的 headless CLI 测试覆盖无需 Web 服务的委派。这些场景不覆盖仍选中已移除 Web 组合包的 profile 升级。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.md
2026-09-18-chat-navigation-performance.md: d990c0cb9a351ea3b3224ce5feec912c176a5fc9
2026-09-18-chat-navigation-performance.zh.md: 1a330d3b6bf6357199eb09ffc1cdb434cda1c1af
@@ -0,0 +1,59 @@
# Agent Note: Bound turn navigation work and batch historical jumps
Status: implemented
English | [中文](2026-09-18-chat-navigation-performance.zh.md)
## Problem
A long Session makes three independent costs visible: a turn rail with one DOM mark per known turn, transcript navigation that rediscovers an already known landing, and historical jumps that publish every fetched page. Each intermediate prepend rebuilds growing conversation indexes and exposes another transcript update before the requested turn is available. Combining these concerns into one controller obscures which operations require DOM geometry and which only apply reading or paging policy.
## Decision
### Fixed-pitch virtual rail
[TurnNavigator](../../../../packages/client/ui-chat/src/client/chat/TurnNavigator.tsx) uses TanStack React Virtual with a fixed 10px pitch. Only the visible range, overscan, and the focused mark with its neighbors remain mounted. The library owns the size container and absolute mark transforms. The full turn metadata remains resident; this is rail virtualization, not transcript virtualization.
The rail uses ResizeObserver-delivered viewport dimensions and fixed item sizes. Its offset calculation clamps against cached geometry and invokes the supported `scrollBy` operation, avoiding the library's DOM-based maximum-offset calculation used by `scrollToIndex`. The first usable size places the latest committed active turn directly after the body has restored its reading state. A ref exposes independent activation and rail-only scrolling. Native pointer targets replace coordinate hit testing, and mark length changes use transforms. Layout containment belongs to the floating rail frame; it does not promise that browser geometry reads flush only that subtree.
### Two-layer transcript navigation
[ChatViewport](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) owns DOM reads, clamped writes, native events, and turn anchors. It returns the actual landing with available turn and semantic-position facts. Reading policy consumes those facts without another hit test; the viewport ignores its own unchanged scroll echoes and events from the nested rail.
[ChatReading](../../../../packages/client/ui-chat/src/client/chat/use-chat-reading.ts) owns follow-tail policy, semantic restoration, and sampled reader movement. [ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) owns loaded or unloaded jumps, paging anchors, replacement tasks, and fallback. [useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) supplies committed inputs and coordinates these owners. Timer and animation-frame completion invoke the owners directly instead of incrementing React tick state. The [pinned-delivery rule](../../../../packages/client/ui-chat/README.md#scroll-ownership) remains independently necessary: small reader gestures cannot be erased by intervening layout growth.
### One publication per historical jump
[Session.loadThrough](../../../../packages/api/session-controller/src/client/sessions/session.ts) retains accepted pages and a private paging cursor until its shared target is covered or loading ends. It reverses the page list and flattens it once, then publishes one ordered prepend. Live events continue through their normal publication path. A later page failure publishes the successful prefix once; stream or window replacement discards obsolete buffered pages. Single-page `loadOlder` remains immediate, and repeated jump requests retain the existing lowest-target policy.
### Theoretical work model
Let `T` be known turns, `V` mounted virtual marks, `P` fetched pages, `B` entries per page, and `W0` entries already loaded. These are work-count estimates, not timing measurements.
| Operation | Previous work | Current work |
|---|---|---|
| Rail DOM and React mark traversal | `O(T)` | `O(V)`; turn metadata and initial index/cache construction remain `O(T)` |
| Active-turn index lookup with unchanged items | Linear search, `O(T)` | Map lookup, expected `O(1)` |
| Rail target-offset calculation | Synchronous DOM extent/viewport reads | Fixed arithmetic and cached sizes; no DOM extent read on this path |
| Known transcript jump | Landing followed by position rediscovery | Reuse the landing; no hit test, but existing anchor-selector and turn lookup costs remain |
| History-driven linear index passes | `O(P * W0 + B * P²)` | `O(W0 + B * P)` for one final pass, plus `O(B * P)` buffer flattening |
| History-driven sorting | Repeated sorting of each growing window | One final-window sort, `O((W0 + B * P) log(W0 + B * P))` |
| Historical prepend publications | `P` | One; live-event and loading-state updates remain independent |
For illustration, 10,000 turns and about 50 mounted marks imply roughly 99.5% fewer rail marks, not a 200× end-to-end speedup. With 20 equal pages and a negligible initial window, a full linear pass after each page visits `210B` entries in aggregate; one final pass visits `20B`, about 10.5× less work for that pass. Page requests remain 20, and the final transcript still needs assembly, rendering, and layout.
## Alternatives considered
**Containment or React batching alone.** Containment does not remove repeated JavaScript work. Suppressing React renders after each published page still leaves conversation assembly and index rebuilds upstream.
**Patch the virtualizer.** The supported callbacks and relative-scroll operation cover fixed-height placement without maintaining a dependency fork. The native scroll container still needs a total extent; removing that extent would remove native scrolling, not virtualize it.
**One large scrolling controller.** It centralizes state but couples DOM mechanics to follow and paging policy. Separate owners allow business changes without copying geometry operations.
**Virtualize the whole transcript.** That introduces renderer lifetime and semantic-anchor constraints outside this change. Batching the requested historical jump removes intermediate publications without changing transcript residency.
## Consequences
Loading a distant turn retains the current transcript until the batch lands, instead of revealing intermediate pages. Buffered pages add references proportional to newly fetched entries. Main-transcript geometry reads, semantic-anchor queries, ordinary native-scroll metrics, final layout, and center-width reflow during Sidebar animation remain; none is claimed eliminated by this change. Transform and color changes may still repaint, and assigning `scrollTop` may still deliver a native scroll event.
[Rail tests](../../../../packages/client/ui-chat/tests/turn-navigator.client.spec.tsx) cover bounded mounting, first placement, fixed-size controls, and focus retention. [Chat tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) cover semantic restoration, follow attribution, own-scroll echoes, and navigation without hit testing. [Session tests](../../../../packages/api/session-controller/tests/session.client.spec.ts) cover atomic prepend publication with live delivery, failure, and replacement. [Browser navigation](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) covers real scrolling and unloaded-turn landing. Functional evidence is separate from the theoretical estimates above.
@@ -0,0 +1,59 @@
# Agent Note: 限制轮次导航工作量并聚合历史跳转
Status: implemented
[English](2026-09-18-chat-navigation-performance.md) | 中文
## Problem
长会话会放大三类独立成本:轮次轨道为每个已知轮次挂载一个 DOM 刻度,正文导航重新查找已知落点,以及历史跳转逐页发布数据。每次中间前插都会重建不断增大的对话索引,并在目标轮次可用前再次更新正文。把这些职责放进一个控制器,会使需要 DOM 几何的操作与只负责阅读、分页策略的操作难以区分。
## Decision
### 固定间距虚拟轨道
[TurnNavigator](../../../../packages/client/ui-chat/src/client/chat/TurnNavigator.tsx) 使用 TanStack React Virtual,间距固定为 10px。只保留可见范围、预加载项,以及键盘焦点刻度及其相邻项。库负责尺寸容器和绝对定位刻度的变换。全部轮次元数据仍驻留内存;这里虚拟化的是轨道,不是正文。
轨道采用 ResizeObserver 提供的视口尺寸和固定条目高度。偏移计算根据缓存几何限制范围,再调用库支持的 `scrollBy`,避开 `scrollToIndex` 使用的基于 DOM 的最大偏移计算。正文恢复阅读状态后,轨道在首次获得可用尺寸时直接定位到最新已提交的活跃轮次。ref 分别提供激活轮次和仅滚动轨道的操作。原生指针目标替代坐标命中测试,刻度长度通过变换改变。布局包含作用于悬浮轨道外框,但不承诺浏览器几何读取只刷新该子树。
### 两层正文导航
[ChatViewport](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) 负责 DOM 读取、限制范围后的写入、原生事件和轮次锚点。它返回实际落点及可用的轮次、语义位置信息。阅读策略直接消费这些信息,无需再次命中测试;视口会忽略自身位置未变的滚动回声,以及内嵌轨道发出的事件。
[ChatReading](../../../../packages/client/ui-chat/src/client/chat/use-chat-reading.ts) 负责跟随底部、语义恢复和读者移动采样。[ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) 负责已加载或未加载轮次跳转、分页锚点、任务替换和回退。[useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) 提供已提交输入并协调各对象。定时器与动画帧完成时直接调用负责对象,不再递增 React tick 状态。[贴底事件规则](../../../../packages/client/ui-chat/README.zh.md#scroll-ownership) 仍有独立必要性:期间发生的布局增长不能抵消读者的小幅操作。
### 每次历史跳转统一发布
[Session.loadThrough](../../../../packages/api/session-controller/src/client/sessions/session.ts) 在共享目标被覆盖或加载结束前,保留已接受的页面和私有分页游标。它只反转一次页面列表并展开一次,然后发布一次有序前插。实时事件继续走正常发布路径。后续页面失败时,已成功取得的部分统一发布一次;流或窗口被替换时丢弃失效的暂存页面。单页 `loadOlder` 仍立即发布,重复跳转请求保留既有的最低目标策略。
### 理论工作量模型
设 `T` 为已知轮次数,`V` 为已挂载虚拟刻度数,`P` 为获取页数,`B` 为每页条目数,`W0` 为原先已加载条目数。以下估算工作量,不代表耗时测量。
| 操作 | 原有工作量 | 当前工作量 |
|---|---|---|
| 轨道 DOM 与 React 刻度遍历 | `O(T)` | `O(V)`;轮次元数据与初始索引、缓存构建仍为 `O(T)` |
| 条目未变时查找活跃轮次索引 | 线性搜索,`O(T)` | Map 查找,期望 `O(1)` |
| 轨道目标偏移计算 | 同步读取 DOM 总高度、视口尺寸 | 固定算术与缓存尺寸;此路径不读取 DOM 滚动总高度 |
| 已知正文轮次跳转 | 定位后重新查找位置 | 复用落点;不做命中测试,但既有锚点选择器与轮次查找成本仍在 |
| 历史驱动的线性索引遍历 | `O(P * W0 + B * P²)` | 最终一次遍历为 `O(W0 + B * P)`,另有 `O(B * P)` 暂存展开 |
| 历史驱动的排序 | 对每个不断增大的窗口重复排序 | 对最终窗口排序一次,`O((W0 + B * P) log(W0 + B * P))` |
| 历史前插发布次数 | `P` | 一次;实时事件与加载状态更新仍独立发生 |
例如,10,000 个轮次只挂载约 50 个刻度,意味着轨道刻度约减少 99.5%,不代表端到端加速 200 倍。若获取 20 个等大页面且初始窗口可忽略,每页到达后做一次全量线性遍历,累计访问 `210B` 个条目;最终只遍历一次访问 `20B`,该遍历工作量约减少到原来的 1/10.5。分页请求仍为 20 次,最终正文仍需装配、渲染和布局。
## Alternatives considered
**只加布局包含或 React 批处理。** 布局包含不会删除重复的 JavaScript 工作。仅抑制每页发布后的 React 渲染,也会保留上游对话装配与索引重建。
**修改虚拟化库。** 现有回调和相对滚动操作已能完成固定高度定位,无需维护依赖分支。原生滚动容器仍需总高度;删除总高度会使原生滚动失效,而不是实现虚拟化。
**单一大滚动控制器。** 它能集中状态,却把 DOM 操作与跟随、分页策略耦合。拆分负责对象后,业务变更无需复制几何操作。
**虚拟化整个正文。** 这会引入本次范围之外的 renderer 生命周期与语义锚点约束。聚合目标历史跳转即可删除中间发布,无需改变正文驻留方式。
## Consequences
跳转远处轮次时,当前正文会保持到整批数据落地,不再逐页展示中间结果。暂存页面增加与新获取条目数成正比的引用。正文几何读取、语义锚点查询、普通原生滚动指标、最终布局,以及侧栏动画改变中间区域宽度时的重排仍然存在;本次不声称消除了这些成本。变换与颜色变化仍可能重绘,赋值 `scrollTop` 仍可能产生原生滚动事件。
[轨道测试](../../../../packages/client/ui-chat/tests/turn-navigator.client.spec.tsx) 覆盖有界挂载、首次定位、固定尺寸控制与焦点保留。[Chat 测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) 覆盖语义恢复、跟随归因、自身滚动回声与不做命中测试的导航。[Session 测试](../../../../packages/api/session-controller/tests/session.client.spec.ts) 覆盖实时事件并发下的原子前插发布、失败与替换。[浏览器导航](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) 覆盖真实滚动与未加载轮次落点。功能证据与上述理论估算相互独立。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md
2026-09-18-messages-input-history-compatibility.md: 1775276deec6180f1fd186b11cb7a757a3328a54
2026-09-18-messages-input-history-compatibility.zh.md: 50367e33c29df0679a44aca1fe680d2bedc8c263
@@ -0,0 +1,31 @@
# Agent Note: Replay assistant blocks in Messages user input
Status: implemented
English | [中文](2026-09-18-messages-input-history-compatibility.zh.md)
## Problem
Saved subagent settlement notices can contain child reasoning and tool calls in parent user messages. Chat Completions and pi-ai omit these blocks from user input, while rejecting them in Messages prevents every later request containing the same history. The [text-only settlement producer](../feature/2026-08-06-manager-owned-subagent-settlement-delivery.md) prevents new notices from carrying these blocks but cannot repair already recorded messages.
## Decision
The [Messages serializer](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) omits `reasoning` and `tool-call` blocks from user and tool-result input. This matches Chat Completions and pi-ai for these two block types, without changing Session records or converting child reasoning into parent user text. The [provider README](../../../../packages/llm/llm-deepseek/README.md#model-experience) owns the input rules, including empty user messages, empty tool results, and rejection of other unsupported blocks.
This partially supersedes the settlement decision's rejection of serializer tolerance and the [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md)'s input rejection. Notice construction still projects nonempty child text for every parent provider. Canonical child output and ordinary assistant reasoning and tool calls remain available to their existing consumers. The omission is based on input role and block type, not the notice source or creation date, so it also applies to newly supplied user and tool-result content.
## Alternatives considered
**Fix only the producer.** New notices are representable, but saved notices still block protocol switching and continuation.
**Rewrite Session history or flatten reasoning into user text.** Rewriting removes original evidence; flattening sends child reasoning as parent input. Request-only omission preserves the durable record and existing input semantics.
**Ignore every unsupported block.** Unknown plugin content has no Messages representation defined here. Compatibility for two known assistant block types does not establish one.
**Move a system update to an earlier retained user turn.** When the corresponding user input is omitted, moving the update before an earlier assistant changes its position in the conversation and rewrites a previously sent prefix. The [documented in-history limitation](../../../../packages/llm/llm-deepseek/README.md#known-limitations-and-deferred-work) remains explicit rather than inventing a user message or relocating the update.
## Consequences
Saved notices with retained text can continue through Messages. The provider no longer diagnoses these two block types as user-input producer errors; the settlement producer remains responsible for text-only notices. This does not promise lossless provider input or compatibility for every historical message sequence.
Verification covers user and tool-result filtering, unchanged durable content, HTTP continuation, and a [headless Session replay](../../../../snapshots/session/deepseek-messages-input-history/snapshot.yml). Serialization tests pin rejection of an in-history update when its user input is entirely omitted, both before another assistant and at request end, and acceptance when text or a tool result retains the corresponding turn.
@@ -0,0 +1,31 @@
# Agent Note: 回放 Messages 用户输入中的助手块
Status: implemented
[English](2026-09-18-messages-input-history-compatibility.md) | 中文
## 问题
已保存的子 Agent 结算通知可能在父级用户消息中包含子级推理和工具调用。Chat Completions 与 pi-ai 会在用户输入中省略这些块,而 Messages 拒绝它们会阻断包含相同历史的所有后续请求。[仅生成文本的结算通知生产方](../feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md)可以避免新通知携带这些块,但无法修复已经记录的消息。
## 决策
[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts)省略用户与工具结果输入中的 `reasoning` 和 `tool-call` 块。这两类块的处理与 Chat Completions、pi-ai 一致,不改变 Session 记录,也不把子级推理转换成父级用户文本。[提供方 README](../../../../packages/llm/llm-deepseek/README.zh.md#model-experience)统一说明输入规则,包括空用户消息、空工具结果,以及对其他不支持块的拒绝。
这部分取代了结算决策对序列化器容错的拒绝,以及 [Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)中的输入拒绝规则。通知构建仍为每种父级提供方投影子级的非空文本。子级的规范输出以及普通助手推理和工具调用仍可供现有消费者使用。省略依据是输入角色和块类型,而不是通知来源或创建日期,因此也适用于新提供的用户和工具结果内容。
## 考虑过的替代方案
**只修复生产方。** 新通知可以表示,但已保存的通知仍会阻断协议切换和会话续接。
**改写 Session 历史或把推理压平为用户文本。** 改写会移除原始证据;压平会把子级推理作为父级输入发送。只在请求中省略可以保留持久化记录和现有输入语义。
**忽略所有不支持的块。** 此处没有定义未知插件内容的 Messages 表示方式。兼容两种已知助手块类型并不意味着其他类型也有对应表示。
**把 system 更新移到更早保留的用户轮次。** 对应用户输入被省略时,将更新移到更早的 assistant 之前,会改变它在对话中的位置,并改写已发送的前缀。保留明确记录的[历史内更新限制](../../../../packages/llm/llm-deepseek/README.zh.md#known-limitations-and-deferred-work),不编造用户消息或移动更新位置。
## 后果
保留文本的已保存通知可以通过 Messages 继续会话。提供方不再将这两种块类型诊断为用户输入生产方错误;结算通知生产方仍负责仅生成文本通知。这不保证提供方输入无损,也不保证兼容所有历史消息序列。
验证覆盖用户与工具结果过滤、持久化内容不变、HTTP 续接,以及 [headless Session 回放](../../../../snapshots/session/deepseek-messages-input-history/snapshot.yml)。序列化测试固定了历史内更新在对应用户输入被完全省略时的拒绝行为,覆盖下一个 assistant 之前和请求结束两种位置;文本或工具结果保留对应轮次时则可以通过。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-agent-teams.md
2026-08-05-agent-teams.md: bdf646db9b1617ded7f50f4145887f34a52ae83c
2026-08-05-agent-teams.zh.md: 5386044edb57dad9fabf114d565808c735846464
2026-08-05-agent-teams.md: d108d4f9833aca2a24c84d18d895e7ec63c0bfc2
2026-08-05-agent-teams.zh.md: abf2ce3716e44effc8e34c0911cd9561d815aa08
@@ -40,7 +40,7 @@ Fresh children have no inherited conversation. Fork children capture the Lead's
Peer communication is a Lead-log mailbox. `team/message/queued` is appended and flushed before delivery. The target message carries the stable message id and sender identity in both durable source metadata and a short model-visible prefix. A target receipt is acknowledged with `team/message/delivered` only after its pending inbox item or recorded user message is flushed. Immediate admission is serialized per target in queued-log order, recovery retries queued-minus-delivered in the same order, and delivery folds live or persisted target inbox/history state before cold resume. Every current-version Team payload is runtime-validated before entering replay state. The Team runtime tracks dispatch and asynchronous acknowledgement work from synchronous admission until settlement; disposal closes admission and awaits both before removing the service. Current waiters wake only after the owning Team event flush succeeds.
`send_message` always attempts Steer delivery. A running target receives the message at the nearest step boundary, an idle target starts a turn, and an inactive teammate cold-resumes. Success means the message is already durable even when temporary delivery failure leaves it queued. The mechanism provides process-local retry and target-Session de-duplication, not a cross-process exactly-once claim. The [Team Steer messaging decision](../../archived/simplification/2026-08-30-team-send-message-steer.md) owns the single-tool scheduling rationale.
`send_message` always attempts Steer delivery. A running target receives the message at the nearest step boundary; an inactive target starts a turn if loaded or cold-resumes otherwise. Success means the message is already durable even when temporary delivery failure leaves it queued. The mechanism provides process-local retry and target-Session de-duplication, not a cross-process exactly-once claim. The [Team Steer messaging decision](../../archived/simplification/2026-08-30-team-send-message-steer.md) owns the single-tool scheduling rationale.
Shared tasks are complete snapshots with Team-local ids and monotonic revisions. Every mutation carries `expectedRevision`. Any member creates, reads, or claims a ready unowned task; the owner or Lead edits and transitions it, while only the Lead assigns another member. Numeric task ids remain within the safe-integer allocation range, and exhaustion fails without reusing an id. Dependencies must name non-deleted tasks and form a complete DAG. Deleted tasks are retained tombstones. `writeScopes` are normalized path prefixes that produce overlap diagnostics but never block claim or authorize a write.
@@ -72,6 +72,8 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may
Package tests cover identity, name and authority checks, provider selection, reserved-id persistence collisions, child-before-Lead flush ordering, durable provisioning failure and pending-inbox JSONL reconciliation, concurrent target-local ordering, pending/history de-duplication, mailbox limits, post-flush notification, bounded disposal with in-flight creation and dispatch cancellation, failed-member cleanup, task CAS and DAG validation, write-scope warnings, wait cancellation/timeout, inbox-preserving interruption, ordinary-fork isolation, legacy-control shadowing, compact declared-schema result rendering, and scoped registration HMR at per-file 100% coverage. A keyless product snapshot loads the Agent Teams profile bundle through `dsh --profile headless` and pins its complete model-visible tool list, Team policy, and durable workflow projection for two teammates, dependent tasks, peer delivery, waiting, completion, and aggregation. A CLI e2e reuses the same deterministic adapter and verifies normal process exit with persisted Team and child logs.
Model-facing member identities and availability follow the [tool projection decision](../simplification/2026-09-15-model-agent-availability-and-team-targets.md); service residency and durable identity remain distinct.
## Consequences
The Lead Session grows with whole task/member snapshots and mailbox acknowledgements. This favors independently inspectable recovery over compact deltas; configured task and pending-mail bounds cap active state, while deleted and delivered history remains append-only until broader Session retention applies.
@@ -40,7 +40,7 @@ fresh child 不继承对话。fork child 只捕获一次 Lead 已完成 turn 前
Peer 通讯使用 Lead 日志 mailbox。投递前先追加并 flush `team/message/queued`。target message 会在持久 source metadata 与短模型可见前缀中同时携带稳定 message id 和 sender identity。只有 pending inbox 条目或已记录用户消息完成 flush,Lead 日志才写入 `team/message/delivered` acknowledgement。即时准入按 target 和 queued 日志顺序串行化,恢复按同一顺序重试 queued-minus-delivered,并在冷恢复前折叠 live 或 persisted target 的 inbox/历史状态。每个当前版本 Team payload 都会经过运行时验证后才进入 replay state。Team runtime 从同步准入到 settlement 全程跟踪 dispatch 与异步 acknowledgement 工作;dispose 会关闭准入,并在移除服务前等待两者。当前 waiter 只在所属 Team event flush 成功后被唤醒。
`send_message` 始终尝试 Steer 投递。running target 在最近的步骤边界收到消息,idle target 启动一个轮次,inactive teammate 则冷恢复。即使临时投递失败让消息保持 queued,成功也表示消息已经持久化。该机制提供进程内重试与 target Session 去重,不宣称跨进程 exactly-once。[Team Steer 消息决策](../../archived/simplification/2026-08-30-team-send-message-steer.md)负责单工具调度的理由。
`send_message` 始终尝试 Steer 投递。running target 在最近的步骤边界收到消息,inactive target 在已加载时启动一个轮次,否则冷恢复。即使临时投递失败让消息保持 queued,成功也表示消息已经持久化。该机制提供进程内重试与 target Session 去重,不宣称跨进程 exactly-once。[Team Steer 消息决策](../../archived/simplification/2026-08-30-team-send-message-steer.md)负责单工具调度的理由。
共享 task 是带 Team-local id 与单调 revision 的完整快照。每次变更都携带 `expectedRevision`。任意 member 可以创建、读取或 claim ready 且无 owner 的任务;Owner 或 Lead 可以编辑和转换;只有 Lead 可以分配给另一个 member。数字 task id 保持在安全整数分配范围内;该范围耗尽时会失败,不会复用 id。依赖必须指向未删除任务,并形成完整 DAG。删除任务保留为 tombstone。`writeScopes` 是规范化路径前缀,只产生重叠诊断,绝不会阻止 claim 或授予写权限。
@@ -72,6 +72,8 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以
Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、provider 选择、预留 id 持久化冲突、child-before-Lead flush 顺序、持久 provisioning 失败与 pending-inbox JSONL 对账、target-local 并发顺序、pending/history 去重、mailbox 限额、flush 后 notification、取消在途创建与 dispatch 的有界 dispose、failed member cleanup、task CAS 与 DAG 校验、write-scope warning、wait cancel/timeout、保留 inbox 的 interrupt、普通 fork 隔离、旧 control shadowing、声明 schema 的紧凑结果渲染与 scoped registration HMR。一条 keyless 产品快照会通过 `dsh --profile headless` 加载 Agent Teams profile bundle,并为两个 teammate、依赖任务、peer 投递、等待、完成和汇总固定完整的面向模型工具列表、Team policy 与持久 workflow 投影。CLI e2e 会复用同一个确定性 adapter,并验证带持久 Team 与 child 日志的正常退出。
模型可见的成员身份与可用状态遵循[工具投影决策](../simplification/2026-09-15-model-agent-availability-and-team-targets.zh.md);服务驻留状态和持久身份仍保持区分。
## Consequences
Lead Session 会随着完整 task/member 快照与 mailbox acknowledgement 增长。该设计用可独立检查的恢复能力换取更紧凑的 delta;配置的 task 与 pending-mail 限额限制 active state,而 deleted 与 delivered 历史会保持 append-only,直到更广泛的 Session retention 生效。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md
2026-08-06-manager-owned-subagent-settlement-delivery.md: 5553d4c06a6776f0ef025e19ae7f4f67fe277a56
2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: 6d7ee756ad531e123508e8667adc4e5f99c2b9ea
2026-08-06-manager-owned-subagent-settlement-delivery.md: 7f6cfc90862b5bb32368b4b0e1fbe068ba0cbf66
2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: 23c5506f79d4d95308b0533e8d507ac856c4d85e
@@ -90,7 +90,7 @@ The refusal and interruption wordings are pinned verbatim in unit tests rather t
**Filter the canonical child output.** Removing nontext blocks in `AssistantOutputFold` would discard assistant content needed by SDK and UI consumers. Only the parent notice requires a text projection.
**Relax the Messages serializer.** Accepting or silently discarding invalid user-role blocks would conceal the producer's role conversion error. Notice construction supplies content that every parent provider can represent while protocol validation remains strict.
**Rely on serializer tolerance for notice construction.** Notice construction supplies text that every parent provider can represent; adapter filtering cannot replace that producer responsibility. The [saved-input compatibility decision](../bug-fix/2026-09-18-messages-input-history-compatibility.md) supersedes the rejection of Messages input filtering for already recorded notices, which a producer fix cannot change.
## Consequences
@@ -90,7 +90,7 @@ Status: implemented
**过滤规范的子级输出。** 在 `AssistantOutputFold` 中删除非文本块,会丢弃 SDK 和 UI 消费方需要的 assistant 内容。只有父级通知需要文本投影。
**放宽 Messages 序列化器。** 接受或静默丢弃用户角色中的无效块,会掩盖生产方的角色转换错误。通知构建提供每种父级提供方都能表示的内容,协议校验仍保持严格。
**依赖序列化器容错来构建通知。** 通知构建提供每种父级提供方都能表示的文本;适配器过滤不能替代生产方的责任。[已保存输入兼容决策](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md)取代了对已记录通知进行 Messages 输入过滤的拒绝,因为生产方修复无法改变这些通知。
## 后果
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md
2026-09-07-deepseek-messages-adapter.md: c667ec3afdd9956282a532e748ad2e2488d26f5b
2026-09-07-deepseek-messages-adapter.zh.md: 0e3425e41d8a9585a1638eedeefcf6ead76a5a78
2026-09-07-deepseek-messages-adapter.md: caec59feb3a84140427bd3ae8b738274c3bc5c47
2026-09-07-deepseek-messages-adapter.zh.md: 030c91cb9a5f566ce09892bb054b63b1d243f6bb
@@ -36,6 +36,6 @@ Both transports use the existing [request-extension registry](../architecture/20
## Consequences
The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. Unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Messages defaults cover the shared base, Web, and standalone first-party compositions. Protocol changes preserve the provider id and saved model selections, but an explicit endpoint override must support the selected protocol.
The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. User and tool-result input follows the [saved-input compatibility rule](../bug-fix/2026-09-18-messages-input-history-compatibility.md); other unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Messages defaults cover the shared base, Web, and standalone first-party compositions. Protocol changes preserve the provider id and saved model selections, but an explicit endpoint override must support the selected protocol.
Verification covers wire fixtures, real Loader composition, per-file unit coverage, [recorded Session replay](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml) with [unknown replay versions](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml), a Web Messages Session replay, and credential-gated text, thinking, tool continuation, image, and cancellation requests. Live gateway checks establish compatibility with the configured gateway; they do not establish compatibility with every Anthropic proxy.
@@ -36,6 +36,6 @@ Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseU
## 结果
该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 默认配置覆盖共享 base、Web 和独立的官方组合。协议变化保留 provider id 与已保存的模型选择,但显式端点覆盖必须支持所选协议。
该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。用户与工具结果输入遵循[已保存输入兼容规则](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md);其他不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 默认配置覆盖共享 base、Web 和独立的官方组合。协议变化保留 provider id 与已保存的模型选择,但显式端点覆盖必须支持所选协议。
验证覆盖协议夹具、真实 Loader 组合、逐文件单元覆盖率、[已记录 Session 回放](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml)与[未知回放版本](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml),Web Messages Session 回放,以及凭证控制的文本、思考、工具续接、图片和取消请求。真实网关检查证明与已配置网关的兼容性,不能证明与所有 Anthropic 代理兼容。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md
2026-09-13-macos-hidden-titlebar-vibrancy.md: 1bc531953cedac8633c4baa6da45aa9bcd77a728
2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: bbc593badbabdccaa037cca836a9554929d614bc
2026-09-13-macos-hidden-titlebar-vibrancy.md: 99ac240698fb229f9a0d66a8000251058b52491e
2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: 26bbb616485e49de2f70a3d142ea84dfd5bd7cb9
@@ -18,7 +18,7 @@ Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which o
**Native theme sync.** The vibrancy material follows `nativeTheme.themeSource`, which otherwise tracks the OS appearance and diverges from the app's own theme preference. The ui-theme boot script and ui-layout `ThemePresenter` publish `html[data-ds-theme-source]` (`light`, `dark`, or `system`; a fixed preference, including registered theme ids, publishes its resolved scheme). The app preload observes the attribute and forwards it over `dsh-desktop:native-theme-set`; main validates the value and the sender (the main window's WebContents, including its local static Web document) and assigns `nativeTheme.themeSource`. Publishing the preference rather than the resolved scheme preserves OS-follow while the preference is `system`.
**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights, carries the collapse toggle, and is a window drag region (`-webkit-app-region: drag`; the toggle opts out). Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances move into the conversation header: a new single, session-scoped slot `conversation.session.header.leading` sits before the breadcrumbs, and ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. Visibility is pure CSS against the AppFrame-published `data-sidebar-collapsed` attribute; no collapse-state pipe is added. The blank-session header keeps the leading seat mounted on darwin so a hidden sidebar always has a reopen control on screen.
**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights, carries the collapse toggle, and is a window drag region (`-webkit-app-region: drag`; the toggle opts out). Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances move into the conversation header: a single, root-scoped slot `conversation.header.leading` sits before the breadcrumbs, and ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. Visibility is pure CSS against the AppFrame-published `data-sidebar-collapsed` attribute; no collapse-state pipe is added. The resident header keeps the leading seat mounted on darwin with or without a selected Session so a hidden sidebar always has a reopen control on screen.
**Drag regions.** The conversation title row is a drag region on darwin with every interactive descendant opted out. Electron computes drag regions from window geometry in DOM order, not stacking: an overlay covering the title band must subtract itself or the band underneath keeps taking the pointer. The right sidebar's fullscreen panel therefore sets `-webkit-app-region: no-drag` on its whole box and restores drag on its tab strips' blank runs.
@@ -40,10 +40,10 @@ Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which o
- The macOS window gains a translucent sidebar and hidden titlebar at zero cost to other platforms: every rule is scoped to `[data-platform='darwin']`, which only the Electron preload sets.
- The vibrancy material follows the app theme, including third-party registered themes (their resolved scheme). Screenshots and screen recordings differ from the flat web rendering.
- `conversation.session.header.leading` is a public slot in the client catalog; any package can occupy the seat, and ui-sidebar's occupant assumes it may render into it whenever the platform matches.
- `conversation.header.leading` is a public slot in the client catalog; any package can occupy the seat, and ui-sidebar's occupant assumes it may render into it whenever the platform matches.
- Drag-region geometry is a window-global invariant: any future overlay that covers the title band on darwin must subtract itself with `-webkit-app-region: no-drag` or its controls become unclickable.
- The transparent window plus vibrancy is accepted to look different in screen sharing and may flash on startup; the transparent `backgroundColor` mitigates the flash.
## Testing
ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the leading-seat registration (component, locale, shared inject face) and its removal on teardown. The ui-conversation skeleton spec pins the leading slot's render call in the active-phase header. The ui-theme corner-shape and full-round style gates cover the new stylesheet.
ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the leading-seat registration (component, locale, shared inject face) and its removal on teardown. The ui-conversation skeleton spec pins the leading slot's render call with and without a selected Session. The ui-theme corner-shape and full-round style gates cover the new stylesheet.
@@ -18,7 +18,7 @@ Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLig
**原生主题同步。** 毛玻璃材质跟随 `nativeTheme.themeSource`,后者默认跟踪系统外观,会与应用自身的主题偏好背离。ui-theme 引导脚本与 ui-layout 的 `ThemePresenter` 发布 `html[data-ds-theme-source]`(`light`、`dark` 或 `system`;固定偏好——包括注册主题 id——发布其解析后的配色)。应用 preload 观察该属性并经 `dsh-desktop:native-theme-set` 转发;主进程校验取值与发送者(主窗口的 WebContents,包括其本地静态 Web 文档)后赋给 `nativeTheme.themeSource`。发布偏好而非解析值,可在偏好为 `system` 时保留跟随系统。
**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯、承载收起按钮,并作为窗口拖拽区(`-webkit-app-region: drag`;按钮退出拖拽)。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口移入会话头部:新增 single、session 作用域的 slot `conversation.session.header.leading` 位于面包屑之前,ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。显隐纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性控制;不新增收起状态管道。darwin 上空白会话的头部保持 leading 座挂载,确保侧边栏隐藏时屏幕上始终有重新打开的控件。
**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯、承载收起按钮,并作为窗口拖拽区(`-webkit-app-region: drag`;按钮退出拖拽)。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口移入会话头部:single、root 作用域的 slot `conversation.header.leading` 位于面包屑之前,ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。显隐纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性控制;不新增收起状态管道。darwin 上常驻头部在选中或未选中 Session 时均保持 leading 座挂载,确保侧边栏隐藏时屏幕上始终有重新打开的控件。
**拖拽区。** darwin 上会话标题行是拖拽区,所有可交互后代退出。Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠:覆盖标题带的浮层必须自行减除,否则下层区域仍会截获指针。因此右侧边栏的全屏面板对整个盒子设 `-webkit-app-region: no-drag`,再在其 tab 条空白处恢复拖拽。
@@ -40,10 +40,10 @@ Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLig
- macOS 窗口获得半透明侧边栏与隐藏标题栏,对其他平台零成本:所有规则限定在 `[data-platform='darwin']` 下,该属性仅由 Electron preload 设置。
- 毛玻璃材质跟随应用主题,含第三方注册主题(取其解析配色)。截图与录屏与纯 Web 的平面渲染不同。
- `conversation.session.header.leading` 是客户端 catalog 中的公开 slot;任何包都可占用该座位,ui-sidebar 的占用者假定平台匹配时随时可能渲染。
- `conversation.header.leading` 是客户端 catalog 中的公开 slot;任何包都可占用该座位,ui-sidebar 的占用者假定平台匹配时随时可能渲染。
- 拖拽区几何是窗口级全局不变量:今后任何在 darwin 上覆盖标题带的浮层必须用 `-webkit-app-region: no-drag` 自行减除,否则其控件不可点击。
- 接受透明窗口 + 毛玻璃在屏幕共享中呈现不同、启动可能闪烁;透明 `backgroundColor` 缓解闪烁。
## Testing
ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 leading 座注册(组件、locale、共享 inject face)及 teardown 移除。ui-conversation skeleton 测试钉住 active 阶段头部对 leading slot 的渲染调用。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表。
ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 leading 座注册(组件、locale、共享 inject face)及 teardown 移除。ui-conversation skeleton 测试钉住 选中或未选中 Session 时对 leading slot 的渲染调用。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
2026-07-26-ci-failover-runbook.md: 10123fe1999c0ad03f977e1cc7c69d788a679c2b
2026-07-26-ci-failover-runbook.zh.md: cff99e6f644bf945f8366c2a723b32222ab902b4
2026-07-26-ci-failover-runbook.md: c2fd2fa6cf673ec4d202702c16a9a85a3a609b8d
2026-07-26-ci-failover-runbook.zh.md: 7fa5887bb552afd98cebd7502202fb5e9f37b1ee
@@ -32,7 +32,7 @@ The two switches are independent: flip only the one whose platform is degraded.
1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER_LINUX` (Linux pool outage) or `DSH_CI_FAILOVER_WINDOWS` (Windows pool outage), value `selfhosted`.
2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued.
3. That is the entire switch. Under the `selfhosted` Linux failover value the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Coverage uses the same four single-worker instrumented partitions and two exempt workers on both Linux pools. The Windows switch has no concurrency or cache branches; it only retargets the native Windows jobs' pool.
3. That is the entire switch. Under the `selfhosted` Linux failover value the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Self-hosted coverage uses four single-worker instrumented partitions and two exempt workers; isolated hosted runners derive worker defaults from available CPUs. The Windows switch selects the native Windows pool and retains explicit worker limits on shared self-hosted runners.
**Dependabot exception.** Both switches' `selfhosted` legs deliberately exclude `dependabot[bot]`: under self-hosted failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VMs. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers. The `blacksmith` value's branches carry no such exclusion, because Blacksmith runners are ephemeral (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)).
@@ -32,7 +32,7 @@ Status: implemented
1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER_LINUX`(Linux 池故障)或 `DSH_CI_FAILOVER_WINDOWS`(Windows 池故障),值 `selfhosted`。
2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。
3. 切换到此完成。在 `selfhosted` 的 Linux 故障切换取值下,工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。覆盖率在两个 Linux 池上都使用 4 个单 worker 插桩分区与 2 个豁免 worker。Windows 开关没有并发或缓存分支;它只重定向原生 Windows 作业的运行器池。
3. 切换到此完成。在 `selfhosted` 的 Linux 故障切换取值下,工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。自托管覆盖率使用 4 个单 worker 插桩分区与 2 个豁免 worker;独占的托管运行器根据可用 CPU 计算默认 worker 数。Windows 开关选择原生 Windows 运行器池,并在共享的自托管运行器上保留显式 worker 限制。
**Dependabot 例外。**两个开关的 `selfhosted` 腿都刻意排除 `dependabot[bot]`:自托管故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖项提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。`blacksmith` 取值下的分支不带此类排除,因为 Blacksmith 运行器是临时的(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
2026-08-08-native-windows-pull-request-ci.md: e4fc7cab8c274148191632e8cc125ac75f2ec1d5
2026-08-08-native-windows-pull-request-ci.zh.md: 27ad602c3748f2920e6b63f271a6a38b11c0ab77
2026-08-08-native-windows-pull-request-ci.md: 64c4366ee8d60dd0137e6e4eb5473c0450204efe
2026-08-08-native-windows-pull-request-ci.zh.md: 48725cdc58ab7b8db22461da64236c6de3bb3027
@@ -14,11 +14,11 @@ A coverage audit found that stale branch state had restored temporary exclusions
The master-only `windows` job in [ci-master.yml](../../../../.github/workflows/ci-master.yml) runs `windows node 24 / wine` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. Wine is outside the PR aggregate under the [master-only platform policy](2026-09-06-master-only-platform-ci.md). The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology.
Every pull request also starts four independent native jobs on the organization-owned `dsh-windows-2025-16core` runner: `windows-build`, `windows-coverage`, `windows-native-tests`, and `windows-observational`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable (`DSH_CI_FAILOVER_WINDOWS`) retargets all four jobs to the in-house pool under `selfhosted` and to Blacksmith's Windows runners under `blacksmith` (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). Per-job deadlines range from 60 to 120 minutes and bound stuck work without treating a performance target as a correctness deadline.
Every pull request starts three native jobs: `windows-build`, `windows-coverage`, and `windows-native-tests`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable (`DSH_CI_FAILOVER_WINDOWS`) selects the organization-owned `dsh-windows-2025-16core` runner by default, the in-house pool under `selfhosted`, or Blacksmith runners under `blacksmith` (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). Blacksmith build and coverage jobs request 16 vCPU; file-serial native tests request 2 vCPU. Per-job deadlines range from 60 to 120 minutes.
`windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. `windows-coverage` remains an ordinary job but is absent from aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the required verdict. `windows-observational` is also absent from aggregate `needs` and uses `continue-on-error` because Linux owns the blocking static, documentation, package, and built-artifact verdicts.
`windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. After its blocking build and production-site checks succeed, `windows-build` runs `check:ci:windows-observational-ready` in the same workspace. This step uses `continue-on-error`, disables fail-fast, and has a 15-minute timeout; failures produce a workflow warning because Linux owns the blocking static, documentation, package, and built-artifact verdicts. The required verdict waits for the step to finish but does not require its diagnostics to pass. `windows-coverage` remains outside aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the verdict.
`windows-coverage` runs [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) without a preceding build, matching the Linux coverage lane: four single-worker instrumented shards beside a two-worker exempt-heavy gate; workspace imports resolve to `src` through the tsconfig paths map, and the lib-consuming suites self-skip on unbuilt checkouts. Both coverage gates raise Vitest's per-test, expect.poll, and hook budgets through `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`. `windows-observational` owns its own workspace build and production-site validation, starts the independent static gates together, and caps `publint` at eight workers. Its built-bin smoke starts only after every other observational gate settles; the smoke's `needs` edge still requires a successful build, while its `after` edges preserve the diagnostic after another gate fails. This keeps bounded real-application startup measurements from competing with tool-catalog, NodeNext, package, and documentation processes. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform.
`windows-coverage` runs [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) without a preceding build, matching the Linux coverage lane: four single-worker instrumented shards beside a two-worker exempt-heavy gate; workspace imports resolve to `src` through the tsconfig paths map, and the lib-consuming suites self-skip on unbuilt checkouts. Both coverage gates raise Vitest's per-test, expect.poll, and hook budgets through `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`. The observational step reuses the successful build, retains the distinct MPA documentation build, starts independent static gates together, and uses CI worker defaults for `publint`, with an eight-worker override on shared self-hosted runners. Its built-bin smoke starts only after every other observational gate settles, including failed gates. The ready aggregate requires a successful prior workspace build; it removes only that build and its internal dependency edges from the shared internal inventory. This keeps bounded real-application startup measurements from competing with tool-catalog, NodeNext, package, and documentation processes. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform.
The 16-core allocation is the measured capacity point for this inventory. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Historical sixteen-shard samples reduced instrumented coverage to 112.66–122.01 seconds. The pull-request coverage job schedules four instrumented children plus two exempt workers without a preceding build, while the self-hosted complete reference runs its unsharded coverage gates serially with one worker. A six-partition pull-request profile creates enough process and type-aware lint contention to violate bounded test deadlines. Sixteen instrumented shards plus two exempt workers would exceed a 16-core allocation before system overhead. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement.
@@ -40,7 +40,7 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor
**Run only Wine on pull requests.** Wine reaches blocking win32 toolchain branches quickly, but can report green while a real NT, NTFS, PowerShell, process, or addon contract is broken.
**Mark every non-blocking native job `continue-on-error`.** The observational job uses this setting because Linux owns its blocking verdict. Coverage remains an ordinary job outside aggregate `needs`, so a threshold failure stays visibly red without blocking the aggregate.
**Mark every non-blocking native job `continue-on-error`.** Only the observational step uses this setting because Linux owns its blocking verdict. Its containing build job must still fail when the blocking build or production site fails. Coverage remains an ordinary job outside aggregate `needs`, so a threshold failure stays visibly red without blocking the aggregate.
**Exclude unsupported-looking files or weaken Windows fixtures.** Rejected because the affected LSP, watcher, persistence, client, and process behavior is supported. Peer-platform branches are marked narrowly; portable outcomes stay in the denominator and are exercised through host-realistic fixtures.
@@ -50,8 +50,8 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor
## Consequences
Wine provides post-merge toolchain evidence. Native coverage and observational results can still be pending or red when `all checks passed` turns green, so branch protection consumes the targeted native build and process checks while reviewers and follow-up automation consume the remaining native results.
Wine provides post-merge toolchain evidence. Native coverage may still be pending or red when `all checks passed` turns green. Observational diagnostics finish inside the required build job. A diagnostic failure leaves the PR check successful; the failed step, workflow warning, and run summary expose the failure.
Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native jobs duplicate setup across the build, coverage, and observational workspaces and repeat builds in the build and observational ones, but they lower each job's process count and expose path, watcher, lifecycle, and fixture defects hidden by the compatibility lane.
Every pull request receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. Observational checks reuse the successful build workspace, avoiding a second checkout, dependency installation, and workspace build. Their sequential position extends the build job and can delay the required verdict; it also separates diagnostic processes from compilation. The ready and master aggregates share one internal diagnostic inventory; master retains its own workspace build and full checks.
Maintainers must preserve two intentional execution topologies: the Wine snapshot uses Linux installation plus a hoisted layout to reach win32 binaries, while the native jobs use separate immutable workspaces on the organization-owned 16-core Windows runner. A failure unique to either topology must be classified against that boundary rather than weakened or silently skipped.
@@ -14,11 +14,11 @@ Wine 在 Linux 内核与区分大小写的 ext4 之上采用 hoisted 依赖布
[ci-master.yml](../../../../.github/workflows/ci-master.yml) 中仅 master 触发的 `windows` 作业在 `ubuntu-latest` 上运行 `windows node 24 / wine`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。根据[仅 master 平台策略](2026-09-06-master-only-platform-ci.zh.md),Wine 不参与 PR 聚合。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。
每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动 4 个相互独立的原生作业:`windows-build`、`windows-coverage`、`windows-native-tests` 与 `windows-observational`。每个作业都会为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量(`DSH_CI_FAILOVER_WINDOWS`)在 `selfhosted` 下把这 4 个作业全部重定向到公司内部运行器池,在 `blacksmith` 下重定向到 Blacksmith 的 Windows 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。各作业采用 60 至 120 分钟的截止时间,以约束卡住的工作,同时不把性能目标当作正确性截止时间。
每个拉取请求都会启动三个原生作业:`windows-build`、`windows-coverage` 和 `windows-native-tests`。每个作业都会为 workspace 符号链接启用开发者模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量(`DSH_CI_FAILOVER_WINDOWS`)默认选择组织自有的 `dsh-windows-2025-16core` 运行器,在 `selfhosted` 下选择公司内部运行器池,在 `blacksmith` 下选择 Blacksmith 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。Blacksmith 构建和覆盖率作业请求 16 vCPU;逐文件串行的原生测试请求 2 vCPU。各作业的截止时间为 60 至 120 分钟。
`windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。`windows-coverage` 仍是常规作业,但不在聚合流程的 `needs` 中,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟必需判定。`windows-observational` 同样不在聚合流程的 `needs` 中,并使用 `continue-on-error`,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。
`windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。阻断性构建和生产网站检查成功后,`windows-build` 在同一工作区运行 `check:ci:windows-observational-ready`。该步骤使用 `continue-on-error`、关闭 fail-fast,并设置 15 分钟超时;失败时会产生工作流警告,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。必需判定会等待该步骤结束,但不要求其诊断通过。`windows-coverage` 仍在聚合流程的 `needs` 之外,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟判定。
`windows-coverage` 与 Linux 覆盖率通道一致,不先构建工作区就运行[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md):4 个单 worker 插桩分片与一个双 worker 的豁免重型门禁并行运行;工作区导入通过 tsconfig paths 映射解析到 `src`,消费构建产物的套件在未构建的检出上会自跳。两项覆盖率门禁都通过 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` 提高 Vitest 的单测试、expect.poll 与 hook 预算。`windows-observational` 拥有自己的工作区构建和生产网站验证,会一起启动相互独立的静态门禁,并将 `publint` 限制为最多 8 个 worker。其 built-bin 冒烟测试只在其他所有观测性门禁结算后启动;冒烟测试的 `needs` 边仍要求构建成功,而 `after` 边会在其他门禁失败后保留这项诊断。这可避免有界的真实应用启动测量与 tool-catalog、NodeNext、包及文档进程争抢资源。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。
`windows-coverage` 与 Linux 覆盖率通道一致,不先构建工作区就运行[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md):4 个单 worker 插桩分片与一个双 worker 的豁免重型门禁并行运行;工作区导入通过 tsconfig paths 映射解析到 `src`,消费构建产物的套件在未构建的检出上会自跳。两项覆盖率门禁都通过 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` 提高 Vitest 的单测试、expect.poll 与 hook 预算。观测步骤复用成功的构建,保留独立的 MPA 文档构建,一起启动相互独立的静态门禁,并让 `publint` 使用 CI worker 默认值,在共享自托管运行器上覆盖为 8 个 worker。其 built-bin 冒烟测试只在其他所有观测性门禁结算后启动,包括失败的门禁。ready 聚合要求此前已成功构建工作区;它仅从内部共用清单中移除该构建及其内部依赖边。这可避免有界的真实应用启动测量与 tool-catalog、NodeNext、包及文档进程争抢资源。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。
16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。历史上的 16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒。拉取请求覆盖率作业会在没有前置构建的情况下调度 4 个插桩子进程和 2 个豁免 worker,而自托管完整参考流程会用 1 个 worker 串行运行未分片的覆盖率门禁。拉取请求若采用 6 分片配置,就会产生足以违反有界测试截止时间的进程与类型感知 lint 争用。16 个插桩分片加 2 个豁免 worker 会在计入系统开销前就超过 16 核分配。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。
@@ -40,7 +40,7 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持
**只在拉取请求上运行 Wine。** Wine 能快速触达阻断性 win32 工具链分支,但即使真实 NT、NTFS、PowerShell、进程或原生插件约定已经损坏,也可能报告绿灯。
**将每个非阻断原生作业都标记为 `continue-on-error`。** 观测性作业采用该设置,因为它的阻断性判定由 Linux 负责。覆盖率仍是聚合流程 `needs` 之外的常规作业,因此阈值失败会保持明显红灯,却不会阻断聚合流程。
**将每个非阻断原生作业都标记为 `continue-on-error`。** 只有观测步骤采用该设置,因为它的阻断性判定由 Linux 负责。包含该步骤的构建作业仍必须在阻断性构建或生产网站失败时失败。覆盖率仍是聚合流程 `needs` 之外的常规作业,因此阈值失败会保持明显红灯,却不会阻断聚合流程。
**排除看似不受支持的文件或削弱 Windows fixture。** 不予采纳,因为受影响的 LSP、watcher、持久化、客户端与进程行为均受支持。仅适用于另一平台的分支采用窄范围标注;可移植结果继续计入分母,并通过符合真实宿主行为的 fixture 验证。
@@ -50,8 +50,8 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持
## 后果
Wine 提供合并后的工具链证据。`all checks passed` 变绿时,原生覆盖率与观测性结果仍可能处于待处理或红灯状态,因此分支保护采用定向原生构建和进程检查,而评审者和后续自动化采用其余原生结果。
Wine 提供合并后的工具链证据。`all checks passed` 变绿时,原生覆盖率仍可能处于待处理或红灯状态。观测诊断在必需的构建作业内完成。诊断失败时,PR 检查仍显示成功;失败步骤、工作流警告和运行摘要会展示该失败。
尽管如此,每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。原生作业会在构建、覆盖率与观测性工作区中重复设置流程,并在构建与观测性工作区中重复构建,但它们会降低每个作业的进程数,并暴露兼容性通道掩盖的路径、watcher、生命周期与 fixture 缺陷。
每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。观测检查复用成功构建的工作区,省去第二次检出、依赖安装和工作区构建。顺序执行会延长构建作业,并可能延迟必需判定;它也让诊断进程与编译分开运行。ready 与 master 聚合共用一份内部诊断清单;master 保留自己的工作区构建和完整检查。
维护者必须保留两种有意设计的执行拓扑:Wine 快照使用 Linux 安装加 hoisted 布局来触达 win32 二进制文件,而原生作业在组织自有的 16 核 Windows 运行器上使用相互独立的不可变工作区。任一拓扑独有的失败都必须依据该边界分类,不得削弱或静默跳过。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md
2026-09-09-blacksmith-failover-leg.md: d4be1ad25c6c5b66e295439d4609726c8918e137
2026-09-09-blacksmith-failover-leg.zh.md: cd6e74aefba2cba9481fa1ae9510bb1ce020325d
2026-09-09-blacksmith-failover-leg.md: 4fb4ed4ccb500887622200e01310230393b3de12
2026-09-09-blacksmith-failover-leg.zh.md: c2c4ee76a3fcd8c9f16c635ac81bc987d40a04e2
@@ -10,9 +10,9 @@ The [failover runbook](2026-07-26-ci-failover-runbook.md) routes degraded enterp
## Decision
`DSH_CI_FAILOVER_LINUX` and `DSH_CI_FAILOVER_WINDOWS` additionally accept the value `blacksmith`. When any repository writer sets it, the platform's participating jobs retarget onto Blacksmith's hosted runners at the vCPU size matching the pool each job replaces; any other value (or unset) keeps today's runner, and `selfhosted` keeps routing to the in-house pools. Like the existing values this is writer-manageable repository state — no merge, re-run the affected jobs — and it costs money only while set, which is why it is an explicit outage or experiment choice, never the default.
`DSH_CI_FAILOVER_LINUX` and `DSH_CI_FAILOVER_WINDOWS` additionally accept the value `blacksmith`. When any repository writer sets it, the platform's participating jobs retarget onto Blacksmith's hosted runners at each job's configured vCPU size; any other value (or unset) keeps today's runner, and `selfhosted` keeps routing to the in-house pools. Like the existing values this is writer-manageable repository state — no merge, re-run the affected jobs — and it costs money only while set, which is why it is an explicit outage or experiment choice, never the default.
Participating jobs under `blacksmith`: the three enterprise Linux workers, the `node-compat` legs, the `all-checks-passed` verdict, and the four Windows lanes in [ci.yml](../../../../.github/workflows/ci.yml) (16-core pools map to `blacksmith-16vcpu-ubuntu-2404` and `blacksmith-16vcpu-windows-2025`, standard-hosted legs to `blacksmith-4vcpu-ubuntu-2404`); `expected-filenames`; the sandbox bwrap leg; and the two manual benchmark matrices, whose 4-32 vCPU tiers map to Blacksmith equivalents while the 64/96-core rows keep the fleet labels because Blacksmith offers no such tier. The blacksmith branches carry no Dependabot exclusion: Blacksmith runners are ephemeral, so the persistent-VM rationale that keeps Dependabot on the hosted pool does not apply. The python-runtime builder stays hosted in full: its PR and master callers (`ci.yml`'s and `ci-master.yml`'s `python-runtime` jobs) pass real-API credentials (`DEEPSEEK_API_KEY_EXTERNAL`) into the reusable workflow, its macOS and arm64 single-exe legs need GitHub's images, and its wheel output feeds the PyPI publication chain. The docs-pages workflow is a tag-dispatched release publication, so it stays hosted too.
Participating jobs under `blacksmith`: the three enterprise Linux workers, the `node-compat` legs, the `all-checks-passed` verdict, and the three Windows jobs in [ci.yml](../../../../.github/workflows/ci.yml), using the per-job runner sizes declared there; `expected-filenames`; the sandbox bwrap leg; and the two manual benchmark matrices, whose 4-32 vCPU tiers map to Blacksmith equivalents while the 64/96-core rows keep the fleet labels because Blacksmith offers no such tier. The blacksmith branches carry no Dependabot exclusion: Blacksmith runners are ephemeral, so the persistent-VM rationale that keeps Dependabot on the hosted pool does not apply. The python-runtime builder stays hosted in full: its PR and master callers (`ci.yml`'s and `ci-master.yml`'s `python-runtime` jobs) pass real-API credentials (`DEEPSEEK_API_KEY_EXTERNAL`) into the reusable workflow, its macOS and arm64 single-exe legs need GitHub's images, and its wheel output feeds the PyPI publication chain. The docs-pages workflow is a tag-dispatched release publication, so it stays hosted too.
Everything else keeps its own runner under every value: jobs pinned to standard hosted runners by workflow spec (`node-24-bench`, `python-sdk`, the master Wine gate, `release-publish` and `release-vendor-publish`, the Cloudflare preview, the weighted-approval pair); jobs that hold credentials (the real-API e2e lanes, the issue App-token jobs, the npm and PyPI publishers); jobs that need a host capability Blacksmith lacks (the Landlock legs and their pack verification, the Seatbelt leg, the linux-arm64 single-exe leg); and the release-shaped chains — the npm publishers, the PyPI publishers, the node-addon-system release chain, and the Pages deployment workflow — which stay entirely hosted because their artifacts feed published registry output. The release rehearsal jobs in `release.yml` and `release-vendor.yml` (`dependencies`, `pack`) keep their existing pre-change routing unchanged: they carry only the pre-existing `selfhosted` branch, so under `blacksmith` they fall back to their hosted `ubuntu-24.04` default exactly as under an unset variable, and they gain no `blacksmith` branch.
@@ -10,9 +10,9 @@ Status: implemented
## 决策
`DSH_CI_FAILOVER_LINUX` 与 `DSH_CI_FAILOVER_WINDOWS` 额外接受值 `blacksmith`。任何仓库写者设置它时,该平台参与切换的作业按与被替换池匹配的 vCPU 档位重定向到 Blacksmith 托管运行器;任何其它值(或未设置)保持今天的运行器,`selfhosted` 继续路由到自有池。与既有取值一样,这是写者可管理的仓库状态——无需合并,重跑受影响的作业即可——并且只在设置期间产生费用,这正是它作为明确的故障或实验选择、而非默认的原因。
`DSH_CI_FAILOVER_LINUX` 与 `DSH_CI_FAILOVER_WINDOWS` 额外接受值 `blacksmith`。任何仓库写者设置它时,该平台参与切换的作业按各作业配置的 vCPU 档位重定向到 Blacksmith 托管运行器;任何其它值(或未设置)保持今天的运行器,`selfhosted` 继续路由到自有池。与既有取值一样,这是写者可管理的仓库状态——无需合并,重跑受影响的作业即可——并且只在设置期间产生费用,这正是它作为明确的故障或实验选择、而非默认的原因。
`blacksmith` 下参与切换的作业:[ci.yml](../../../../.github/workflows/ci.yml) 中三个企业级 Linux 工作作业、`node-compat` 各腿、`all-checks-passed` 判定作业与四条 Windows 通道(16 核池映射到 `blacksmith-16vcpu-ubuntu-2404` 与 `blacksmith-16vcpu-windows-2025`,标准托管腿映射到 `blacksmith-4vcpu-ubuntu-2404`);`expected-filenames`;sandbox 的 bwrap 腿;以及两个手动基准矩阵——其 4-32 vCPU 档位映射到 Blacksmith 等价档,64/96 核行保留自有池标签,因为 Blacksmith 没有对应档位。blacksmith 分支不带 Dependabot 排除:Blacksmith 运行器是临时的,因此把 Dependabot 留在托管池的持久虚拟机理由不适用。python-runtime 构建器整体保持托管:其 PR 与 master 调用方(`ci.yml` 与 `ci-master.yml` 的 `python-runtime` 作业)会把真实 API 凭据(`DEEPSEEK_API_KEY_EXTERNAL`)传入该可复用工作流,其 macOS 与 arm64 单文件分支需要 GitHub 镜像,且其 wheel 产物供给 PyPI 发布链。docs-pages 工作流是 tag 派发的发布,因此同样保持托管。
`blacksmith` 下参与切换的作业:[ci.yml](../../../../.github/workflows/ci.yml) 中三个企业级 Linux 工作作业、`node-compat` 各腿、`all-checks-passed` 判定作业与三个 Windows 作业,使用该工作流为各作业声明的运行器规格;`expected-filenames`;sandbox 的 bwrap 腿;以及两个手动基准矩阵——其 4-32 vCPU 档位映射到 Blacksmith 等价档,64/96 核行保留自有池标签,因为 Blacksmith 没有对应档位。blacksmith 分支不带 Dependabot 排除:Blacksmith 运行器是临时的,因此把 Dependabot 留在托管池的持久虚拟机理由不适用。python-runtime 构建器整体保持托管:其 PR 与 master 调用方(`ci.yml` 与 `ci-master.yml` 的 `python-runtime` 作业)会把真实 API 凭据(`DEEPSEEK_API_KEY_EXTERNAL`)传入该可复用工作流,其 macOS 与 arm64 单文件分支需要 GitHub 镜像,且其 wheel 产物供给 PyPI 发布链。docs-pages 工作流是 tag 派发的发布,因此同样保持托管。
其余作业在任何取值下都保留自己的运行器:被 workflow spec 钉死在标准托管运行器上的作业(`node-24-bench`、`python-sdk`、master 的 Wine 门禁、`release-publish` 与 `release-vendor-publish`、Cloudflare preview、weighted-approval 一对);持有凭据的作业(真实 API 的 e2e 通道、issue App token 作业、npm 与 PyPI 发布作业);需要 Blacksmith 缺失的宿主能力的作业(Landlock 各腿及其打包校验、Seatbelt 腿、linux-arm64 单文件分支);以及发布形态的链——npm 发布、PyPI 发布、node-addon-system 发布链与 Pages 部署工作流——它们整体保持托管,因为其产物供给已发布的 registry 输出。`release.yml` 与 `release-vendor.yml` 中的发布演练作业(`dependencies`、`pack`)保持改动前既有的路由不变:它们只带既有的 `selfhosted` 分支,因此在 `blacksmith` 取值下与变量未设置时一样回落到托管的 `ubuntu-24.04` 默认,并不新增 `blacksmith` 分支。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md
2026-09-15-shipped-optional-bundles.md: 40ef69d3880f8d1c925c1e3c423e59a628f22e09
2026-09-15-shipped-optional-bundles.zh.md: adf2b979b9d23b7510c077f3633e2d030732a7b4
2026-09-15-shipped-optional-bundles.md: c9f560f5c07ec6fb18030b77a0d1f53bd27dc045
2026-09-15-shipped-optional-bundles.zh.md: e91b6580e29cd7ae142c9c61ed6aac7e5e1476eb
@@ -14,7 +14,7 @@ The launcher names in `OPTIONAL_BUNDLES` (`packages/boot/app-boot/src/profile.ts
Default-product isolation keeps its rules with one declared exception: an optional bundle's dependency graph is outside the default product. The static gate skips the `dependencies` edge from `@deepseek-ai/dsh` to a listed bundle and still rejects a runtime import, a shipped composition, a preset, or a default template that names it, an experimental dependency the list does not name, and a listed name that is not a runtime dependency or not a bundle. The workspace-constraints check accepts the same `dependencies` edges and no other runtime section, and the packed-install release check skips them from the installed entry package while requiring each listed bundle to be installed.
Agent Teams ships this way, as `@deepseek-ai/dsh-experimental-agent-team-profile` and `@deepseek-ai/dsh-experimental-agent-team-web-profile`. Auto review is a published experimental package the page's install guide names as its example, not an optional bundle.
The [single-bundle decision](../architecture/2026-09-18-agent-teams-single-bundle.md) supersedes the two Agent Teams selections with one `@deepseek-ai/dsh-experimental-agent-team-profile` selection. This note retains the installation-owned optional-bundle policy. Auto review is a published experimental package the page's install guide names as its example, not an optional bundle.
## Alternatives considered
@@ -26,4 +26,4 @@ Agent Teams ships this way, as `@deepseek-ai/dsh-experimental-agent-team-profile
## Consequences
Optional bundles are downloaded with the product and stay inactive until selected; the runtime isolation smokes still observe no experimental module in a default composition. Switching on an optional Web layer loads its client plugin through the live client module graph. A bundle that needs a companion layer, such as the Agent Teams Web layer over its Host layer, says so in its description; the manager does not select companions automatically.
Optional bundles are downloaded with the product and stay inactive until selected; the runtime isolation smokes still observe no experimental module in a default composition. Switching on an optional Web layer loads its client plugin through the live client module graph.
@@ -14,7 +14,7 @@ Web 插件页只管理用户装进 profile 的组合包。像 Agent Teams 这样
默认产品隔离的规则保持不变,只声明一个例外:可选组合包的依赖图在默认产品之外。静态门禁跳过从 `@deepseek-ai/dsh` 到列表中组合包的 `dependencies` 边,仍然拒绝运行时 import、随附组合、preset 或默认模板对它的引用,拒绝列表没有点名的实验依赖,也拒绝不是运行时依赖或不是组合包的列表项。workspace 约束检查接受同样的 `dependencies` 边而不接受其他运行时依赖段;发布时的 packed-install 检查对已安装入口包跳过这些边,并要求列表中的每个组合包都已安装。
Agent Teams 以这种方式交付,即 `@deepseek-ai/dsh-experimental-agent-team-profile` 与 `@deepseek-ai/dsh-experimental-agent-team-web-profile`。Auto review 是一个已发布的实验包,插件页的安装引导拿它作示例,它不是可选组合包。
[单组合包决策](../architecture/2026-09-18-agent-teams-single-bundle.zh.md)将两个 Agent Teams 选项替换为一个 `@deepseek-ai/dsh-experimental-agent-team-profile` 选项。本记录保留由安装方决定可选组合包的策略。Auto review 是一个已发布的实验包,插件页的安装引导拿它作示例,它不是可选组合包。
## 考虑过的替代方案
@@ -26,4 +26,4 @@ Agent Teams 以这种方式交付,即 `@deepseek-ai/dsh-experimental-agent-tea
## 影响
可选组合包随产品一起下载,选中前保持不活动;运行时隔离 smoke 在默认组合中仍观察不到任何实验模块。开启一个可选 Web 层会通过实时客户端模块图加载它的客户端插件。需要配套层的组合包,例如 Agent Teams Web 层依赖其 Host 层,会在描述里说明;管理器不会自动选中配套层。
可选组合包随产品一起下载,选中前保持不活动;运行时隔离 smoke 在默认组合中仍观察不到任何实验模块。开启一个可选 Web 层会通过实时客户端模块图加载它的客户端插件。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.md
2026-09-15-model-agent-availability-and-team-targets.md: e1f7f95cfa333be6d05677b4b19775c744f2817c
2026-09-15-model-agent-availability-and-team-targets.zh.md: 0447d7d4059eb75d1fb9169389cd83a54292aadf
@@ -0,0 +1,29 @@
# Agent Note: Model agent availability and Team targets
Status: implemented
English | [中文](2026-09-15-model-agent-availability-and-team-targets.zh.md)
## Problem
Team operations accept member names, but returning both names and Session UUIDs invites the model to choose an unusable address. Loaded and stored agents also have distinct internal statuses despite requiring the same model action to continue work.
## Decision
The Team tool adapter returns `target` from the existing member name and omits member Session IDs from creation and listing results. Messaging, interruption, and task assignment consume that same name. Task `ownerName`, task IDs, and message IDs retain their existing meanings. Internal services, Web views, and persisted Team records retain Session identity.
Normal subagent tool results and Team service views project loaded-between-turns and stored agents as `inactive`. Only an executing turn is `running`; Team retains `provisioning` and `failed` for member creation, and ordinary subagent diagnostic rows retain their read-failure reasons. Interruption projects its previous status by the same rule. Availability does not report task outcomes or waiting dependencies.
The Team service owns roster and interruption availability for both tools and Web. The ordinary subagent projection belongs to its list tool. Runtime loading, cold resume, interruption authority, and mailbox delivery retain their existing behavior. Ordinary subagents continue to use `agent_id`; stable subagent names and historical-data handling are deferred. No persistence type or Session format changes.
## Alternatives considered
**Add stable names to ordinary subagents now.** This requires a durable naming policy and explicit handling of historical children without names. It is independent of removing misleading Team identities and model status distinctions.
**Merge internal runtime states.** Residency determines whether delivery starts a loaded agent or restores one from storage. Underlying Agent status and registry presence retain that distinction; Team views expose only turn availability.
## Consequences
Models can copy a Team result's target directly into applicable operations. They lose member UUIDs and residency details from these tool results; neither is needed for Team addressing. Ordinary subagent and Team addressing remain different until a separate naming decision.
Focused service and tool tests cover every status projection, returned-target operations, diagnostic rows, and live versus stored children. Recorded headless Team and SDK subagent scenarios pin the assembled model output and schemas. The [Agent Teams decision](../feature/2026-08-05-agent-teams.md) continues to own roster durability, scoping, and authority.
@@ -0,0 +1,29 @@
# Agent Note: 模型可见的 Agent 可用状态与 Team 目标
Status: implemented
[English](2026-09-15-model-agent-availability-and-team-targets.md) | 中文
## 问题
Team 操作接受成员名称,但同时返回名称和 Session UUID 容易使模型选择不可用的地址。已加载与仅存储的 agent 也有不同的内部状态,尽管模型继续其工作时采取相同操作。
## 决策
Team 工具适配器从现有成员名称生成 `target`,并从创建和列表结果中省略成员 Session ID。消息、中断和任务分配使用同一名称。任务的 `ownerName`、任务 ID 和消息 ID 保持现有含义。内部服务、Web 视图和持久化 Team 记录保留 Session 身份。
普通 subagent 工具结果与 Team 服务视图把已加载但处于轮次之间的 agent 和仅存储的 agent 都投影为 `inactive`。只有正在执行轮次时才是 `running`;Team 保留表示成员创建状态的 `provisioning` 与 `failed`,普通 subagent 的诊断行保留读取失败原因。中断结果按同一规则投影之前的状态。可用状态不报告任务结果或等待依赖。
Team 服务为工具和 Web 统一提供 roster 与中断的可用状态。普通 subagent 的投影归其列表工具所有。运行时加载、冷恢复、中断权限和邮箱投递保持现有行为。普通 subagent 继续使用 `agent_id`;稳定的 subagent 名称和历史数据处理暂缓。不修改持久化类型或 Session 格式。
## 考虑过的替代方案
**立即为普通 subagent 增加稳定名称。** 这需要持久化命名策略,以及对历史无名称子级的明确处理。它与移除误导性的 Team 身份和模型状态区别相互独立。
**合并内部运行状态。** 驻留状态决定投递是启动已加载的 agent,还是从存储恢复。底层 Agent 状态与注册表中的存在性保留该区别;Team 视图仅暴露轮次可用状态。
## 影响
模型可以把 Team 结果的 target 直接复制到适用操作。这些工具结果不再提供成员 UUID 和驻留细节;Team 寻址不需要它们。在另行决定命名方案之前,普通 subagent 与 Team 的寻址方式仍不同。
定向服务与工具测试覆盖所有状态投影、返回目标的操作、诊断行和在线与仅存储子级。录制的 headless Team 与 SDK subagent 场景固定组装后的模型输出和 schema。[Agent Teams 决策](../feature/2026-08-05-agent-teams.zh.md)继续拥有 roster 持久化、作用域和权限规则。
+39 -68
View File
@@ -42,14 +42,15 @@ jobs:
node-24:
if: github.event_name == 'pull_request'
runs-on: >-
${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404'
${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-8vcpu-ubuntu-2404'
|| vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
&& github.event.pull_request.user.login != 'dependabot[bot]'
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|| 'dsh-ubuntu-24-04-16core' }}
name: node 24 / static
env:
DSH_GATE_CONCURRENCY: '8'
# Shared hosts expose host CPUs, not an isolated allocation.
DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }}
# Stop the aggregate at the first blocking gate failure so a red run
# does not keep burning enterprise runner time on the remaining gates.
DSH_GATE_FAIL_FAST: '1'
@@ -116,11 +117,10 @@ jobs:
|| 'dsh-ubuntu-24-04-16core' }}
name: node 24 / coverage
env:
# Partitioning replaces the instrumented share; this budget gives the
# exempt-heavy gate two workers on both hosted and failover runners.
DSH_COVERAGE_MAX_WORKERS: '6'
DSH_COVERAGE_PARTITIONS: '4'
DSH_GATE_CONCURRENCY: '3'
# Shared hosts expose host CPUs, not an isolated allocation.
DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '6' || '' }}
DSH_COVERAGE_PARTITIONS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '4' || '' }}
DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '3' || '' }}
# Managed-scope teardown cases run past the default per-test budget when
# this lane's partitions, workers, and sibling gates share one host: the
# disposal cases in subprocess-local and bash-sandbox exceeded 5000ms on
@@ -246,17 +246,17 @@ jobs:
|| 'dsh-ubuntu-24-04-16core' }}
name: node 24 / snapshots and artifacts
env:
DSH_GATE_CONCURRENCY: '10'
# Shared hosts expose host CPUs, not an isolated allocation.
DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '10' || '' }}
DSH_OXLINT_THREADS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }}
DSH_PUBLINT_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }}
DSH_WEB_SNAPSHOT_WORKERS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '6' || '' }}
DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '' }}
DSH_NODE_COMPAT_SKIP_TYPECHECK: '1'
DSH_OXLINT_THREADS: '8'
DSH_PUBLINT_CONCURRENCY: '8'
DSH_WEB_SNAPSHOT_WORKERS: '6'
# A failing gate aborts its running siblings: a failing build stops the
# independent Node compatibility smoke, and a failing reader (e.g.
# publint) stops the remaining artifact consumers.
DSH_GATE_FAIL_FAST: '1'
# Failover halves snapshot concurrency for the shared 64-core VM.
DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '32' }}
steps:
# Runner cleanup owns tool caches and fixtures, including cancelled runs.
- name: Use runner-owned temporary storage
@@ -491,14 +491,12 @@ jobs:
secrets:
DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}
# Every pull request also gets real Windows-kernel signals. The former
# monolithic windows-native job is split into smaller jobs so one slow
# coverage gate does not hold up build/static results, while the total
# per-job process count stays lower. Observational checks are non-blocking.
# Native builds and process tests block pull requests independently of coverage.
# Observational checks reuse the successful build and remain non-blocking.
# Dependabot PRs are excluded from the self-hosted pool and stay queued for
# the hosted runner.
# Blacksmith uses 2 vCPU for file-serial native tests; builds, coverage,
# and observational gates retain 16 for their concurrent work.
# Blacksmith uses 2 vCPU for file-serial native tests and 16 for coverage
# and the shared build/observational workspace.
windows-build:
if: github.event_name == 'pull_request'
runs-on: >-
@@ -507,7 +505,7 @@ jobs:
&& github.event.pull_request.user.login != 'dependabot[bot]'
&& fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
|| 'dsh-windows-2025-16core' }}
name: windows node 24 / build
name: windows node 24 / build & observational
timeout-minutes: 60
env:
# A failing build or site aborts the sibling gate on the same runner.
@@ -543,6 +541,23 @@ jobs:
- name: Run blocking Windows builds
shell: pwsh
run: pnpm run check:ci:windows-blocking
- name: Run Windows observational gates on built artifacts
id: observational
continue-on-error: true
timeout-minutes: 15
shell: pwsh
env:
DSH_GATE_FAIL_FAST: ''
DSH_PUBLINT_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }}
run: pnpm run check:ci:windows-observational-ready
- name: Report Windows observational failures
continue-on-error: true
if: steps.observational.outcome == 'failure'
shell: pwsh
run: |
$message = 'Windows observational checks failed; see the observational step for diagnostics.'
Write-Output "::warning::$message"
$message | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
windows-coverage:
if: github.event_name == 'pull_request'
@@ -555,10 +570,11 @@ jobs:
name: windows node 24 / coverage
timeout-minutes: 120
env:
DSH_COVERAGE_MAX_WORKERS: '6'
DSH_COVERAGE_PARTITIONS: '4'
# Shared hosts expose host CPUs, not an isolated allocation.
DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '6' || '' }}
DSH_COVERAGE_PARTITIONS: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '4' || '' }}
DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '3' || '' }}
DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'
DSH_GATE_CONCURRENCY: '3'
# A gate failure aborts the sibling gate instead of waiting out its
# multi-minute instrumented run.
DSH_GATE_FAIL_FAST: '1'
@@ -670,51 +686,6 @@ jobs:
packages/workflow/tool-ralph/tests/integration.spec.ts
packages/subprocess/subprocess-local/tests/process-exit.spec.ts
windows-observational:
if: github.event_name == 'pull_request'
continue-on-error: true
runs-on: >-
${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025'
|| vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
&& github.event.pull_request.user.login != 'dependabot[bot]'
&& fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
|| 'dsh-windows-2025-16core' }}
name: windows node 24 / observational
timeout-minutes: 60
env:
DSH_PUBLINT_CONCURRENCY: '8'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Enable Developer Mode (symlink support)
shell: pwsh
run: >-
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
- name: Install (immutable)
shell: pwsh
# See 2026-08-30-windows-refs-store-block-clone-install for the
# ReFS block-clone rationale; detect the workspace filesystem and
# pass --package-import-method=clone only on ReFS.
run: >-
$drive = (Split-Path -Qualifier $env:GITHUB_WORKSPACE).TrimEnd(':');
$fs = (Get-Volume -DriveLetter $drive).FileSystem;
if ($fs -eq 'ReFS') {
corepack pnpm install --frozen-lockfile --package-import-method=clone
} else {
pnpm install --frozen-lockfile
}
- name: Run Windows observational gates
shell: pwsh
run: pnpm run check:ci:windows-observational
# Single stable required check for branch protection: require "all checks
# passed" instead of enumerating matrix legs whose names change as lanes and
# node versions evolve. Every blocking job in THIS workflow must be listed in
-1
View File
@@ -48,7 +48,6 @@
"@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^",
"@deepseek-ai/dsh-cordis-client-runner": "workspace:^",
"@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:^",
"@deepseek-ai/dsh-experimental-agent-team-web-profile": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-goal-round-driver": "workspace:^",
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/desktop/README.md
README.md: f7144fb776d114c519de327cbb4c2624a67ab76f
README.zh.md: 1b62ebedfc5cc7e7344f3eea4d690d187bc8db82
README.md: 72b2d739408569224a42c62719ff6bff0e928ddc
README.zh.md: efe7f4ed8f6304b797f9b2e862083219fe30d814
+2 -2
View File
@@ -265,7 +265,7 @@ An unpacked artifact contains Electron, the materialized dsh production tree, pn
## Updates
Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version.
Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the backdrop and background blur.
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries.
@@ -297,7 +297,7 @@ With `feishu-test`, an HTTP 401 JSON response containing `error.code: "UNAUTHENT
Login and policy requests share an in-memory Session, separate from product windows and the updater; restarting requires a new login. Closing cancels login, and navigation failure provides localized retry guidance. Returning to the service triggers a fresh policy query; a redirect, cookie, or HTTP 422 is not a valid policy decision. Cancellation, expiry, and invalid responses retain any known mandatory block. Fixed login outcomes appear in process diagnostics and the optional update journal; cookies, OAuth parameters, and remote error text are not recorded by the login controller. Live Harness gateway/API integration and macOS login qualification remain unverified.
A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On Windows the mandatory window has a native title bar and can be moved, resized, and maximized. Closing it exits the application after cleanup without clearing the update requirement; Esc does not dismiss it. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact.
A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay fades in and out in place, keeps its backdrop during update-state changes, and redirects parent-window focus and keyboard input to the overlay. On Windows the mandatory window has a native title bar and can be moved, resized, and maximized. Closing it exits the application after cleanup without clearing the update requirement; Esc does not dismiss it. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact.
Failures retain blocking, localized retry guidance, and folded diagnostics inside the modal. The allowed download-page action appears in recovery states, not beside normal download or installation. Requesting the browser immediately exposes a copy alternative even while the OS request is pending; a resolved request does not prove the page opened. Copy failure reveals the complete, read-only address for manual copying. Browser and clipboard outcomes do not overwrite updater errors. Only a fresh valid no-force response clears the block; the top-menu check remains available while blocked.
+2 -2
View File
@@ -266,7 +266,7 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up
## 更新
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留蒙层与背景模糊。
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。
@@ -298,7 +298,7 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up
登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。Windows 强更窗口使用原生标题栏,可拖动、调整大小和最大化。关闭窗口会在完成清理后退出应用,不会解除更新要求;Esc 不会关闭窗口。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 强更窗口使用原生标题栏,可拖动、调整大小和最大化。关闭窗口会在完成清理后退出应用,不会解除更新要求;Esc 不会关闭窗口。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。
+1 -1
View File
@@ -8,7 +8,7 @@
<link rel="stylesheet" href="update-dialog.css">
<link rel="stylesheet" href="mandatory-update.css">
</head>
<body>
<body class="update-dialog">
<main role="alertdialog" aria-modal="true" aria-labelledby="title" aria-describedby="detail">
<header><h1 id="title"></h1></header>
<p id="detail"></p>
@@ -7,6 +7,8 @@ const element = id => document.getElementById(id)
const format = (message, values) => message.replaceAll(/\{([^{}]+)\}/gu, (match, key) => values[key] ?? match)
function render(view) {
document.body.classList.toggle('visible', view.policy.blocking)
if (!view.policy.blocking) return
const wasConfirming = current?.confirmation !== undefined
current = view
const { locale: { id, messages }, policy, update, confirmation, navigation } = view
+6 -1
View File
@@ -5,7 +5,8 @@
background: transparent;
}
* { box-sizing: border-box; }
body { margin: 0; height: 100vh; display: grid; place-items: center; background: rgb(0 0 0 / 24%); }
body { margin: 0; height: 100vh; display: grid; place-items: center; isolation: isolate; }
body::before { content: ""; position: fixed; inset: 0; z-index: -1; background: rgb(0 0 0 / 24%); }
main { width: min(380px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; padding: 22px 24px 24px; border-radius: 24px; background: #fff; box-shadow: 0 0 1px rgb(0 0 0 / 20%), 0 0 4px rgb(0 0 0 / 2%), 0 12px 32px rgb(0 0 0 / 8%); }
header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
main:focus { outline: none; }
@@ -24,3 +25,7 @@ summary { cursor: pointer; color: #5b6472; }
pre { margin: 8px 0 0; padding: 10px; max-height: min(180px, 30vh); overflow: auto; border-radius: 8px; background: #f5f5f5; font: 12px/18px monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
button:disabled { opacity: 0.5; cursor: default; }
[hidden] { display: none !important; }
body.update-dialog { opacity: 0; transition: opacity 150ms ease; }
body.update-dialog.visible { opacity: 1; }
@starting-style { body.update-dialog.visible { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { body.update-dialog { transition: none; } }
+1 -1
View File
@@ -7,7 +7,7 @@
<title></title>
<link rel="stylesheet" href="update-dialog.css">
</head>
<body>
<body class="update-dialog">
<main id="dialog" role="dialog" tabindex="-1" aria-modal="true" aria-labelledby="title" aria-describedby="detail" hidden>
<header><h1 id="title"></h1><button id="close" class="close" type="button"><img src="update-close.svg" alt=""></button></header>
<p id="detail"></p>
+18 -3
View File
@@ -5,7 +5,7 @@ let responding = false
function respond(index) {
if (responding || view === undefined) return
responding = true
void api.respond(index).catch(() => { responding = false })
void api.respond(view.revision, index).catch(() => { responding = false })
}
document.getElementById('close').addEventListener('click', () => { respond(view.cancelId) })
document.addEventListener('keydown', event => {
@@ -18,7 +18,14 @@ document.addEventListener('keydown', event => {
event.preventDefault()
controls[next].focus()
})
void api.status().then(state => {
function render(state) {
if (state === null) {
responding = true
document.body.classList.remove('visible')
return
}
if (view !== undefined && state.revision <= view.revision) return
responding = false
view = state
document.documentElement.lang = state.locale
document.title = state.title
@@ -29,6 +36,8 @@ void api.status().then(state => {
document.getElementById('technical-details').hidden = state.technicalDetails === ''
document.getElementById('technical-details-label').textContent = state.technicalDetailsLabel
document.getElementById('technical-details-content').textContent = state.technicalDetails
document.getElementById('technical-details').open = false
document.getElementById('actions').replaceChildren()
for (const [index, label] of state.buttons.entries()) {
const button = document.createElement('button')
button.type = 'button'
@@ -38,5 +47,11 @@ void api.status().then(state => {
document.getElementById('actions').append(button)
}
document.querySelector('main').hidden = false
document.getElementById('dialog').scrollTop = 0
document.body.classList.add('visible')
document.getElementById('dialog').focus()
})
}
let received = false
const unsubscribe = api.subscribe(state => { received = true; render(state) })
window.addEventListener('pagehide', unsubscribe, { once: true })
void api.status().then(state => { if (!received) render(state) })
+39 -32
View File
@@ -474,43 +474,50 @@ async function main(): Promise<void> {
if (manual) await Promise.all([checkPolicyManually(), updateSchedule.check(true)])
return
}
let state = updates.state
if (manual || state.phase === 'idle' || (state.phase === 'error' && state.failedOperation === 'check')) {
const controller = new AbortController()
ordinaryDialogs.add(controller)
const progress = mainWindow === undefined ? Promise.resolve() : updateDialog.show(mainWindow, { type: 'info', title: messages.updateCheckTitle,
message: messages.updateChecking, buttons: [messages.later], cancelId: 0, signal: controller.signal })
try {
let controller: AbortController | undefined
let progress: Promise<unknown> | undefined
try {
let state = updates.state
if (manual || state.phase === 'idle' || (state.phase === 'error' && state.failedOperation === 'check')) {
controller = new AbortController()
ordinaryDialogs.add(controller)
progress = mainWindow === undefined ? Promise.resolve() : updateDialog.show(mainWindow, { type: 'info', title: messages.updateCheckTitle,
message: messages.updateChecking, buttons: [messages.later], cancelId: 0, signal: controller.signal })
if (!joinedPolicyAuthentication) {
void checkPolicyManually('deferred').catch((error: unknown) => { console.error(error) })
}
state = await updateSchedule.check(true)
} finally { controller.abort(); ordinaryDialogs.delete(controller); await progress }
}
if (isMandatory()) { mandatoryUI?.focus(); return }
if (state.phase === 'error' && state.failedOperation === 'check') { await showUpdateFailure(state); return }
if (state.phase === 'idle') {
await ordinaryMessageBox({ type: 'info', title: messages.updateCheckTitle,
message: formatDesktopMessage(messages.updateCurrent, { version: app.getVersion() }) })
return
}
if (state.phase === 'ready' || (state.phase === 'error' && state.failedOperation === 'install')) {
if (state.version !== undefined) {
failedOperation = 'install'
await showUpdateFailure(await updates.install(state.version))
}
return
}
if (state.phase !== 'available' && !(state.phase === 'error' && state.failedOperation === 'download')) return
if (manual) {
const result = await ordinaryMessageBox({ title: messages.updateCheckTitle, message: messages.updateAvailable,
detail: formatDesktopMessage(messages.updateDetail, { version: state.version ?? '' }),
buttons: [messages.updateDownload], cancelId: 1 })
if (result.response !== 0) return
}
if (!isMandatory() && state.version !== undefined) {
failedOperation = 'download'
await showUpdateFailure(await downloadUpdate(state.version))
if (isMandatory()) { mandatoryUI?.focus(); return }
if (state.phase === 'error' && state.failedOperation === 'check') { await showUpdateFailure(state); return }
if (state.phase === 'idle') {
await ordinaryMessageBox({ type: 'info', title: messages.updateCheckTitle,
message: formatDesktopMessage(messages.updateCurrent, { version: app.getVersion() }) })
return
}
if (state.phase === 'ready' || (state.phase === 'error' && state.failedOperation === 'install')) {
if (state.version !== undefined) {
failedOperation = 'install'
await showUpdateFailure(await updates.install(state.version))
}
return
}
if (state.phase !== 'available' && !(state.phase === 'error' && state.failedOperation === 'download')) return
if (manual) {
const result = await ordinaryMessageBox({ title: messages.updateCheckTitle, message: messages.updateAvailable,
detail: formatDesktopMessage(messages.updateDetail, { version: state.version ?? '' }),
buttons: [messages.updateDownload], cancelId: 1 })
if (result.response !== 0) return
}
if (!isMandatory() && state.version !== undefined) {
controller?.abort()
failedOperation = 'download'
await showUpdateFailure(await downloadUpdate(state.version))
}
} finally {
controller?.abort()
if (controller !== undefined) ordinaryDialogs.delete(controller)
await progress
}
}).catch((error: unknown) => showUpdateFailure({ phase: 'error', failedOperation,
message: desktopErrorState(error).message }))
+14 -2
View File
@@ -49,6 +49,7 @@ const page = 'dsh-app://shell/mandatory-update.html'
/** A modal child blocks the product window without cancelling work in the Host. */
export class DesktopMandatoryUpdateWindow {
private window: BrowserWindow | undefined
private closing: ReturnType<typeof setTimeout> | undefined
private disposed = false
private error: string | undefined
private action: Promise<void> | undefined
@@ -142,11 +143,20 @@ export class DesktopMandatoryUpdateWindow {
this.clearNavigation()
this.restart = undefined
this.deferred = false
this.window?.destroy()
this.window = undefined
if (this.window !== undefined && this.closing === undefined) {
const window = this.window
window.webContents.send(MANDATORY_IPC.state, this.view())
this.closing = setTimeout(() => {
this.closing = undefined
if (this.window === window) this.window = undefined
window.destroy()
}, 150)
}
this.error = undefined
return
}
clearTimeout(this.closing)
this.closing = undefined
if (this.navigationUrl !== this.options.policy().page) this.clearNavigation()
if (this.options.update().phase === 'error') this.restart = undefined
if (this.window === undefined) {
@@ -186,6 +196,8 @@ export class DesktopMandatoryUpdateWindow {
/** Detach IPC and release the modal during application shutdown. */
dispose(): void {
this.disposed = true
clearTimeout(this.closing)
this.closing = undefined
this.finishConfirmation(false)
this.attention.reset()
this.clearNavigation()
+7 -2
View File
@@ -3,7 +3,12 @@ import { contextBridge, ipcRenderer } from 'electron'
import { UPDATE_DIALOG_IPC, type UpdateDialogApi, type UpdateDialogView } from './update-dialog.ts'
const api: UpdateDialogApi = {
status: () => ipcRenderer.invoke(UPDATE_DIALOG_IPC.status) as Promise<UpdateDialogView>,
respond: index => ipcRenderer.invoke(UPDATE_DIALOG_IPC.respond, index) as Promise<void>,
status: () => ipcRenderer.invoke(UPDATE_DIALOG_IPC.status) as Promise<UpdateDialogView | null>,
respond: (revision, index) => ipcRenderer.invoke(UPDATE_DIALOG_IPC.respond, revision, index) as Promise<void>,
subscribe: (listener) => {
const receive = (_event: Electron.IpcRendererEvent, view: UpdateDialogView | null): void => { listener(view) }
ipcRenderer.on(UPDATE_DIALOG_IPC.changed, receive)
return () => { ipcRenderer.removeListener(UPDATE_DIALOG_IPC.changed, receive) }
},
}
if (location.href === 'dsh-app://shell/update-dialog.html') contextBridge.exposeInMainWorld('dshUpdateDialog', api)
+52 -23
View File
@@ -4,10 +4,12 @@ import type { DesktopLocale } from './locale.ts'
import { createUpdateOverlay } from './update-overlay.ts'
/** Channels available only to the isolated update-dialog document. */
export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', respond: 'dsh-update-dialog:respond' } as const
export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', changed: 'dsh-update-dialog:changed', respond: 'dsh-update-dialog:respond' } as const
/** Text and choices supplied by the main process, never by product documents. */
export interface UpdateDialogView {
/** Identifies the displayed choices; a response from an older prompt is rejected. */
readonly revision: number
readonly locale: string
readonly title: string
readonly message: string
@@ -26,17 +28,22 @@ export interface UpdateDialogOptions extends MessageBoxOptions {
/** The document can select only a displayed response index. */
export interface UpdateDialogApi {
status(): Promise<UpdateDialogView>
respond(index: number): Promise<void>
status(): Promise<UpdateDialogView | null>
respond(revision: number, index: number): Promise<void>
subscribe(listener: (view: UpdateDialogView | null) => void): () => void
}
// main.ts's protocol.handle shell route serves this document and its renderer assets; the modal requires that route.
const page = 'dsh-app://shell/update-dialog.html'
/** One replaceable confirmation window; aborted checks and mandatory policy cancel ordinary prompts. */
/** One fading backdrop with replaceable confirmation content; aborted checks and mandatory policy cancel ordinary prompts. */
export class DesktopUpdateDialog {
private disposed = false
private active: { window: BrowserWindow; view: UpdateDialogView; finish: (index: number) => void } | undefined
private revision = 0
private window: BrowserWindow | undefined
private parent: BrowserWindow | undefined
private closing: ReturnType<typeof setTimeout> | undefined
private active: { window: BrowserWindow; view: UpdateDialogView; finish: (index: number, retain?: boolean) => void } | undefined
/** Focus the current explanation or confirmation without replacing it or granting permission. */
focus(): void { this.active?.window.focus() }
@@ -46,9 +53,11 @@ export class DesktopUpdateDialog {
* @param locale - Shell-owned copy.
*/
constructor(private readonly preload: string, private readonly locale: DesktopLocale) {
ipcMain.handle(UPDATE_DIALOG_IPC.status, event => this.owned(event).view)
ipcMain.handle(UPDATE_DIALOG_IPC.respond, (event, index: unknown) => {
const active = this.owned(event)
ipcMain.handle(UPDATE_DIALOG_IPC.status, (event) => { this.owned(event); return this.active?.view ?? null })
ipcMain.handle(UPDATE_DIALOG_IPC.respond, (event, revision: unknown, index: unknown) => {
this.owned(event)
const active = this.active
if (active === undefined || revision !== active.view.revision) throw new Error('desktop update: stale dialog response')
if (typeof index !== 'number' || !Number.isInteger(index)
|| (index !== active.view.cancelId && (index < 0 || index >= active.view.buttons.length))) {
throw new Error('desktop update: invalid dialog response')
@@ -60,34 +69,45 @@ export class DesktopUpdateDialog {
/**
* @param parent - Window blocked by this confirmation.
* @param options - Main-owned localized content, response choices, and optional cancellation signal.
* @returns A displayed response, or the cancel response when closed, replaced, aborted, or unable to load.
* @returns A displayed response, or cancellation on replacement, abort, close, or load failure; backdrop dismissal fades independently.
*/
show(parent: BrowserWindow, options: UpdateDialogOptions): Promise<MessageBoxReturnValue> {
this.cancel()
const buttons = options.buttons ?? [this.locale.messages.updateAcknowledge]
const cancelId = options.cancelId ?? buttons.length - 1
if (this.disposed || options.signal?.aborted === true || parent.isDestroyed()) {
return Promise.resolve({ response: cancelId, checkboxChecked: false })
}
const window = createUpdateOverlay(parent, this.preload, options.title ?? this.locale.messages.updateTitle)
const view: UpdateDialogView = { locale: this.locale.id, title: options.title ?? '', message: options.message,
if (this.parent !== parent) { this.cancel(); this.close() }
this.active?.finish(this.active.view.cancelId, true)
clearTimeout(this.closing)
this.closing = undefined
const existing = this.window
const window = existing ?? createUpdateOverlay(parent, this.preload, options.title ?? this.locale.messages.updateTitle, false)
this.window = window
this.parent = parent
const view: UpdateDialogView = { revision: ++this.revision, locale: this.locale.id, title: options.title ?? '', message: options.message,
detail: options.detail ?? '', buttons, cancelId, closeLabel: this.locale.messages.updateClose,
technicalDetails: options.technicalDetails ?? '', technicalDetailsLabel: this.locale.messages.updateTechnicalDetails }
return new Promise((resolve) => {
const abort = (): void => { finish(cancelId) }
const finish = (response: number): void => {
if (this.active?.window !== window) return
const finish = (response: number, retain = false): void => {
if (this.active?.view !== view) return
this.active = undefined
options.signal?.removeEventListener('abort', abort)
if (!window.isDestroyed()) window.destroy()
if (!retain && !window.isDestroyed()) {
window.webContents.send(UPDATE_DIALOG_IPC.changed, null)
this.closing = setTimeout(() => { this.close() }, 150)
}
resolve({ response, checkboxChecked: false })
}
this.active = { window, view, finish }
options.signal?.addEventListener('abort', abort, { once: true })
window.once('closed', abort)
if (existing !== undefined) { window.webContents.send(UPDATE_DIALOG_IPC.changed, view); return }
const failed = (): void => { if (this.window === window) { this.cancel(); this.close() } }
window.once('closed', failed)
window.webContents.on('will-navigate', (event, url) => { if (url !== page) event.preventDefault() })
window.webContents.once('render-process-gone', abort)
void window.loadURL(page).catch(abort)
window.webContents.once('render-process-gone', failed)
void window.loadURL(page).catch(failed)
})
}
@@ -99,16 +119,25 @@ export class DesktopUpdateDialog {
if (this.disposed) return
this.disposed = true
this.cancel()
this.close()
ipcMain.removeHandler(UPDATE_DIALOG_IPC.status)
ipcMain.removeHandler(UPDATE_DIALOG_IPC.respond)
}
private owned(event: IpcMainInvokeEvent): NonNullable<DesktopUpdateDialog['active']> {
const active = this.active
if (active === undefined || event.sender !== active.window.webContents
|| event.senderFrame !== active.window.webContents.mainFrame || event.senderFrame.url !== page) {
private close(): void {
clearTimeout(this.closing)
this.closing = undefined
const window = this.window
this.window = undefined
this.parent = undefined
if (window !== undefined && !window.isDestroyed()) window.destroy()
}
private owned(event: IpcMainInvokeEvent): void {
const window = this.window
if (window === undefined || event.sender !== window.webContents
|| event.senderFrame !== window.webContents.mainFrame || event.senderFrame.url !== page) {
throw new Error('desktop update: rejected unowned dialog renderer')
}
return active
}
}
+15 -3
View File
@@ -5,15 +5,27 @@ import { BrowserWindow } from 'electron'
* @param parent - Product window whose content is blocked while the overlay is open.
* @param preload - Isolated shell-only preload.
* @param title - Localized window title.
* @param nativeModal - Use a native modal; false keeps overlays out of macOS sheets.
* @returns A transparent child that follows its parent's content bounds and releases its listeners on close.
*/
export function createUpdateOverlay(parent: BrowserWindow, preload: string, title: string): BrowserWindow {
export function createUpdateOverlay(parent: BrowserWindow, preload: string, title: string, nativeModal = true): BrowserWindow {
const window = new BrowserWindow({
parent, modal: true, show: false, frame: false, transparent: true,
parent, modal: nativeModal || process.platform !== 'darwin', show: false, frame: false, transparent: true,
...parent.getContentBounds(), resizable: false, minimizable: false, maximizable: false,
skipTaskbar: true, hasShadow: false, title,
webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true },
})
// macOS native modals animate the entire viewport as a sheet.
const focus = (): void => { if (!window.isDestroyed()) window.focus() }
const blockInput = (event: Electron.Event): void => { event.preventDefault(); focus() }
if (!nativeModal && process.platform === 'darwin') {
parent.on('focus', focus)
parent.webContents.on('before-input-event', blockInput)
window.once('closed', () => {
parent.off('focus', focus)
parent.webContents.off('before-input-event', blockInput)
})
}
const follow = (): void => { if (!window.isDestroyed()) window.setBounds(parent.getContentBounds()) }
parent.on('move', follow)
parent.on('resize', follow)
@@ -39,7 +51,7 @@ export function createUpdateOverlay(parent: BrowserWindow, preload: string, titl
/** A native Windows modal retains its own title bar while the product window remains blocked. */
export function createMandatoryUpdateWindow(parent: BrowserWindow, preload: string, title: string,
platform: NodeJS.Platform = process.platform): BrowserWindow {
if (platform !== 'win32') return createUpdateOverlay(parent, preload, title)
if (platform !== 'win32') return createUpdateOverlay(parent, preload, title, false)
const window = new BrowserWindow({
parent, modal: true, show: false, title,
width: 640, height: 560, minWidth: 480, minHeight: 360,
+2
View File
@@ -73,12 +73,14 @@ export async function qualifyUpdateDialogs(root, fixture) {
const name = active ? 'update-active-tasks.png' : 'update-ready.png'
await writeFile(join(root, name), (await window.webContents.capturePage()).toPNG())
screenshots.push(name)
const closed = once(window, 'closed', { signal: AbortSignal.timeout(10_000) })
if (active) {
await window.webContents.executeJavaScript("setTimeout(() => document.querySelector('.primary').click(), 0); undefined")
} else {
window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
}
const result = await pending
await closed
assert.equal(window.isDestroyed(), true)
assert.equal(result.phase, active ? 'installing' : 'ready')
assert.equal(f.installations.length, active ? 1 : 0)
+7 -2
View File
@@ -289,7 +289,7 @@ beforeEach(() => {
vi.spyOn(console, 'info').mockImplementation(() => {})
vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm')
vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime')
vi.stubGlobal('process', { ...process, platform: 'win32', resourcesPath: 'desktop-test-resources' })
vi.stubGlobal('process', { ...process, platform: 'win32', arch: 'x64', resourcesPath: 'desktop-test-resources' })
vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined)
vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined)
vi.stubEnv('DSH_DESKTOP_UPDATE_JOURNAL_DIR', undefined)
@@ -846,6 +846,8 @@ describe('desktop main startup', () => {
expect(host.stop).not.toHaveBeenCalled()
request.mockImplementationOnce(async () => Response.json({ code: 0, data: { biz_code: 0, biz_data: null } }))
await vi.advanceTimersByTimeAsync(20_000)
expect(modal.isDestroyed()).toBe(false)
await vi.advanceTimersByTimeAsync(150)
expect(modal.isDestroyed()).toBe(true)
expect(host.stop).not.toHaveBeenCalled()
expect(request.mock.calls[0]![1]!.headers).toMatchObject({ 'x-client-bundle-id': 'com.deepseek.dsh', 'x-client-version': '1.0.0' })
@@ -860,7 +862,10 @@ describe('desktop main startup', () => {
harness.dialog.showMessageBox.mockImplementationOnce(({ signal }: { signal: AbortSignal }) => {
checking.resolve(signal)
return new Promise((resolve) => { signal.addEventListener('abort', () => { resolve({ response: 0 }) }, { once: true }) })
}).mockResolvedValueOnce({ response: 0 })
}).mockImplementationOnce(() => {
expect((harness.dialog.showMessageBox.mock.calls[0]![0] as { signal: AbortSignal }).signal.aborted).toBe(false)
return Promise.resolve({ response: 0 })
})
const submenu = applicationMenuItems()
const action = submenu.find(item => item.label === 'Check for Updates…')
expect(action?.click).toBeTypeOf('function')
@@ -24,7 +24,7 @@ function fakeWindow() {
loadURL: vi.fn(async () => {}), destroy: vi.fn(), isDestroyed: () => false,
isFocused: () => true, isMinimized: () => false, focus: vi.fn(), show: vi.fn(), restore: vi.fn() })
}
afterEach(() => { ui?.dispose(); vi.clearAllMocks(); native.handlers.clear() })
afterEach(() => { ui?.dispose(); vi.clearAllMocks(); native.handlers.clear(); vi.useRealTimers() })
function setup() {
window = fakeWindow()
let policy: DesktopPolicyState = { blocking: true, checking: false, page: 'https://downloads.example.com/desktop' }
@@ -130,3 +130,25 @@ it('exits the application when the mandatory window is closed without clearing t
expect(native.quit).toHaveBeenCalledOnce()
expect(f.view().policy.blocking).toBe(true)
})
it('fades on clearance, reuses a reblocked window, and cancels teardown on disposal', () => {
vi.useFakeTimers()
const f = setup()
f.policy({ blocking: false, checking: false })
expect(window.webContents.send).toHaveBeenLastCalledWith(MANDATORY_IPC.state,
f.view())
vi.advanceTimersByTime(100)
expect(window.destroy).not.toHaveBeenCalled()
f.policy({ blocking: true, checking: false })
vi.advanceTimersByTime(150)
expect(window.destroy).not.toHaveBeenCalled()
expect(window.loadURL).toHaveBeenCalledOnce()
f.policy({ blocking: false, checking: false })
vi.advanceTimersByTime(150)
expect(window.destroy).toHaveBeenCalledOnce()
expect(ui!.confirmationWindow).toBeUndefined()
f.policy({ blocking: true, checking: false })
f.policy({ blocking: false, checking: false })
ui!.dispose()
expect(vi.getTimerCount()).toBe(0)
})
+62 -3
View File
@@ -10,11 +10,13 @@ const fixture = await vi.hoisted(async () => {
class FakeWindow extends EventEmitter {
destroyed = false
readonly webContents = Object.assign(new EventEmitter(), {
send: vi.fn(),
mainFrame: { url: 'dsh-app://shell/update-dialog.html' },
setWindowOpenHandler: vi.fn(),
insertCSS: vi.fn(async () => 'blur'),
removeInsertedCSS: vi.fn(async () => {}),
})
readonly focus = vi.fn()
readonly show = vi.fn()
readonly setBounds = vi.fn()
readonly loadURL = vi.fn(async () => {})
@@ -33,6 +35,7 @@ vi.mock('electron', () => ({ BrowserWindow: fixture.FakeWindow, ipcMain: {
let dialogs: DesktopUpdateDialog | undefined
afterEach(() => {
vi.useRealTimers()
dialogs?.dispose()
dialogs = undefined
for (const window of fixture.windows) if (!window.isDestroyed()) window.destroy()
@@ -48,7 +51,9 @@ function setup() {
})
const invoke = (channel: string, ...args: unknown[]) => {
const window = fixture.windows.at(-1)!
return fixture.handlers.get(channel)!({ sender: window.webContents, senderFrame: window.webContents.mainFrame }, ...args)
const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
const view = fixture.handlers.get(UPDATE_DIALOG_IPC.status)!(event) as { revision: number }
return fixture.handlers.get(channel)!(event, ...(channel === UPDATE_DIALOG_IPC.respond ? [view.revision, ...args] : args))
}
return { parent, show, invoke }
}
@@ -73,7 +78,7 @@ it('follows the parent geometry and removes listeners when closed or replaced',
const f = setup()
const first = f.show()
const window = fixture.windows.at(-1)!
expect(window.options).toMatchObject({ frame: false, transparent: true, modal: true, width: 900, height: 650 })
expect(window.options).toMatchObject({ frame: false, transparent: true, modal: process.platform !== 'darwin', width: 900, height: 650 })
window.emit('ready-to-show')
expect(window.show).toHaveBeenCalledOnce()
f.parent.emit('resize')
@@ -85,7 +90,7 @@ it('follows the parent geometry and removes listeners when closed or replaced',
expect((await next).response).toBe(1)
expect(f.parent.listenerCount('resize')).toBe(0)
expect(f.parent.listenerCount('move')).toBe(0)
expect(f.parent.webContents.removeInsertedCSS).toHaveBeenCalledTimes(2)
expect(f.parent.webContents.removeInsertedCSS).toHaveBeenCalledTimes(1)
})
it('cancels on abort, renderer failure, disposal, or an already-closed parent', async () => {
@@ -132,3 +137,57 @@ it('supplies localized disclosure copy without putting diagnostics in the ordina
dialogs!.cancel()
await pending
})
it('replaces content without releasing the backdrop and rejects a response from the previous prompt', async () => {
const f = setup()
const abort = new AbortController()
const first = f.show(abort.signal)
const window = fixture.windows.at(-1)!
const old = f.invoke(UPDATE_DIALOG_IPC.status) as { revision: number }
const next = f.show()
expect((await first).response).toBe(1)
expect(fixture.windows).toHaveLength(2)
expect(window.loadURL).toHaveBeenCalledOnce()
expect(window.webContents.send.mock.calls).toEqual([[UPDATE_DIALOG_IPC.changed, f.invoke(UPDATE_DIALOG_IPC.status)]])
expect(f.parent.webContents.removeInsertedCSS).not.toHaveBeenCalled()
abort.abort()
expect(() => fixture.handlers.get(UPDATE_DIALOG_IPC.respond)!({
sender: window.webContents, senderFrame: window.webContents.mainFrame,
}, old.revision, 0)).toThrow(/stale/)
f.invoke(UPDATE_DIALOG_IPC.respond, 0)
expect((await next).response).toBe(0)
})
it('fades out before releasing the backdrop and cancels pending removal when another prompt opens', async () => {
vi.useFakeTimers()
const f = setup()
const first = f.show()
const window = fixture.windows.at(-1)!
dialogs!.cancel()
await first
expect(window.isDestroyed()).toBe(false)
expect(window.webContents.send).toHaveBeenLastCalledWith(UPDATE_DIALOG_IPC.changed, null)
const next = f.show()
await vi.advanceTimersByTimeAsync(150)
expect(window.isDestroyed()).toBe(false)
dialogs!.cancel()
await next
await vi.advanceTimersByTimeAsync(150)
expect(window.isDestroyed()).toBe(true)
expect(f.parent.listenerCount('focus')).toBe(0)
expect(f.parent.webContents.listenerCount('before-input-event')).toBe(0)
})
it.runIf(process.platform === 'darwin')('blocks parent keyboard input and redirects focus without a native sheet', async () => {
const f = setup()
const pending = f.show()
const window = fixture.windows.at(-1)!
const event = { preventDefault: vi.fn() }
f.parent.webContents.emit('before-input-event', event)
expect(event.preventDefault).toHaveBeenCalledOnce()
f.parent.emit('focus')
expect(window.focus).toHaveBeenCalledTimes(2)
dialogs!.dispose()
await pending
expect(f.parent.webContents.listenerCount('before-input-event')).toBe(0)
})
@@ -25,12 +25,13 @@ function page(name: string) {
it.each(['en', 'zh-CN'])('keeps ordinary diagnostics folded, text-only, and keyboard-accessible: %s', async (language) => {
const p = page('update-dialog')
const locale = resolveDesktopLocale(language)
const state: UpdateDialogView = { locale: locale.id, title: locale.messages.updateFailedTitle,
const state: UpdateDialogView = { revision: 1, locale: locale.id, title: locale.messages.updateFailedTitle,
message: locale.messages.updateStopFailed, detail: '', buttons: [locale.messages.updateAcknowledge], cancelId: 0,
closeLabel: locale.messages.updateClose, technicalDetailsLabel: locale.messages.updateTechnicalDetails,
technicalDetails: '<img src=x onerror="window.compromised=true">\nexit 0; shutdown acknowledged false' }
const respond = vi.fn(async () => {})
const api: UpdateDialogApi = { status: async () => state, respond }
let publish!: (view: UpdateDialogView | null) => void
const api: UpdateDialogApi = { status: async () => state, respond, subscribe: (listener) => { publish = listener; return () => {} } }
Object.defineProperty(p.dom.window, 'dshUpdateDialog', { value: api })
p.run()
await expect.poll(() => p.element('dialog').hidden).toBe(false)
@@ -54,7 +55,24 @@ it.each(['en', 'zh-CN'])('keeps ordinary diagnostics folded, text-only, and keyb
expect(disclosure.open).toBe(false)
expect(respond).not.toHaveBeenCalled()
p.document.dispatchEvent(new p.dom.window.KeyboardEvent('keydown', { key: 'Escape', cancelable: true }))
expect(respond).toHaveBeenCalledWith(0)
expect(respond).toHaveBeenCalledWith(1, 0)
const backdrop = p.document.body
const dialog = p.element('dialog')
dialog.scrollTop = 170
publish({ ...state, revision: 2, message: locale.messages.updateChecking, technicalDetails: '', buttons: ['OK'] })
expect(p.document.body).toBe(backdrop)
expect(p.element('dialog')).toBe(dialog)
expect(dialog.scrollTop).toBe(0)
expect(backdrop.classList.contains('visible')).toBe(true)
expect(p.element('actions').childElementCount).toBe(1)
expect(disclosure.open).toBe(false)
expect(p.document.activeElement).toBe(dialog)
dialog.scrollTop = 80
publish(state)
expect(dialog.scrollTop).toBe(80)
expect(p.element('title').textContent).toBe(locale.messages.updateChecking)
publish(null)
expect(backdrop.classList.contains('visible')).toBe(false)
})
it('keeps mandatory diagnostics expandable without clearing the block or authorizing installation', async () => {
@@ -100,6 +118,11 @@ it('keeps mandatory diagnostics expandable without clearing the block or authori
expect(disclosure.hidden).toBe(true)
expect(p.element('error').hidden).toBe(true)
expect(p.document.getElementById('quit')).toBeNull()
expect(p.document.body.classList.contains('visible')).toBe(true)
publish({ ...initial, policy: { blocking: false, checking: false } })
expect(p.document.body.classList.contains('visible')).toBe(false)
publish(initial)
expect(p.document.body.classList.contains('visible')).toBe(true)
p.dom.window.dispatchEvent(new p.dom.window.Event('pagehide'))
expect(unsubscribe).toHaveBeenCalledOnce()
})
+25
View File
@@ -25,3 +25,28 @@ it('gives the Windows mandatory modal native move, resize, and maximize controls
expect(window.show).toHaveBeenCalledOnce()
expect(window.webContents.setWindowOpenHandler).toHaveBeenCalledOnce()
})
it('keeps the macOS mandatory overlay stationary and blocks parent keyboard input until close', () => {
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
const parent = Object.assign(new EventEmitter(), {
getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }),
webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }),
isDestroyed: () => false,
})
const window = Object.assign(new EventEmitter(), {
webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(),
setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => false,
})
native.create.mockReturnValue(window)
try {
createMandatoryUpdateWindow(parent as unknown as BrowserWindow, 'owned', 'Update required', 'darwin')
expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false }))
const event = { preventDefault: vi.fn() }
parent.webContents.emit('before-input-event', event)
expect(event.preventDefault).toHaveBeenCalledOnce()
expect(window.focus).toHaveBeenCalledOnce()
window.emit('closed')
expect(parent.listenerCount('focus')).toBe(0)
expect(parent.webContents.listenerCount('before-input-event')).toBe(0)
} finally { platform.mockRestore() }
})
+4 -9
View File
@@ -1,4 +1,4 @@
// Keyless assembled-browser coverage for the opt-in Agent Teams Web profiles
// Keyless assembled-browser coverage for the opt-in Agent Teams bundle
// over the real Host Typert Remote flow.
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -18,11 +18,9 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-team-panel', import.meta.url))
const PANEL_EXPECTED = join(SNAPSHOT_DIR, 'task.expected.md')
const OVERLAY = fileURLToPath(new URL('./agent-team-panel.overlay.yml', import.meta.url))
const HOST_PATCH = fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/cordis.patch.yml', import.meta.url))
const WEB_PATCH = fileURLToPath(new URL('../../../packages/experimental/agent-team-web-profile/cordis.patch.yml', import.meta.url))
const TEAM_PATCH = fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/cordis.patch.yml', import.meta.url))
const INSTALL_ANCHORS = [
fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/package.json', import.meta.url)),
fileURLToPath(new URL('../../../packages/experimental/agent-team-web-profile/package.json', import.meta.url)),
]
const MODE = webSnapshotMode()
@@ -33,11 +31,8 @@ function profileEntries(path: string): unknown[] {
}
describe('Agent Teams panel overlay', () => {
it('matches the shipped Host and Web profile layers', () => {
expect(profileEntries(OVERLAY)).toEqual([
...profileEntries(HOST_PATCH),
...profileEntries(WEB_PATCH),
])
it('matches the shipped Agent Teams bundle', () => {
expect(profileEntries(OVERLAY)).toEqual(profileEntries(TEAM_PATCH))
})
})
+1 -3
View File
@@ -1,5 +1,4 @@
# Equivalent to the two explicit Agent Teams profile layers over the scaffold's
# stable base and Web bundles.
# Equivalent to the Agent Teams bundle over the stable base and Web bundles.
- id: tool-subagent-control
disabled: true
@@ -28,6 +27,5 @@
freshProvider: spawn
forkProvider: fork
- insert:
- id: ui-agent-team
name: '@deepseek-ai/dsh-experimental-client-ui-agent-team'
+18 -16
View File
@@ -198,35 +198,37 @@ describe('web e2e: long Chat interaction contract', () => {
const turnNavigation = page.getByRole('navigation', { name: 'Turn navigation' })
await turnNavigation.waitFor({ state: 'visible', timeout: 15_000 })
// The whole-log outline offers every fixture turn before any paging, with
// the live tail mark current.
const marks = turnNavigation.getByRole('button')
await expect.poll(() => marks.count(), { timeout: 15_000 }).toBe(FIXTURE_TURNS)
expect(await marks.last().getAttribute('aria-current')).toBe('true')
await expect.poll(() => marks.last().getAttribute('aria-current'), { timeout: 15_000 }).toBe('true')
expect(await marks.count()).toBeLessThan(FIXTURE_TURNS)
// The oldest turn is an unloaded mark whose outline preview already
// carries both the prompt and the settled response.
const firstTurnButton = turnNavigation
.getByRole('button', { name: 'Load and jump to turn 1', exact: true })
const railScroller = turnNavigation.locator('[class*="scroller"]')
await railScroller.hover()
await page.mouse.wheel(0, -FIXTURE_TURNS * 10)
await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(0)
await firstTurnButton.focus()
const preview = page.getByRole('tooltip')
await preview.waitFor({ state: 'visible', timeout: 5_000 })
expect(await preview.textContent()).toContain(FIXTURE.markers.user(1))
await expect.poll(() => preview.textContent(), { timeout: 5_000 }).toContain(FIXTURE.markers.user(1))
expect(await preview.textContent()).toContain(FIXTURE.markers.assistant(1))
const firstTurnPosition = await firstTurnButton.evaluate(button => (
button.parentElement?.style.getPropertyValue('--turn-natural-position') ?? ''
const markPitch = () => turnNavigation.getByRole('button').evaluateAll(buttons => (
buttons[1]!.getBoundingClientRect().top - buttons[0]!.getBoundingClientRect().top
))
expect(firstTurnPosition).toBe('0px')
expect(await markPitch()).toBe(10)
const loadEarlier = page.getByRole('button', { name: 'Load earlier', exact: true })
const loadedMarks = turnNavigation.getByRole('button', { name: /^Jump to turn / })
const loadedBefore = await loadedMarks.count()
const loadedRows = page.locator('[data-chat-flow-key]')
const loadedBefore = await loadedRows.count()
await loadEarlier.click()
// Paging converts marks to their loaded form without moving the
// fixed-pitch ladder.
await expect.poll(() => loadedMarks.count(), { timeout: 15_000 }).toBeGreaterThan(loadedBefore)
expect(await firstTurnButton.evaluate(button => (
button.parentElement?.style.getPropertyValue('--turn-natural-position') ?? ''
))).toBe(firstTurnPosition)
await expect.poll(() => loadedRows.count(), { timeout: 15_000 }).toBeGreaterThan(loadedBefore)
await railScroller.hover()
await page.mouse.wheel(0, -FIXTURE_TURNS * 10)
await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(0)
await firstTurnButton.waitFor({ state: 'visible' })
expect(await markPitch()).toBe(10)
// Activating the still-unloaded oldest mark pages the rest in and lands
// on the turn's own row.
await firstTurnButton.focus()
+25 -15
View File
@@ -588,7 +588,7 @@ describe('web e2e: long Chat scroll contract', () => {
})
}, 180_000)
it.skipIf(MODE === 'record')('offers every outline turn on the rail and jumps to an unloaded one', async () => {
it.skipIf(MODE === 'record')('virtualizes the outline rail and jumps to an unloaded turn', async () => {
await withScrollWorld({
failureShot: 'web-e2e-turn-rail-jump',
seeds: [{ fixture: HISTORY_FIXTURE, id: RAIL_SESSION_ID }],
@@ -596,31 +596,41 @@ describe('web e2e: long Chat scroll contract', () => {
await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns))
await expectBottom(world.page)
// The whole-log outline reaches the rail before any paging: one mark
// per fixture turn, the oldest still in its load-and-jump form.
const rail = world.page.getByRole('navigation', { name: 'Turn navigation' })
await expect.poll(() => rail.getByRole('button').count(), { timeout: 15_000 })
.toBe(HISTORY_FIXTURE.turns)
const latest = rail.getByRole('button', { name: `Jump to turn ${String(HISTORY_FIXTURE.turns)}`, exact: true })
await expect.poll(() => latest.getAttribute('aria-current'), { timeout: 15_000 }).toBe('true')
expect(await rail.getByRole('button').count()).toBeLessThan(HISTORY_FIXTURE.turns)
const firstUnloaded = rail.getByRole('button', { name: 'Load and jump to turn 1', exact: true })
expect(await firstUnloaded.count()).toBe(1)
// Fixed pitch: the ladder keeps its natural height, scrolls inside the
// frame, and (following the active tail mark) fades its upper end.
expect(await rail.evaluate(nav => nav.style.getPropertyValue('--turn-natural-height')))
.toBe(`${String((HISTORY_FIXTURE.turns - 1) * 10 + 12)}px`)
expect(await firstUnloaded.count()).toBe(0)
const railScroller = rail.locator('[class*="scroller"]')
await expect.poll(() => railScroller.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true)
await expect.poll(() => rail.locator('[class*="fadeTop"]').count(), { timeout: 15_000 }).toBe(1)
const bodyBeforeRailScroll = await scrollGeometry(world.page)
await railScroller.hover()
await world.page.mouse.wheel(0, -HISTORY_FIXTURE.turns * 10)
await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(0)
await firstUnloaded.waitFor({ state: 'visible' })
expect((await scrollGeometry(world.page)).scrollTop).toBe(bodyBeforeRailScroll.scrollTop)
// Activate the unloaded mark by keyboard: pointer input belongs to the
// rail frame, while each mark is the keyboard/AT destination. Focus
// first shows the outline-backed preview: prompt and settled response
// both travel ahead of the events.
const beforeRows = await loadedFlowRows(world.page)
await firstUnloaded.focus()
const tooltip = world.page.getByRole('tooltip')
await expect.poll(() => tooltip.count(), { timeout: 15_000 }).toBe(1)
expect(await tooltip.textContent()).toContain(HISTORY_FIXTURE.markers.user(1))
await expect.poll(() => tooltip.textContent(), { timeout: 5_000 }).toContain(HISTORY_FIXTURE.markers.user(1))
expect(await tooltip.textContent()).toContain(HISTORY_FIXTURE.markers.assistant(1))
const previewId = await tooltip.getAttribute('id')
const hoveredMark = () => rail.evaluate(element => element.querySelector<HTMLButtonElement>('button:hover')?.dataset.index ?? null)
await expect.poll(hoveredMark).not.toBeNull()
const hoveredBefore = await hoveredMark()
await world.page.mouse.wheel(0, 80)
await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(80)
await expect.poll(hoveredMark).toBe(String(Number(hoveredBefore) + 8))
await nextPaint(world.page)
expect(await firstUnloaded.evaluate(element => document.activeElement === element)).toBe(true)
expect(await firstUnloaded.getAttribute('aria-describedby')).toBe(previewId)
expect(await tooltip.textContent()).toContain(HISTORY_FIXTURE.markers.user(1))
await world.page.mouse.wheel(0, -80)
await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(0)
const transcriptLayers = await tooltip.evaluate((preview) => {
const railSlot = preview.closest('nav')?.parentElement
const codeBanner = document.querySelector<HTMLElement>('.md-code-block > :first-child')
@@ -16,7 +16,7 @@
- button "3 tool calls" [expanded]
- button "Context injection @deepseek-ai/dsh-system-prompt"
- button "Think I will inspect the current Session's dynamic Cordis Plugins before defining the snapshot Package."
- 'button "Tool call cordis_inspect_self · {}"'
- button "Inspect plugins 0 dynamic plugins"
- button "Think No dynamic Plugins are present, so I will define the requested Host and Client Package."
- button "Register Cordis Plugin snapshot noop does nothing, for the snapshot Ready" [expanded]
- tablist "Plugin source":
@@ -3,16 +3,12 @@
- button "刷新"
- button "添加插件"
- heading "官方" [level=3]
- text: "6"
- text: "5"
- list:
- listitem:
- button "查看 智能体团队": 智能体团队
- text: Beta 启用智能体团队协作与团队工具。
- text: Beta 启用团队协作、团队工具、成员列表和共享任务看板。
- switch "启用 智能体团队"
- listitem:
- button "查看 智能体团队 Web 界面": 智能体团队 Web 界面
- text: Beta 在浏览器中查看团队成员、任务看板和成员会话。
- switch "启用 智能体团队 Web 界面"
- listitem:
- button "查看 终端": 终端
- text: 限制 agent 运行的每一条命令。
@@ -3,16 +3,12 @@
- button "刷新"
- button "添加插件"
- heading "官方" [level=3]
- text: "6"
- text: "5"
- list:
- listitem:
- button "查看 智能体团队": 智能体团队
- text: Beta 启用智能体团队协作与团队工具。
- text: Beta 启用团队协作、团队工具、成员列表和共享任务看板。
- switch "启用 智能体团队"
- listitem:
- button "查看 智能体团队 Web 界面": 智能体团队 Web 界面
- text: Beta 在浏览器中查看团队成员、任务看板和成员会话。
- switch "启用 智能体团队 Web 界面"
- listitem:
- button "查看 终端": 终端
- text: 限制 agent 运行的每一条命令。
@@ -3,16 +3,12 @@
- button "刷新"
- button "添加插件"
- heading "官方" [level=3]
- text: "6"
- text: "5"
- list:
- listitem:
- button "查看 智能体团队": 智能体团队
- text: Beta 启用智能体团队协作与团队工具。
- text: Beta 启用团队协作、团队工具、成员列表和共享任务看板。
- switch "启用 智能体团队"
- listitem:
- button "查看 智能体团队 Web 界面": 智能体团队 Web 界面
- text: Beta 在浏览器中查看团队成员、任务看板和成员会话。
- switch "启用 智能体团队 Web 界面"
- listitem:
- button "查看 终端": 终端
- text: 限制 agent 运行的每一条命令。
+43 -6
View File
@@ -23,7 +23,8 @@ import {
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import {
connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE,
connectFreshWorkspace, connectFreshWorkspaceZh, expandOwningTurnProcess, newEnglishPage, saveFailureShot,
writeComposerDraft, ZH_BROWSER_LOCALE,
} from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome', import.meta.url))
@@ -34,6 +35,7 @@ const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
const COMMAND_MENU_ZH_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-zh.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')
const PLAN_ACTIVE_ZH_EXPECTED = join(SNAPSHOT_DIR, 'plan-active-zh.expected.md')
const CONNECTION_ERROR_EXPECTED = join(SNAPSHOT_DIR, 'connection-error.expected.md')
// Post-reload golden: the same settled conversation rebuilt purely from
// persistence + history — byte-equal rendering is exactly the recovery claim.
@@ -153,7 +155,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await inputPage.keyboard.insertText('这是任务')
await expect.poll(() => input.textContent()).toBe(`${token} 这是任务`)
for (let i = 0; i < 5; i++) await input.press('Backspace')
const tokenText = () => input.locator('[data-lexical-text][style*="warn-label"]').textContent()
const tokenText = () => input.locator('[data-lexical-text][style*="business-primary"]').textContent()
await expect.poll(() => input.textContent()).toBe(token)
await expect.poll(() => input.getAttribute('data-phase')).toBe('claimed')
await expect.poll(tokenText).toBe(token)
@@ -195,7 +197,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
}
})
it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => {
it.skipIf(MODE === 'record')('shows active Plan as the business-state status action', async () => {
const activeScaffold = await launchWebScaffold()
const activePage = await newEnglishPage(browser)
const activeTripwire = watchConsole(activePage)
@@ -220,14 +222,15 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE)
const planStyle = await planButton.evaluate((element) => {
const probe = document.createElement('span')
probe.style.color = 'var(--dsw-alias-state-warn-label)'
probe.style.backgroundColor = 'var(--dsw-alias-state-warn-tertiary)'
probe.style.color = 'var(--dsw-alias-state-business-primary)'
probe.style.backgroundColor = 'var(--dsw-alias-state-business-tertiary)'
document.body.append(probe)
const actual = getComputedStyle(element)
const reference = getComputedStyle(probe)
const result = {
color: actual.color,
backgroundColor: actual.backgroundColor,
height: actual.height,
borderRadius: actual.borderRadius,
fontSize: actual.fontSize,
referenceColor: reference.color,
@@ -238,6 +241,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
})
expect(planStyle.color).toBe(planStyle.referenceColor)
expect(planStyle.backgroundColor).toBe(planStyle.referenceBackgroundColor)
expect(planStyle.height).toBe('28px')
expect(planStyle.borderRadius).toBe('999px')
expect(planStyle.fontSize).toBe('13px')
await planButton.click()
@@ -253,6 +257,39 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
}
})
it.skipIf(MODE === 'record')('shows the active Plan chip with the Chinese copy', async () => {
const zhScaffold = await launchWebScaffold()
const zhPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
const zhTripwire = watchConsole(zhPage)
try {
await zhPage.goto(zhScaffold.authenticatedUrl, { waitUntil: 'load' })
await zhPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspaceZh(zhPage, zhScaffold.workspaceCwd)
const input = zhPage.locator('[data-composer-input]').first()
await zhPage.getByRole('button', { name: '添加文件或调用指令' }).click()
const menu = zhPage.getByRole('listbox', { name: '触发候选建议' })
await menu.waitFor({ timeout: 10_000 })
await menu.getByRole('option', { name: '计划 plan 进入或退出计划模式', exact: true }).click()
await expect.poll(() => input.textContent()).toBe('/计划 ')
await input.press('Enter')
const planButton = zhPage.getByRole('button', { name: '计划模式已开启,按下关闭' })
await planButton.waitFor({ timeout: 10_000 })
await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('')
const planSnapshot = await captureStableAria(zhPage, '[class*="frame"]', zhScaffold.workspaceCwd)
await compareOrRefreshGolden(PLAN_ACTIVE_ZH_EXPECTED, planSnapshot, MODE)
await planButton.click()
await expect.poll(() => planButton.count()).toBe(0)
expect(zhTripwire.pageErrors).toEqual([])
expect(zhTripwire.warnings).toEqual([])
} catch (error) {
await saveFailureShot(zhPage, 'web-e2e-plan-active-zh').catch(() => undefined)
throw error
} finally {
await zhPage.close()
await zhScaffold.close()
}
})
it('sends the first prompt from the empty-state hero (all modes)', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send'))
if (MODE !== 'record') {
@@ -564,7 +601,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.v3.jsonl', 'replay.override.json', 'command-menu.expected.md',
'command-menu-fuzzy.expected.md', 'command-menu-zh.expected.md', 'connection-error.expected.md',
'hero.expected.md', 'plan-active.expected.md',
'hero.expected.md', 'plan-active.expected.md', 'plan-active-zh.expected.md',
'reloaded.expected.md', 'reloaded-expanded.expected.md',
])
})
+11 -11
View File
@@ -86,10 +86,10 @@ describe('web e2e: plugin configuration pages', () => {
// Every page the shipped web composition exposes: the shell executor, the
// agent loop, subagent selection, and the DeepSeek search provider, after
// the two official bundles the installation ships switched off.
// the official Team bundle the installation ships switched off.
await panel.getByRole('button', { name: '查看 网页搜索', exact: true }).waitFor({ timeout: 20_000 })
const official = panel.locator('[data-plugin-group="official"]')
expect(await official.locator('[data-plugin-package]').count()).toBe(2)
expect(await official.locator('[data-plugin-package]').count()).toBe(1)
expect(await official.locator('[data-plugin-item]').count()).toBe(4)
for (const title of ['终端', 'Agent 循环', 'Subagent', '网页搜索']) {
expect(await official.getByRole('button', { name: `查看 ${title}`, exact: true }).count()).toBe(1)
@@ -177,15 +177,15 @@ describe('web e2e: plugin configuration pages', () => {
const save = panel.getByRole('button', { name: '保存', exact: true })
await save.click()
await expect.poll(async () => (await settingsDocument()).includes('subagent-model-selection:'), { timeout: 10_000 })
.toBe(true)
await expect.poll(() => save.isDisabled(), { timeout: 5_000 }).toBe(true)
expect(await settingsDocument()).toContain('maxDepth: 2')
expect(await settingsDocument()).toContain('enabled: true')
expect(await settingsDocument()).toContain('allowedModels:')
expect(await settingsDocument()).toContain('provider:')
expect(await settingsDocument()).toContain('model:')
// The page stays open once the save landed; a settled form offers no save to repeat.
// The Save label returns only after both namespace controllers settle.
await expect.poll(() => save.isDisabled(), { timeout: 10_000 }).toBe(true)
const saved = await settingsDocument()
expect(saved).toContain('subagent-model-selection:')
expect(saved).toContain('maxDepth: 2')
expect(saved).toContain('enabled: true')
expect(saved).toContain('allowedModels:')
expect(saved).toContain('provider:')
expect(saved).toContain('model:')
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true')
await toggle.click()
+67 -11
View File
@@ -1,18 +1,20 @@
// Web e2e scenario: the plugin manager page behind the sidebar's Plugins entry over a
// managed scaffold profile: installed bundles, their rows, and bundle enablement. Zero
// model calls: everything is client state plus the profile files and the settings
// model calls: everything is client state, seeded Session state, profile files, and the settings
// document, so there is no fixture and a stray stream would fail loud on the open llm seam.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { FiberState } from '@deepseek-ai/cordis'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { join } from 'node:path'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-manager', import.meta.url))
const MANAGER_EXPECTED = join(SNAPSHOT_DIR, 'manager.expected.md')
@@ -75,9 +77,9 @@ describe('web e2e: plugin manager', () => {
// group, followed by the official plugins that registered their configuration, and its other bundles
// stay off the page.
expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(1)
expect(await panel.locator('[data-plugin-group="official"] [data-plugin-package]').count()).toBe(2)
expect(await panel.locator('[data-plugin-group="official"] [data-plugin-package]').count()).toBe(1)
expect(await panel.locator('[data-plugin-group="official"] [data-plugin-item]').count()).toBe(4)
expect(await panel.getByText('Beta', { exact: true }).count()).toBe(2)
expect(await panel.getByText('Beta', { exact: true }).count()).toBe(1)
// A bundle that is off still shows the rows its patch declares, without switches.
await panel.getByRole('button', { name: '查看 bundle' }).click()
await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 })
@@ -97,7 +99,7 @@ describe('web e2e: plugin manager', () => {
await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click()
const packageName = panel.locator('[data-plugin-name]')
expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile')
expect(await panel.getByText('启用智能体团队协作与团队工具。').count()).toBe(1)
expect(await panel.getByText('启用团队协作、团队工具、成员列表和共享任务看板。').count()).toBe(1)
try {
await page.getByRole('button', { name: '设置', exact: true }).click()
await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '中文' }).click()
@@ -106,13 +108,10 @@ describe('web e2e: plugin manager', () => {
await page.keyboard.press('Escape')
await panel.getByRole('heading', { name: 'Agent Teams', exact: true }).waitFor()
expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile')
expect(await panel.getByText('Enable agent team collaboration and team tools.').count()).toBe(1)
expect(await panel.getByText('Enable team collaboration, team tools, the member roster, and the shared task board.').count()).toBe(1)
await panel.getByRole('button', { name: 'Back to plugins' }).click()
for (const title of ['Agent Teams', 'Agent Teams Web UI']) {
await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor()
expect(await panel.getByRole('switch', { name: `Enable ${title}`, exact: true }).count()).toBe(1)
}
expect(await panel.getByText('View team members, the task board, and teammate sessions in the browser.').count()).toBe(1)
await panel.getByRole('button', { name: 'View Agent Teams', exact: true }).waitFor()
expect(await panel.getByRole('switch', { name: 'Enable Agent Teams', exact: true }).count()).toBe(1)
// The official configuration pages follow the language too, from their own dictionary.
for (const title of ['Shell', 'Agent loop', 'Subagent', 'Web search']) {
await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor()
@@ -132,6 +131,63 @@ describe('web e2e: plugin manager', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('enables the Team tools and browser plugin with one bundle switch', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-team'))
const panel = await openPluginsPanel()
const toggle = panel.getByRole('switch', { name: '启用 智能体团队', exact: true })
const teamRows = () => [...scaffold.ctx.loader.entries()]
.filter(entry => ['agent-team', 'tool-agent-team', 'ui-agent-team'].includes(entry.options.id))
const teamPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
const teamTripwire = watchConsole(teamPage)
try {
await teamPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspaceZh(teamPage, scaffold.workspaceCwd)
const agent = scaffold.ctx.agents.list()[0]
if (agent === undefined) throw new Error('connected Team workspace did not create an Agent')
// Session actions render only after the conversation leaves its blank state.
agent.session.append('turn/start', { turn: 1 })
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Team UI lifecycle' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await scaffold.ctx.sessions.flush(agent.session)
await teamPage.getByRole('button', { name: 'Team UI lifecycle', exact: true }).waitFor()
const action = teamPage.locator('[data-team-action]')
expect(await action.count()).toBe(0)
await toggle.click()
try {
await expect.poll(() => teamRows().filter(entry => entry.fiber?.state === FiberState.ACTIVE).length, { timeout: 20_000 }).toBe(3)
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true')
await action.waitFor({ timeout: 20_000 })
await action.getByRole('button', { name: /Agent Team/iu }).click()
await action.getByText('还没有共享任务').waitFor()
await action.getByText('lead', { exact: true }).waitFor()
const manifest = JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as {
dsh: { profile: { bundles: string[] } }
}
expect(manifest.dsh.profile.bundles).toEqual([
'@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-experimental-agent-team-profile',
])
await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click()
for (const id of ['agent-team', 'tool-agent-team', 'ui-agent-team']) {
await panel.locator('[data-plugin-row]', { hasText: id }).first().waitFor()
}
await panel.getByRole('button', { name: '返回插件列表' }).click()
} finally {
const back = panel.getByRole('button', { name: '返回插件列表' })
if (await back.count() > 0) await back.click()
if (await toggle.getAttribute('aria-checked') === 'true') await toggle.click()
await expect.poll(() => teamRows().filter(entry => entry.fiber?.state === FiberState.ACTIVE).length, { timeout: 20_000 }).toBe(0)
await expect.poll(() => action.count(), { timeout: 20_000 }).toBe(0)
}
expect(teamTripwire.pageErrors).toEqual([])
expect(teamTripwire.warnings).toEqual([])
} finally {
await teamPage.close()
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('checks a spec before installing it and words what the check refused', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-install'))
const panel = await openPluginsPanel()
+17
View File
@@ -458,6 +458,23 @@ describe('web e2e: seeded history renders through cold resume', () => {
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false')
})
it.skipIf(MODE === 'record')('restores the active turn rail mark across Chat and Trajectory', async () => {
const rail = page.getByRole('navigation', { name: 'Turn navigation' })
const current = rail.locator('[aria-current="true"]')
await current.waitFor({ state: 'visible' })
const active = await current.getAttribute('aria-label')
const scroller = page.locator('[data-conversation-scroll]')
const top = await scroller.evaluate(element => element.scrollTop)
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
await page.getByLabel('Trajectory timeline', { exact: true }).waitFor({ state: 'visible' })
await page.getByRole('tab', { name: 'Chat', exact: true }).click()
await expect.poll(() => current.getAttribute('aria-label')).toBe(active)
await expect.poll(async () => Math.abs(await scroller.evaluate(element => element.scrollTop) - top)).toBeLessThanOrEqual(2)
expect(tripwire.pageErrors).toEqual([])
})
it.skipIf(MODE === 'record')('file-path tool rows rebuilt from the cold log open the right Sidebar', async () => {
onTestFailed(async () => {
await mkdir(fileURLToPath(new URL('../../../.artifacts/screenshots/0907-2205-sidebar', import.meta.url)), { recursive: true })
+60
View File
@@ -0,0 +1,60 @@
/** Global navigation before the first Session, through the shipped Web composition. */
import { chromium, type Browser } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
let scaffold: WebScaffold
let browser: Browser
beforeAll(async () => {
scaffold = await launchWebScaffold()
browser = await chromium.launch()
})
afterAll(async () => {
try { await browser?.close() }
finally { await scaffold?.close() }
})
describe('navigation without a selected Session', () => {
it.each(['web', 'win32', 'linux', 'darwin'] as const)('%s preserves its empty-header geometry and sidebar access', async (platform) => {
const page = await newEnglishPage(browser)
try {
if (platform !== 'web') {
await page.addInitScript((value) => {
const mark = () => { document.documentElement.setAttribute('data-platform', value) }
if (document.documentElement === null) document.addEventListener('DOMContentLoaded', mark, { once: true })
else mark()
}, platform)
}
const tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.getByRole('textbox', { name: 'Choose workspace', exact: true }).waitFor()
const sessionHeader = page.locator('[data-slot="conversation.session.header"]')
expect(await sessionHeader.count()).toBe(0)
const header = page.locator('[data-slot="conversation.header"] > header')
expect(await header.evaluate(element => element.getBoundingClientRect().height)).toBe(platform === 'darwin' ? 40 : 0)
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
const reopen = platform === 'darwin'
? page.locator('[data-conversation-header-leading]').getByRole('button', { name: 'Open sidebar', exact: true })
: page.getByRole('button', { name: 'Open sidebar', exact: true })
await reopen.waitFor({ state: 'visible' })
expect(await sessionHeader.count()).toBe(0)
expect(await header.evaluate(element => element.getBoundingClientRect().height)).toBe(platform === 'darwin' ? 40 : 0)
if (platform === 'darwin') {
expect(await page.locator('[data-conversation-header-leading]').getByRole('button', { name: 'New session', exact: true }).isVisible()).toBe(true)
}
await reopen.click()
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).waitFor({ state: 'visible' })
expect(await sessionHeader.count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
} catch (error) {
await saveFailureShot(page, `sessionless-header-${platform}`)
throw error
} finally {
await page.close()
}
})
})
@@ -4,7 +4,7 @@
- button "Refresh Team"
- button "Close"
- heading "Members" [level=3]
- 'button "lead Idle · Model: deepseek-v4-flash" [disabled]'
- 'button "lead Inactive · Model: deepseek-v4-flash" [disabled]'
- heading "Shared tasks" [level=3]
- button "New task"
- article:
+101
View File
@@ -0,0 +1,101 @@
/** Authored Session rendering of compact recorded Tool results. */
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 {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/tool-details', import.meta.url))
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/tool-details/session.v3.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/tool-details/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'tool-details-web-e2e'
const PROMPT = 'Show a goal, a to-do list, and reminders.'
describe.skipIf(MODE === 'record')('web e2e: compact Tool details', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
const fixture = await readFile(FIXTURE, 'utf8')
expect(fixtureUserPrompts(fixture)).toEqual([PROMPT])
scaffold = await launchWebScaffold({})
await seedSession(scaffold, fixture, SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { 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()
const skillRow = page.locator('[data-tool="create_goal"]')
await expandOwningTurnProcess(page, skillRow)
await skillRow.waitFor({ timeout: 15_000 })
}, 120_000)
afterAll(async () => {
try { await browser?.close() } finally { await scaffold?.close() }
})
it('expands every recorded tool through its keyed renderer', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-tool-details'))
const rows = page.locator('[data-tool]')
expect(await rows.count()).toBe(44)
for (const row of await rows.all()) {
await row.getByRole('button', { expanded: false }).first().click()
expect(await row.getByRole('listitem').count()).toBeGreaterThan(0)
expect(await row.locator('[class*="ioCard"]').count()).toBe(0)
expect(await row.getByRole('button', { name: 'Inspect', exact: true }).count()).toBe(1)
}
const changedTodos = page.locator('[data-tool="todo_write"]').nth(1)
await changedTodos.getByText('1 added · 1 updated · 1 removed', { exact: true }).waitFor()
for (const summary of await page.locator('[data-tool] details > summary').all()) await summary.click()
await page.locator('[data-tool="create_goal"]').getByText('Awaiting continuation', { exact: true }).waitFor()
const schema = page.locator('[data-tool="cordis_inspect_list"] pre').first()
await expect.poll(() => schema.textContent()).toContain('"properties"')
const snapshots = await Promise.all(Array.from({ length: 44 }, (_, index) =>
captureStableAria(page, `[data-chat-call-id="details-call-${index + 1}"] [data-tool]`, scaffold.workspaceCwd)))
const snapshot = snapshots.join('\n')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
await page.setViewportSize({ width: 360, height: 800 })
const card = page.locator('[data-tool="schedule_create"]')
expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
const reminders = page.locator('[data-tool="schedule_list"]')
await reminders.scrollIntoViewIfNeeded()
const list = reminders.getByRole('list').first()
await list.evaluate((element) => {
const second = element.children[1]!
element.scrollTop += second.getBoundingClientRect().top - element.getBoundingClientRect().top
})
expect(await list.evaluate(element => element.scrollTop)).toBeGreaterThan(0)
const geometry = await reminders.evaluate((element) => {
const title = element.querySelector('ul')!.children[1]!.querySelector('[class*="heading"] > span')!.getBoundingClientRect()
const inspect = element.querySelector('[class*="inspectButton"]')!.getBoundingClientRect()
return {
verticalOverlap: Math.min(title.bottom, inspect.bottom) - Math.max(title.top, inspect.top),
horizontalGap: inspect.left - title.right,
}
})
expect(geometry.verticalOverlap).toBeGreaterThan(0)
expect(geometry.horizontalGap).toBeGreaterThanOrEqual(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it('keeps the snapshot inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'ui.expected.md'])
})
})
+2 -2
View File
@@ -187,7 +187,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
expect(await terminalWorkflow.getAttribute('aria-expanded')).toBe('false')
expect(await terminalWorkflow.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer')
await terminalWorkflow.click()
const terminalPhase = page.getByRole('button', { name: /^Run/ })
const terminalPhase = page.locator('[data-workflow-run][data-run-status="completed"] [data-disclosure-row]').nth(1)
await terminalPhase.waitFor()
expect(await terminalPhase.getAttribute('aria-expanded')).toBe('false')
expect(await terminalPhase.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer')
@@ -210,7 +210,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
const snapshot = await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
await workflow.click()
const phase = page.getByRole('button', { name: /^Run/ })
const phase = page.locator('[data-workflow-run][data-run-status="completed"] [data-disclosure-row]').nth(1)
await phase.waitFor()
expect(await phase.getAttribute('aria-expanded')).toBe('false')
await phase.click()
+53 -4
View File
@@ -17,7 +17,7 @@ import { join, sep } from 'node:path'
import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session'
import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
import { logPath } from '../../../packages/session/session-persistence-jsonl/src/format.ts'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
@@ -754,8 +754,57 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
expect(tripwire.warnings).toEqual([])
// The directory-browser aria golden is this spec's one owned artifact;
// the seed it reuses is owned (and inventory-guarded) by seeded-history.
await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep', 'directory-browser.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, [
'.gitkeep', 'directory-browser.expected.md', 'new-session.expected.md',
])
})
})
describe('web e2e: New Session after an outdated blank cache', () => {
it('opens a fresh conversation instead of reusing the recorded conversation', async () => {
const scaffold = await launchWebScaffold({})
let browser: Browser | undefined
try {
const now = Date.UTC(2026, 8, 16)
const id = await seedSession(scaffold, await readFile(SEED, 'utf8'), 'new-session-stale-blank',
undefined, { createdAt: now - 60_000 })
const stored = await scaffold.ctx.sessionPersistence.stat(id)
if (stored === undefined) throw new Error('seeded Session is missing')
// A durable log may advance after its last blank projection checkpoint.
// An unregistered Session writes only the checkpoint, leaving the persisted log intact.
const blank = scaffold.ctx.sessions.prepare(id, {
eventState: 'detached', seed: [], meta: stored.header, inheritedEventCount: SessionLogOffset(0),
})
await scaffold.ctx.sessionProjectionCache.write(blank)
const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd, 'New session regression')
await workspace.attachSession(id)
browser = await chromium.launch()
const page = await newEnglishPage(browser)
await page.clock.setFixedTime(now)
const tripwire = watchConsole(page)
onTestFailed(() => saveFailureShot(page, 'web-e2e-new-session-stale-blank'))
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor()
const selected = page.locator('[role="treeitem"][aria-selected="true"]')
await expect.poll(() => selected.innerText()).not.toBe('New Session')
const previous = (await selected.innerText()).split('\n')[0]!
const before = await captureStableAria(page, '[role="tree"]', scaffold.workspaceCwd)
await page.getByRole('button', { name: 'New session', exact: true }).last().click()
await expect.poll(() => selected.innerText()).toBe('New Session')
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ state: 'hidden' })
await expect.poll(() => workspace.sessionIds.length).toBe(2)
expect(workspace.sessionIds).toContain(id)
await page.getByRole('treeitem').filter({ hasText: previous }).waitFor()
const after = await captureStableAria(page, '[role="tree"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'new-session.expected.md'),
`Before New Session\n${before}\nAfter New Session\n${after}`, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
} finally {
await browser?.close()
await scaffold.close()
}
})
})
+2
View File
@@ -44,6 +44,7 @@
"tests/trajectory-virtualization.e2e.ts",
"tests/lifecycle-chrome.e2e.ts",
"tests/details-session-lifecycle.e2e.ts",
"tests/sessionless-header.e2e.ts",
"tests/document-preview.e2e.ts",
"tests/office-fixture.ts",
"tests/sidebar-browser.e2e.ts",
@@ -130,6 +131,7 @@
"tests/auto-review-denial.e2e.ts",
"tests/bash-abort-row.e2e.ts",
"tests/skill-tool-row.e2e.ts",
"tests/tool-details.e2e.ts",
"tests/turn-tail-actions.e2e.ts",
"tests/goal-multi-turn-actions.e2e.ts",
"tests/chat-scroll-fixture.ts",
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: e2c9d9bfe22f9a3b86822676f62569b39d3e7d0f
config-catalog.zh.md: d36255cccf920f7d358b4f3dd064c3abdd5dd69d
config-catalog.md: 8033eeb611a33f7634757743417c4bc0e787e016
config-catalog.zh.md: 706f711e6e2a09b04899c9d5502d6f1130bc6204
-1
View File
@@ -3905,7 +3905,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/dsh-cmdline` ([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts))
- `@deepseek-ai/dsh-deque` ([`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts))
- `@deepseek-ai/dsh-experimental-agent-team-profile` ([`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts))
- `@deepseek-ai/dsh-experimental-agent-team-web-profile` ([`packages/experimental/agent-team-web-profile/src/index.ts`](../packages/experimental/agent-team-web-profile/src/index.ts))
- `@deepseek-ai/dsh-experimental-browser-use-runtime` ([`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts))
- `@deepseek-ai/dsh-experimental-webworker-packer` ([`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts))
- `@deepseek-ai/dsh-experimental-webworker-runtime` ([`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts))
-1
View File
@@ -3906,7 +3906,6 @@ export interface Config {
- `@deepseek-ai/dsh-cmdline`([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts))
- `@deepseek-ai/dsh-deque`([`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts))
- `@deepseek-ai/dsh-experimental-agent-team-profile`([`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts))
- `@deepseek-ai/dsh-experimental-agent-team-web-profile`([`packages/experimental/agent-team-web-profile/src/index.ts`](../packages/experimental/agent-team-web-profile/src/index.ts))
- `@deepseek-ai/dsh-experimental-browser-use-runtime` ([`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts))
- `@deepseek-ai/dsh-experimental-webworker-packer`([`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts))
- `@deepseek-ai/dsh-experimental-webworker-runtime`([`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts))
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/module-graph.md
module-graph.md: e6942411a8e342464bce47782f33e3d9e4a06914
module-graph.zh.md: 992a67df770f5ea83dc5f902e723e876af55cdb4
module-graph.md: 531e97991ded62406d7e68c7cc3f5286c9764af8
module-graph.zh.md: fd757d1bdc42c18357a2319619e910ee215d1ea2
-2
View File
@@ -231,7 +231,6 @@ flowchart TD
subgraph group_experimental["packages/experimental"]
pkg_experimental_agent_team["experimental-agent-team"]
pkg_experimental_agent_team_profile["experimental-agent-team-profile"]
pkg_experimental_agent_team_web_profile["experimental-agent-team-web-profile"]
pkg_experimental_auto_review["experimental-auto-review"]
pkg_experimental_browser_use_chrome_devtools_mcp["experimental-browser-use-chrome-devtools-mcp"]
pkg_experimental_browser_use_playwright_mcp["experimental-browser-use-playwright-mcp"]
@@ -1383,7 +1382,6 @@ flowchart TD
| [`client-web`](../packages/client/web) | `client` | — |
| [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — |
| [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — |
| [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | — |
| [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — |
| [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | — |
| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | — |
-2
View File
@@ -233,7 +233,6 @@ flowchart TD
subgraph group_experimental["packages/experimental"]
pkg_experimental_agent_team["experimental-agent-team"]
pkg_experimental_agent_team_profile["experimental-agent-team-profile"]
pkg_experimental_agent_team_web_profile["experimental-agent-team-web-profile"]
pkg_experimental_auto_review["experimental-auto-review"]
pkg_experimental_browser_use_chrome_devtools_mcp["experimental-browser-use-chrome-devtools-mcp"]
pkg_experimental_browser_use_playwright_mcp["experimental-browser-use-playwright-mcp"]
@@ -1385,7 +1384,6 @@ flowchart TD
| [`client-web`](../packages/client/web) | `client` | — |
| [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — |
| [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — |
| [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | — |
| [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — |
| [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | — |
| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | — |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/agent-team.md
agent-team.md: 6f20ee11cbaf66028a5a3123d1682a0e9a87b014
agent-team.zh.md: 5f2cfae582f275f25380cbe996f5bad711c0f917
agent-team.md: 653bbbcad50d723690c013161a0c25eea301ff0b
agent-team.zh.md: e9ef2d5dc824a81d1c666d0ea7a270c0cfe2c92e
+3 -3
View File
@@ -21,7 +21,7 @@ interface TeamMemberSnapshot {
}
```
Every member starts in `provisioning` and reaches exactly one terminal roster phase, `active` or `failed`. Runtime `running`/`idle`/`inactive` status is derived separately and never rewrites this record.
Every member starts in `provisioning` and reaches exactly one terminal roster phase, `active` or `failed`. Roster `running`/`inactive` status is derived separately and never rewrites this record.
## Durable mailbox
@@ -38,7 +38,7 @@ interface TeamMessageSnapshot {
}
```
Every message attempts Steer delivery. A running target receives it at the nearest step boundary, an idle target starts a turn, and an inactive teammate cold-resumes. Scheduling is not stored in the durable record because callers cannot select another mode.
Every message attempts Steer delivery. A running target receives it at the nearest step boundary; an inactive target starts a turn if loaded or cold-resumes otherwise. Scheduling is not stored in the durable record because callers cannot select another mode.
The target Session keeps message identity and sender attribution on both the pending inbox item and the eventual user message. Folding that source across inbox and history is the target-side de-duplication key; the model-visible framing repeats the id and sender.
@@ -168,7 +168,7 @@ async waitForChange(caller: Agent, timeoutMs: number, signal: AbortSignal): Prom
* @param targetName - durable teammate name.
* @returns the target status sampled before cancellation.
*/
interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'idle' | 'inactive' }
interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'inactive' }
/**
* Resolve a caller without throwing, used by scoped-tool installation and observers.
+3 -3
View File
@@ -21,7 +21,7 @@ interface TeamMemberSnapshot {
}
```
每个 member 都从 `provisioning` 开始,并且只到达一个终态 roster phase:`active` 或 `failed`。运行时 `running`/`idle`/`inactive` 状态单独派生,绝不会重写该记录。
每个 member 都从 `provisioning` 开始,并且只到达一个终态 roster phase:`active` 或 `failed`。roster 的 `running`/`inactive` 状态单独派生,绝不会重写该记录。
## 持久 mailbox
@@ -38,7 +38,7 @@ interface TeamMessageSnapshot {
}
```
每条消息都会尝试 Steer 投递。running target 在最近的步骤边界收到消息,idle target 启动一个轮次,inactive teammate 则冷恢复。调用方不能选择其他模式,因此持久记录不存储调度方式。
每条消息都会尝试 Steer 投递。running target 在最近的步骤边界收到消息,inactive target 在已加载时启动一个轮次,否则冷恢复。调用方不能选择其他模式,因此持久记录不存储调度方式。
target Session 会在 pending inbox 条目和最终用户消息上保留消息身份与发送者归因。跨 inbox 与历史折叠该 source 构成 target 侧去重键;模型可见的 framing 会重复 id 和发送者。
@@ -168,7 +168,7 @@ async waitForChange(caller: Agent, timeoutMs: number, signal: AbortSignal): Prom
* @param targetName - durable teammate name.
* @returns the target status sampled before cancellation.
*/
interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'idle' | 'inactive' }
interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'inactive' }
/**
* Resolve a caller without throwing, used by scoped-tool installation and observers.
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/slots.md
slots.md: a9f0ee6ab2ef37065ab965a7989f5989350bca8b
slots.zh.md: da6a38eab6aac1c2670b131fa49a832ce41f711e
slots.md: 1b7dbd9e8ce81d298094df8a2adf7da39178541d
slots.zh.md: b852e3f204c0acea49f94cffcedf2eebad0e432e
+7 -6
View File
@@ -144,12 +144,13 @@ root
│ │ │ └─ tool.view.cordis
│ │ ├─ conversation.message.images
│ │ └─ conversation.trajectory.images
│ ├─ conversation.session.header
│ │ ├─ conversation.session.header.lineage
│ │ ├─ conversation.session.header.leading
│ │ ├─ conversation.session.header.actions
│ │ ├─ conversation.session.header.utilities
│ │ └─ conversation.session.header.corner
│ ├─ conversation.header
│ │ ├─ conversation.header.leading
│ │ └─ conversation.session.header
│ │ ├─ conversation.session.header.lineage
│ │ ├─ conversation.session.header.actions
│ │ ├─ conversation.session.header.utilities
│ │ └─ conversation.session.header.corner
│ ├─ conversation.composer
│ │ ├─ conversation.approval.detail
│ │ └─ conversation.plan-review.actions
+7 -6
View File
@@ -144,12 +144,13 @@ root
│ │ │ └─ tool.view.cordis
│ │ ├─ conversation.message.images
│ │ └─ conversation.trajectory.images
│ ├─ conversation.session.header
│ │ ├─ conversation.session.header.lineage
│ │ ├─ conversation.session.header.leading
│ │ ├─ conversation.session.header.actions
│ │ ├─ conversation.session.header.utilities
│ │ └─ conversation.session.header.corner
│ ├─ conversation.header
│ │ ├─ conversation.header.leading
│ │ └─ conversation.session.header
│ │ ├─ conversation.session.header.lineage
│ │ ├─ conversation.session.header.actions
│ │ ├─ conversation.session.header.utilities
│ │ └─ conversation.session.header.corner
│ ├─ conversation.composer
│ │ ├─ conversation.approval.detail
│ │ └─ conversation.plan-review.actions
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/subagent.md
subagent.md: 3a95e7ce4d39994d4147c4e5bc1f012fc39343a3
subagent.zh.md: d10f872a696d65127505aaa725f31c987adb834d
subagent.md: 7a8bafdb5acbd51f19f42ab6906ccf99f1ee715b
subagent.zh.md: b43a9f9fcefcbef0841e2b01a84eb8e4d2846e9c
Loaded 100 of 343 files, more files were not shown because too many files have changed in this diff. Show more