Merge remote-tracking branch 'origin/master' into feat/current-version-display

This commit is contained in:
winewill committed 2026-09-20 17:46:50 +08:00
commit 9d7e5e30ac
382 files changed
+8994 -8625

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/bug-fix/2026-09-16-movable-mandatory-update-window.md
2026-09-16-movable-mandatory-update-window.md: 4695074429b76b467e6ec90201632dbb5a007e68
2026-09-16-movable-mandatory-update-window.zh.md: 3b416c53dc02abce1276bb70a13bb45b4c1afa44
2026-09-16-movable-mandatory-update-window.md: da996e5b37f990ff773e3ec88de0baed3c4b54f3
2026-09-16-movable-mandatory-update-window.zh.md: 15fd4eb2520a39d8259321a747b33c833b56cbb1
@@ -1,6 +1,7 @@
# Agent Note: Movable mandatory-update window
Status: implemented
Archived: 2026-09-18
English | [中文](2026-09-16-movable-mandatory-update-window.zh.md)
@@ -1,6 +1,7 @@
# Agent Note: 可移动的强更窗口
Status: implemented
Archived: 2026-09-18
[English](2026-09-16-movable-mandatory-update-window.md) | 中文
+3
View File
@@ -751,6 +751,9 @@
"bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml": "sha256:a4404fe8e35ca5b6371f9cdb58fea6724cf3e7737bb2db8a3a321d534454b17c",
"bug-fix/2026-09-09-content-sized-diagram-previews.md": "sha256:5239e98e5c057d6f7e10c3f10a0f67273778d99b0c4f10b31a1921c3c6474b80",
"bug-fix/2026-09-09-content-sized-diagram-previews.zh.md": "sha256:c1ee3790a9d5c3561e1d93d5e649afa904c7d19b9d77248c85ef16daccd953e2",
"bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml": "sha256:e526ce14ae9557e24364e3686778b130090445ef2152d976b7cc93e76eac6401",
"bug-fix/2026-09-16-movable-mandatory-update-window.md": "sha256:67546cea0b3de2b3e32957a508dbde097d91dd919fe067c8dbecb2c65b50c366",
"bug-fix/2026-09-16-movable-mandatory-update-window.zh.md": "sha256:20a0aade6ee71c89e84d70e23c89bb51e8a38c674d103dd43022feabb8d27be3",
"bug-fix/2026-09-11-session-controller-fork-turn-cut.i18n.yaml": "sha256:4442529ced1c8e48d30f7b50c65ff03eb92dce9815beb438cedbf29f234bce47",
"bug-fix/2026-09-11-session-controller-fork-turn-cut.md": "sha256:a996f24a896c7fc5d81e015db3eae27d8a46c853287572e815623ce35eae1b12",
"bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md": "sha256:6486f0eacf5067ef9850dc32f1c27b2109a7d4e9e3c498366f5c38742bb53ac5",
@@ -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-31-released-session-format-migrations.md
2026-08-31-released-session-format-migrations.md: 273b51ac7ce9c507c2322499788dc2084b857415
2026-08-31-released-session-format-migrations.zh.md: 69132ec1763ad12b51480e150c3968aa1dcacf61
2026-08-31-released-session-format-migrations.md: e21793e0ea3d0c5376cbb3949c5d6ef599b9afb1
2026-08-31-released-session-format-migrations.zh.md: 637d8fcf0206661df9a6f1b3b470ad8a55746720
@@ -131,7 +131,7 @@ Existing write handles retain the process-local claim and kernel-backed cross-pr
### Parent catalog prerequisites
V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; absent entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs without backfilling that child, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. An unsupported generation or unreadable header elsewhere in that scan refuses migration because it cannot prove unrelatedness. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The storage decoder classifies physical JSON and compression failures before the collector adds child context; filesystem reads and cancellation keep their original errors. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md).
Runtime child-failure handling in this paragraph is partially superseded by [session-local catalog preparation](../bug-fix/2026-09-19-session-local-subagent-migration.md): opening the parent still completes its catalog from direct-child reads, but unreadable children do not block healthy parent history. V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; new complete entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs and retain unknown-mode membership without inventing discovery fields, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. Unreadable or unsupported headers are excluded from discovery; failures reading a recognized child preserve its header identity as unknown-mode membership. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The collector reports child decoding and descriptor failures as warnings with the child path; cancellation and source-consistency checks still stop preparation. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md).
Raw parent/child migration tests retain 24 historical creation-time conflicts as rejection evidence. Detached controls align only the child creation time and prove event-preserving restoration, including a published child without a descriptor. Headless/ACP and SDK snapshot adapters validate live parent/child clocks before normalization and preserve their equality during refresh; committed predecessor generations remain unchanged.
@@ -131,7 +131,7 @@ POSIX publication 使用 hard-link creation 加目录 sync;Windows 使用 no-o
### 父目录前置事实
V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;缺失记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志但不补填对应子 Session,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。扫描中其他任何不支持代际或不可读 header 都会拒绝迁移,因为无法证明它与父 Session 无关。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。存储解码器先将物理 JSON 与压缩错误归类,再由收集器补充子日志信息;文件系统读取与取消保留原始错误。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。
本段的运行时子会话错误处理部分由[逐会话目录准备](../bug-fix/2026-09-19-session-local-subagent-migration.zh.md)取代:打开父会话仍通过读取直属子会话补齐目录,但不可读子会话不再阻断健康父历史。V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;新增完整记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志与未知模式成员关系,不编造发现字段,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。不可读或不支持的 header 不参与发现;读取已识别子会话失败时,其 header 身份保留为未知模式成员关系。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。收集器将子解码和 descriptor 失败报告为带子路径的警告;取消与来源一致性检查仍中止准备。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。
原始父子迁移测试保留 24 个历史创建时间冲突作为拒绝证据。独立的内存对照只对齐子创建时间,证明恢复保留事件,包括已发布但没有 descriptor 的子 Session。Headless/ACP 与 SDK 快照适配器在规范化之前校验实时父子时钟,并在刷新时保持两者相等;已提交的前代文件保持不变。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md
2026-09-15-messages-v1-base-url.md: 97f7294c28988e6f115963033cdac6794d70d6f3
2026-09-15-messages-v1-base-url.zh.md: ccb8c5930dbbfa6274770cc03087b4d8077fe27b
2026-09-15-messages-v1-base-url.md: d9014756eacd1752495bfecb52860634ece08ae1
2026-09-15-messages-v1-base-url.zh.md: 8f5803e4bdc68195164e187d1aa8cd537f71c5d6
@@ -12,7 +12,7 @@ The Messages transport appends the Anthropic-standard `/v1` namespace to a confi
The shared Messages API owner trims trailing slashes and treats only a final path segment exactly equal to `v1` as the existing API version. It preserves that root and appends `/messages` or `/files`; every other base receives `/v1/messages` or `/v1/files`. The same resolved root scopes cached file uploads. The official `https://api.deepseek.com/anthropic` base therefore resolves to `/anthropic/v1`, and an explicit `/anthropic/v1` base remains unchanged.
Chat Completions retains its independent URL behavior. The Messages rule does not infer support for `v1beta`, `v2`, `v4`, or other version-like suffixes; deployments that include those segments receive the standard `/v1` namespace beneath them.
The Messages rule does not infer support for `v1beta`, `v2`, `v4`, or other version-like suffixes; deployments that include those segments receive the standard `/v1` namespace beneath them.
## Alternatives considered
@@ -12,7 +12,7 @@ Messages 传输会在配置的基址后追加 Anthropic 标准 `/v1` 命名空
共享 Messages API 所有者移除末尾斜线,仅把严格等于 `v1` 的最后路径段视为已有 API 版本。它保留该根地址并追加 `/messages` 或 `/files`;其他基址均追加 `/v1/messages` 或 `/v1/files`。缓存文件上传也使用同一个解析后的根地址划分作用域。因此,官方 `https://api.deepseek.com/anthropic` 基址解析为 `/anthropic/v1`,显式 `/anthropic/v1` 基址保持不变。
Chat Completions 保留独立的 URL 行为。Messages 规则不会推断对 `v1beta`、`v2`、`v4` 或其他版本式后缀的支持;包含这些路径段的部署会在其下获得标准 `/v1` 命名空间。
Messages 规则不推断 `v1beta`、`v2`、`v4` 或其他类似版本后缀的支持;包含这些路径段的部署会在其下接收标准 `/v1` 命名空间。
## 考虑过的替代方案
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md
2026-09-16-messages-historical-tool-input.md: 201b0f21ad041395a4c4101d6919b2878e22c9ab
2026-09-16-messages-historical-tool-input.zh.md: bcc637982420124b401ef65c668b068394b6d915
2026-09-16-messages-historical-tool-input.md: 85e4047bb5274eb7c7adb458d564e1268d4ea81e
2026-09-16-messages-historical-tool-input.zh.md: 5a8c31dbee32094f124102ccac9ffb7b8b81b177
@@ -10,7 +10,7 @@ Chat Completions retains tool arguments as strings, including malformed JSON fro
## Decision
The [Messages serializer](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) follows the [pi-ai history conversion](../../../../packages/llm/llm-pi-ai/src/replay.ts): malformed JSON and non-object values become `{}` only in the outgoing historical tool input. Call ids, names, results, and original Session records remain intact. This applies with valid, absent, or unusable native replay metadata and does not execute the historical call again.
The [Messages serializer](../../../../packages/llm/llm-deepseek/src/serialize.ts) follows the [pi-ai history conversion](../../../../packages/llm/llm-pi-ai/src/replay.ts): malformed JSON and non-object values become `{}` only in the outgoing historical tool input. Call ids, names, results, and original Session records remain intact. This applies with valid, absent, or unusable native replay metadata and does not execute the historical call again.
This supersedes the historical argument rejection in the [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md). New Messages responses still require valid object arguments before successful completion; output-limit truncation retains its existing pruning behavior. No Session event, persistence type, or protocol configuration changes.
@@ -10,7 +10,7 @@ Chat Completions 将工具参数保留为字符串,其中可能包含失败调
## 决策
[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) 遵循 [pi-ai 历史转换](../../../../packages/llm/llm-pi-ai/src/replay.ts)的做法:只在发出的历史工具输入中,将非法 JSON 和非对象值替换为 `{}`。调用 ID、名称、结果和原始 Session 记录保持不变。原生回放元数据有效、缺失或不可用时均采用此规则,也不会重新执行历史调用。
[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/serialize.ts) 遵循 [pi-ai 历史转换](../../../../packages/llm/llm-pi-ai/src/replay.ts)的做法:只在发出的历史工具输入中,将非法 JSON 和非对象值替换为 `{}`。调用 ID、名称、结果和原始 Session 记录保持不变。原生回放元数据有效、缺失或不可用时均采用此规则,也不会重新执行历史调用。
这取代了 [Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)中的历史参数拒绝规则。新生成的 Messages 响应在成功完成前仍要求工具参数是有效对象;达到输出上限时仍按现有规则裁剪。不改变 Session 事件、持久化类型或协议配置。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-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
2026-09-18-messages-input-history-compatibility.md: dd974fb1ae883716002a1381f4c374dee792b3b3
2026-09-18-messages-input-history-compatibility.zh.md: 6e850328db63d5ae5cb508aa3529e9f2507182b0
@@ -10,7 +10,7 @@ Saved subagent settlement notices can contain child reasoning and tool calls in
## 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.
The [Messages serializer](../../../../packages/llm/llm-deepseek/src/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.
@@ -10,7 +10,7 @@ Status: implemented
## 决策
[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 序列化器](../../../../packages/llm/llm-deepseek/src/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)中的输入拒绝规则。通知构建仍为每种父级提供方投影子级的非空文本。子级的规范输出以及普通助手推理和工具调用仍可供现有消费者使用。省略依据是输入角色和块类型,而不是通知来源或创建日期,因此也适用于新提供的用户和工具结果内容。
@@ -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-19-session-local-subagent-migration.md
2026-09-19-session-local-subagent-migration.md: 26911ea6e2cb31126d13e4c8c6488ff3b45563e6
2026-09-19-session-local-subagent-migration.zh.md: 673040a3dc73d3665e42013a4704ef2d5911b23d
@@ -0,0 +1,33 @@
# Agent Note: Complete parent catalogs without migrating child catalogs
Status: implemented
English | [中文](2026-09-19-session-local-subagent-migration.zh.md)
## Problem
Opening a historical parent requires discovery facts from its direct children. A child's corrupt body or invalid descriptor, or an unreadable header elsewhere in the root, could reject the parent's V4 preparation and hide otherwise readable history.
## Decision
Opening A discovers candidate B Sessions through headers, reads their own descriptors, and completes A's catalog. Historical child reads use the V0–V3 catalog; current child reads use native validation. These reads neither prepare B's own catalog nor publish B's successor. Opening B separately performs B's migration and catalog preparation. Frontend discovery retains its parent-catalog source. Unknown-mode rows remain browsable; `list_agents` continues to select only known continuable children.
JSONL omits unreadable or unsupported headers from discovery. A child-body or descriptor-field failure produces a warning with the child path and retains the child's header identity. If the parent lacks a complete entry, migration appends `subagent/catalog`, projected as `mode: 'unknown'` alongside healthy siblings. Existing complete entries remain authoritative. Cancellation and source-consistency failures still stop the operation. Inspected child revisions, including failed decodes, are rechecked before reuse and publication so repairs cannot silently reuse stale evidence. Released predecessors remain byte-identical.
This partially supersedes child-failure propagation in [released migrations](../architecture/2026-08-31-released-session-format-migrations.md) and [incomplete child evidence](2026-09-19-v3-incomplete-child-catalog-evidence.md). Their format conversion, descriptor cardinality, conflict validation, and immutable publication rules remain active.
## Alternatives considered
**Skip catalog completion and discover children in each consumer.** This introduces incomplete tool results and additional UI states despite the parent's readable child descriptors. The parent catalog already serves these consumers.
**Drop unreadable children from the catalog.** Published parents would lose their navigation entries. Header identity is sufficient to retain membership without inventing a mode or label.
**Recursively prepare child catalogs.** A only needs B's own descriptor. Preparing B's descendants adds unrelated work and failure dependencies.
**Refuse A when one child cannot be read.** The child's discovery metadata does not justify withholding the parent's healthy history. Warnings retain the child location without inventing mode or label fields.
## Consequences
Initial parent preparation still scans headers and reads direct-child bodies one at a time. It does not claim to eliminate this I/O. Published V4 parents use their catalog without rescanning children. Unknown entries are not automatically rewritten after publication. Opening a child retries its log and determines browsing capabilities from its actual descriptor, or reports the child-local error. Catalog payload v0 retains its two known modes; v1 also supports `unknown`. Readers support both versions; healthy creation and complete historical facts retain v0, while unknown migration facts use v1. The V4 Session header is unchanged, and older readers reject the new payload version.
Raw and compressed tests cover catalog completion, child-local errors, source changes and repairs, unchanged child generations, and deferred child-catalog preparation. The shipped Web migration snapshot checks the parent catalog with a corrupt sibling, then opens the child independently. Its additional historical child raises the corpus budget from ten to eleven roles; the guard rejects a twelfth role.
@@ -0,0 +1,33 @@
# Agent Note:补齐父目录而不迁移子目录
Status: implemented
[English](2026-09-19-session-local-subagent-migration.md) | 中文
## 问题
打开历史父会话需要直属子会话的发现事实。子日志正文损坏、descriptor 无效,或根目录其他位置的 header 不可读,都可能拒绝父会话的 V4 准备,使原本可读的历史无法打开。
## 决策
打开 A 时通过 header 发现候选 B Session,读取其自身 descriptor,并补齐 A 的 catalog。历史子日志使用 V0–V3 catalog 读取,当前子日志使用原生校验。这些读取不准备 B 自己的 catalog,也不发布 B 的后继。单独打开 B 时才执行 B 的迁移与目录准备。前端发现继续读取父 catalog。模式未知的条目仍可浏览;`list_agents` 继续只选择已知可继续的子会话。
JSONL 在发现时跳过不可读或不支持的 header。子正文或 descriptor 字段失败会产生带子路径的警告,并保留该子的 header 身份。父目录没有完整条目时,迁移追加 `subagent/catalog`,与健康兄弟项一同投影为目录条目,其模式为 `mode: 'unknown'`。已有完整条目仍具有权威性。取消与来源一致性失败仍中止操作。复用和发布前会重新检查已检查子日志的修订,包括解码失败的日志,使修复不能静默复用旧证据。已发布前驱保持逐字节不变。
本决策部分取代[已发布迁移](../architecture/2026-08-31-released-session-format-migrations.zh.md)和[不完整子证据](2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)中的子错误传播规则。其格式转换、descriptor 数量、冲突校验和不可变发布规则仍有效。
## 考虑过的替代方案
**跳过目录补齐,让每个消费者发现子项。** 父会话明明有可读的子 descriptor,却会因此产生不完整工具结果和额外 UI 状态。父 catalog 已经服务这些消费者。
**从目录删除不可读子会话。** 已发布父会话会丢失相应导航入口。Header 身份足以保留成员关系,无须编造模式或标签。
**递归准备子目录。** A 只需要 B 自身的 descriptor。准备 B 的后代会增加无关工作与失败依赖。
**一个子会话不可读就拒绝 A。** 子会话的发现元数据不足以成为阻断健康父历史的理由。警告保留子位置,不虚构 mode 或 label 字段。
## 后果
首次父准备仍扫描 header 并逐个读取直属子正文,不宣称消除此 I/O。已发布 V4 父会话使用其 catalog,不重新扫描子日志。发布后不自动重写未知条目。打开子会话会重试其日志,根据实际 descriptor 确定浏览能力,或报告该子会话的错误。Catalog 载荷 v0 保留两种已知模式,v1 另支持 `unknown`。读取器支持两版;正常创建及完整历史事实继续使用 v0,未知迁移事实使用 v1。V4 Session header 不变,旧读取器拒绝新载荷版本。
原始和压缩测试覆盖目录补齐、子会话错误隔离、来源变化与修复、子代际不变和延后的子目录准备。随产品 Web 迁移快照在损坏兄弟存在时检查父目录,再独立打开子会话。额外的历史子角色使语料预算由十个增加到十一个;守卫拒绝第十二个角色。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md
2026-09-19-v3-incomplete-child-catalog-evidence.md: 22ca91502425257ed30aa4962f5a38497b8a8a92
2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: c11e6de2c27b9892a72c28f327b6d06834d84038
2026-09-19-v3-incomplete-child-catalog-evidence.md: 400a84f376a7babc0a95492ca33a3bcd0761cd79
2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: 535951630bc600647724d50107e74e83ab626739
@@ -10,9 +10,9 @@ A valid V3 child log can have no own descriptor, an unsupported descriptor versi
## Decision
The V3→V4 migration appends a missing parent catalog entry only when exactly one supported own child descriptor supplies its discovery fields. Other descriptor counts and unsupported versions contribute no new catalog fact. Existing parent entries and child events remain intact. This replaces only the missing-evidence refusal in the [adjacent migration decision](../architecture/2026-08-31-released-session-format-migrations.md).
The V3→V4 migration appends a complete missing parent catalog entry only when exactly one supported own child descriptor supplies its discovery fields. Other descriptor counts and unsupported versions retain unknown-mode membership through [session-local catalog preparation](2026-09-19-session-local-subagent-migration.md). Existing parent entries and child events remain intact. This replaces only the missing-evidence refusal in the [adjacent migration decision](../architecture/2026-08-31-released-session-format-migrations.md).
Known child creation times must still match existing parent entries. Mode/label and descriptor fields are checked only for exactly one supported own descriptor. Invalid fields in that descriptor, corrupt logs, unreadable membership, unsupported selected generations, and changed source revisions retain their existing failures.
The JSONL failure-propagation rules below are partially superseded by [session-local catalog preparation](2026-09-19-session-local-subagent-migration.md); the converter still validates supplied facts and conflicts. Known child creation times must still match existing parent entries. Mode/label and descriptor fields are checked only for exactly one supported own descriptor. JSONL isolates invalid descriptor fields and corrupt child bodies while retaining known header identity; unreadable or unsupported headers are excluded from discovery. Changed source revisions still refuse publication.
## Alternatives considered
@@ -24,6 +24,6 @@ Known child creation times must still match existing parent entries. Mode/label
## Consequences
A historical child without a usable descriptor remains readable by id but may be absent from its parent's direct-child catalog. Existing catalog entries remain visible. Once a V4 successor is published, opening it does not rescan historical children; automatic later catalog repair is outside this migration. Preparation still rechecks child revisions before publication, so newly available evidence cannot silently bypass source consistency checks.
A historical child with a readable header but no usable descriptor retains unknown-mode membership in its parent's direct-child catalog. Existing catalog entries remain visible. Once a V4 successor is published, opening it does not rescan historical children; automatic later mode backfill is outside this migration. Preparation still rechecks child revisions before publication, so newly available evidence cannot silently bypass source consistency checks.
Unit, JSONL read/write, and Preview packing tests cover unavailable evidence, unchanged source bytes, retained entries, identity conflicts, and evidence changes before publication.
@@ -10,9 +10,9 @@ Status: implemented
## Decision
V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字段时,追加缺失的父目录项。其他 descriptor 数量及不受支持的版本不贡献新的目录事实。已有父目录项和子事件保持完整。这只替代[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)中的证据缺失拒绝规则。
V3→V4 迁移仅在恰好一个受支持的自身子 descriptor 提供发现字段时,追加完整的缺失父目录项。其他 descriptor 数量和不支持的版本通过[逐会话目录准备](2026-09-19-session-local-subagent-migration.zh.md)保留模式未知的成员关系。已有父目录项和子事件保持不变。这取代[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)中对缺失证据的拒绝。
已知子创建时间仍必须与已有父目录项一致。只有恰好一个受支持的自身 descriptor 时,才校验其字段并比较 mode/label。该 descriptor 字段无效、日志损坏、成员关系不可读、所选代际不受支持以及源修订变化仍保留原有失败行为。
JSONL 错误传播规则由[逐会话目录准备](2026-09-19-session-local-subagent-migration.zh.md)部分取代;转换器仍校验已提供事实及冲突。已知子创建时间必须匹配已有父目录项。只有恰好一个受支持的自身 descriptor 时才检查 mode/label 和 descriptor 字段。JSONL 隔离无效 descriptor 字段和损坏子正文,并保留已知 header 身份;不可读或不支持的 header 不参与发现。来源修订变化仍拒绝发布。
## Alternatives considered
@@ -24,6 +24,6 @@ V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字
## Consequences
没有可用 descriptor 的历史子 Session 仍可按 id 读取,但可能不出现在其父级的直属子目录中。已有目录项仍可见。V4 后继发布后,打开它不会重新扫描历史子日志;后续自动修复目录不属于本迁移。准备过程仍会在发布前复查子修订,因此新出现的证据无法悄悄绕过源一致性检查。
历史子会话具有可读 header 但没有可用 descriptor 时,在父直属子目录中保留未知模式成员关系。已有目录项仍可见。V4 后继发布后,打开它不重新扫描历史子日志;自动补齐后续模式不属于本迁移。准备过程仍在发布前重新检查子修订,因此新出现的证据不能静默绕过来源一致性检查。
单元、JSONL 读写和 Preview 打包测试覆盖不可用证据、源字节不变、条目保留、身份冲突以及发布前的证据变化。
@@ -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-20-windows-embedded-mandatory-update.md
2026-09-20-windows-embedded-mandatory-update.md: 99577a4d85adc3fe13ea0ae262d5aa1af6128237
2026-09-20-windows-embedded-mandatory-update.zh.md: abe4a9577d8e11d8de2089dc53ef07433e10bbdb
@@ -0,0 +1,25 @@
# Agent Note: Windows embedded mandatory update
Status: implemented
English | [中文](2026-09-20-windows-embedded-mandatory-update.zh.md)
## Problem
A second native Windows window does not resize atomically with its parent, producing a detached overlay during maximization (#4566). Native modality also disables the parent controls that users need to exit.
## Decision
Windows uses one native window and a shell-origin frame below the caption. This partially supersedes the presentation decision in the [mandatory-update client](../feature/2026-09-11-desktop-mandatory-update-client.md); that note retains policy and installation ownership. macOS retains its native overlay.
The isolated application preload transfers a private MessageChannel endpoint directly to the shell frame. Only messages on the paired private port authorize update IPC; synthetic window events cannot invoke actions. The main process validates the current application main frame and rebinds state delivery when the main window changes. Destroyed windows receive neither state nor focus operations.
## Alternatives considered
**Synchronize a second native window.** Parent movement, maximization and restoration expose independently scheduled geometry updates; removing the second window removes that synchronization problem.
**Treat source and origin on window events as authentication.** Product scripts can construct those event fields. A private port keeps action authority outside the shared product document.
## Consequences
Caption menus and Web controls remain reachable. The DOM overlay blocks ordinary background input but cannot prevent product scripts from hiding or imitating presentation; it is not a tamper-proof security surface. Main-process policy and installation authorization remain separate from that display. Regression coverage requires forged-event rejection, real channel delivery, caption keyboard operation, and destroyed/replacement-window lifecycle cases.
@@ -0,0 +1,25 @@
# Agent Note: Windows 内嵌强更弹窗
Status: implemented
[English](2026-09-20-windows-embedded-mandatory-update.md) | 中文
## 问题
Windows 的第二个原生窗口无法与父窗口原子地缩放,最大化时会出现脱离的蒙层(#4566)。原生模态还会禁用用户退出应用所需的父窗口控件。
## 决策
Windows 使用一个原生窗口和顶栏下方的壳源站 frame。此决策部分取代[强更客户端](../feature/2026-09-11-desktop-mandatory-update-client.zh.md)的展示决策;该说明继续负责策略与安装归属。macOS 保留原生蒙层。
隔离的应用预加载将私有 MessageChannel 端点直接传给壳 frame。只有配对私有端口上的消息才能授权更新 IPC;合成的 window 事件不能调用操作。主进程校验当前应用主 frame,并在主窗口变化时重新绑定状态投递。已销毁窗口不再接收状态或焦点操作。
## 考虑过的替代方案
**同步第二个原生窗口。** 父窗口移动、最大化与还原会暴露独立调度的几何更新;去掉第二个窗口即可消除这一同步问题。
**把 window 事件的 source 和 origin 当作身份认证。** 产品脚本可以构造这些事件字段。私有端口将操作权限保留在共享产品文档之外。
## 后果
顶栏菜单和 Web 控件仍可触达。DOM 蒙层阻止普通背景输入,但不能阻止产品脚本隐藏或仿造展示;它不是防篡改的安全界面。主进程策略和安装授权与展示相互独立。回归覆盖要求包括拒绝伪造事件、真实通道投递、顶栏键盘操作,以及窗口销毁和替换的生命周期用例。
@@ -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-20-unified-image-request-pipeline.md
2026-08-20-unified-image-request-pipeline.md: 7431a474b55a9dae82b1c9cd36b770b31fe39602
2026-08-20-unified-image-request-pipeline.zh.md: 247a57b8017f513c552204d951ef6931f7f9d045
2026-08-20-unified-image-request-pipeline.md: b79eeb7d908d664362851c28bda64dab163aac89
2026-08-20-unified-image-request-pipeline.zh.md: 444234715256c48167b0b45dbd8843a9c80079f7
@@ -34,7 +34,7 @@ Every retained request image is preceded by its display name or complete attachm
### DeepSeek Files lifecycle
The direct `deepseek-official` adapter normally uploads every retained request version through its selected protocol’s Files API and sends file-id references. [Messages](2026-09-07-deepseek-messages-adapter.md) and Chat Completions share the lifecycle while retaining their own endpoint and wire formats. A [bounded inline fallback](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md) sends the same deterministic request versions when file resolution fails. The default catalog advertises `deepseek-v4-flash-vision-exp` as image-capable. Uploaded ids are indexed by Files endpoint and API-key scope plus `variantId`. Uploads request seven days by default. Chat records the returned `expires_at`; Messages metadata omits expiry, so its local reuse deadline uses the original upload time plus the configured lifetime without promising remote deletion. A mapping with no more than one hour of reuse remaining is replaced without a preceding retrieve call. The index never stores the API key.
The direct `deepseek-official` adapter uploads every retained request version through the [Messages Files API](2026-09-07-deepseek-messages-adapter.md) and sends file-id references. A [bounded inline fallback](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md) sends the same deterministic request versions when file resolution fails. Uploaded ids are indexed by Files endpoint and API-key scope plus `variantId`. Uploads request seven days by default. Messages metadata omits expiry, so local reuse uses the original upload time plus the configured lifetime without promising remote deletion. A mapping with no more than one hour of reuse remaining is replaced without a preceding retrieve call. The index never stores the API key.
An upload is indexed only after the response returns a valid native file object with a matching byte count and the client establishes the protocol’s reuse deadline. A missing or inconsistent response leaves no local mapping, so a later request uploads again. Concurrent upload resolution for one scoped `variantId` shares one provider operation; one waiter cannot cancel another, and the upload stops when every waiter has cancelled. A malformed upload index is an empty cache and is replaced on the next successful upload; filesystem I/O failures remain errors. If the model endpoint reports expired, deleted, missing, or invalid ids and names one or more ids used by the request, only those mappings are removed. A stale-file response without a specific id removes every mapping used by that model request. The affected request bytes are uploaded again and the model request is retried once. A second stale rejection clears the mappings identified by its response and returns the error without a third model request. One upload quota error first lists the configured number of oldest harness-owned `dsh-` files, then deletes that collected set and retries once; deleting after pagination keeps provider cursors valid. Public file operations expose list, retrieve, delete, one-variant release, and namespace-wide release. Every Files request carries the shared Harness `User-Agent`. The client enforces a 128MiB upload limit, a 32MiB request-image limit, 10,000-file and 25GiB quotas, and one-hour to 30-day expiry range.
@@ -34,7 +34,7 @@ Status: implemented
### DeepSeek Files 生命周期
直接 `deepseek-official` 适配器通常通过所选协议的 Files API 上传每张保留的请求版本,并发送 file-id 引用。[Messages](2026-09-07-deepseek-messages-adapter.zh.md) 与 Chat Completions 共享生命周期,各自保留端点和协议格式。文件解析失败时,[有界内联回退](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md)会发送相同的确定性请求版本。默认 catalog 把 `deepseek-v4-flash-vision-exp` 公布为支持图片。上传 ID 按 Files 端点和 API key 作用域以及 `variantId` 写入索引。上传默认请求 7 天有效期。Chat 记录返回的 `expires_at`;Messages 元数据不含过期时间,因此本地复用期限使用原始上传时间加配置的生存期,但不保证远端文件删除。本地映射剩余复用时间不超过一小时时会直接替换,不会先查询远端文件。索引绝不存储 API key。
直连 `deepseek-official` 适配器通过 [Messages Files API](2026-09-07-deepseek-messages-adapter.zh.md) 上传每个保留的请求版本,并发送 file-id 引用。文件解析失败时,[有界内联回退](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md)发送相同的确定性请求版本。上传 id 按 Files 端点、API-key 作用域及 `variantId` 建立索引。上传默认请求七天有效期。Messages 元数据不含过期时间,因此本地复用使用原始上传时间加配置的有效期,不承诺远端删除。剩余复用时间不超过一小时的映射会直接替换,不先调用 retrieve。索引从不存储 API 密钥。
只有上传响应返回有效的原生文件对象、字节数匹配,且客户端确定了该协议的复用期限时,上传结果才会写入索引。缺失或不一致的响应不会留下本地映射,后续请求会重新上传。同一作用域和 `variantId` 的并发解析共享一次提供方上传;单个等待方无法取消其他等待方,全部等待方取消时才会停止上传。格式损坏的上传索引按空缓存处理,并在下一次成功上传时替换;文件系统 I/O 失败仍是错误。如果模型端点报告 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出具体 ID,适配器会删除该次模型请求使用的全部映射。受影响的请求字节会重新上传,模型请求只重试一次。第二次仍报告文件失效时,适配器会按响应清理映射并返回错误,不会发起第三次模型请求。一次上传配额错误会先列出配置数量的最旧 `dsh-` 文件,再删除收集到的文件并重试一次;分页完成后才删除,避免游标失效。公开文件操作提供列表、查询、删除、单个变体释放和整个作用域释放。每个 Files 请求都携带 Harness 的共享 `User-Agent`。客户端执行 Files 单次上传 128MiB、请求单图 32MiB、10,000 个文件、25GiB,以及一小时到 30 天有效期限制。
@@ -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: b79c2526461843a9534d6a997029281a584e0bed
2026-09-07-deepseek-messages-adapter.zh.md: 49a967828e22803296ae3d4c8eef943b9ec8e3c5
2026-09-07-deepseek-messages-adapter.md: 29f712e57c8048ddb0c05b5f56fd4793c52bec5f
2026-09-07-deepseek-messages-adapter.zh.md: 3cf60fc2101fb835423b74a6532e5edcc5bee911
@@ -6,29 +6,29 @@ English | [中文](2026-09-07-deepseek-messages-adapter.zh.md)
## Problem
Deployments expose DeepSeek through Anthropic Messages gateways as well as chat-completions. Messages represents thinking, signatures, tool calls, tool results, and cumulative usage differently. Translating only the endpoint or flattening assistant history loses information needed by subsequent tool turns.
Messages represents thinking, signatures, tool calls, tool results, and cumulative usage as native protocol fields. Translating only the endpoint or flattening assistant history loses information needed by subsequent tool turns.
## Decision
The [DeepSeek adapter](../../../../packages/llm/llm-deepseek/README.md) serves multiple protocols under one `deepseek-official` route and `llm-deepseek` settings namespace. `common/` shares configuration, the model catalog, capability resolution, and Files lifecycle; `protocols/chat-completions/` and `protocols/messages/` own serialization, stream conversion, and transport. Cordis YAML selects the implementation through `protocol`, defaulting to `messages`; shipped first-party compositions inherit that default. The existing `PreparedAdapterCall` freezes protocol, endpoint, credential reference, and model capabilities; retries retain that generation while subsequent calls read new configuration.
The [DeepSeek adapter](../../../../packages/llm/llm-deepseek/README.md) serves Messages under one `deepseek-official` route and `llm-deepseek` settings namespace. The [single-protocol decision](../simplification/2026-09-19-deepseek-messages-only.md) owns the transport scope. `PreparedAdapterCall` freezes the endpoint, credential reference, and model capabilities; retries retain that generation while subsequent calls read new configuration.
The adapter follows the [DeepSeek compatibility documentation](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) and [Anthropic streaming protocol](https://platform.claude.com/docs/en/build-with-claude/streaming). The pi-ai Anthropic implementation informed the handling of adjacent user messages, cumulative usage, fragmented tool arguments, and optional thinking signatures. DeepSeek effort uses `output_config.effort`; an Anthropic thinking token budget does not control DeepSeek effort. Both protocols forward explicit `temperature` values; DeepSeek accepts that parameter with thinking enabled and ignores its value, so callers retain their existing thinking configuration.
The adapter follows the [DeepSeek compatibility documentation](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) and [Anthropic streaming protocol](https://platform.claude.com/docs/en/build-with-claude/streaming). The pi-ai Anthropic implementation informed the handling of adjacent user messages, cumulative usage, fragmented tool arguments, and optional thinking signatures. DeepSeek effort uses `output_config.effort`; an Anthropic thinking token budget does not control DeepSeek effort. The adapter forwards explicit `temperature` values; DeepSeek accepts that parameter with thinking enabled and ignores its value, so callers retain their existing thinking configuration.
Assistant blocks remain the durable model-visible content. A versioned `ReplayEnvelope` stores only the protocol format, model identity, aligned block kinds, and signatures absent from those blocks. Same-model Messages continuation restores signatures verbatim, including empty signatures; foreign history carries no invented signature. Unusable metadata follows the existing [replay degradation rule](../architecture/2026-07-14-provider-routed-llm-adapters.md): the request omits signatures with a warning while preserving durable content; historical tool arguments use the [empty-input fallback](../bug-fix/2026-09-16-messages-historical-tool-input.md) when Messages cannot represent them. This keeps provider replay data opaque to the loop while preserving it through Session persistence and block pruning.
Both protocols prefer Files references for deterministic request images and share upload caching, refresh, quota recovery, and attachment offload. The Files client retains the selected protocol and configured endpoint: Messages follows the [exact `/v1` root rule](../bug-fix/2026-09-15-messages-v1-base-url.md), while Chat Completions appends `/files`. Messages Files requests carry the required beta header. Cached ids remain scoped by the resolved Files root and credential, so equivalent `/v1` and unversioned Messages roots share uploads. Messages metadata omits expiry, so local reuse is bounded from the original upload time without asserting remote deletion. A Files-resolution failure rebuilds the complete request under the independent inline-image budget; caller cancellation stops it. The shared image policy preserves the 128 MiB retained-image budget, 20 MiB inline base64 budget, and oldest-prefix offload in both requests and token measurement.
The adapter prefers Files references for deterministic request images, with upload caching, refresh, quota recovery, and attachment offload. The Files client follows the [exact `/v1` root rule](../bug-fix/2026-09-15-messages-v1-base-url.md) and sends the required beta header. Cached ids remain scoped by the resolved Files root and credential, so equivalent `/v1` and unversioned roots share uploads. Metadata omits expiry, so local reuse is bounded from the original upload time without asserting remote deletion. A Files-resolution failure rebuilds the complete request under the independent inline-image budget; caller cancellation stops it. The image policy preserves the 128 MiB retained-image budget, 20 MiB inline base64 budget, and oldest-prefix offload in both requests and token measurement.
JSON syntax failure after a successful HTTP response does not establish a transport failure. Files decoding uses the existing `INVALID_RESPONSE` code with operation context instead of relabeling the failure as `TRANSPORT` or adding a retry; the [provider README](../../../../packages/llm/llm-deepseek/README.md) owns the error fields.
System updates use the existing [route capability](2026-09-02-in-history-system-prompt-replacement.md) when explicitly declared for an endpoint/model. Messages retains the initial top-level system and emits later snapshots as native system turns after the corresponding user/tool-result turn, preserving previously sent prefixes. This placement differs from the loop's system-before-user admission; serialization changes neither the durable log nor conversation-turn order. Undeclared routes consolidate the latest snapshot at the top level, including direct compaction calls. Capability inference from protocol or model names is insufficient because support and update semantics depend on the deployed endpoint.
Web always displays DeepSeek without a protocol selector. Both protocols share `baseURL` and `apiKeyEnv`, with no nested per-protocol configuration map. Without an endpoint override, resolution uses the selected protocol’s official default; Messages uses `https://api.deepseek.com/anthropic`. Switching retains existing endpoint overrides, whose compatibility belongs to the deployment. Messages follows the exact `/v1` root rule rather than inferring compatibility from other version-like suffixes. One model catalog includes `deepseek-flash` text/image and in-history system capabilities and retains the V4 entries. Explicit `chat-completions` remains supported with its own official default and appends `/chat/completions` without adding a version segment.
Web displays DeepSeek with an endpoint and credential reference. Without an endpoint override, resolution uses `https://api.deepseek.com/anthropic`. Overrides must support Messages. The adapter follows the exact `/v1` root rule rather than inferring compatibility from other version-like suffixes. One model catalog advertises the capabilities declared by its entries.
Both transports use the existing [request-extension registry](../architecture/2026-08-21-deepseek-llm-api-request-extensions.md) after native serialization and accept captured contributions after HTTP 2xx, before reading the stream. Session-log delivery and plugin inventory retain their owners and remain outside model input. The auxiliary [web-search provider](../../../../packages/web/web-search-deepseek/README.md) retains its separate endpoint, request, and settings.
The adapter uses the existing [request-extension registry](../architecture/2026-08-21-deepseek-llm-api-request-extensions.md) after native serialization and accepts captured contributions after HTTP 2xx, before reading the stream. Session-log delivery and plugin inventory retain their owners and remain outside model input. The auxiliary [web-search provider](../../../../packages/web/web-search-deepseek/README.md) retains its separate endpoint, request, and settings.
## Alternatives considered
**Separate plugins per protocol.** This duplicates credentials, catalogs, settings cards, and provider identities, and forces users to reselect models when the wire protocol changes. Protocol folders inside one plugin retain implementation isolation; Responses can add an implementation without changing the user configuration structure.
**Separate Messages provider identity.** This duplicates credentials, catalogs, and settings cards, and forces users to reselect models when adopting Messages. The `deepseek-official` identity keeps those user choices stable.
**Delegate the new route to pi-ai or the Anthropic SDK.** Both provide maintained protocol implementations, but the requested direct adapter needs DeepSeek-specific configuration, attachment policy, credential resolution, and retry ownership. A small stream translator with a maintained SSE parser keeps these responsibilities explicit; the library-backed adapter remains available independently.
@@ -38,6 +38,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. 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.
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 serves the shared base, Web, and standalone first-party compositions. Explicit endpoint overrides must support Messages.
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.
@@ -6,29 +6,29 @@ Status: implemented
## 问题
部署可以通过 Anthropic Messages 网关或 chat-completions 调用 DeepSeek。Messages 对思考、签名、工具调用、工具结果和累计用量的表示不同。仅替换端点或压平助手历史会丢失后续工具轮次需要的信息。
Messages 以原生协议字段表示思考、签名、工具调用、工具结果和累计用量。仅替换端点或压平助手历史会丢失后续工具轮次需要的信息。
## 决策
[DeepSeek 适配器](../../../../packages/llm/llm-deepseek/README.zh.md)通过一个 `deepseek-official` 路由和 `llm-deepseek` 设置命名空间支持多个协议。`common/` 共享配置、模型目录、能力解析和 Files 生命周期;`protocols/chat-completions/` 与 `protocols/messages/` 分别负责协议序列化、流转换和传输。`protocol` 配置在 Cordis YAML 中选择实现,默认 `messages`;随产品交付的官方组合继承该默认值。已有 `PreparedAdapterCall` 冻结协议、端点、凭据引用与模型能力,重试保持同一代配置,后续调用读取新配置。
[DeepSeek 适配器](../../../../packages/llm/llm-deepseek/README.zh.md)在唯一的 `deepseek-official` 路由和 `llm-deepseek` 设置命名空间下提供 Messages。[单协议决策](../simplification/2026-09-19-deepseek-messages-only.zh.md)拥有传输范围。`PreparedAdapterCall` 固定端点、凭据引用与模型能力;重试保留该代配置,后续调用读取新配置。
适配器遵循 [DeepSeek 兼容文档](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) 和 [Anthropic 流协议](https://platform.claude.com/docs/en/build-with-claude/streaming)。pi-ai 的 Anthropic 实现为相邻用户消息、累计用量、工具参数分片和可选思考签名的处理提供参考。DeepSeek 通过 `output_config.effort` 设置思考强度;Anthropic 思考 token 预算不控制 DeepSeek 思考强度。两种协议都转发显式 `temperature` 值;DeepSeek 在启用思考时接受该参数但忽略其值,因此调用方可以保留已有思考配置。
适配器遵循 [DeepSeek 兼容文档](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) 和 [Anthropic 流协议](https://platform.claude.com/docs/en/build-with-claude/streaming)。pi-ai 的 Anthropic 实现为相邻用户消息、累计用量、工具参数分片和可选思考签名的处理提供参考。DeepSeek 通过 `output_config.effort` 设置思考强度;Anthropic 思考 token 预算不控制 DeepSeek 思考强度。适配器转发显式 `temperature` 值;DeepSeek 在启用思考时接受该参数但忽略其值,因此调用方可以保留已有思考配置。
助手内容块保留持久化的模型可见内容。带版本的 `ReplayEnvelope` 仅保存协议格式、模型标识、对齐的块类型以及内容块未包含的签名。同模型续接原样恢复签名,包括空签名;外部历史不生成虚构签名。不可用的元数据遵循现有[回放降级规则](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):请求省略签名并记录警告,保留持久化内容;Messages 无法表示历史工具参数时使用[空输入兜底](../bug-fix/2026-09-16-messages-historical-tool-input.zh.md)。提供者回放数据对循环保持不透明,同时能够随 Session 持久化和内容块裁剪保留。
两种协议均优先为确定性请求图片使用 Files 引用,并共享上传缓存、刷新、配额恢复和附件卸载。Files 客户端保留所选协议与已配置端点:Messages 遵循[严格匹配 `/v1` 的根地址规则](../bug-fix/2026-09-15-messages-v1-base-url.zh.md),Chat Completions 则追加 `/files`。Messages Files 请求携带必需的 beta 标头。缓存 id 按解析后的 Files 根地址和凭据限定作用域,因此等价的 `/v1` 与无版本 Messages 根地址可以复用上传。Messages 元数据不含过期时间,因此本地复用从原始上传时间起受限,但不宣称远端文件已删除。Files 解析失败会按独立的内联图片预算重建完整请求;调用方取消则停止请求。共享图片策略在请求与 token 计量中保留 128 MiB 的保留图片预算、20 MiB 的内联 base64 预算,以及最旧前缀卸载。
适配器优先使用确定性请求图片的 Files 引用,并管理上传缓存、刷新、配额恢复与附件省略。Files 客户端遵循[精确 `/v1` 根地址规则](../bug-fix/2026-09-15-messages-v1-base-url.zh.md)并发送必需的 beta 标头。缓存 id 按解析后的 Files 根地址与凭据限定作用域,因此等价的 `/v1` 与无版本根地址共用上传。元数据不含过期时间,因此本地复用以原始上传时间为界,不断言远端删除。Files 解析失败会按独立内联图片预算重建完整请求;调用方取消会停止它。图片策略在请求和 token 计量中保留 128 MiB 保留图片预算、20 MiB 内联 base64 预算及最旧前缀省略。
成功 HTTP 响应之后的 JSON 语法错误不足以证明传输故障。Files 解码复用已有的 `INVALID_RESPONSE` code 并补充操作上下文,不将其改标为 `TRANSPORT` 或添加重试;错误字段由 [provider README](../../../../packages/llm/llm-deepseek/README.zh.md) 维护。
系统提示词更新在端点与模型显式声明支持时,使用现有[路由能力](2026-09-02-in-history-system-prompt-replacement.zh.md)。Messages 保留初始顶层 system,在对应的用户或工具结果轮次之后,将后续快照发送为原生 system 轮次,保留此前发送的前缀。这个位置不同于循环先 system、后 user 的接纳顺序;序列化既不改写持久化日志,也不改变对话轮次的顺序。未声明能力的路由将最新快照归并到顶层,直接压缩调用也如此。仅凭协议或模型名称推断能力并不充分,因为支持情况和更新语义取决于实际部署的端点。
Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseURL` 与 `apiKeyEnv`,没有嵌套的协议配置表。未提供地址覆盖时使用当前协议的官方默认值;Messages 为 `https://api.deepseek.com/anthropic`。切换协议保留已有端点覆盖,部署者负责其兼容性。Messages 遵循严格匹配 `/v1` 的根地址规则,不根据其他版本式后缀推断兼容性。模型目录只维护一份,包含 `deepseek-flash` 的文本/图片和历史内 system 更新能力,也保留 V4 条目。显式 `chat-completions` 仍受支持,并使用自己的官方默认值,追加 `/chat/completions` 而不增加版本段。
Web 显示 DeepSeek,并提供端点与凭据引用。没有端点覆盖时,解析使用 `https://api.deepseek.com/anthropic`。覆盖地址必须支持 Messages。适配器遵循精确 `/v1` 根地址规则,不从其他类似版本的后缀推断兼容性。单一模型目录公布各条目声明的能力。
两种传输都在原生序列化后使用现有[请求扩展注册表](../architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md),并在 HTTP 2xx 后、读取流之前接受已捕获贡献。会话日志投递和插件清单仍由原有包负责,并留在模型输入之外。辅助 [web 搜索提供方](../../../../packages/web/web-search-deepseek/README.zh.md)保留独立的端点、请求与设置。
适配器在原生序列化后使用现有[请求扩展注册表](../architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md),并在 HTTP 2xx 后、读取流之前接受已捕获贡献。会话日志投递和插件清单仍由原有包负责,并留在模型输入之外。辅助 [web 搜索提供方](../../../../packages/web/web-search-deepseek/README.zh.md)保留独立的端点、请求与设置。
## 考虑过的替代方案
**每个协议独立插件。** 这会重复凭据配置、模型目录、设置卡片和 provider ID,并迫使用户在底层协议变化时重选模型。单插件中的协议目录保留实现隔离;Responses 可以增加自己的实现而不改变用户配置结构。
**独立的 Messages 提供方身份。** 这会重复凭据、目录与设置卡,并迫使用户在采用 Messages 时重新选择模型。`deepseek-official` 身份使这些用户选择保持稳定。
**把新路由委托给 pi-ai 或 Anthropic SDK。** 两者均提供持续维护的协议实现,但所需的直接适配器需要 DeepSeek 专属配置、附件策略、凭证解析和重试所有权。小型流转换器配合持续维护的 SSE 解析器使这些职责保持明确;库实现适配器仍可独立使用。
@@ -38,6 +38,6 @@ Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseU
## 结果
该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。用户与工具结果输入遵循[已保存输入兼容规则](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md);其他不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 默认配置覆盖共享 base、Web 和独立的官方组合。协议变化保留 provider id 与已保存的模型选择,但显式端点覆盖必须支持所选协议。
该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。用户与工具结果输入遵循[已保存输入兼容规则](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md);其他不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 服务共享 base、Web 和独立的官方组合。显式端点覆盖必须支持 Messages。
验证覆盖协议夹具、真实 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-11-desktop-mandatory-update-client.md
2026-09-11-desktop-mandatory-update-client.md: d66d3257360270a72d5e774282d9f7c61981929f
2026-09-11-desktop-mandatory-update-client.zh.md: fd6e751a4082578cc4d54746e0fc584104a89226
2026-09-11-desktop-mandatory-update-client.md: ff55f30836a37e49229adac6414cb3a98bca4d6c
2026-09-11-desktop-mandatory-update-client.zh.md: 6c17a9fbd0ce2741b635b742be682d4c5fd9f8c6
@@ -14,11 +14,11 @@ The shell polls the guest protocol in the [API proposal](../../proposed/feature/
A coordinator owns one immutable installed-client identity, one in-flight request, a deadline, interval jitter, and bounded failure backoff. Manual checks bypass scheduling but join in-flight work. Disposal aborts the request, awaits settlement, and prevents late publication. A flattened `40005` establishes blocking independently of optional title, detail, and download-page fields; localized fallback text and a retry action remain available, while an absent or unapproved page is not offered. Transport, JSON, business-code, or HTTP failures cannot clear a known block. Only a valid fresh no-force response clears it. Same-version offline persistence is not implemented because its product behavior remains undecided.
User-initiated ordinary checks trigger an independent policy refresh without waiting for or presenting its failures. A confirmed block alone cancels ordinary dialogs and transfers presentation to the mandatory window. Test authentication waits until an active ordinary dialog finishes; cancellation and failure leave the updater result intact. Concurrent callers reuse the active ordinary or authentication operation instead of creating competing windows.
User-initiated ordinary checks trigger an independent policy refresh without waiting for or presenting its failures. A confirmed block alone cancels ordinary dialogs and transfers presentation to the mandatory dialog. Test authentication waits until an active ordinary dialog finishes; cancellation and failure leave the updater result intact. Concurrent callers reuse the active ordinary or authentication operation instead of creating competing windows.
A shell-owned modal uses a separate sandboxed preload with dependency-free IPC names. The main process verifies its exact document URL and WebContents identity. Server title/detail are text, never HTML. Close, Esc, ordinary updater prompts, plugin-window entry, plugin mutations, and recovery actions cannot bypass the policy. The Host keeps existing work running; the existing updater's task-aware confirmation alone authorizes installation teardown. Closing the application remains available.
On macOS a shell-owned modal uses a separate sandboxed preload, and the main process verifies its exact document URL and WebContents identity. Windows embeds the shell-origin dialog in the main document below the caption so maximization and resizing have one native owner; a second native window caused visibly unsynchronized resizing (#4566). The isolated application preload owns a private MessageChannel to the frame and forwards actions to main-process IPC, which verifies the current app origin and main-frame identity. Product scripts share the DOM and can hide or imitate presentation, but cannot obtain the privileged port, clear main-process policy, or approve task teardown through window message events. The caption and its Web controls remain reachable by design. Server title/detail are text, never HTML. Esc and ordinary updater prompts cannot dismiss the block; main-process guards refuse plugin-window entry and plugin mutations while it is active. The Host keeps existing work running; task-aware installation confirmation remains separate from policy. Closing the application remains available. Windows presentation and its trust limits are owned by the [embedded-update decision](../bug-fix/2026-09-20-windows-embedded-mandatory-update.md).
The modal exposes user-initiated download and separate installation through the ordinary coordinator. Successful preparation and task inspection turn the same modal into installation confirmation without creating another window. Deferral retains the block and package; a retry inspects tasks and requests fresh confirmation. File verification and preparation share one visible phase. Restart feedback describes stopping tasks only after affected tasks are observed. Both dialog types require the owned document and main frame; closing, aborting, or losing an ordinary renderer cancels its approval.
The modal exposes user-initiated download and separate installation through the ordinary coordinator. Successful preparation and task inspection turn the same modal into installation confirmation without creating another window. Deferral retains the block and package; a retry inspects tasks and requests fresh confirmation. File verification and preparation share one visible phase. Restart feedback describes stopping tasks only after affected tasks are observed. Both dialog types require the owned IPC sender; closing, aborting, or losing an ordinary renderer cancels its approval.
The recovery layout keeps localized errors and folded technical details inside the block. Page actions appear only for failure or absent-artifact recovery, not beside the normal action. Exact HTTPS origins are checked before browser or clipboard use. A browser request immediately exposes copy recovery; its completion cannot prove external page loading. Only copy failure reveals the complete validated address for manual copying. Navigation and copy results are independent of updater errors and ignore completion after a changed policy destination. Neither browser opening, readiness, nor installer handoff clears policy or changes the updater target.
@@ -14,11 +14,11 @@ Status: implemented
协调器拥有一份不可变的已安装客户端身份、单一在途请求、截止时间、间隔抖动和有上限的失败退避。手动检查绕过调度,但复用在途请求。dispose(资源释放)中止请求、等待结算,并阻止迟到发布。扁平化 `40005` 建立阻塞不依赖可选的标题、详情和下载页面字段;本地化兜底文案与重试操作仍可使用,缺失或未获批准的页面地址不会展示。传输、JSON、业务码或 HTTP 错误都不能清除已知阻塞。只有新的有效无需强更响应才能清除它。同版本离线持久化未实现,因为其产品行为尚未确定。
用户发起常规检查时会触发独立的策略刷新,但不会等待或展示策略失败。只有已确认的强更决定会取消常规弹窗,并把展示移交给强更窗口。测试环境鉴权会等待当前常规弹窗结束;取消和失败会保留 updater 结果。并发调用方复用当前常规操作或鉴权操作,不创建相互竞争的窗口。
用户发起常规检查时会触发独立的策略刷新,但不会等待或展示策略失败。只有已确认的强更决定会取消常规弹窗,并把展示移交给强更弹窗。测试环境鉴权会等待当前常规弹窗结束;取消和失败会保留 updater 结果。并发调用方复用当前常规操作或鉴权操作,不创建相互竞争的窗口。
壳拥有的模态窗口使用独立沙箱预加载和无依赖的 IPC 名称。主进程校验其精确文档 URL 和 WebContents 身份。服务端标题/详情按文本展示,绝不作为 HTML。关闭、Esc、常规更新弹窗、插件窗口入口、插件修改和恢复操作都不能绕过策略。Host 保持现有工作运行;只有现有 updater 的任务影响确认才能授权安装收尾。仍可退出应用。
macOS 的壳模态窗口使用独立沙箱预加载,主进程校验其精确文档 URL 和 WebContents 身份。Windows 将壳源站弹窗嵌入主文档顶栏下方,让最大化与缩放只有一个原生窗口负责;第二个原生窗口曾造成可见的缩放不同步(#4566)。隔离的应用预加载拥有通向 frame 的私有 MessageChannel,将操作转发至校验当前应用源站和主 frame 身份的主进程 IPC。产品脚本共享 DOM,可以隐藏或仿造展示,但不能获得特权端口、清除主进程策略,或通过 window 消息事件批准任务收尾。顶栏及其中的 Web 控件按设计保持可触达。服务端标题/详情按文本展示,绝不作为 HTML。Esc 和常规更新弹窗不能解除阻塞;阻塞期间主进程拒绝插件窗口入口和插件修改。Host 保持现有工作运行,任务影响安装确认独立于策略。仍可退出应用。 Windows 展示及其信任限制由[内嵌强更决策](../bug-fix/2026-09-20-windows-embedded-mandatory-update.zh.md)负责。
模态窗口通过常规协调器提供用户发起的下载和独立安装。准备和任务检查成功后,同一弹窗转为安装确认,不创建另一个窗口。稍后更新保留阻塞与安装包;重试会检查任务并要求重新确认。文件校验与准备共用一个可见阶段。只有观测到受影响任务后,重启提示才描述正在停止任务。两类弹窗均要求所属文档与主 frame;关闭、中止或失去常规渲染进程会取消其批准。
模态窗口通过常规协调器提供用户发起的下载和独立安装。准备和任务检查成功后,同一弹窗转为安装确认,不创建另一个窗口。稍后更新保留阻塞与安装包;重试会检查任务并要求重新确认。文件校验与准备共用一个可见阶段。只有观测到受影响任务后,重启提示才描述正在停止任务。两类弹窗均要求所属 IPC 发送者;关闭、中止或失去常规渲染进程会取消其批准。
恢复界面在阻塞窗口内保留本地化错误和折叠技术详情。页面操作仅在失败或没有可用产物时出现,不与正常操作并列。浏览器或剪贴板使用前校验精确 HTTPS 源站。请求打开浏览器后立即提供复制恢复入口;请求完成不能证明外部网页加载。仅在复制失败时展示完整、经过校验的地址供手动复制。导航和复制结果独立于 updater 错误,策略目标地址改变后忽略迟到结果。打开浏览器、下载就绪和安装器交接均不清除策略或改变 updater 目标。
@@ -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-11-persistence-type-history.md
2026-09-11-persistence-type-history.md: c7cddea5ecc479a64ad5f24f5447e869779b7f9e
2026-09-11-persistence-type-history.zh.md: 001ce197c4cd12dc64a5e2bd710e39cb2b61f23a
2026-09-11-persistence-type-history.md: 87e87041dd44c4f671409a75c1b374312736342a
2026-09-11-persistence-type-history.zh.md: cfe5c4f7bd1998d4329dc422607cfe33273d1a5a
@@ -14,7 +14,7 @@ One normalized type model supplies the readable persistence catalog, the complet
The dedicated [persistence-change records](../../../../docs/persistence-changes/README.md) bind acknowledgement to the after digest of each affected root. A generated companion stores complete after schemas. The initial record covers all roots; later records name each root's predecessor. A predecessor's after schema supplies the next before schema. Verification rejects ambiguous history and requires current source to match the terminal recorded state without consulting Git history or remote services.
Automatic classification permits optional body additions, required-to-optional body changes, and ordinary event additions within one version. The scoped [source-attribution policy](2026-09-17-persistence-schema-review.md) also permits qualified new producer kinds when both saved schemas opt in. Other structural changes require a version-bump decision that includes an increasing header version in that record. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) continues to own adjacent migration work. Every structural difference requires an explicit record, including additions allowed within the same version.
Automatic classification permits optional body additions, required-to-optional body changes, and ordinary event additions within one version. The scoped [source-attribution policy](2026-09-17-persistence-schema-review.md) also permits qualified new producer kinds when both saved schemas opt in. The [event-payload version rule](../../../../docs/persistence-changes/README.md#compatibility-rules) admits higher numeric payload versions when every old alternative remains unchanged and records the older-reader limitation. Other structural changes require a version-bump decision that includes an increasing header version in that record. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) continues to own adjacent migration work. Every structural difference requires an explicit record, including additions allowed within the same version.
The new document kind preserves the compatibility reasoning and evidence for a historical type transition. Agent Notes retain mechanism-level decisions; they do not become a growing inventory of individual acknowledgements. Machine declarations remain identical across the bilingual pair and are parsed once.
@@ -14,7 +14,7 @@ Status: implemented
专用的[持久化变更记录](../../../../docs/persistence-changes/README.zh.md)将确认绑定到每个受影响根的变更后摘要。生成的伴随文件保存完整的变更后 schema。初始记录覆盖所有根;后续记录为每个根指定前驱。前驱的变更后 schema 提供下一次的变更前 schema。验证拒绝有歧义的历史,并要求当前源码匹配最终记录状态,无需查询 Git 历史或远端服务。
自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。当双方保存的 schema 均声明支持时,受限的[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)也允许添加符合条件的生产者 kind。其他结构变更需要升版本决策,并在该记录中包含递增的头部版本。[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)继续负责相邻迁移工作。每个结构差异都需要显式记录,包括允许保持同一版本的新增。
自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。当双方保存的 schema 均声明支持时,受限的[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)也允许添加符合条件的生产者 kind。[事件载荷版本规则](../../../../docs/persistence-changes/README.zh.md#compatibility-rules) 允许在原样保留所有旧分支时增加更高的数字载荷版本,并记录旧读取器限制。其他结构变更需要升版本决策,并在该记录中包含递增的头部版本。[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)继续负责相邻迁移工作。每个结构差异都需要显式记录,包括允许保持同一版本的新增。
新文档类型保留历史类型转换的兼容性说明和证据。Agent Note 保留机制层面的决策,不积累单次确认清单。双语对中的机器声明保持相同,且只解析一次。
@@ -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-19-deepseek-messages-only.md
2026-09-19-deepseek-messages-only.md: c10ecae950bb12f4556021de21c9716ad2906227
2026-09-19-deepseek-messages-only.zh.md: 3cfadac6ebc0b4bb1587a8b72e9b169d2ade2ee9
@@ -0,0 +1,25 @@
# Agent Note: One Messages transport for the official DeepSeek route
Status: implemented
English | [中文](2026-09-19-deepseek-messages-only.zh.md)
## Problem
The official DeepSeek route needs Messages thinking replay, image uploads, and in-history updates. Selecting a second transport duplicates serializers, stream handling, Files wire formats, configuration branches, and fixtures without adding a required capability to this route.
## Decision
`dsh-llm-deepseek` uses Messages exclusively. One adapter resolves a request-local configuration snapshot and performs the request. Configuration exposes one endpoint and no protocol selector. The Files client uses Messages authentication, metadata, and pagination directly; quota cleanup scans every page before selecting the oldest owned files.
The [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md) continues to own native thinking replay, system-message placement, image recovery, and request-extension acceptance. Replay discriminators and upload-index namespaces identify durable data and retain their existing values. The separate pi-ai adapter retains its provider-specific protocols.
## Alternatives considered
**Keep Chat Completions behind an optional selector.** This retains the duplicated implementation and test matrix even though the official route requires only Messages.
**Keep a generic protocol dispatcher with one implementation.** The dispatcher adds a second dependency representation and forwards every adapter operation without selecting anything.
## Consequences
The official route requires a Messages-compatible endpoint. Its mock-backed recovery, Loader, CLI, and Web tests exercise Messages; removing a wire implementation does not remove those behavior checks. Committed Session generations remain readable through provider-neutral content and the retained Messages replay metadata.
@@ -0,0 +1,25 @@
# Agent Note: One Messages transport for the official DeepSeek route
Status: implemented
[English](2026-09-19-deepseek-messages-only.md) | 中文
## 问题
官方 DeepSeek 路由需要 Messages 思考回放、图片上传与历史内更新。选择第二种传输会重复序列化器、流处理、Files 协议格式、配置分支与夹具,却没有为这条路由增加必需能力。
## 决策
`dsh-llm-deepseek` 仅使用 Messages。一个适配器解析请求局部的配置快照并执行请求。配置暴露一个端点,不提供协议选择器。Files 客户端直接使用 Messages 认证、元数据与分页;配额清理扫描全部页面后再选择最旧的自有文件。
[Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)继续拥有原生思考回放、系统消息位置、图片恢复与请求扩展接受规则。回放判别值和上传索引命名空间标识持久数据,并保留现有值。独立的 pi-ai 适配器保留各提供方协议。
## 考虑过的替代方案
**在可选选择器后保留 Chat Completions。** 即使官方路由只需要 Messages,这仍会保留重复实现与测试矩阵。
**保留只有一个实现的通用协议分派器。** 分派器增加第二种依赖表示,并转发每个适配器操作,却没有进行任何选择。
## 后果
官方路由要求兼容 Messages 的端点。其基于 mock 的恢复、Loader、CLI 与 Web 测试使用 Messages;移除一种协议实现不会移除这些行为检查。已提交的 Session 代际继续通过提供方无关内容与保留的 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/testing/2026-06-19-real-api-e2e-ci.md
2026-06-19-real-api-e2e-ci.md: b218d77f0817416b01459fbb70f5cacffe048ee8
2026-06-19-real-api-e2e-ci.zh.md: 52c988dad00ddb3ee7f0cd2d23da9771f0900808
2026-06-19-real-api-e2e-ci.md: bdb20b3d5413077a4ddf1c1d9293b3b808cd6c8c
2026-06-19-real-api-e2e-ci.zh.md: b95f7df62e0a57d4c58fc5c12a83905dd848c55e
@@ -49,7 +49,7 @@ The repo secret is named `DEEPSEEK_API_KEY_EXTERNAL`; it is mapped to the `DEEPS
- **Step-scoped secret.** `DEEPSEEK_API_KEY` is set in the `env:` of only the preflight and e2e steps, never job-level — so checkout/setup-node/install never see it. A compromised install-time lifecycle script in a dependency cannot read a secret that isn't in its environment.
- **`permissions: contents: read`.** The job only reads the repo to run tests; it needs no write scopes (no PR comments, no status writes), so the `GITHUB_TOKEN` is dropped to least privilege.
- **`DEEPSEEK_BASE_URL` pinned** to `https://api.deepseek.com` on the e2e step. The adapter would default to this when unset ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`), but pinning is self-documenting and hermetic — a stray repo-root `.env` (which `vitest.e2e.config.ts` loads if present) cannot silently redirect the run to another endpoint.
- **`DEEPSEEK_BASE_URL` pinned** to `https://api.deepseek.com/anthropic` on the e2e step. The adapter would default to this when unset ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`), but pinning is self-documenting and hermetic — a stray repo-root `.env` (which `vitest.e2e.config.ts` loads if present) cannot silently redirect the run to another endpoint.
- **No secret echoed.** The preflight prints only `DEEPSEEK_API_KEY present.` — not the value or its length.
### Scope, runtime shape
@@ -49,7 +49,7 @@ repo secret 命名为 `DEEPSEEK_API_KEY_EXTERNAL`;映射到适配器和测试
- **步骤级 secret。** `DEEPSEEK_API_KEY` 仅在 preflight 和 e2e 步骤的 `env:` 中设置,从不在 job 级设置——因此 checkout/setup-node/install 永远看不到它。依赖中被入侵的安装时生命周期脚本无法读取不在其环境中的 secret。
- **`permissions: contents: read`。** job 仅读取仓库以运行测试;不需要写权限(无 PR 评论、无 status 写入),因此 `GITHUB_TOKEN` 降至最小权限。
- **`DEEPSEEK_BASE_URL` 固定**为 e2e 步骤上的 `https://api.deepseek.com`。适配器在未设置时会默认使用此值([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`),但显式固定具有自文档性和密封性——仓库根目录的 `.env`(如果存在,`vitest.e2e.config.ts` 会加载它)无法静默地将运行重定向到其他端点。
- **`DEEPSEEK_BASE_URL` 固定**为 e2e 步骤上的 `https://api.deepseek.com/anthropic`。适配器在未设置时会默认使用此值([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`),但显式固定具有自文档性和密封性——仓库根目录的 `.env`(如果存在,`vitest.e2e.config.ts` 会加载它)无法静默地将运行重定向到其他端点。
- **不回显 secret。** preflight 仅打印 `DEEPSEEK_API_KEY present.`——不打印值或长度。
### 范围与运行时形态
+2
View File
@@ -101,6 +101,8 @@ pnpm run test:docs # quick documentation checks (no build; doc-quick aggreg
pnpm run website:build # VitePress build (doubles as dead-link check)
pnpm dsh --profile headless "task" # run one task from source (needs DEEPSEEK_API_KEY)
pnpm run demo:ptc -- "task" # headless PTC mode run (needs key)
pnpm run dev:web | dev:desktop # build, then launch; Web also rebuilds client bundles on edits. start:web | start:desktop skip the build
make web|dev-web|desktop|dev-desktop|build # the same commands; ARGS='--no-open' forwards options
```
### Host sandbox failures
+31
View File
@@ -0,0 +1,31 @@
# Short names for the Web and Desktop application commands. The package.json
# scripts they call stay the source of truth; docs/development.md documents both.
.DEFAULT_GOAL := help
.PHONY: help build web desktop dev-web dev-desktop
PNPM ?= pnpm
ARGS ?=
help:
@echo "make build pnpm run build complete repository build"
@echo "make web pnpm run start:web serve the built Web artifacts from source"
@echo "make desktop pnpm run start:desktop launch the built Desktop artifacts"
@echo "make dev-web pnpm run dev:web build, serve, and rebuild Web on source edits"
@echo "make dev-desktop pnpm run dev:desktop build, then launch Desktop"
@echo "ARGS='--no-open --port 3081' forwards options to the launched application;"
@echo "the Web commands accept dsh web flags, the Desktop launcher accepts none."
build:
$(PNPM) run build
web:
$(PNPM) run start:web $(ARGS)
desktop:
$(PNPM) run start:desktop $(ARGS)
dev-web:
$(PNPM) run dev:web $(ARGS)
dev-desktop:
$(PNPM) run dev:desktop $(ARGS)
+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 README.md
README.md: 36adfe913902e75ce0673adc2cb5cb692151a291
README.zh.md: f6861b4c9490741b5e5f2d825a370766178dd9f6
README.md: 423bccd7d9352dbc48f1705f37d35642e74f5b41
README.zh.md: c760f87e4ac3b7dbb7bb55d733e31db5ab379836
+2
View File
@@ -54,6 +54,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
Start with the [development guide](docs/development.md) and [architecture documentation](docs/architecture.md).
`pnpm run dev:web` builds, serves, and rebuilds client bundles on source edits in one terminal, and `make help` lists the matching Make targets for Web and Desktop; the guide's application commands section owns the full table.
For agents, follow [AGENTS.md](AGENTS.md).
## Citation
+2
View File
@@ -75,6 +75,8 @@ pnpm dsh web
请先阅读[开发指南](docs/development.zh.md)与[架构文档](docs/architecture.zh.md)。
`pnpm run dev:web` 会在一个终端里完成构建、启动,并在源码修改时重建 client bundle;`make help` 列出 Web 与 Desktop 对应的 Make target。完整表格见开发指南的「应用命令」一节。
面向 agent:请遵循 [AGENTS.md](AGENTS.md)。
## 引用
+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/cli/reference/README.md
README.md: cf0e1400f2dc8cd29cc59cefbdda379df2e0b899
README.zh.md: a01818f59ccf54f3f826c68505bfb830a9316664
README.md: 33465e8c96df9cbfbecc038616caf297146ec971
README.zh.md: d25d27851ca789a3db358157368b97c3669ed79d
+1 -1
View File
@@ -85,7 +85,7 @@ Git-hosted plugins that ship sources build during install through their `prepare
## Web profile
`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until a separate `pnpm run dev:web` watcher rebuilds client bundles.
`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until `pnpm run dev:web` rebuilds client bundles; that command builds once, starts this same launcher, and keeps client bundles rebuilt, or with `--no-serve` runs only the watchers beside a `dsh web` started elsewhere.
```sh
dsh web
+1 -1
View File
@@ -87,7 +87,7 @@ dsh --profile tui
## Web Profile
`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在单独运行的 `pnpm run dev:web` watcher 重建客户端 bundle 之前保持空闲。
`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在 `pnpm run dev:web` 重建客户端 bundle 之前保持空闲;该命令先构建一次,再启动这同一个启动器并持续重建客户端 bundle,加 `--no-serve` 则只运行 watcher、配合别处启动的 `dsh web`。
```sh
dsh web
+3 -6
View File
@@ -520,7 +520,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
successText: 'ACP BUILT PROFILE OK',
})
const home = mkdtempSync(join(tmpdir(), 'dsh-built-acp-'))
writeFileSync(join(home, 'settings.yaml'), 'llm-deepseek:\n protocol: chat-completions\n')
const child = execa(process.execPath, [dshBin, '--profile', 'acp'], {
cwd: home,
reject: false,
@@ -606,7 +605,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
successText: 'published headless profile reached the mock',
})
const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-'))
writeFileSync(join(home, 'settings.yaml'), 'llm-deepseek:\n protocol: chat-completions\n')
try {
const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], {
DSH_HOME: home,
@@ -618,7 +616,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
expect(result.stdout).toBe('published headless profile reached the mock')
expect(result.stderr).toBe('dsh: reasoning:\nInspecting the published entry.')
expect(server.requests.length).toBeGreaterThan(0)
expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true)
expect(server.requests.every(request => request.path === '/v1/messages')).toBe(true)
expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry')
} finally {
await server.close()
@@ -743,7 +741,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
successText: 'launching endpoint reached the mock',
})
const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-'))
writeFileSync(join(home, 'settings.yaml'), 'llm-deepseek:\n protocol: chat-completions\n')
const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-'))
writeFileSync(join(home, '.credentials.yaml'), `version: 1\nrefs:\n DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 })
createEnvironmentProbeProfile(home, project)
@@ -766,8 +763,8 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
expect(result.stdout).not.toContain(apiKey)
expect(result.stderr).not.toContain(apiKey)
expect(server.requests).toHaveLength(1)
expect(server.requests[0]?.path).toBe('/chat/completions')
expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${apiKey}`)
expect(server.requests[0]?.path).toBe('/v1/messages')
expect(server.requests[0]?.headers['x-api-key']).toBe(apiKey)
expect(JSON.stringify(server.requests[0]?.body)).not.toContain(apiKey)
} finally {
await server.close()
@@ -4,7 +4,6 @@
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
protocol: !!js process.env.DSH_SNAPSHOT_PROTOCOL
apiKeyEnv: DSH_SNAPSHOT_API_KEY
baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL
thinking: disabled
@@ -18,9 +18,9 @@ const IMAGE_OFFLOAD_CONFIG = fileURLToPath(new URL('./fixtures/image-offload.cor
const SNAPSHOTS_DIR = fileURLToPath(new URL('../../../../../../snapshots/acp/', import.meta.url))
const READ_IMAGE_WORKSPACE = fileURLToPath(new URL('../../../../../../snapshots/session/read-image/workspace/', import.meta.url))
it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and inline fallback in assembled requests', async (protocol) => {
const filesPath = protocol === 'messages' ? '/v1/files' : '/files'
const modelPath = protocol === 'messages' ? '/v1/messages' : '/chat/completions'
it('pins Messages Files offload and inline fallback in assembled requests', async () => {
const filesPath = '/v1/files'
const modelPath = '/v1/messages'
const requests: Record<string, unknown>[] = []
const fileRequests: Array<{ method: string; path: string; bytes: number }> = []
let rejectFiles = false
@@ -49,21 +49,13 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in
return
}
const createdAt = Math.floor(Date.now() / 1_000)
response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(protocol === 'messages' ? {
response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({
id: 'file-api-snapshot-1',
type: 'file',
size_bytes: file.size,
created_at: new Date(createdAt * 1_000).toISOString(),
filename: 'dsh-snapshot.png',
mime_type: file.type,
} : {
id: 'file-api-snapshot-1',
object: 'file',
bytes: file.size,
created_at: createdAt,
filename: 'dsh-snapshot.png',
purpose: 'user_data',
expires_at: createdAt + Number(form.get('expires_after[seconds]')),
}))
return
}
@@ -73,35 +65,18 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in
}
requests.push(JSON.parse(body.toString('utf8')) as Record<string, unknown>)
response.writeHead(200, { 'content-type': 'text/event-stream' })
if (protocol === 'messages') {
const toolCall = requests.length === 1
const events = [
{ type: 'message_start', message: { id: 'offload-response', model: 'deepseek-v4-flash-vision-exp', usage: { input_tokens: 3, output_tokens: 0 } } },
{ type: 'content_block_start', index: 0, content_block: toolCall
? { type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } }
: { type: 'text', text: 'DONE' } },
{ type: 'content_block_stop', index: 0 },
{ type: 'message_delta', delta: { stop_reason: toolCall ? 'tool_use' : 'end_turn' }, usage: { output_tokens: 1 } },
{ type: 'message_stop' },
]
response.end(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(''))
return
}
const events = requests.length === 1
? [
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"native-read-image","type":"function","function":{"name":"read_image","arguments":"{\\"file_path\\":\\"red.png\\"}"}}]},"index":0,"finish_reason":null}]}',
'data: {"choices":[{"delta":{},"index":0,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
'data: [DONE]',
'',
]
: [
'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',
'data: {"choices":[{"delta":{"content":"DONE"},"index":0,"finish_reason":null}]}',
'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
'data: [DONE]',
'',
]
response.end(events.join('\n\n'))
const toolCall = requests.length === 1
const events = [
{ type: 'message_start', message: { id: 'offload-response', model: 'deepseek-v4-flash-vision-exp', usage: { input_tokens: 3, output_tokens: 0 } } },
{ type: 'content_block_start', index: 0, content_block: toolCall
? { type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } }
: { type: 'text', text: 'DONE' } },
{ type: 'content_block_stop', index: 0 },
{ type: 'message_delta', delta: { stop_reason: toolCall ? 'tool_use' : 'end_turn' }, usage: { output_tokens: 1 } },
{ type: 'message_stop' },
]
response.end(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(''))
return
})().catch((error: unknown) => {
response.writeHead(500, { 'content-type': 'text/plain' }).end(String(error))
})
@@ -137,7 +112,6 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in
fixtureFile: join(SNAPSHOTS_DIR, 'image-offload-request', 'session.jsonl'),
workspaceDir: READ_IMAGE_WORKSPACE,
env: {
DSH_SNAPSHOT_PROTOCOL: protocol,
DSH_SNAPSHOT_API_KEY: 'snapshot-key',
DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}`,
},
@@ -162,14 +136,9 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in
+ ' Copy to a writable path ending in .png before editing.'
}
const normalizedAccess = accessText(result.cwd)
const imagePrefix = protocol === 'messages' ? '' : '\n'
const fileImage = protocol === 'messages'
? { type: 'image', source: { type: 'file', file_id: 'file-api-snapshot-1' } }
: { type: 'file', file_id: 'file-api-snapshot-1' }
const fileImage = { type: 'image', source: { type: 'file', file_id: 'file-api-snapshot-1' } }
const offloadedImage = `[image omitted to fit request image limits; ${attachmentId}.${normalizedAccess}]`
const imageHandle = `Image ${attachmentId}; request preview 1x1px.${normalizedAccess}`
const normalizedToolImageHandle = `Image "red.png" (${attachmentId}); request preview 1x1px.${normalizedAccess}`
.replaceAll(result.cwd, '{{cwd}}')
const runtimeContext = 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n'
+ 'Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\n'
+ 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).'
@@ -179,80 +148,31 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in
{ type: 'text', text: 'Compare the older image ' },
{ type: 'text', text: offloadedImage },
{ type: 'text', text: ' with the newer image ' },
{ type: 'text', text: `${imagePrefix}${imageHandle}` },
{ type: 'text', text: imageHandle },
fileImage,
{ type: 'text', text: ', then use read_image on red.png and reply with DONE.' },
...protocol === 'messages' ? [{ type: 'text', text: runtimeContext }] : [],
{ type: 'text', text: runtimeContext },
])
if (protocol === 'messages') {
const followup = requests[1]?.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>
expect(followup).toHaveLength(3)
expect(followup[0]?.content.filter(block => block.type === 'image')).toEqual([])
expect(followup[0]?.content.filter(block => block.text === offloadedImage)).toHaveLength(2)
expect(followup[1]).toEqual({ role: 'assistant', content: [
{ type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } },
] })
expect(followup[2]?.role).toBe('user')
expect(followup[2]?.content).toHaveLength(1)
const toolResult = followup[2]!.content[0]!
expect(toolResult.type).toBe('tool_result')
expect(toolResult.tool_use_id).toBe('native-read-image')
const content = toolResult.content as Array<Record<string, unknown>>
expect(content[0]?.type).toBe('text')
expect(content[0]?.text).toContain('image/png image, 1x1 px, 69 bytes')
expect(content.slice(1)).toEqual([
{ type: 'text', text: `Image "red.png" (${attachmentId}); request preview 1x1px.${normalizedAccess}` },
fileImage,
])
} else {
const followup = structuredClone((requests[1]?.messages as unknown[]).slice(1)) as Array<{
role?: unknown
content?: unknown
}>
const toolMessage = followup.find(message => message.role === 'tool')
if (toolMessage === undefined || typeof toolMessage.content !== 'string') {
throw new Error('native read_image request has no tool content')
}
const cwdSpellings = [...new Set([result.cwd, ...result.cwdAliases].flatMap(cwd => (
cwd.startsWith('/private/') ? [cwd, cwd.slice('/private'.length)] : [cwd, `/private${cwd}`]
)))]
let toolContent = toolMessage.content
for (const cwd of cwdSpellings) toolContent = toolContent.replaceAll(cwd, '{{cwd}}')
toolMessage.content = toolContent
expect(followup).toEqual([
{
role: 'user',
content: `Compare the older image ${offloadedImage} with the newer image ${offloadedImage}, then use read_image on red.png and reply with DONE.`,
},
{
role: 'user',
content: runtimeContext,
},
{
role: 'assistant',
content: '',
tool_calls: [{
id: 'native-read-image',
type: 'function',
function: { name: 'read_image', arguments: '{"file_path":"red.png"}' },
}],
},
{
role: 'tool',
tool_call_id: 'native-read-image',
content: '<path>{{cwd}}/red.png</path>\n<type>image</type>\n<content>\nimage/png image, 1x1 px, 69 bytes\n'
+ `</content>\n${normalizedToolImageHandle}`,
},
{
role: 'user',
content: [
{ type: 'text', text: 'Attached image(s) from tool result:' },
{ type: 'file', file_id: 'file-api-snapshot-1' },
],
},
])
}
const followup = requests[1]?.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>
expect(followup).toHaveLength(3)
expect(followup[0]?.content.filter(block => block.type === 'image')).toEqual([])
expect(followup[0]?.content.filter(block => block.text === offloadedImage)).toHaveLength(2)
expect(followup[1]).toEqual({ role: 'assistant', content: [
{ type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } },
] })
expect(followup[2]?.role).toBe('user')
expect(followup[2]?.content).toHaveLength(1)
const toolResult = followup[2]!.content[0]!
expect(toolResult.type).toBe('tool_result')
expect(toolResult.tool_use_id).toBe('native-read-image')
const content = toolResult.content as Array<Record<string, unknown>>
expect(content[0]?.type).toBe('text')
expect(content[0]?.text).toContain('image/png image, 1x1 px, 69 bytes')
expect(content.slice(1)).toEqual([
{ type: 'text', text: `Image "red.png" (${attachmentId}); request preview 1x1px.${normalizedAccess}` },
fileImage,
])
rejectFiles = true
const fallback = await runScenario(input, {
@@ -262,7 +182,6 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in
fixtureFile: join(SNAPSHOTS_DIR, 'image-offload-request', 'session.jsonl'),
workspaceDir: READ_IMAGE_WORKSPACE,
env: {
DSH_SNAPSHOT_PROTOCOL: protocol,
DSH_SNAPSHOT_API_KEY: 'snapshot-fallback-key',
DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}`,
},
@@ -280,12 +199,10 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in
{ type: 'text', text: 'Compare the older image ' },
{ type: 'text', text: `[image omitted to fit request image limits; ${attachmentId}.${fallbackAccess}]` },
{ type: 'text', text: ' with the newer image ' },
{ type: 'text', text: `${imagePrefix}Image ${attachmentId}; request preview 1x1px.${fallbackAccess}` },
protocol === 'messages'
? { type: 'image', source: { type: 'base64', media_type: 'image/png', data: image } }
: { type: 'image_url', image_url: { url: `data:image/png;base64,${image}` } },
{ type: 'text', text: `Image ${attachmentId}; request preview 1x1px.${fallbackAccess}` },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: image } },
{ type: 'text', text: ', then use read_image on red.png and reply with DONE.' },
...protocol === 'messages' ? [{ type: 'text', text: runtimeContext }] : [],
{ type: 'text', text: runtimeContext },
])
} finally {
await new Promise<void>(resolve => server.close(() => { resolve() }))
@@ -83,7 +83,9 @@ async function expectHeadlessStream(normalized: string, expectedPath: string): P
}
/** Serve one deterministic DeepSeek-compatible response while retaining its request body. */
async function deepseekDefaultsServer(options: { waitForTitleRequest?: boolean; protocol?: 'messages' } = {}): Promise<DeepSeekDefaultsServer> {
async function deepseekDefaultsServer(
options: { waitForTitleRequest?: boolean; piAiCompatibility?: true } = {},
): Promise<DeepSeekDefaultsServer> {
const requests: JsonObject[] = []
const paths: string[] = []
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
@@ -103,7 +105,7 @@ async function deepseekDefaultsServer(options: { waitForTitleRequest?: boolean;
timer = setTimeout(write, 60)
return
}
if (options.protocol === 'messages') {
if (options.piAiCompatibility !== true) {
response.end([
{ type: 'message_start', message: { id: 'defaults-response', model: 'deepseek-v4-flash', usage: { input_tokens: 3, output_tokens: 0 } } },
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
@@ -582,7 +584,7 @@ describe('headless stream-json snapshots', () => {
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('keeps provider comments alive and sends DeepSeek defaults through the one-shot app', async () => {
const server = await deepseekDefaultsServer({ protocol: 'messages' })
const server = await deepseekDefaultsServer()
try {
const result = await runLoaderSmoke({
label: 'DeepSeek adapter defaults headless stream-json snapshot',
@@ -638,7 +640,7 @@ describe('headless stream-json snapshots', () => {
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('keeps the compatibility stream open until the title request arrives', async () => {
const server = await deepseekDefaultsServer({ waitForTitleRequest: true })
const server = await deepseekDefaultsServer({ waitForTitleRequest: true, piAiCompatibility: true })
try {
const response = await fetch(server.url, {
method: 'POST',
@@ -675,7 +677,7 @@ describe('headless stream-json snapshots', () => {
})
it('sends pi-ai DeepSeek compatibility through the one-shot app', async () => {
const server = await deepseekDefaultsServer({ waitForTitleRequest: true })
const server = await deepseekDefaultsServer({ waitForTitleRequest: true, piAiCompatibility: true })
try {
const result = await runLoaderSmoke({
label: 'pi-ai DeepSeek compatibility headless stream-json snapshot',
@@ -8,7 +8,7 @@ import { withDefaultWeb, webGet } from './default-web-process.ts'
const experimentalName = '@deepseek-ai/dsh-experimental-client-ui-agent-team'
it('boots the default Web profile without experimental Host modules, mounted plugins, or Client entries', async (test) => {
it('boots default Web without experimental modules or an active built-in Browser', async (test) => {
await withDefaultWeb(test, async ({ url, request }) => {
const auth = await webGet(url, test.signal)
const cookie = auth.headers['set-cookie']?.[0]?.split(';', 1)[0]
@@ -29,6 +29,11 @@ it('boots the default Web profile without experimental Host modules, mounted plu
expect(roster.plugins.length).toBeGreaterThan(roster.entries.length)
expect(roster.modules.some(url => modulePackage(url) === '@deepseek-ai/dsh')).toBe(true)
expect(roster.client.entries.length).toBeGreaterThan(0)
const browserName = '@deepseek-ai/dsh-client-ui-sidebar-browser'
const browserEntry = roster.entries.find(entry => entry.name === browserName)
expect(browserEntry).toBeDefined()
expect(browserEntry!.state).toBeUndefined()
expect(delivered.entries.some(entry => entry.id === browserName)).toBe(false)
expect(experimentalRuntimeReferences(roster)).toEqual([])
const contaminated = await request('mount-experimental')
+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: 2b41f6deeda70db186fb38ee6b5590bf8c459835
README.zh.md: 24128293d5a246e33d36ba1ecbc10ae3cf594ea5
README.md: aa2d9163067ea48b398e200b644fbeda6ea370a9
README.zh.md: a27b551b4fc2ea37d5a2da45edbcf4b2901e5dde
+2 -2
View File
@@ -93,7 +93,7 @@ After an explicit build, `start:desktop` reconstructs the disposable project and
pnpm run start:desktop
```
Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths.
The Web counterparts are `pnpm run dev:web` and `pnpm run start:web`, documented in the [development guide](../../docs/development.md). Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths.
## Package
@@ -322,7 +322,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 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.
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 isolated application preload mounts a shell-origin frame inside the main window, covering the content below the 40 DIP caption with a backdrop and dialog. It blocks background page input without creating another native window; moving and maximizing affect only the main window. Caption menus retain mouse and keyboard operation; Web controls in the caption remain reachable. The shared DOM is a presentation constraint, not a security boundary: product scripts can hide the overlay but cannot clear main-process policy or authorize installation through it. Update actions and state use a private MessageChannel between the isolated preload and shell frame. The parent remains enabled for native move, resize, minimize, maximize, and close controls. Closing the application performs cleanup without clearing the update requirement; Esc does not dismiss the overlay. 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.
+3 -3
View File
@@ -54,7 +54,7 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm
Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。
Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离预加载和壳 frame 之间的私有 MessageChannel 传递。
macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。
@@ -93,7 +93,7 @@ pnpm run dev:desktop
pnpm run start:desktop
```
Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。
Web 侧的对应命令是 `pnpm run dev:web` 与 `pnpm run start:web`,见[开发指南](../../docs/development.zh.md)。Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。
## 打包
@@ -322,7 +322,7 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up
登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 强更窗口使用原生标题栏,可拖动、调整大小和最大化。关闭窗口会在完成清理后退出应用,不会解除更新要求;Esc 不会关闭窗口。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。
@@ -0,0 +1,42 @@
/** The embedded shell page delegates actions only to its owning application preload. */
if (window.parent !== window && window.dshMandatoryUpdate === undefined) {
let state
let port
let sequence = 0
const listeners = new Set()
const requests = new Map()
const initial = Promise.withResolvers()
const receive = event => {
const message = event.data
if (message?.type === 'dsh-mandatory-state' && message.state) {
state = message.state
initial.resolve(state)
for (const listener of listeners) listener(state)
}
if (message?.type === 'dsh-mandatory-result') {
const request = requests.get(message.id)
if (!request) return
requests.delete(message.id)
if (message.ok) request.resolve()
else request.reject(new Error('Update action failed'))
}
}
window.addEventListener('message', event => {
if (!event.isTrusted || event.source !== window.parent || event.origin !== 'dsh-app://app'
|| event.data?.type !== 'dsh-mandatory-connect' || event.ports.length !== 1 || port) return
port = event.ports[0]
port.onmessage = receive
})
window.addEventListener('pagehide', () => { port?.close() }, { once: true })
window.dshMandatoryUpdate = {
status: () => state ? Promise.resolve(state) : initial.promise,
subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener) },
action: (action, version, revision) => {
const id = ++sequence
const request = Promise.withResolvers()
requests.set(id, request)
port.postMessage({ type: 'dsh-mandatory-action', id, action, version, revision })
return request.promise
},
}
}
@@ -32,6 +32,7 @@
<label id="manual-copy" hidden><span id="address-label"></span><textarea id="address" readonly rows="3" spellcheck="false"></textarea></label>
</div>
</main>
<script src="mandatory-update-frame.js"></script>
<script src="mandatory-update.js"></script>
</body>
</html>
@@ -43,6 +43,11 @@ try {
for (const owner of [application, project]) {
await symlink(join(repo, 'node_modules/.pnpm/node_modules'), join(owner, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir')
}
const target = `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`
const targetRoot = join(application, '.desktop-build/targets', target)
await mkdir(targetRoot, { recursive: true })
await symlink(join(repo, 'apps/desktop/.desktop-build/targets', target, 'runtime'), join(targetRoot, 'runtime'),
process.platform === 'win32' ? 'junction' : 'dir')
await writeFile(join(profile, 'cordis.patch.yml'), JSON.stringify([
{ id: 'webserver', config: { host: '127.0.0.1', port: 0 } },
{ id: 'llm-deepseek', disabled: true }, { id: 'session-title-llm', disabled: true },
+1 -1
View File
@@ -410,7 +410,7 @@ async function main(): Promise<void> {
protocol.handle(SCHEME, (request) => {
const url = new URL(request.url)
// Serves shell URLs declared in update-dialog.ts and mandatory-update-window.ts plus their renderer assets.
// Removing this route leaves those modal windows empty while their parent remains blocked.
// Removing this route leaves those shell windows and embedded documents empty while their parent remains blocked.
if (url.hostname === 'shell') return serveWebDocument(request, join(app.getAppPath(), 'renderer'))
if (url.hostname === 'app') {
if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname.startsWith('/assets/')
+51 -8
View File
@@ -2,10 +2,10 @@
import { app, BrowserWindow, clipboard, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
import type { DesktopLocale } from './locale.ts'
import type { DesktopUpdateState } from './ipc.ts'
import { assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
import { desktopPolicyPage, type DesktopPolicyState } from './mandatory-update-policy.ts'
import { MANDATORY_IPC } from './mandatory-update-ipc.ts'
import { createMandatoryUpdateWindow } from './update-overlay.ts'
import { createUpdateOverlay } from './update-overlay.ts'
import { DesktopUpdateAttention } from './update-attention.ts'
/** A renderer action never carries a URL or authorizes a different version. */
@@ -46,8 +46,12 @@ export interface MandatoryUpdateWindowOptions {
// main.ts's protocol.handle shell route serves this document and its renderer assets; the modal requires that route.
const page = 'dsh-app://shell/mandatory-update.html'
/** A modal child blocks the product window without cancelling work in the Host. */
/** Shell-owned update presentation; Windows embeds it in the main document without a child window. */
export class DesktopMandatoryUpdateWindow {
private readonly embedded = process.platform === 'win32'
private embeddedParent: BrowserWindow | undefined
private embeddedBlocking = false
private readonly publishEmbedded = (): void => { this.embeddedBlocking = false; this.sync() }
private window: BrowserWindow | undefined
private closing: ReturnType<typeof setTimeout> | undefined
private disposed = false
@@ -65,6 +69,8 @@ export class DesktopMandatoryUpdateWindow {
/** @param options - Main-process actions and immutable deployment/navigation settings. */
constructor(private readonly options: MandatoryUpdateWindowOptions) {
this.attention = new DesktopUpdateAttention(options.locale)
this.embeddedParent = this.embedded ? options.parent() : undefined
this.embeddedParent?.webContents.on('did-finish-load', this.publishEmbedded)
ipcMain.handle(MANDATORY_IPC.status, (event) => { this.assertSender(event); return this.view() })
ipcMain.handle(MANDATORY_IPC.action, (event, action: unknown, version: unknown, confirmationRevision: unknown) => {
this.assertSender(event)
@@ -103,7 +109,7 @@ export class DesktopMandatoryUpdateWindow {
}
/** Active modal used as the owner of shell installation-confirmation dialogs. */
get confirmationWindow(): BrowserWindow | undefined { return this.window }
get confirmationWindow(): BrowserWindow | undefined { return this.embedded ? this.options.parent() : this.window }
/**
* @param version - Updater-owned target, already downloaded and verified.
@@ -119,8 +125,9 @@ export class DesktopMandatoryUpdateWindow {
this.confirmation = { version, active, revision: ++this.confirmationRevision, resolve }
this.sync()
const parent = this.options.parent()
if (parent !== undefined && this.window !== undefined) {
this.attention.ready(version, parent, this.window, () => {
const owner = this.confirmationWindow
if (parent !== undefined && owner !== undefined) {
this.attention.ready(version, parent, owner, () => {
if (!this.disposed && this.options.policy().blocking && this.confirmation !== undefined) this.focus()
})
} else this.finishConfirmation(false)
@@ -137,6 +144,18 @@ export class DesktopMandatoryUpdateWindow {
/** Publish current status, create the block immediately, or close it only after policy clearance. */
sync(): void {
if (this.disposed) return
if (this.embedded) {
const parent = this.options.parent()
if (parent !== this.embeddedParent) {
if (this.embeddedParent !== undefined && !this.embeddedParent.isDestroyed()) {
this.embeddedParent.webContents.off('did-finish-load', this.publishEmbedded)
}
this.embeddedBlocking = false
this.embeddedParent = parent
if (parent !== undefined && !parent.isDestroyed()) parent.webContents.on('did-finish-load', this.publishEmbedded)
}
if (parent === undefined || parent.isDestroyed()) return
}
if (!this.options.policy().blocking) {
this.finishConfirmation(false)
this.attention.reset()
@@ -153,16 +172,25 @@ export class DesktopMandatoryUpdateWindow {
}, 150)
}
this.error = undefined
if (this.embedded && this.embeddedBlocking) {
this.embeddedBlocking = false
this.embeddedParent?.webContents.send(MANDATORY_IPC.state, this.view())
}
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.embedded) {
this.embeddedBlocking = true
this.embeddedParent?.webContents.send(MANDATORY_IPC.state, this.view())
return
}
if (this.window === undefined) {
const parent = this.options.parent()
if (parent === undefined) return
const window = createMandatoryUpdateWindow(parent, this.options.preload, this.options.locale.messages.mandatoryTitle)
const window = createUpdateOverlay(parent, this.options.preload, this.options.locale.messages.mandatoryTitle, false)
this.window = window
window.setMenu(null)
window.on('close', (event) => { if (!this.disposed && this.options.policy().blocking) { event.preventDefault(); app.quit() } })
@@ -187,13 +215,15 @@ export class DesktopMandatoryUpdateWindow {
focus(): void {
this.sync()
const parent = this.options.parent()
if (parent?.isDestroyed()) return
if (parent?.isMinimized()) parent.restore()
parent?.show()
if (this.embedded) parent?.focus()
this.window?.show()
this.window?.focus()
}
/** Detach IPC and release the modal during application shutdown. */
/** Detach IPC and release the modal during shutdown, including after the main window closes. */
dispose(): void {
this.disposed = true
clearTimeout(this.closing)
@@ -203,6 +233,10 @@ export class DesktopMandatoryUpdateWindow {
this.clearNavigation()
ipcMain.removeHandler(MANDATORY_IPC.status)
ipcMain.removeHandler(MANDATORY_IPC.action)
if (this.embeddedParent !== undefined && !this.embeddedParent.isDestroyed()) {
this.embeddedParent.webContents.off('did-finish-load', this.publishEmbedded)
this.embeddedParent.webContents.send(MANDATORY_IPC.state, { ...this.view(), policy: { blocking: false, checking: false } })
}
this.window?.destroy()
this.window = undefined
}
@@ -258,6 +292,15 @@ export class DesktopMandatoryUpdateWindow {
}
private assertSender(event: IpcMainInvokeEvent): void {
if (this.embedded) {
const parent = this.options.parent()
if (parent === undefined || parent.isDestroyed() || event.sender !== parent.webContents
|| event.senderFrame !== parent.webContents.mainFrame) {
throw new Error('desktop policy: rejected unowned renderer')
}
assertDesktopSender(event, ['app'])
return
}
if (event.sender !== this.window?.webContents || event.senderFrame !== this.window.webContents.mainFrame
|| event.senderFrame.url !== page) throw new Error('desktop policy: rejected unowned renderer')
}
+2
View File
@@ -5,6 +5,7 @@ import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePrese
import { markDocumentPlatform, syncWindowFullscreen } from './preload-platform.ts'
import { syncNativeTheme } from './preload-theme.ts'
import { syncWindowsAppearance } from './preload-windows.ts'
import { installMandatoryUpdateOverlay } from './preload-mandatory-overlay.ts'
const product: DshDesktopProductApi = {
protocolVersion: 1,
@@ -21,6 +22,7 @@ const product: DshDesktopProductApi = {
if (location.protocol === `${SCHEME}:` && location.hostname === 'app') {
syncWindowsAppearance()
if (process.platform === 'win32') installMandatoryUpdateOverlay()
contextBridge.exposeInMainWorld('__DSH_DIRECTORY_PICKER__', {
pick: () => ipcRenderer.invoke(DESKTOP_IPC.directoryPick) as Promise<string | null>,
})
@@ -0,0 +1,85 @@
/** Mounts the shell-owned Windows update document inside the main window's content area. */
import { ipcRenderer } from 'electron'
import { MANDATORY_IPC } from './mandatory-update-ipc.ts'
import type { MandatoryUpdateView } from './mandatory-update-window.ts'
import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
/** Keep update actions on a private channel to the shell frame; the shared product DOM is not a tamper-proof display. */
export function installMandatoryUpdateOverlay(): void {
let disposed = false
let state: MandatoryUpdateView | undefined
let host: HTMLDivElement | undefined
let frame: HTMLIFrameElement | undefined
let port: MessagePort | undefined
let closing: ReturnType<typeof setTimeout> | undefined
const publish = (): void => { port?.postMessage({ type: 'dsh-mandatory-state', state }) }
const remove = (): void => { port?.close(); port = undefined; host?.remove(); host = undefined; frame = undefined }
const render = (): void => {
if (state === undefined || document.readyState === 'loading') return
if (!state.policy.blocking) {
if (frame === undefined || closing !== undefined) return
publish()
closing = setTimeout(remove, 150)
return
}
clearTimeout(closing)
closing = undefined
if (frame === undefined) {
host = document.createElement('div')
host.style.cssText = `position:fixed;top:${WINDOWS_TITLEBAR_HEIGHT}px;left:0;right:0;bottom:0;z-index:2147483647;backdrop-filter:blur(2px)`
const shadow = host.attachShadow({ mode: 'closed' })
frame = document.createElement('iframe')
frame.title = state.locale.messages.mandatoryTitle
frame.style.cssText = 'display:block;width:100%;height:100%;border:0;background:transparent'
frame.src = 'dsh-app://shell/mandatory-update.html'
shadow.append(frame)
document.documentElement.append(host)
frame.addEventListener('load', () => {
port?.close()
const channel = new MessageChannel()
port = channel.port1
port.onmessage = message
frame?.contentWindow?.postMessage({ type: 'dsh-mandatory-connect' }, 'dsh-app://shell', [channel.port2])
publish()
frame?.focus()
})
}
publish()
}
const receive = (_event: Electron.IpcRendererEvent, next: MandatoryUpdateView): void => { state = next; render() }
const message = (event: MessageEvent<unknown>): void => {
const value = event.data
if (typeof value !== 'object' || value === null || !('type' in value)) return
if (value.type !== 'dsh-mandatory-action' || !('id' in value) || !Number.isSafeInteger(value.id)
|| !('action' in value) || !('version' in value) || !('revision' in value)) return
const target = port
if (target === undefined) return
void ipcRenderer.invoke(MANDATORY_IPC.action, value.action, value.version, value.revision).then(
() => { target.postMessage({ type: 'dsh-mandatory-result', id: value.id, ok: true }) },
() => { target.postMessage({ type: 'dsh-mandatory-result', id: value.id, ok: false }) },
)
}
const blockBackgroundKey = (event: KeyboardEvent): void => {
if (frame === undefined || state?.policy.blocking !== true
|| event.composedPath().some(target => target instanceof HTMLElement && target.hasAttribute('data-windows-menu'))) return
event.preventDefault()
event.stopImmediatePropagation()
frame.focus()
}
ipcRenderer.on(MANDATORY_IPC.state, receive)
void ipcRenderer.invoke(MANDATORY_IPC.status).then((initial: MandatoryUpdateView) => {
if (!disposed && state === undefined) { state = initial; render() }
}, () => {
// Development may omit policy configuration; a later main-frame load still resynchronizes configured policy.
})
window.addEventListener('keydown', blockBackgroundKey, true)
window.addEventListener('DOMContentLoaded', render, { once: true })
window.addEventListener('pagehide', () => {
disposed = true
clearTimeout(closing)
remove()
ipcRenderer.off(MANDATORY_IPC.state, receive)
window.removeEventListener('keydown', blockBackgroundKey, true)
window.removeEventListener('DOMContentLoaded', render)
}, { once: true })
}
-16
View File
@@ -47,19 +47,3 @@ export function createUpdateOverlay(parent: BrowserWindow, preload: string, titl
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
return window
}
/** 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, false)
const window = new BrowserWindow({
parent, modal: true, show: false, title,
width: 640, height: 560, minWidth: 480, minHeight: 360,
movable: true, resizable: true, maximizable: true,
backgroundColor: '#f5f5f5',
webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true },
})
window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() })
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
return window
}
+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/tests/README.md
README.md: 6171b058adad8e34a3275c013bd875e00f586afc
README.zh.md: fa18cd947e1315248b5449eaf9bc426bba3d18fc
README.md: a2684a2bced4326bdc316ff56d80003061d0136d
README.zh.md: fba5b65aa41aa14b9971f80676c8f0fef4d7be9e
+2 -2
View File
@@ -33,7 +33,7 @@ The local command builds Desktop and executes Electron 44 with the actual HTTP u
| Ordinary updater | Same/older-version rejection, user-authorized full download, SHA-512 rejection, interrupted transfer, stalled-feed/download deadlines, explicit retry, coalesced requests, same-address feed replacement, readiness, and separate install handoff pass |
| Mandatory policy | Flattened `40005`, exact release headers, guest requests, no-force validation, failure retention, interval/backoff, timeout, and disposal regressions pass |
| Ordinary scheduling | Fake-clock regressions with the real coordinator verify bounded jitter/backoff, manual joins, success reset, no automatic download retry, wall-clock independence, and disposal. Main-entry tests verify focus/resume throttling, immediate explicit checks, and quit cleanup |
| Actual mandatory window | Text-only server content, native Windows move/resize/maximize and restore, Escape blocking, direct download, same-modal task confirmation, deferral, recovery-only page actions, navigation/copy feedback, and fresh policy clearance pass; a focused test verifies close-to-exit without clearing policy |
| Actual mandatory window | Text-only server content, an embedded Windows shell frame with no additional native window and an enabled main window, Escape blocking, direct download, same-modal task confirmation, deferral, recovery-only page actions, navigation/copy feedback, and fresh policy clearance pass; a focused test verifies close-to-exit without clearing policy |
| Actual ordinary dialog | Isolated preload, 380px card, 24px corners, black primary button, parent blur, Escape cancellation retaining readiness, and task-warning approval with recorded installation handoff pass |
| Unlocked Windows interaction | OS-level clicks and screenshots of the actual mandatory renderer confirm Escape blocking, user-started download, readiness, red policy-error feedback, and modal clearance with the parent enabled again. A fixture-provided native task-warning dialog returns to readiness on deferral; task activity is simulated, not a full Host workload |
| Main entry | A known block refuses plugin mutations and recovery without stopping the Host; fresh success closes the block; packaged policy ignores environment overrides; installer failure after a clean stop restores the Host before another confirmation and retains mandatory blocking |
@@ -47,7 +47,7 @@ Chromium headless shell revision 1228 is installed in the ignored `.desktop-buil
The [built Host scenario](fixtures/host-update-qualification.mjs) uses the actual profile Loader, standard agent preset, task services, and Node background processes. It verifies queued turns/steps, a running model request, pending questions/approvals, global and agent jobs in running/stopping states, admission locking without cancellation, restored admission, and rejected inspection after Host disposal. Only model responses and human answers are substituted. Two independent invocations pass concurrently with private profiles and session data; all owned agents, jobs, and Hosts finish before a success report is written.
The [Electron workspace runner](../scripts/test-workspace-updates.ts) executes a private copy of the compiled main entry with the real preload, workspace, and separate Host process. It acknowledges the first-run notice and drives renderer buttons through Electron input events. Ten scenarios pass, covering menu feedback, download failure and retry, separate installation confirmation, real confirmation-time task creation, deferral, mandatory blocking, and actual Host teardown timeout. Both ordinary and mandatory failures restore a replacement Host and require fresh installation confirmation; recovery does not clear mandatory policy. Delivery uses a local server and installation is intercepted; these are not signed installed-app results.
The [Electron workspace runner](../scripts/test-workspace-updates.ts) executes a private copy of the compiled main entry with the real preload, workspace, and separate Host process. It acknowledges the first-run notice and drives renderer buttons through Electron input events and Chromium debugger input for the embedded Windows frame. The private application reuses the prepared target runtime resources. Ten scenarios pass, covering menu feedback, download failure and retry, separate installation confirmation, real confirmation-time task creation, deferral, mandatory blocking, and actual Host teardown timeout. Both ordinary and mandatory failures restore a replacement Host and require fresh installation confirmation; recovery does not clear mandatory policy. Delivery uses a local server and installation is intercepted; these are not signed installed-app results.
The [Windows signature runner](../scripts/test-windows-update-signature.mjs) uses the installed electron-builder metadata generator and `NsisUpdater` verifier with the public release certificate and real executable inputs. Matching publisher attributes pass; the same valid signature with a different expected publisher and an unsigned executable are rejected. A missing-publisher negative control confirms that verification is skipped. Unit regressions cover DN escaping, incomplete identities, and explicit or host-default Windows targets; removing the publisher configuration fails both metadata cases. This check does not download, install, or sign an artifact.
+2 -2
View File
@@ -33,7 +33,7 @@ Host、客户端与 Desktop 产物构建完成后,在 Windows 仓库根目录
| 常规 updater | 同版本/旧版本拒绝、用户授权完整下载、SHA-512 拒绝、传输中断、清单/下载停滞截止时间、显式重试、请求合并、同地址清单替换、就绪状态和独立安装交接通过 |
| 强更策略 | 扁平化 `40005`、完整发布头、游客请求、无需强更验证、失败保留、间隔/退避、超时和 dispose(资源释放)回归通过 |
| 常规调度 | 真实协调器配合模拟时钟的回归验证有上限的抖动/退避、手动复用、成功重置、不自动重试下载、不受系统时钟调整影响,以及 dispose。主入口测试验证聚焦/恢复节流、显式检查立即执行和退出清理 |
| 真实强更窗口 | 服务端纯文本内容、Windows 原生窗口移动/缩放/最大化及还原、Esc 阻塞、直接下载、同弹窗任务确认、稍后更新、仅恢复态页面操作、导航/复制反馈和新策略解除通过;定向测试验证关闭时退出且不清除策略 |
| 真实强更窗口 | 服务端纯文本内容、Windows 内嵌 shell frame、不创建额外原生窗口且主窗口保持启用、Esc 阻塞、直接下载、同弹窗任务确认、稍后更新、仅恢复态页面操作、导航/复制反馈和新策略解除通过;定向测试验证关闭时退出且不清除策略 |
| 真实常规弹窗 | 隔离预加载、380px 卡片、24px 圆角、黑色主按钮、父窗口模糊、Esc 取消后保留就绪,以及任务警告批准后的安装交接记录均通过 |
| 解锁后的 Windows 交互 | 对真实强更页面执行系统级点击并截图,确认 Esc 阻塞、用户发起下载、就绪、红色策略错误提示,以及弹窗解除后父窗口恢复可用。fixture 提供的原生任务警告弹窗在选择稍后更新后回到就绪态;任务活动为模拟,不是完整 Host 工作负载 |
| 主入口 | 已知阻塞拒绝插件修改和恢复,但不停止 Host;新成功响应关闭阻塞;打包策略忽略环境覆盖;正常停止后的安装器失败在下次确认前恢复 Host,并保留强更阻塞 |
@@ -47,7 +47,7 @@ Chromium headless shell revision 1228 已安装在忽略目录 `.desktop-build/p
[构建后 Host 场景](fixtures/host-update-qualification.mjs) 使用真实 profile Loader、standard agent 预设、任务服务和 Node 后台进程。它验证排队的轮次/步骤、运行中的模型请求、等待答复的提问/审批、运行/停止中的全局和 agent job、不会取消任务的准入锁定、准入恢复,以及 Host 释放后的检查拒绝。仅模型响应和人工答复使用替身。两个独立调用使用私有 profile 和会话数据并发通过;全部自有 agent、job 和 Host 完成后才写入成功报告。
[Electron 工作区运行器](../scripts/test-workspace-updates.ts) 在私有目录中执行编译后的主入口,使用真实预加载、工作区和独立 Host 进程。它确认首次启动声明,并通过 Electron 输入事件操作页面按钮。十个场景通过,覆盖菜单反馈、下载失败与重试、独立安装确认、确认期间真实任务创建、推迟安装、强更阻塞和真实 Host 停止超时。普通与强更失败均恢复替代 Host,并要求重新确认安装;恢复不会清除强更策略。分发使用本地服务器,安装被拦截;这些不是签名已安装应用的验证结果。
[Electron 工作区运行器](../scripts/test-workspace-updates.ts) 在私有目录中执行编译后的主入口,使用真实预加载、工作区和独立 Host 进程。它确认首次启动声明,并通过 Electron 输入事件及嵌入式 Windows frame 的 Chromium 调试器输入操作页面按钮。私有应用复用已准备的目标运行时资源。十个场景通过,覆盖菜单反馈、下载失败与重试、独立安装确认、确认期间真实任务创建、推迟安装、强更阻塞和真实 Host 停止超时。普通与强更失败均恢复替代 Host,并要求重新确认安装;恢复不会清除强更策略。分发使用本地服务器,安装被拦截;这些不是签名已安装应用的验证结果。
[Windows 验签运行器](../scripts/test-windows-update-signature.mjs) 使用已安装的 electron-builder 元数据生成器和 `NsisUpdater` 验签器,输入为公开发布证书和真实可执行文件。发布者属性匹配时通过;同一有效签名在预期发布者不同时被拒绝,未签名可执行文件也被拒绝。缺少发布者的负对照确认验签被跳过。单元回归覆盖 DN 转义、不完整身份,以及显式或宿主默认 Windows 目标;移除发布者配置会使两条元数据用例失败。此检查不下载、安装或签名产物。
+36 -31
View File
@@ -1,5 +1,8 @@
/** Actual Electron HTTP/download qualification; installer execution is forbidden. */
import { mandatoryFrame } from './mandatory-frame.mjs'
import { setTimeout as delay } from 'node:timers/promises'
import assert from 'node:assert/strict'
import { once } from 'node:events'
import fs from 'node:fs'
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import { join, sep } from 'node:path'
@@ -225,13 +228,14 @@ async function main() {
})
await scenario('mandatory-real-window-policy-download-retry-and-clear', async () => {
protocol.handle('dsh-app', async request => {
if (new URL(request.url).hostname === 'app') return new Response('<!doctype html><title>Updater qualification</title>', { headers: { 'content-type': 'text/html' } })
const path = new URL(request.url).pathname.slice(1)
assert.ok(['mandatory-update.html', 'mandatory-update.js', 'mandatory-update.css', 'update-dialog.css'].includes(path))
assert.ok(['mandatory-update-frame.js', 'mandatory-update.html', 'mandatory-update.js', 'mandatory-update.css', 'update-dialog.css'].includes(path))
const mime = path.endsWith('.js') ? 'text/javascript' : path.endsWith('.css') ? 'text/css' : 'text/html'
return new Response(await readFile(new URL(`../../renderer/${path}`, import.meta.url)), { headers: { 'content-type': mime } })
})
const parent = new BrowserWindow({ show: true, width: 900, height: 650 })
await parent.loadURL('data:text/html;charset=utf-8,<title>Local updater qualification</title>')
const parent = new BrowserWindow({ show: true, width: 900, height: 650, webPreferences: { preload: fileURLToPath(new URL('../../lib/preload-app.cjs', import.meta.url)), sandbox: true, contextIsolation: true } })
await parent.loadURL('dsh-app://app/')
const f = await fixture('hold-download')
const config = resolveDesktopPolicyConfig({ origin: server.url, allowedPageOrigins: ['https://downloads.example.com'],
intervalMs: 600_000, timeoutMs: 1000 }, true)
@@ -262,51 +266,46 @@ async function main() {
server.policy('force')
await policy.check('launch')
const window = modal.confirmationWindow
assert.ok(window?.isModal())
assert.ok(window)
const contents = process.platform === 'win32' ? await mandatoryFrame(parent) : window.webContents
assert.equal(window.isModal(), false)
assert.equal(parent.isEnabled(), true)
window.webContents.on('console-message', event => { console.log('mandatory renderer:', event.message) })
window.webContents.on('preload-error', (_event, _path, error) => { console.error('mandatory preload:', error) })
async function until(expression) {
try { return await window.webContents.executeJavaScript(`new Promise((resolve, reject) => {
try { return await contents.executeJavaScript(`new Promise((resolve, reject) => {
const observer = new MutationObserver(check);
const deadline = setTimeout(() => { observer.disconnect(); reject(new Error('Modal DOM condition timed out')); }, 10000);
function check() { if (${expression}) { observer.disconnect(); clearTimeout(deadline); resolve(true); } }
observer.observe(document, { subtree: true, childList: true, attributes: true, characterData: true }); check();
})`) } catch (error) {
console.error('Modal condition:', expression, await window.webContents.executeJavaScript('({ url: location.href, text: document.body?.innerText, bridge: typeof window.dshMandatoryUpdate })'))
console.error('Modal condition:', expression, await contents.executeJavaScript('({ url: location.href, text: document.body?.innerText, bridge: typeof window.dshMandatoryUpdate })'))
throw error
}
}
await until("document.getElementById('title')?.textContent === '需要更新'")
assert.equal(await window.webContents.executeJavaScript("document.querySelector('#title b') === null"), true)
assert.equal(await contents.executeJavaScript("document.querySelector('#title b') === null"), true)
if (process.platform === 'win32') {
assert.equal(window.isMovable(), true)
assert.equal(window.isResizable(), true)
assert.equal(window.isMaximizable(), true)
const bounds = window.getBounds()
window.setPosition(bounds.x + 20, bounds.y + 20)
assert.notDeepEqual(window.getBounds(), bounds)
window.maximize()
assert.equal(window.isMaximized(), true)
window.unmaximize()
assert.equal(window.isMaximized(), false)
assert.equal(window, parent)
assert.equal(parent.getChildWindows().length, 0)
}
assert.equal(await window.webContents.executeJavaScript("document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }))"), false)
assert.equal(await contents.executeJavaScript("document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }))"), false)
assert.equal(window.isDestroyed(), false)
assert.equal(f.installations.length, 0)
await window.webContents.executeJavaScript("document.getElementById('page').click()")
await contents.executeJavaScript("document.getElementById('page').click()")
await until("document.getElementById('browser-message').textContent.includes('无法打开浏览器')")
assert.deepEqual(opened, ['https://downloads.example.com/desktop'])
await window.webContents.executeJavaScript("document.getElementById('copy').click()")
await contents.executeJavaScript("document.getElementById('copy').click()")
await copyComplete.promise
await until("document.getElementById('copy').textContent === '已复制链接'")
assert.deepEqual(copied, opened)
await f.coordinator.check()
modal.sync()
await until("!document.getElementById('update').hidden")
await window.webContents.executeJavaScript("document.getElementById('update').click()")
await contents.executeJavaScript("document.getElementById('update').click()")
await server.arrived()
assert.equal(f.coordinator.state.phase, 'downloading')
assert.equal(await window.webContents.executeJavaScript("document.getElementById('page').hidden"), true)
assert.equal(await contents.executeJavaScript("document.getElementById('page').hidden"), true)
server.release()
await until("document.getElementById('update').textContent === '停止任务并更新' && !document.getElementById('update').disabled")
assert.equal(f.installations.length, 0)
@@ -314,14 +313,14 @@ async function main() {
server.policy('stall')
await policy.check('manual', true)
assert.equal(window.isDestroyed(), false)
assert.equal(await window.webContents.executeJavaScript("document.getElementById('error').hidden"), true)
const snapshot = await window.webContents.executeJavaScript(`({
assert.equal(await contents.executeJavaScript("document.getElementById('error').hidden"), true)
const snapshot = await contents.executeJavaScript(`({
title: document.getElementById('title').textContent, detail: document.getElementById('detail').textContent,
status: document.getElementById('status').textContent, version: document.getElementById('version').textContent,
buttons: [...document.querySelectorAll('button')].filter(button => button.getClientRects().length > 0).map(button => button.textContent),
})`)
assert.deepEqual(snapshot, JSON.parse(await readFile(new URL('../expected/mandatory-update-zh.json', import.meta.url), 'utf8')))
await window.webContents.executeJavaScript("document.getElementById('later').click()")
await contents.executeJavaScript("document.getElementById('later').click()")
await until("document.getElementById('update').textContent === '继续安装更新' && !document.getElementById('update').disabled")
assert.equal(f.installations.length, 0)
let capture
@@ -333,19 +332,19 @@ async function main() {
}
const restartRequested = Promise.withResolvers()
f.restart(async () => { restartRequested.resolve(); return false })
await window.webContents.executeJavaScript("document.getElementById('update').click()")
await contents.executeJavaScript("document.getElementById('update').click()")
await restartRequested.promise
await until("!document.getElementById('update').disabled")
assert.equal(f.installations.length, 0)
assert.equal(policy.state.blocking, true)
f.restart(async () => { throw new DesktopUpdatePreparationError('stop-failed', resolveDesktopLocale('zh-CN').messages.updateStopFailed,
'exit 0; shutdown acknowledged false') })
await window.webContents.executeJavaScript("document.getElementById('update').click()")
await contents.executeJavaScript("document.getElementById('update').click()")
await until("!document.getElementById('technical-details').hidden && !document.getElementById('update').disabled")
assert.equal(await window.webContents.executeJavaScript("document.getElementById('technical-details').open"), false)
assert.equal(await contents.executeJavaScript("document.getElementById('technical-details').open"), false)
for (const expanded of [false, true]) {
if (expanded) {
assert.equal(await window.webContents.executeJavaScript("document.getElementById('technical-details-label').click(); document.getElementById('technical-details').open"), true)
assert.equal(await contents.executeJavaScript("document.getElementById('technical-details-label').click(); document.getElementById('technical-details').open"), true)
}
const name = expanded ? 'mandatory-error-expanded.png' : 'mandatory-error-collapsed.png'
await writeFile(join(root, name), (await window.webContents.capturePage()).toPNG())
@@ -354,14 +353,20 @@ async function main() {
assert.equal(policy.state.blocking, true)
assert.equal(f.installations.length, 0)
server.policy('clear')
const closed = process.platform === 'win32' ? undefined : once(window, 'closed')
await policy.check('manual', true)
assert.equal(window.isDestroyed(), true)
if (closed) { await closed; assert.equal(window.isDestroyed(), true) }
else {
const deadline = Date.now() + 10000
while (!contents.detached && Date.now() < deadline) await delay(20)
assert.equal(contents.detached, true)
}
assert.equal(parent.isDestroyed(), false)
} finally {
server.release()
parent.destroy()
modal.dispose()
await policy.dispose()
parent.destroy()
shell.openExternal = originalOpen
clipboard.writeText = originalCopy
clipboard.readText = originalRead
+35
View File
@@ -0,0 +1,35 @@
import { WINDOWS_TITLEBAR_HEIGHT } from '../../lib/types/windows-layout.js'
import { setTimeout as delay } from 'node:timers/promises'
export async function mandatoryFrame(parent) {
const deadline = Date.now() + 10000
while (Date.now() < deadline) {
const frame = parent.webContents.mainFrame.frames.find(frame => frame.url === 'dsh-app://shell/mandatory-update.html')
if (frame && await frame.executeJavaScript("document.readyState === 'complete'")) return frame
await delay(20)
}
throw new Error('Mandatory update frame did not load')
}
export async function mandatoryFrameDriver(parent) {
const frame = await mandatoryFrame(parent)
if (!parent.webContents.debugger.isAttached()) parent.webContents.debugger.attach('1.3')
const contents = new Proxy(parent.webContents, { get(target, key) {
if (key === 'executeJavaScript') return frame.executeJavaScript.bind(frame)
if (key === 'sendInputEvent') return event => {
if (typeof event.y !== 'number') return target.sendInputEvent(event)
return target.debugger.sendCommand('Input.dispatchMouseEvent', {
type: event.type === 'mouseDown' ? 'mousePressed' : event.type === 'mouseUp' ? 'mouseReleased' : 'mouseMoved',
x: event.x, y: event.y + WINDOWS_TITLEBAR_HEIGHT, button: event.button ?? 'none', clickCount: event.clickCount ?? 0,
})
}
const value = Reflect.get(target, key)
return typeof value === 'function' ? value.bind(target) : value
} })
return new Proxy(parent, { get(target, key) {
if (key === 'webContents') return contents
if (key === 'isDestroyed') return () => frame.detached
const value = Reflect.get(target, key)
return typeof value === 'function' ? value.bind(target) : value
} })
}
+46 -22
View File
@@ -1,4 +1,6 @@
import { WINDOWS_TITLEBAR_HEIGHT } from '../../lib/types/windows-layout.js'
/** Real Electron main entry, preload, shared Web Host, and local updater; no installer executes. */
import { mandatoryFrameDriver } from './mandatory-frame.mjs'
import assert from 'node:assert/strict'
import { registerHooks } from 'node:module'
import { appendFile, readFile, writeFile } from 'node:fs/promises'
@@ -95,6 +97,9 @@ async function documentReady(window, expression) {
})`))
}
async function windowAt(url) {
if (process.platform === 'win32' && url === 'dsh-app://shell/mandatory-update.html') {
return mandatoryFrameDriver(await windowAt('dsh-app://app/'))
}
const existing = BrowserWindow.getAllWindows().find(window => window.webContents.getURL() === url)
if (existing) return existing
return new Promise((resolve, reject) => {
@@ -146,13 +151,15 @@ async function press(window, expression) {
if (!target.contains(document.elementFromPoint(point.x, point.y))) throw new Error('Click target is obscured');
return point;
})()`))
window.webContents.sendInputEvent({ type: 'mouseMove', ...point })
window.webContents.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, ...point })
window.webContents.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, ...point })
await window.webContents.sendInputEvent({ type: 'mouseMove', ...point })
await window.webContents.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, ...point })
await window.webContents.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, ...point })
}
async function clickText(window, label) {
const title = await window.webContents.executeJavaScript("document.getElementById('title').textContent")
await press(window, `[...document.querySelectorAll('button')].find(button => button.textContent.trim() === ${JSON.stringify(label)})`)
await waitFor(() => window.isDestroyed(), `dialog response: ${label}`)
await waitFor(async () => window.isDestroyed()
|| await window.webContents.executeJavaScript("document.getElementById('title').textContent") !== title, `dialog response: ${label}`)
}
async function dialogWith(message) {
let found
@@ -173,9 +180,9 @@ async function qualify() {
try {
await import(entry)
console.log('workspace qualification: compiled main module loaded')
const applicationUrl = await fixture.ready.promise
await fixture.ready.promise
console.log('workspace qualification: Host process ready')
mainWindow = await windowAt(new URL('/', applicationUrl).href)
mainWindow = await windowAt('dsh-app://app/')
await documentReady(mainWindow, `document.querySelector('[class*="frame"]') && window.dshDesktop?.updates`)
assert.equal(mainWindow.isVisible(), true, 'Workspace qualification requires a visible application window')
console.log('workspace qualification: workspace document ready')
@@ -187,7 +194,15 @@ async function qualify() {
console.log('workspace qualification: startup API requests settled')
const messages = resolveDesktopLocale(app.getLocale()).messages
await screenshot(mainWindow, 'workspace.png')
const menu = Menu.getApplicationMenu().items[0].submenu.items
let menu
if (process.platform === 'win32') {
const popup = Menu.prototype.popup
try {
Menu.prototype.popup = function (options) { menu = this.items; options.callback?.() }
await mainWindow.webContents.executeJavaScript("document.querySelector('[data-windows-menu]').shadowRoot.querySelector('button').click()")
await waitFor(() => menu !== undefined, 'Windows application menu')
} finally { Menu.prototype.popup = popup }
} else menu = Menu.getApplicationMenu().items[0].submenu.items
const checkMenu = menu.find(item => item.label === messages.checkUpdatesMenu)
assert.ok(checkMenu)
if (interactive) {
@@ -203,9 +218,8 @@ async function qualify() {
const checking = await dialogWith(messages.updateChecking)
await screenshot(checking, 'checking.png')
server.release()
await waitFor(() => checking.isDestroyed(), 'checking dialog to close')
console.log('workspace qualification: checking dialog closed')
const current = await dialogWith(messages.updateCurrent.replace('{version}', app.getVersion()))
assert.equal(current, checking)
await clickText(current, messages.updateAcknowledge)
assert.equal(server.requests.filter(path => path === '/payload.exe').length, 0)
cases.push('native-menu-checking-and-current-without-download')
@@ -272,16 +286,21 @@ async function qualify() {
await documentReady(mandatory, `document.getElementById('title')?.textContent === '需要更新'`)
console.log('workspace qualification: mandatory title rendered')
assert.equal(await mandatory.webContents.executeJavaScript(`document.getElementById('title').children.length`), 0)
assert.equal(mandatory.isMovable(), true)
assert.equal(mandatory.isResizable(), true)
assert.equal(mandatory.isMaximizable(), true)
const originalBounds = mandatory.getBounds()
mandatory.setPosition(originalBounds.x + 20, originalBounds.y + 20)
assert.notDeepEqual(mandatory.getBounds(), originalBounds)
mandatory.maximize()
await waitFor(() => mandatory.isMaximized(), 'mandatory maximize')
mandatory.unmaximize()
await waitFor(() => !mandatory.isMaximized(), 'mandatory restore')
assert.equal(mandatory.isModal(), false)
await waitFor(() => mainWindow.isEnabled(), 'ordinary modal releases the main window')
assert.equal(mainWindow.isEnabled(), true)
if (process.platform === 'win32') assert.equal(mainWindow.getChildWindows().length, 0)
const originalBounds = mainWindow.getBounds()
mainWindow.setPosition(originalBounds.x + 20, originalBounds.y + 20)
mainWindow.maximize()
await waitFor(() => mainWindow.isMaximized(), 'blocked parent maximize')
mainWindow.unmaximize()
await waitFor(() => !mainWindow.isMaximized(), 'blocked parent restore')
if (process.platform === 'win32') {
const viewport = await mandatory.webContents.executeJavaScript('({ width: innerWidth, height: innerHeight })')
const bounds = mainWindow.getContentBounds()
assert.deepEqual(viewport, { width: bounds.width, height: bounds.height - WINDOWS_TITLEBAR_HEIGHT })
}
mandatory.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
mandatory.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' })
assert.equal((await control('status')).queued, 1)
@@ -329,7 +348,8 @@ async function qualify() {
server.policy('force')
checkMenu.click()
const forcedRecovery = await windowAt('dsh-app://shell/mandatory-update.html')
let forcedRecovery = await windowAt('dsh-app://shell/mandatory-update.html')
await waitFor(() => mainWindow.isEnabled(), 'ordinary modal releases the main window for recovery')
await press(forcedRecovery, `document.getElementById('update')`)
await documentReady(forcedRecovery, `document.getElementById('update')?.textContent === ${JSON.stringify(messages.installAndRestart)}`)
assert.equal(BrowserWindow.getAllWindows().some(window => window.webContents.getURL() === 'dsh-app://shell/update-dialog.html'), false)
@@ -340,7 +360,11 @@ async function qualify() {
await documentReady(forcedRecovery, `document.getElementById('error')?.textContent === ${JSON.stringify(messages.updateStopFailed)}`)
await screenshot(forcedRecovery, 'mandatory-stop-failure.png')
await waitFor(() => fixture.host !== forcedHost && fixture.readyHosts.has(fixture.host), 'mandatory replacement Host readiness')
assert.equal(mainWindow.isEnabled(), false)
if (process.platform === 'win32') {
await waitFor(() => forcedRecovery.isDestroyed(), 'recovery reload replaces the embedded document')
forcedRecovery = await mandatoryFrameDriver(mainWindow)
}
assert.equal(mainWindow.isEnabled(), true)
assert.equal(fixture.installations.length, 0)
await control('queue')
await press(forcedRecovery, `document.getElementById('update')`)
@@ -349,7 +373,7 @@ async function qualify() {
await press(forcedRecovery, `document.getElementById('later')`)
await waitFor(() => fixture.coordinator.state.phase === 'ready', 'mandatory deferred retry readiness')
assert.equal(forcedRecovery.isDestroyed(), false)
assert.equal(mainWindow.isEnabled(), false)
assert.equal(mainWindow.isEnabled(), true)
assert.equal(fixture.installations.length, 0)
server.policy('clear')
checkMenu.click()
+22 -12
View File
@@ -62,7 +62,9 @@ const harness = await vi.hoisted(async () => {
getZoomFactor: () => 1,
focus: vi.fn(),
sendInputEvent: vi.fn(),
send: vi.fn(),
send: vi.fn((channel: string, state: { policy?: { blocking: boolean } }) => {
if (channel === 'dsh-desktop:mandatory-state' && state.policy?.blocking) policyBlocked.resolve()
}),
})
readonly show = vi.fn()
readonly hide = vi.fn()
@@ -71,7 +73,7 @@ const harness = await vi.hoisted(async () => {
readonly setSize = vi.fn()
readonly setTitleBarOverlay = vi.fn()
constructor(readonly options: { show: boolean; modal?: boolean }) {
super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this); if (options.modal) policyBlocked.resolve()
super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this)
}
isDestroyed() { return this.destroyed }
fullscreen = false
@@ -468,7 +470,7 @@ describe('desktop main startup', () => {
expect(testAuth.login).not.toHaveBeenCalled()
})
it('retains the same blocking window and running Host after expired test login is cancelled', async () => {
it('retains the embedded block and running Host after expired test login is cancelled', async () => {
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 }
const request = vi.fn<typeof fetch>().mockImplementation(async () =>
@@ -479,7 +481,7 @@ describe('desktop main startup', () => {
const host = await readyForUpdate()
await harness.policyBlocked.promise
await vi.advanceTimersByTimeAsync(1000)
const modal = harness.windows.find(window => window.options.modal)!
const modal = harness.windows[0]!
const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame }
harness.dialog.showMessageBox.mockResolvedValue({ response: 0 })
testAuth.login.mockResolvedValueOnce('cancelled')
@@ -867,14 +869,15 @@ describe('desktop main startup', () => {
vi.stubGlobal('fetch', request)
const host = await readyForUpdate()
await harness.policyBlocked.promise
const modal = harness.windows.find(window => window.options.modal)!
const modal = harness.windows[0]!
expect(modal).toBeDefined()
expect(harness.windows).toHaveLength(1)
const status = harness.handlers.get(MANDATORY_IPC.status)!
const action = harness.handlers.get(MANDATORY_IPC.action)!
const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame }
expect(status(owned)).toMatchObject({ policy: { blocking: true } })
const unowned = [
{ ...owned, sender: harness.windows[0]!.webContents },
{ ...owned, sender: {} },
{ ...owned, senderFrame: { url: 'dsh-app://app/index.html' } },
{ ...owned, senderFrame: { url: 'https://untrusted.example.com/' } },
]
@@ -899,7 +902,8 @@ describe('desktop main startup', () => {
await vi.advanceTimersByTimeAsync(20_000)
expect(modal.isDestroyed()).toBe(false)
await vi.advanceTimersByTimeAsync(150)
expect(modal.isDestroyed()).toBe(true)
expect(modal.isDestroyed()).toBe(false)
expect(modal.webContents.send.mock.calls.at(-1)).toMatchObject([MANDATORY_IPC.state, { policy: { blocking: false } }])
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' })
})
@@ -1115,7 +1119,7 @@ describe('desktop main startup', () => {
})
async function answerMandatory(action: 'install' | 'later') {
const modal = harness.windows.find(window => window.options.modal && !window.isDestroyed())!
const modal = harness.windows[0]!
const event = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame }
await vi.waitFor(() => {
expect(harness.handlers.get(MANDATORY_IPC.status)!(event)).toHaveProperty('confirmation')
@@ -1124,14 +1128,14 @@ describe('desktop main startup', () => {
await harness.handlers.get(MANDATORY_IPC.action)!(event, action, view.confirmation.version, view.confirmation.revision)
}
it('releases the mandatory modal when the confirmed installer quits Electron', async () => {
it('closes the main window when the confirmed installer quits Electron', async () => {
harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] }
vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: {
show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/',
} })))
const host = await readyForUpdate()
await harness.policyBlocked.promise
const modal = harness.windows.find(window => window.options.modal)!
const modal = harness.windows[0]!
const preparing = harness.prepareUpdate()
await answerMandatory('install')
await host.stopping.promise
@@ -1175,7 +1179,10 @@ describe('desktop main startup', () => {
await expect(retry).resolves.toBe(false)
expect(replacement.updateTasks.mock.calls).toEqual([['inspect']])
expect(replacement.stop).not.toHaveBeenCalled()
if (mandatory) expect(harness.windows.find(window => window.options.modal)?.isDestroyed()).toBe(false)
if (mandatory) {
expect(harness.windows).toHaveLength(1)
expect(harness.windows[0]!.isDestroyed()).toBe(false)
}
})
it.each([false, true])('restores a confirmed non-graceful exit without approving installation, mandatory: %s', async (mandatory) => {
@@ -1209,7 +1216,10 @@ describe('desktop main startup', () => {
await expect(retry).resolves.toBe(false)
expect(replacement.updateTasks.mock.calls).toEqual([['inspect']])
expect(replacement.stop).not.toHaveBeenCalled()
if (mandatory) expect(harness.windows.find(window => window.options.modal)?.isDestroyed()).toBe(false)
if (mandatory) {
expect(harness.windows).toHaveLength(1)
expect(harness.windows[0]!.isDestroyed()).toBe(false)
}
})
it('does not replace a Host whose failed stop has not confirmed process exit', async () => {
@@ -13,7 +13,7 @@ vi.mock('electron', () => ({ ipcMain: {
handle: (channel: string, handler: (...args: unknown[]) => unknown) => native.handlers.set(channel, handler),
removeHandler: (channel: string) => native.handlers.delete(channel),
}, app: { quit: native.quit }, shell: { openExternal: native.open }, clipboard: { writeText: native.write, readText: native.read } }))
vi.mock('../src/update-overlay.ts', () => ({ createMandatoryUpdateWindow: () => window }))
vi.mock('../src/update-overlay.ts', () => ({ createUpdateOverlay: vi.fn(() => window) }))
let window: ReturnType<typeof fakeWindow>
let ui: DesktopMandatoryUpdateWindow | undefined
@@ -21,12 +21,14 @@ function fakeWindow() {
const contents = Object.assign(new EventEmitter(), { mainFrame: { url: 'dsh-app://shell/mandatory-update.html' },
send: vi.fn(), setWindowOpenHandler: vi.fn() })
return Object.assign(new EventEmitter(), { webContents: contents, setMenu: vi.fn(), setTitle: vi.fn(),
loadURL: vi.fn(async () => {}), destroy: vi.fn(), isDestroyed: () => false,
loadURL: vi.fn(async () => {}), destroy: vi.fn(), isDestroyed: (): boolean => false,
isFocused: () => true, isMinimized: () => false, focus: vi.fn(), show: vi.fn(), restore: vi.fn() })
}
afterEach(() => { ui?.dispose(); vi.clearAllMocks(); native.handlers.clear(); vi.useRealTimers() })
function setup() {
afterEach(() => { ui?.dispose(); vi.restoreAllMocks(); vi.clearAllMocks(); native.handlers.clear(); vi.useRealTimers() })
function setup(platform: NodeJS.Platform = 'darwin') {
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
window = fakeWindow()
if (platform === 'win32') window.webContents.mainFrame.url = 'dsh-app://app/'
let policy: DesktopPolicyState = { blocking: true, checking: false, page: 'https://downloads.example.com/desktop' }
let update: DesktopUpdateState = { phase: 'ready', version: '1.0.1-nightly.1' }
const install = vi.fn(async () => update)
@@ -42,6 +44,16 @@ function setup() {
update(next: DesktopUpdateState) { update = next; ui!.sync() } }
}
it('releases IPC after the Windows main window has already been destroyed', () => {
setup('win32')
vi.spyOn(window, 'isDestroyed').mockReturnValue(true)
Object.defineProperty(window, 'webContents', { get() { throw new Error('Object has been destroyed') } })
expect(() => { ui!.sync(); ui!.focus() }).not.toThrow()
expect(() => { ui!.dispose() }).not.toThrow()
expect(native.handlers.size).toBe(0)
expect(() => { ui!.dispose() }).not.toThrow()
})
it('waits for a version-bound second click in the same modal and rejects obsolete or hidden responses', async () => {
const f = setup()
const pending = ui!.confirm('1.0.1-nightly.1', false)
@@ -152,3 +164,38 @@ it('fades on clearance, reuses a reblocked window, and cancels teardown on dispo
ui!.dispose()
expect(vi.getTimerCount()).toBe(0)
})
it('publishes Windows overlays into the main document without creating or destroying a window', () => {
const f = setup('win32')
expect(window.loadURL).not.toHaveBeenCalled()
expect(ui!.confirmationWindow).toBe(window)
expect(window.webContents.send).toHaveBeenLastCalledWith(MANDATORY_IPC.state, f.view())
window.webContents.emit('did-finish-load')
expect(window.webContents.send).toHaveBeenCalledTimes(2)
const event = { sender: window.webContents, senderFrame: { ...window.webContents.mainFrame } }
expect(() => native.handlers.get(MANDATORY_IPC.status)!(event)).toThrow(/unowned/)
f.policy({ blocking: false, checking: false })
expect(window.destroy).not.toHaveBeenCalled()
ui!.dispose()
expect(window.destroy).not.toHaveBeenCalled()
expect(window.webContents.listenerCount('did-finish-load')).toBe(0)
})
it('rebinds policy updates and reloads to a replacement Windows main window', () => {
setup('win32')
const previous = window
window = fakeWindow()
window.webContents.mainFrame.url = 'dsh-app://app/?recovery=1#home'
ui!.sync()
expect(previous.webContents.listenerCount('did-finish-load')).toBe(0)
expect(window.webContents.send.mock.calls.at(-1)).toMatchObject([MANDATORY_IPC.state, { policy: { blocking: true } }])
window.webContents.send.mockClear()
window.webContents.emit('did-finish-load')
expect(window.webContents.send).toHaveBeenCalledOnce()
expect(() => native.handlers.get(MANDATORY_IPC.status)!({
sender: previous.webContents, senderFrame: previous.webContents.mainFrame,
})).toThrow(/unowned/)
expect(() => native.handlers.get(MANDATORY_IPC.status)!({
sender: window.webContents, senderFrame: window.webContents.mainFrame,
})).not.toThrow()
})
+14 -1
View File
@@ -1,4 +1,5 @@
import { afterEach, expect, it, vi } from 'vitest'
import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts'
import { syncWindowsAppearance } from '../src/preload-windows.ts'
import { DESKTOP_IPC, type DshDesktopProductApi } from '../src/ipc.ts'
@@ -10,8 +11,9 @@ vi.mock('electron', () => electron)
vi.mock('../src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn(), syncWindowFullscreen: vi.fn() }))
vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() }))
vi.mock('../src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() }))
vi.mock('../src/preload-mandatory-overlay.ts', () => ({ installMandatoryUpdateOverlay: vi.fn() }))
afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() })
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.clearAllMocks(); vi.resetModules() })
it('limits product documents to update status and a native confirmation action', async () => {
vi.stubGlobal('location', new URL('dsh-app://app/index.html'))
@@ -76,3 +78,14 @@ it.each(['dsh-app://app/', 'dsh-app://shell/plugin-manager.html', 'https://examp
expect(syncWindowsAppearance).toHaveBeenCalledTimes(url === 'dsh-app://app/' ? 1 : 0)
},
)
it.each(['win32', 'darwin'] as const)('installs the embedded mandatory UI only in the Windows app document (%s)', async (platform) => {
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
for (const url of ['dsh-app://app/', 'dsh-app://shell/mandatory-update.html', 'https://example.com/']) {
vi.resetModules()
vi.mocked(installMandatoryUpdateOverlay).mockClear()
vi.stubGlobal('location', new URL(url))
await import('../src/preload-app.ts')
expect(installMandatoryUpdateOverlay).toHaveBeenCalledTimes(platform === 'win32' && url === 'dsh-app://app/' ? 1 : 0)
}
})
@@ -0,0 +1,104 @@
import { MessageChannel } from 'node:worker_threads'
import { JSDOM } from 'jsdom'
import { afterEach, expect, it, vi } from 'vitest'
import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts'
import { MANDATORY_IPC } from '../src/mandatory-update-ipc.ts'
import { resolveDesktopLocale } from '../src/locale.ts'
import type { MandatoryUpdateView } from '../src/mandatory-update-window.ts'
const ipc = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn(), invoke: vi.fn(async () => {}) }))
vi.mock('electron', () => ({ ipcRenderer: ipc }))
let dom: JSDOM
let channel: MessageChannel
afterEach(() => {
dom.window.dispatchEvent(new dom.window.Event('pagehide'))
dom.window.close()
channel?.port1.close()
channel?.port2.close()
vi.restoreAllMocks()
vi.unstubAllGlobals()
vi.clearAllMocks()
vi.useRealTimers()
})
function setup() {
dom = new JSDOM('<html><body><button>Product action</button><div data-windows-menu></div></body></html>', { url: 'dsh-app://app/' })
vi.stubGlobal('window', dom.window)
vi.stubGlobal('document', dom.window.document)
vi.stubGlobal('HTMLElement', dom.window.HTMLElement)
vi.stubGlobal('MessageChannel', function createChannel() { channel = new MessageChannel(); return channel })
vi.spyOn(dom.window.document, 'readyState', 'get').mockReturnValue('complete')
const shadow = vi.spyOn(dom.window.HTMLElement.prototype, 'attachShadow')
installMandatoryUpdateOverlay()
const publish = ipc.on.mock.calls.find(([name]) => name === MANDATORY_IPC.state)![1] as
(event: unknown, state: MandatoryUpdateView) => void
const view: MandatoryUpdateView = { locale: resolveDesktopLocale('zh-CN'), policy: { blocking: true, checking: false },
update: { phase: 'available', version: '2.0.0' }, deferred: false }
publish({}, view)
ipc.invoke.mockClear()
const root = shadow.mock.results[0]!.value as ShadowRoot
const frame = root.querySelector('iframe')!
// JSDOM has no browsing context for a shadow-root iframe; transport uses real paired Node ports.
const post = vi.spyOn(dom.window, 'postMessage').mockImplementation(() => {})
Object.defineProperty(frame, 'contentWindow', { value: dom.window })
const focus = vi.spyOn(frame, 'focus').mockImplementation(() => {})
frame.dispatchEvent(new dom.window.Event('load'))
expect(post).toHaveBeenCalledWith({ type: 'dsh-mandatory-connect' }, 'dsh-app://shell', [channel.port2])
return { publish, view, root, frame, focus, host: root.host as HTMLElement }
}
it('uses an in-page frame below the caption and reuses it on updates', () => {
const f = setup()
expect(f.host.style.top).toBe('40px')
expect(f.host.style.bottom).toBe('0px')
expect(f.frame.src).toBe('dsh-app://shell/mandatory-update.html')
expect(dom.window.document.body.style.filter).toBe('')
f.publish({}, { ...f.view, update: { phase: 'downloading', version: '2.0.0', percent: 30 } })
expect(f.root.querySelector('iframe')).toBe(f.frame)
})
it('rejects forged window events and accepts actions only through the transferred private port', async () => {
const f = setup()
const data = { type: 'dsh-mandatory-action', id: 1, action: 'download', version: '2.0.0', revision: undefined }
dom.window.dispatchEvent(new dom.window.MessageEvent('message', { source: f.frame.contentWindow, origin: 'dsh-app://shell', data }))
expect(ipc.invoke).not.toHaveBeenCalled()
const result = new Promise<unknown>(resolve => channel.port2.on('message', (message: unknown) => {
if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'dsh-mandatory-result') resolve(message)
}))
channel.port2.postMessage(data)
await expect(result).resolves.toEqual({ type: 'dsh-mandatory-result', id: 1, ok: true })
expect(ipc.invoke).toHaveBeenCalledExactlyOnceWith(MANDATORY_IPC.action, 'download', '2.0.0', undefined)
})
it('keeps caption menu keyboard events available while blocking background shortcuts', () => {
const f = setup()
const host = dom.window.document.querySelector('[data-windows-menu]')!
const shadow = host.attachShadow({ mode: 'open' })
const button = dom.window.document.createElement('button')
shadow.append(button)
const received = vi.fn()
button.addEventListener('keydown', received)
for (const key of ['Enter', 'ArrowDown', 'ArrowRight']) {
const event = new dom.window.KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true })
button.dispatchEvent(event)
expect(event.defaultPrevented).toBe(false)
}
expect(received).toHaveBeenCalledTimes(3)
const background = new dom.window.KeyboardEvent('keydown', { key: 'n', bubbles: true, cancelable: true })
dom.window.document.body.dispatchEvent(background)
expect(background.defaultPrevented).toBe(true)
expect(f.focus).toHaveBeenCalled()
})
it('clears once without postponing removal on progress ticks, and closes the channel on navigation', () => {
vi.useFakeTimers()
const f = setup()
const cleared = { ...f.view, policy: { blocking: false, checking: false } }
f.publish({}, cleared)
vi.advanceTimersByTime(100)
f.publish({}, cleared)
vi.advanceTimersByTime(50)
expect(f.host.isConnected).toBe(false)
dom.window.dispatchEvent(new dom.window.Event('pagehide'))
expect(ipc.off).toHaveBeenCalledWith(MANDATORY_IPC.state, expect.any(Function))
})
+2 -22
View File
@@ -1,31 +1,11 @@
import { EventEmitter } from 'node:events'
import { expect, it, vi } from 'vitest'
import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron'
import { createMandatoryUpdateWindow } from '../src/update-overlay.ts'
import { createUpdateOverlay } from '../src/update-overlay.ts'
const native = vi.hoisted(() => ({ create: vi.fn<(options: BrowserWindowConstructorOptions) => object>() }))
vi.mock('electron', () => ({ BrowserWindow: function (options: object) { return native.create(options) } }))
it('gives the Windows mandatory modal native move, resize, and maximize controls', () => {
const window = Object.assign(new EventEmitter(), {
webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), isDestroyed: () => false,
})
native.create.mockReturnValue(window)
const parent = {} as BrowserWindow
expect(createMandatoryUpdateWindow(parent, 'owned', 'Update required', 'win32')).toBe(window)
expect(native.create).toHaveBeenCalledWith(expect.objectContaining({
parent, modal: true, show: false, title: 'Update required',
movable: true, resizable: true, maximizable: true,
minWidth: 480, minHeight: 360,
}))
const options = native.create.mock.calls[0]![0]
expect(options.webPreferences).toMatchObject({ preload: 'owned', sandbox: true, nodeIntegration: false })
expect(options).not.toHaveProperty('frame', false)
window.emit('ready-to-show')
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(), {
@@ -39,7 +19,7 @@ it('keeps the macOS mandatory overlay stationary and blocks parent keyboard inpu
})
native.create.mockReturnValue(window)
try {
createMandatoryUpdateWindow(parent as unknown as BrowserWindow, 'owned', 'Update required', 'darwin')
createUpdateOverlay(parent as unknown as BrowserWindow, 'owned', 'Update required', false)
expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false }))
const event = { preventDefault: vi.fn() }
parent.webContents.emit('before-input-event', event)
+27 -1
View File
@@ -318,7 +318,7 @@ describe('web e2e: clickable links gallery', () => {
await seedSession(scaffold, galleryFixture(imageUrl), SEED_ID, undefined, { createdAt: GALLERY_TIME })
browser = await chromium.launch()
page = await newEnglishPage(browser)
await page.route(/https?:\/\/docs\.example\.test\/.*/u, async route => route.fulfill({
await page.context().route(/https?:\/\/docs\.example\.test\/.*/u, async route => route.fulfill({
contentType: 'text/html',
body: `<h1>${new URL(route.request().url()).pathname}</h1>`,
}))
@@ -454,5 +454,31 @@ describe('web e2e: clickable links gallery', () => {
await expect.poll(() => browserAddress.inputValue()).toBe(GUIDE_URL)
await markdown.locator(`a[href="${HTTP_URL}"]`).click()
await expect.poll(() => browserAddress.inputValue()).toBe(HTTP_URL)
await page.getByRole('button', { name: 'Settings', exact: true }).click()
await page.getByRole('button', { name: 'Built-in browser', exact: true }).click()
await page.getByRole('menuitem', { name: 'New browser tab', exact: true }).click()
await expect.poll(() => scaffold.ctx.settings.get('ui-chat')).toMatchObject({ linkOpening: 'new-tab' })
await page.keyboard.press('Escape')
const popupPromise = page.waitForEvent('popup')
await guideLink.click()
const popup = await popupPromise
try {
await popup.waitForURL(GUIDE_URL)
expect(await popup.evaluate(() => window.opener === null)).toBe(true)
expect(await browserAddress.inputValue()).toBe(HTTP_URL)
} finally {
await popup.close()
}
await page.reload()
await page.getByRole('button', { name: 'Settings', exact: true }).click()
await page.getByRole('button', { name: 'New browser tab', exact: true }).click()
await page.getByRole('menuitem', { name: 'Built-in browser', exact: true }).click()
await expect.poll(() => scaffold.ctx.settings.get('ui-chat')).toMatchObject({ linkOpening: 'sidebar' })
await page.keyboard.press('Escape')
await guideLink.click()
await expect.poll(() => browserAddress.inputValue()).toBe(GUIDE_URL)
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
})
@@ -7,3 +7,5 @@
- id: settings-controller
config:
nativeOpen: true
- id: ui-sidebar-browser
disabled: false
+1 -1
View File
@@ -24,7 +24,6 @@ describe.skipIf(MODE === 'record')('web e2e: DeepSeek Messages conversation', ()
beforeAll(async () => {
replayFixture = await selectedSessionFixture(FIXTURE, false)
scaffold = await launchWebScaffold({
deepSeekMessages: true,
replayFixture,
paceMs: 5,
replayProviders: [{
@@ -36,6 +35,7 @@ describe.skipIf(MODE === 'record')('web e2e: DeepSeek Messages conversation', ()
}],
}],
})
await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-messages', model: 'deepseek-v4-flash' })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
@@ -1,4 +1,4 @@
/** Opt-in Web Messages configuration, credential reuse, and recovery from a saved Chat Completions selection. */
/** Web DeepSeek configuration, credential reuse, and saved model selection. */
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -12,14 +12,14 @@ import { connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCALE } from './s
const EXPECTED = fileURLToPath(new URL('./expected/deepseek-messages-settings/', import.meta.url))
describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages opt-in', () => {
describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages settings', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, deepSeekMessages: true })
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
@@ -68,7 +68,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages opt-
await expect(scaffold.ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash')).resolves.toMatchObject({
name: 'Messages Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history',
})
expect(scaffold.ctx.settings.get('llm-deepseek')).toMatchObject({ protocol: 'messages' })
expect(scaffold.ctx.settings.get('llm-deepseek')).not.toHaveProperty('protocol')
expect(settings).not.toContain('sk-e2e-')
const credentials = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
expect(credentials).toContain('DEEPSEEK_API_KEY: sk-e2e-messages')
@@ -84,7 +84,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages opt-
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps a saved Chat Completions selection available after the YAML protocol switch', async () => {
it('keeps a saved DeepSeek selection available after editing provider settings', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-default'))
await page.keyboard.press('Escape')
await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
@@ -21,7 +21,7 @@
- text: 自定义设置 API 地址
- textbox "API 地址":
- /placeholder: https://api.deepseek.com/anthropic
- text: 请填写与当前连接配置兼容的 API 地址。
- text: 请填写兼容 Anthropic Messages 协议的 API 地址。
- region "模型目录":
- text: 模型目录 正在使用适配器默认模型
- textbox "模型 ID 1":
@@ -21,7 +21,7 @@
- text: 自定义设置 API 地址
- textbox "API 地址":
- /placeholder: https://api.deepseek.com/anthropic
- text: 请填写与当前连接配置兼容的 API 地址。
- text: 请填写兼容 Anthropic Messages 协议的 API 地址。
- region "模型目录":
- text: 模型目录 正在使用适配器默认模型
- textbox "模型 ID 1":
@@ -21,7 +21,7 @@
- text: 自定义设置 API 地址
- textbox "API 地址":
- /placeholder: https://api.deepseek.com/anthropic
- text: 请填写与当前连接配置兼容的 API 地址。
- text: 请填写兼容 Anthropic Messages 协议的 API 地址。
- region "模型目录":
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
@@ -0,0 +1,5 @@
Third-party bundle card: image, 36×36, decoded
Disabled Agent Teams card: image, 36×36, decoded
Agent Teams detail: image, 36×36, decoded
Third-party bundle detail: image, 36×36, decoded
Independent search row: image, 30×30, decoded
@@ -0,0 +1,28 @@
- dialog "Settings":
- navigation:
- text: Settings
- button "General"
- button "Models"
- button "Built-in plugins"
- button "Agent presets"
- button "Open configuration file"
- button "Close"
- text: Permission Choose the default permission mode for new sessions
- button "Workspace Write"
- text: Language
- button "English"
- text: Appearance
- button "Light"
- button "Dark"
- button "System" [pressed]
- text: Font size Only affects conversation content 14
- button "Increase font size"
- button "Decrease font size"
- text: px Conversation display Controls process content in completed turns
- button "Compact"
- text: Performance & usage Choose how much performance and usage information to show
- button "Detailed"
- text: Developer tools Show tools and information for debugging and troubleshooting
- switch "Developer tools"
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
- button "Queue"
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36"><circle cx="18" cy="18" r="14" fill="#145AF3" /></svg>

After

Width:  |  Height:  |  Size: 138 B

@@ -1,6 +1,7 @@
{
"name": "@fixture/bundle",
"version": "0.0.1",
"icon": "./icon.svg",
"private": true,
"description": "Registry description for the fixture bundle.",
"type": "module",
@@ -1,4 +1,5 @@
{
"name": "Search plugin",
"icon": "./icon.svg",
"description": "Search package introduction."
}
+5 -3
View File
@@ -1,4 +1,4 @@
/** Published dsh web + pnpm dev:web → browser HMR, with no page reload. */
/** Built dsh web + the `pnpm run dev:web --no-serve` watchers → browser HMR, with no page reload. */
import { existsSync, globSync, statSync } from 'node:fs'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
@@ -108,12 +108,14 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a
const failures: unknown[] = []
try {
subprocessFiber = await subprocessCtx.plugin(LocalSubprocessRuntime)
// Watchers only: the built `dsh web` below is the server under test, and the
// built tree is this lane's precondition rather than something to rebuild.
watcher = subprocessCtx.subprocess.spawn(spawnSpec(
['pnpm', 'run', 'dev:web'],
['pnpm', 'run', 'dev:web', '--skip-build', '--no-serve'],
REPO_ROOT,
{ ...clientBuildEnvironment },
))
await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web')
await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web --skip-build --no-serve')
host = subprocessCtx.subprocess.spawn(spawnSpec(
[process.execPath, binPath, 'web', '--no-open', '--port', '0'],
world,
@@ -92,7 +92,9 @@ describe('web e2e: Markdown inline-code links', () => {
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
scaffold = await launchWebScaffold({
extraOverlayPath: fileURLToPath(new URL('./sidebar-browser.overlay.yml', import.meta.url)),
})
await seedSession(scaffold, markdownFixture(LINK_URL), SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
@@ -0,0 +1,3 @@
# Exercise the settings surface without a built-in browser provider.
- id: ui-sidebar-browser
disabled: true
+46 -1
View File
@@ -10,7 +10,7 @@
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import type { Browser, ConsoleMessage, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
@@ -211,6 +211,51 @@ describe('web e2e: plan review takeover round trip', () => {
}
}, 60_000)
it.skipIf(MODE === 'record')('opens a review that arrives while a global panel is active', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review-panel-return'))
const agent = scaffold.ctx.agents.get(reviewedSession)
if (agent === undefined) throw new Error('The reviewed Session has no active agent')
// The Plugins panel replaces the Conversation and its right Sidebar. The
// review arrives while neither is mounted; returning mounts both in one
// commit, and the review's automatic open runs before the Sidebar's own
// effects. A crash there retires the opener for the rest of the page.
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
await expect.poll(() => page.locator('[data-composer-input]').count()).toBe(0)
const crashes: string[] = []
const onConsole = (message: ConsoleMessage): void => {
if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) crashes.push(message.text())
}
page.on('console', onConsole)
const controller = new AbortController()
const asked = scaffold.ctx.userQuestions.ask({
agent, signal: controller.signal,
questions: [{ id: 'off-screen', question: 'Approve this plan?',
detail: '# Off-screen review\n\nSubmitted while the Plugins panel was open.',
options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent: { kind: 'plan-review', approve: 'Approve' },
}],
})
const outcome = asked.then(value => value, (error: unknown) => ({ error }))
try {
const row = page.locator('[role="treeitem"]').filter({ has: page.locator('[data-state="warning"]') }).first()
await row.waitFor({ timeout: 10_000 })
await row.click()
const card = page.locator('[data-plan-review-key]')
await card.waitFor({ timeout: 10_000 })
const preview = page.locator('[data-plan-preview^="dsh-resource://plan-review/"]')
await preview.waitFor({ state: 'visible', timeout: 10_000 })
expect(await preview.getByText('Submitted while the Plugins panel was open.').isVisible()).toBe(true)
expect(await card.getByRole('button', { name: 'Open plan in sidebar' }).count()).toBe(1)
expect(await page.locator('[data-slot-error]').count()).toBe(0)
expect(crashes).toEqual([])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
} finally {
page.off('console', onConsole)
controller.abort()
await outcome
}
}, 60_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.v3.jsonl', 'review.expected.md', 'sidebar.expected.md', 'preview.expected.md',
+45 -1
View File
@@ -140,6 +140,50 @@ describe('web e2e: plugin manager', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('decodes manifest icons for disabled bundles and independent plugin rows', async () => {
const panel = await openPluginsPanel()
const fixtureIcon = `data:image/svg+xml;base64,${(await readFile(join(FIXTURE_PLUGINS, 'fixture-bundle/icon.svg'))).toString('base64')}`
const teamIcon = `data:image/svg+xml;base64,${(await readFile(fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/icon.svg', import.meta.url)))).toString('base64')}`
const images: string[] = []
const checkImage = async (selector: string, source: string, label: string) => {
const image = panel.locator(`${selector} img`).first()
await image.waitFor()
expect(await image.getAttribute('src')).toBe(source)
await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
const size = await image.evaluate((node: HTMLImageElement) => ({
width: node.width, height: node.height, naturalWidth: node.naturalWidth,
}))
expect(size.naturalWidth).toBeGreaterThan(0)
images.push(`${label}: image, ${size.width}×${size.height}, decoded`)
}
await checkImage('[data-plugin-package="@fixture/bundle"]', fixtureIcon, 'Third-party bundle card')
const team = panel.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-agent-team-profile"]')
expect(await team.getByRole('switch').getAttribute('aria-checked')).toBe('false')
await checkImage('[data-plugin-package="@deepseek-ai/dsh-experimental-agent-team-profile"]', teamIcon, 'Disabled Agent Teams card')
try {
for (const colorScheme of ['light', 'dark'] as const) {
await page.emulateMedia({ colorScheme })
expect(await team.locator('img').evaluate((node: HTMLImageElement) => node.naturalWidth)).toBe(36)
if (MODE === 'refresh') {
const path = fileURLToPath(new URL(`../../../.artifacts/plugin-icons-${process.pid}-${colorScheme}.png`, import.meta.url))
await page.screenshot({ path })
console.log(`Plugin icon screenshot: ${path}`)
}
}
} finally {
await page.emulateMedia({ colorScheme: null })
}
await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click()
await checkImage('[data-plugin-detail]', teamIcon, 'Agent Teams detail')
await panel.getByRole('button', { name: '返回插件列表' }).click()
await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).click()
await checkImage('[data-plugin-detail]', fixtureIcon, 'Third-party bundle detail')
await checkImage('[data-plugin-row="fixture-search"]', fixtureIcon, 'Independent search row')
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'icons.expected.md'), images.join('\n'), MODE)
await panel.getByRole('button', { name: '返回插件列表' }).click()
expect(tripwire.pageErrors).toEqual([])
})
it('localizes independent exports and falls back per field without activating the plugins', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-exports'))
const panel = await openPluginsPanel()
@@ -331,7 +375,7 @@ describe('web e2e: plugin manager', () => {
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [
'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md',
'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md',
])
})
})
+68 -1
View File
@@ -13,6 +13,7 @@ import type {} from '@deepseek-ai/dsh-api-session-controller'
import { normalizeSessionSnapshots } from '@deepseek-ai/dsh-session-snapshot'
import { launchWebScaffold, webSnapshotMode } from './scaffold.ts'
const childFixturePath = fileURLToPath(new URL('../../../snapshots/web/preset-migration/session.1.v2.jsonl', import.meta.url))
const fixturePath = fileURLToPath(new URL('../../../snapshots/web/preset-migration/session.v2.jsonl', import.meta.url))
describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration through the Web Host', () => {
@@ -32,6 +33,33 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t
await mkdir(dirname(predecessor), { recursive: true })
await writeFile(predecessor, source)
const childId = SessionId('preset-migration-child')
const brokenId = SessionId('preset-migration-broken-child')
const childFixture = (await readFile(childFixturePath, 'utf8')).trimEnd().split('\n')
.map((line): Record<string, unknown> => JSON.parse(line) as Record<string, unknown>)
const childRows: Record<string, unknown>[] = childFixture.slice(1).map((row, seq) => ({ ...row, seq, time: seq + 3 }))
const childHeader = { ...childFixture[0], id: childId, parentSession: id, cwd: scaffold.workspaceCwd }
const childSource = Buffer.concat([childHeader, ...childRows].map(row => zstdCompressSync(Buffer.from(JSON.stringify(row) + '\n'))))
const childPath = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, childId, 2, 'zstd')
const brokenPath = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, brokenId, 2, 'zstd')
await mkdir(dirname(childPath), { recursive: true })
await mkdir(dirname(brokenPath), { recursive: true })
await writeFile(childPath, childSource)
await writeFile(brokenPath, Buffer.concat([
zstdCompressSync(Buffer.from(JSON.stringify({ ...childHeader, id: brokenId }) + '\n')),
zstdCompressSync(Buffer.from('{broken\n' + JSON.stringify({ type: 'turn/end', seq: 1, time: 2,
data: { turn: 1, reason: { kind: 'completed' } } }) + '\n')),
]))
const brokenSource = await readFile(brokenPath)
const corruptHeaderId = SessionId('preset-migration-corrupt-header')
const corruptHeaderPath = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, corruptHeaderId, 2, 'zstd')
const corruptHeaderSource = Buffer.from('invalid Zstandard header frame')
await mkdir(dirname(corruptHeaderPath), { recursive: true })
await writeFile(corruptHeaderPath, corruptHeaderSource)
const listed = await scaffold.ctx.sessionController.list({}, new AbortController().signal)
expect(listed.items.map(item => item.sessionId)).toEqual(expect.arrayContaining([id, childId, brokenId]))
expect(listed.items.map(item => item.sessionId)).not.toContain(corruptHeaderId)
const reader = await scaffold.ctx.sessionPersistence.open(id, 'read')
try {
expect(reader.header.agentPreset).toBe('ptc')
@@ -48,7 +76,35 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t
expect(resolved.agent.session.snapshotEvents()
.filter(event => event.type === 'agent-preset/selected')
.map(event => event.data.agentPreset)).toEqual(withSelections ? ['ptc', 'standard', 'ptc'] : [])
expect(await scaffold.ctx.subagents.listChildren(id)).toEqual([
{ id: brokenId, createdAt: childFixture[0]?.['createdAt'], mode: 'unknown' },
{ id: childId, createdAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child' },
])
const childSuccessor = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, childId, SESSION_FORMAT_VERSION, 'zstd')
await expect(readFile(childSuccessor)).rejects.toMatchObject({ code: 'ENOENT' })
const signal = new AbortController().signal
const address = { kind: 'subagent', parentSessionId: id, childSessionId: childId, mode: 'one-shot' } as const
const childPage = await scaffold.ctx.sessionController.page({ address, throughSeq: childRows.length - 1 }, signal)
expect(childPage.records.map(record => record.event.type)).toEqual(childRows.map(row => row['type']))
await expect(scaffold.ctx.sessionController.page({ address: { ...address, childSessionId: brokenId, mode: 'unknown' }, throughSeq: 1 }, signal))
.rejects.toThrow()
await expect(scaffold.ctx.sessionController.page({ address: { kind: 'session', sessionId: id }, throughSeq: rows.length - 1 }, signal))
.resolves.toMatchObject({ hasMore: false })
const childWriter = await scaffold.ctx.sessionPersistence.open(childId, 'write')
await childWriter.close()
const childPublished = await readFile(childSuccessor)
const childText = Buffer.concat(scanZstdFrames(childPublished).frames
.map(({ start, end }) => zstdDecompressSync(childPublished.subarray(start, end)))).toString('utf8')
expect(childText.trimEnd().split('\n').map((line): unknown => JSON.parse(line))).toEqual([
{ ...childHeader, version: SESSION_FORMAT_VERSION, agentPreset: 'ptc' }, ...childRows,
])
expect(await readFile(childPath)).toEqual(childSource)
expect(await readFile(brokenPath)).toEqual(brokenSource)
await expect(readFile(generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, brokenId, SESSION_FORMAT_VERSION, 'zstd')))
.rejects.toMatchObject({ code: 'ENOENT' })
await scaffold.ctx.sessions.flush(resolved.agent.session)
const publishedBytes = await readFile(successor)
const published = Buffer.concat(scanZstdFrames(publishedBytes).frames
.map(({ start, end }) => zstdDecompressSync(publishedBytes.subarray(start, end)))).toString('utf8')
@@ -58,12 +114,23 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t
&& (row['data'] as { agentPreset: string }).agentPreset === 'code'
? { ...row, data: { agentPreset: 'ptc' } }
: row),
{ type: 'subagent/catalog', seq: rows.length, time: rows.at(-1)?.['time'], data: {
version: 1, childId: brokenId, childCreatedAt: childFixture[0]?.['createdAt'], mode: 'unknown',
} },
{ type: 'subagent/catalog', seq: rows.length + 1, time: rows.at(-1)?.['time'], data: {
version: 0, childId, childCreatedAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child',
} },
// Agent activation closes its restored prefix with a fresh seed marker.
{ type: 'session/end-seed', seq: rows.length, time: 0, data: {} },
{ type: 'session/end-seed', seq: rows.length + 2, time: 0, data: {} },
{ type: 'permission/preset', seq: rows.length + 3, time: 0, data: { preset: 'workspace-write' } },
{ type: 'sandbox/mode', seq: rows.length + 4, time: 0, data: { mode: 'workspace-write' } },
{ type: 'approval/policy', seq: rows.length + 5, time: 0, data: { policy: 'ask' } },
].map(row => JSON.stringify(row)).join('\n') + '\n'
const context = { sessionIds: [id], cwd: scaffold.workspaceCwd }
expect(normalizeSessionSnapshots([published], context)).toEqual(normalizeSessionSnapshots([expected], context))
expect(await readFile(predecessor)).toEqual(source)
await expect(scaffold.ctx.sessionPersistence.open(corruptHeaderId, 'read')).rejects.toThrow(/invalid frame magic/)
expect(await readFile(corruptHeaderPath)).toEqual(corruptHeaderSource)
expect((await readdir(dirname(predecessor))).filter(name => name.endsWith('.jsonl.zstd')).sort())
.toEqual(['session.v2.jsonl.zstd', `session.v${SESSION_FORMAT_VERSION}.jsonl.zstd`])
} finally {
+8 -19
View File
@@ -261,10 +261,9 @@ class RouteOnlyAdapter extends LlmAdapter {
}
}
function replayProviders(contextWindow: number | undefined, messages: boolean): typeof REPLAY_PROVIDERS {
function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS {
return REPLAY_PROVIDERS.map(provider => ({
...provider,
id: messages ? 'deepseek-messages' : provider.id,
models: provider.models.map(model => ({
...model,
...contextWindow === undefined ? {} : { contextWindow },
@@ -391,8 +390,6 @@ export interface LaunchOptions {
* keyless first-run configuration lane; the default disables the adapter.
*/
deepSeekMissingCredential?: boolean
/** Record or replay a Messages scenario; older scenarios explicitly retain their recorded Chat Completions route. */
deepSeekMessages?: boolean
/** Leave the current welcome notice pending; ordinary scenarios pre-acknowledge it before browser boot. */
welcomeNoticePending?: boolean
/**
@@ -494,7 +491,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
throw new Error('deepSeekMissingCredential is a keyless replay/refresh option')
}
const maskDeepSeekCredential = mode !== 'record' && options.deepSeekMissingCredential === true
const messages = options.deepSeekMessages === true
const originalDeepSeekCredential = process.env.DEEPSEEK_API_KEY
let credentialEnvironmentRestored = false
const restoreCredentialEnvironment = (): void => {
@@ -571,13 +567,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// Without HMR the profile applies configuration changes at its next start.
...options.profile?.hmr === false ? [{ id: 'hmr', disabled: true }] : [],
{ id: 'session-log-deepseek', config: { enabled: false } },
// The historical Messages fixture retains its recorded route during replay;
// live configuration uses the shared DeepSeek route. Explicit overlays win.
...messages
? [{ id: 'agent-default-model', config: { provider: mode === 'record' || maskDeepSeekCredential ? 'deepseek-official' : 'deepseek-messages', model: maskDeepSeekCredential ? 'deepseek-flash' : 'deepseek-v4-flash' } }]
: mode === 'record' || options.deepSeekMissingCredential === true
? []
: [{ id: 'agent-default-model', config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }],
...mode === 'record' || options.deepSeekMissingCredential === true
? []
: [{ id: 'agent-default-model', config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }],
...extraOverlayPatches,
// The roster's shipped presets are the plugin's own, bundled inside
// `dsh-agent-presets` and prepended by it. Pin only the machine-local
@@ -693,10 +685,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
baseURL: options.deepSeekSearch.baseURL,
},
}],
...maskDeepSeekCredential && !messages ? [] : [
{ id: 'llm-deepseek', disabled: mode !== 'record' && !maskDeepSeekCredential,
config: messages ? {} : { protocol: 'chat-completions' } },
],
{ id: 'llm-deepseek', disabled: mode !== 'record' && !maskDeepSeekCredential },
]
const patches: PatchOptions[] = [...basePatches, ...surfacePatches, ...overlayPatches]
@@ -870,7 +859,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
if (mode !== 'record' && replayFixture !== undefined) {
replayHandle = installLlmReplay(ctx, {
file: replayFixture,
providers: (options.replayProviders ?? replayProviders(options.replayContextWindow, messages)).map(provider => ({
providers: (options.replayProviders ?? replayProviders(options.replayContextWindow)).map(provider => ({
...provider,
...(options.replayRetryPolicy === undefined ? {} : { retryPolicy: options.replayRetryPolicy }),
})),
@@ -885,8 +874,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// a fixture would, with streaming that still fails loud: the scenario
// issues no model calls, and one that slipped in must not pass quietly.
ctx.effect(() => ctx.llm.registerAdapter(
replayProviders(options.replayContextWindow, messages).map(provider => provider.id),
new RouteOnlyAdapter(replayProviders(options.replayContextWindow, messages)),
replayProviders(options.replayContextWindow).map(provider => provider.id),
new RouteOnlyAdapter(replayProviders(options.replayContextWindow)),
), 'web e2e scaffold: route-only adapter')
}
if (publicProxy === undefined || publicHost === undefined || publicPrefix === undefined) {
+65
View File
@@ -190,6 +190,50 @@ describe('web e2e: settings modal and General preferences', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('scrolls overflowing section navigation independently of the title and settings content', async () => {
const overflowPage = await browser.newPage({ viewport: { width: 1280, height: 768 }, locale: ZH_BROWSER_LOCALE })
onTestFinished(() => overflowPage.close())
onTestFailed(() => saveFailureShot(overflowPage, 'web-e2e-settings-navigation'))
const console = watchConsole(overflowPage)
await overflowPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await overflowPage.getByRole('button', { name: '设置', exact: true }).click()
const dialog = overflowPage.getByRole('dialog', { name: '设置', exact: true })
const navigation = dialog.getByRole('navigation')
const list = navigation.locator('[class*="_navList"]')
const title = navigation.getByText('设置', { exact: true })
const options = dialog.locator('[class*="_options"]')
const last = navigation.getByRole('button').last()
await last.waitFor()
expect(await list.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(false)
// A short viewport overflows the shipped sections without synthetic registrations.
await overflowPage.setViewportSize({ width: 1280, height: 240 })
expect(await list.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true)
const titleBefore = await title.boundingBox()
const contentBefore = await options.innerText()
await navigation.getByRole('button').first().hover()
await overflowPage.mouse.wheel(0, 1000)
await expect.poll(() => last.evaluate((el) => {
const rect = el.getBoundingClientRect()
return el.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2))
})).toBe(true)
const navScroll = await list.evaluate(el => el.scrollTop)
expect(navScroll).toBeGreaterThan(0)
expect(await title.boundingBox()).toEqual(titleBefore)
expect(await options.evaluate(el => el.scrollTop)).toBe(0)
await options.hover()
await overflowPage.mouse.wheel(0, 1000)
await expect.poll(() => options.evaluate(el => el.scrollTop)).toBeGreaterThan(0)
expect(await list.evaluate(el => el.scrollTop)).toBe(navScroll)
expect(await title.boundingBox()).toEqual(titleBefore)
await last.click()
await expect.poll(() => last.getAttribute('aria-current')).toBe('true')
await expect.poll(() => options.innerText()).not.toBe(contentBefore)
expect(console.pageErrors).toEqual([])
expect(console.warnings).toEqual([])
})
it('stores Permission as the default for future sessions without changing an existing session', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission'))
const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before'))
@@ -727,10 +771,31 @@ describe('web e2e: settings modal and General preferences', () => {
}
}, 90_000)
it('hides link-opening settings when the built-in browser is disabled', async () => {
const fresh = await launchWebScaffold({
developerTools: false,
extraOverlayPath: fileURLToPath(new URL('./no-sidebar-browser.overlay.yml', import.meta.url)),
})
onTestFinished(() => fresh.close())
const withoutBrowser = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' })
onTestFinished(() => withoutBrowser.close())
const browserConsole = watchConsole(withoutBrowser)
await withoutBrowser.goto(fresh.authenticatedUrl, { waitUntil: 'load' })
await withoutBrowser.getByRole('button', { name: 'Settings', exact: true }).click()
const dialog = withoutBrowser.getByRole('dialog', { name: 'Settings' })
await dialog.getByRole('button', { name: 'Detailed', exact: true }).waitFor()
expect(await dialog.getByText('Open chat links in', { exact: true }).count()).toBe(0)
const snapshot = await captureStableAria(withoutBrowser, '[role="dialog"]', fresh.workspaceCwd)
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'dialog-no-browser.expected.md'), snapshot, MODE)
expect(browserConsole.pageErrors).toEqual([])
expect(browserConsole.warnings).toEqual([])
})
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [
'dialog-en.expected.md',
'dialog-no-browser.expected.md',
'dialog.expected.md',
'plugin-instances.expected.md',
'plugins.expected.md',
+5 -1
View File
@@ -29,7 +29,11 @@ describe.skipIf(MODE === 'record')('web e2e: Sidebar Browser', () => {
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false })
scaffold = await launchWebScaffold({
replayFixture: FIXTURE,
compareReplaySession: false,
extraOverlayPath: fileURLToPath(new URL('./sidebar-browser.overlay.yml', import.meta.url)),
})
browser = await chromium.launch()
page = await newEnglishPage(browser)
await page.route('https://browser.test/**', async (route) => {
@@ -0,0 +1,3 @@
# Opt in to Browser for its integration scenarios.
- id: ui-sidebar-browser
disabled: false
Loaded 100 of 382 files, more files were not shown because too many files have changed in this diff. Show more