Merge origin/master into xtr/tool-executor-plugins

This commit is contained in:
_Kerman committed 2026-10-21 15:56:44 +08:00
commit a53ecb2f3a
917 files changed
+31142 -666757

No files matched your search

@@ -9,8 +9,8 @@
en: 6bb727a659ba1503
zh: d2ebf5c8164a807e
/agent-note-current-profile-plugin-management-shares-cli-transactions/decision:
en: 863b4369c39f3933
zh: 7dd44a91ccaaef8f
en: d2e13562b5aef903
zh: 07face30189c07fc
/agent-note-current-profile-plugin-management-shares-cli-transactions/alternatives-considered:
en: 30c609de89084351
zh: 27f2747ad9d9b752
@@ -16,7 +16,7 @@ Configuration watches use Chokidar write stabilization by default. Its ordinary
Profile files remain the persisted state: entry toggles edit only `disabled` in the last override matching the entry id and any module-name assertion, appending when none matches, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; a failed removal preserves the actual partial state and a diagnostic path, while a failed or cancelled installation restores the profile files it snapshotted.
This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service. Management operations return results to callers without adding messages to live Agents. The agent tool is enabled in Creator mode and disabled by default in the base bundle and other shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.
This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service. Management operations return results to callers without adding messages to live Agents. The agent tool is enabled in Standard and Creator modes and disabled by default in the base bundle and other shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.
CLI calls inherit the terminal and authentication environment; service calls retain the subprocess credential scrub and bounded diagnostics. Management records carry error codes and parameters for locale-owned Web presentation. Reconciliation compares entry identity, fiber identity, configuration and diagnostics before and after updating: unchanged inactive entries remain warnings, while newly affected failures reject the operation. Explicit enablement targets must activate.
@@ -16,7 +16,7 @@ Web 和 Agent 控件需要修改运行中的 profile,同时避免另建包安
profile 文件保持为持久状态:条目开关只修改最后一条符合条目 id 及模块名称断言的覆盖项中的 `disabled`,没有匹配项时追加,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败的删除保留实际的部分状态与诊断路径,失败或被取消的安装则恢复它快照的 profile 文件。
这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service。管理操作向调用方返回结果,不向存活 Agent 添加消息。创造模式启用该 Agent 工具;base 组合包和其他内置预设默认禁用。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。
这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service。管理操作向调用方返回结果,不向存活 Agent 添加消息。标准模式和创造模式启用该 Agent 工具;base 组合包和其他内置预设默认禁用。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。
CLI 调用继承终端和认证环境;service 调用保留子进程凭据清理与有界诊断。管理结果提供错误码和参数,由 Web 词典呈现文案。重载前后比较 entry、fiber、配置与诊断:未变化的已有故障保留为警告,本次影响到的新故障使操作失败。显式启用的目标必须成功激活。
@@ -9,8 +9,8 @@
en: 44615a7a0939054e
zh: b8efa721321ba0d7
/agent-note-creator-mode-installs-persistent-plugin-bundles/decision:
en: c0d74db1b2b16576
zh: b8713a6aadf007f9
en: 38ecabc170c418c7
zh: d380f53fa508328e
/agent-note-creator-mode-installs-persistent-plugin-bundles/alternatives-considered:
en: af527d8aabdfd59c
zh: 34388da992d2ab56
@@ -12,6 +12,8 @@ Agents need to install capabilities and use them during the same conversation. G
Creator mode enables the existing `plugin_manager` tool. Agents author packages and Loader YAML patches as workspace files, then install them through `install_bundle`. An MCP connection is a configuration-only bundle inserting the installed `dsh-mcp-client`; a UI bundle includes a Host entry and a Client artifact. Profile locking, package installation, enablement and HMR remain owned by the existing manager. The entire management tool requires `danger-full-access` or approval for one call: profile changes can load code with host permissions and affect other sessions. Each execution, including inventory reads, uses the shared sandbox escalation helper and approval service before accessing the manager. Under lower sandbox modes, `ask` requests approval and `never` denies; a full-access session needs no additional approval. A grant does not change session permissions, but its profile changes persist. This keeps shell-equivalent Host access explicit without requiring a permanent session-wide elevation. Human-operated Web and CLI controls retain their existing behavior.
The default Standard mode enables the same tool under the same profile condition and approval gate. Without it, Standard sessions did not install plugins directly and spent extra turns and tokens working around the missing tool; the change also moves Standard and Creator toward one capability set. A full-access Standard session can therefore install Host code without a separate prompt, as its shell already can. PTC and Minimal keep the tool disabled.
The model sees two read-only Cordis inspection tools. Generated-code define/run/stop/undefine and dynamic self-inspection tool APIs are absent. Existing runtime and Client consumers retain their services; historical session cards remain readable. This supersedes only the model-facing mutation workflow in the [self-referential toolset reference](../../../../packages/extensions/cordis-host-runner/README.md); its runtime ownership and sandbox rationale remain independently relevant. The [profile transaction decision](2026-09-14-current-profile-plugin-management.md) continues to govern locking, package installation, and partial failures.
Visual creation requests default to an installed Client plugin rendered in the current Web page unless the user names another destination. The development skill supplies a minimal package and effect-owned Client registration. Discovery ends when the required APIs are known; a working first version is installed before optional visual refinement. Verification uses the connected page where available. Browser authentication or operating-system setup is not a prerequisite for plugin installation, and a mock preview cannot establish an in-app result.
@@ -12,6 +12,8 @@ agent 需要安装能力并在同一段对话中使用。生成代码工具在
创造模式启用现有的 `plugin_manager` 工具。agent 将包和 Loader YAML patch 写入工作区文件,再通过 `install_bundle` 安装。MCP 连接是插入已安装 `dsh-mcp-client` 的纯配置组合包;UI 组合包包含 Host 入口和 Client 产物。profile 锁、包安装、启停和 HMR 继续由现有管理器负责。 整个管理工具要求 `danger-full-access` 或单次调用的批准:profile 变更能以宿主权限加载代码,并影响其他会话。每次执行(包括查询列表)都会先使用共享沙箱提权函数和审批服务,再访问管理器。在较低沙箱模式下,`ask` 请求审批,`never` 拒绝;完整权限会话无需额外审批。批准不改变会话权限,但本次操作的 profile 变更会持久化。这让与 shell 等价的宿主访问需要明确授权,同时不必永久提升整个会话的权限。由用户直接操作的 Web 和 CLI 控件保持原有行为。
默认的标准模式以相同的 profile 条件和审批门槛启用同一工具。缺少该工具时,标准模式会话不会直接安装插件,而要额外花费轮次和 token 绕过;此改动也让标准模式和创造模式朝同一能力集收敛。因此,完整权限的标准模式会话无需单独审批即可安装 Host 代码,与其 shell 已有的能力一致。PTC 和 Minimal 仍禁用该工具。
模型可见两个只读 Cordis 检查工具,不再提供生成代码的 define/run/stop/undefine 和动态自省工具 API。现有运行时和 Client 消费者保留其服务;历史会话卡片仍然可读。这仅取代[自引用工具集参考](../../../../packages/extensions/cordis-host-runner/README.zh.md)中的模型侧变更流程;其运行时所有权和沙箱依据仍有独立价值。[profile 事务决策](2026-09-14-current-profile-plugin-management.zh.md)继续规定锁、包安装和部分失败行为。
除非用户指定其他目标,视觉创建请求默认通过已安装的 Client 插件显示在当前 Web 页面。开发 skill 提供最小包和由 effect 管理的 Client 注册示例。已知所需 API 后结束探查,在可选视觉优化之前先安装能工作的初版。有条件时使用已连接页面验证。浏览器认证或操作系统设置不是安装插件的前提,mock 预览不能证明应用内结果。
@@ -9,8 +9,8 @@
en: f96bf1ebc0719fb0
zh: e9a1144f0e76572d
/agent-note-background-hover-and-screenshot-coordinates/decision:
en: 0cdd295d5f1872ed
zh: fe03ec9f7df94a4a
en: 0f45528ea6c5988c
zh: 9bde9df417ddb1ce
/agent-note-background-hover-and-screenshot-coordinates/alternatives-considered:
en: 5fc4beaf79761c50
zh: 4948333b0020be28
@@ -12,7 +12,7 @@ Hover-only controls can sometimes be revealed without moving the user's pointer.
Native hover first uses a same-window element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Entering its revealed descendants preserves the parent UI; leaving restores it. An unavailable route falls back to the real system pointer. An AX action failure propagates because received effects may be uncertain. The result records the route and whether the system pointer moved. System movement requires the selected foreground app and AppKit's mouse hit test, which excludes input-transparent overlays. No route grants [cross-call ownership](2026-10-13-cua-continuous-hover.md).
Model-facing coordinate input defaults to pixels in the returned screenshot. `relative: true` accepts integer thousandths from 0 to 1000 for click, hover, scroll and drag; it does not apply to AX indices. The Host owns the mapping to native window points. An application-wide observation refreshes its inventory; a scoped observation preserves other windows' image mappings. Window resizing invalidates the old image, while movement preserves local coordinates. Relative endpoints remain inside the window and do not depend on screenshot resolution.
Model-facing coordinate input defaults to pixels in the returned screenshot. `relative: true` accepts integer thousandths from 0 to 999 for click, hover, scroll and drag; it does not apply to AX indices. The Host owns the mapping to native window points. An application-wide observation refreshes its inventory; a scoped observation preserves other windows' image mappings. Window resizing invalidates the old image, while movement preserves local coordinates. Relative endpoints remain inside the window and do not depend on screenshot resolution.
`LlmAdapter.imageRequestTarget` exposes the same dimensions and byte target used by image serialization. DeepSeek and pi-ai share their existing projection functions with this method. Other adapters preserve normalized images unless they declare resizing. Screenshot bytes returned to programs match the request image; its durable source is admitted and stored once. The native PNG metadata is checked against decoded source dimensions before any input mapping is published.
@@ -12,7 +12,7 @@ Status: implemented
原生悬停优先寻找同一窗口内同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的元素或祖先。进入其展开区域的子控件时保持父控件展开,离开后恢复。没有可用路径时回退到真实系统鼠标。AX 动作失败直接返回,因为已产生的效果可能不确定。结果记录实际路径及是否移动系统鼠标。系统移动要求所选应用处于前台,并使用 AppKit 的鼠标命中查询排除不接收输入的覆盖窗口。所有路径都遵守[跨调用不分配所有权](2026-10-13-cua-continuous-hover.zh.md)的决定。
面向模型的坐标默认使用返回截图中的像素。click、hover、scroll 和 drag 传入 `relative: true` 时接受 0~1000 的千分比整数,不适用于 AX 索引。Host 负责换算原生窗口 point。应用级观察刷新窗口清单,单窗口观察保留其他窗口的图片映射。窗口尺寸变化使旧图片失效,移动窗口保留局部坐标。相对坐标的端点保持在窗口内,不随截图分辨率改变。
面向模型的坐标默认使用返回截图中的像素。click、hover、scroll 和 drag 传入 `relative: true` 时接受 0~999 的千分比整数,不适用于 AX 索引。Host 负责换算原生窗口 point。应用级观察刷新窗口清单,单窗口观察保留其他窗口的图片映射。窗口尺寸变化使旧图片失效,移动窗口保留局部坐标。相对坐标的端点保持在窗口内,不随截图分辨率改变。
`LlmAdapter.imageRequestTarget` 提供与图片序列化相同的尺寸和字节目标。DeepSeek 和 pi-ai 在该方法中复用已有投影函数。其他适配器除非声明缩放,否则保留归一化图片。返回程序的截图字节与模型请求图片一致,持久原图只接入并保存一次。发布输入映射前,原生 PNG 元数据必须与解码后的原图尺寸一致。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-08-26-web-rich-text-quotes.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-web-rich-text-quotes.md
/agent-note-durable-rich-text-quotes-in-the-web-composer:
en: 49522a0a81db6981
zh: 92e81872377e96e9
/agent-note-durable-rich-text-quotes-in-the-web-composer/problem:
en: 7bd295c086ad9905
zh: 704dc665acfaa5ec
/agent-note-durable-rich-text-quotes-in-the-web-composer/decision:
en: d27134e46ab55ea0
zh: 3f1da063b661a1b3
/agent-note-durable-rich-text-quotes-in-the-web-composer/alternatives-considered:
en: 1999da9f900c9be3
zh: cf6aad990a180851
/agent-note-durable-rich-text-quotes-in-the-web-composer/verification:
en: b320c262d9c08ee4
zh: 6047f864213a2b4c
/agent-note-durable-rich-text-quotes-in-the-web-composer/consequences:
en: bb6f4338aad0ec41
zh: 93a4396b619492b1
@@ -0,0 +1,37 @@
# Agent Note: Durable rich-text quotes in the Web composer
Status: implemented
English | [中文](2026-08-26-web-rich-text-quotes.zh.md)
## Problem
Selected transcript excerpts need atomic editing and reload-safe drafts without depending on the availability of their source messages.
## Decision
`ui-conversation` owns an inline `QuoteNode` beside reference nodes in the existing editor runtime. Its clipboard projection is canonical Markdown, while its detect projection is one atomic character. The existing semantic `DraftSnapshot` persists quote text and clipboard-coordinate spans beside references; raw Lexical JSON and runtime node identities remain outside browser storage. Session switching, Workspace transfer, and failed-send restoration use this same snapshot. Quote controls receive runtime-only locale labels.
`ui-chat` accepts one nonblank browser selection within displayed user content, including pending steering and local submission echoes or finalized Assistant text and reasoning. Streaming and interrupted Assistant output, tools, injected context, and Markdown controls are excluded. Selection extraction follows visible line and block breaks and inserts through the current Session input actions.
Text interaction belongs to the shared primitive and the Chat-owned list Slot. Quote registers only gesture admission and insertion. Local absolute placement keeps actions in the content coordinate system; outside presses and Escape close the same area-owned interaction for every consumer. Dismissal and selection clearing are separate operations so a failed insertion preserves the excerpt. Context-menu consumers cancel their originating browser event synchronously; rendering a Slot cannot retroactively cancel it.
The [text-derived blockquote decision](../simplification/2026-10-13-text-derived-message-quotes.md) owns sent-message recognition and presentation. The [UserText evaluator](../architecture/2026-10-10-user-text-json-nodes.md) renders derived quote nodes through the existing matcher registry.
Quotes display a compact single-line preview; clipped excerpts expose the complete text in a shared inline HoverCard. Their text has no source-navigation action. The editable draft alone exposes whole-node removal. Only selected text survives message persistence: no source Session, message, or selection coordinates are retained.
## Alternatives considered
**Persist Lexical JSON or introduce another draft format.** Editor serialization would become a product storage obligation, while a second draft format would duplicate restoration and transfer ownership. The existing semantic snapshot already carries reference spans and extends to quotes.
**Retain source navigation.** Availability checks would require source coordinates, subscriptions, history pagination, focus routing, and failure states. A future navigation feature must define those durable coordinates and target-owned actions before adding them.
**Store UserText nodes or callbacks.** Text nodes are derived presentation, and callbacks are runtime state. Both can change independently of the logged input and semantic draft.
## Verification
Composer tests cover atomic removal and undo, semantic storage round trips, Markdown serialization, and failed-send restoration. UserText tests cover text recognition, literal excerpt rendering, literal references, and credential spelling. The keyless Web scenario exercises selection, clipped previews, draft switching and reload, persistence, and ordinary Markdown submission through the shipped composition.
## Consequences
The draft and Session retain enough data for atomic editing and replay without retaining source-history dependencies. UserText remains a display projection; its JSON nodes never replace authoritative content. Browser-local storage retains unsent selected text under the existing draft policy.
@@ -0,0 +1,37 @@
# Agent Note:Web composer 中的持久富文本 quote
Status: implemented
[English](2026-08-26-web-rich-text-quotes.md) | 中文
## 问题
所选 transcript 摘录需要原子编辑与可跨刷新恢复的草稿,不依赖来源消息的可用性。
## 决策
`ui-conversation` 在现有编辑器 runtime 中于 reference 节点旁拥有行内 `QuoteNode`。其剪贴板投影是规范 Markdown,检测投影则是一个原子字符。现有语义 `DraftSnapshot` 在 reference 旁持久化 quote 文本与剪贴板坐标范围;原始 Lexical JSON 与 runtime 节点身份不进入浏览器存储。Session 切换、Workspace 转移和发送失败恢复使用同一个 snapshot。Quote 控件接收仅用于 runtime 的 locale 标签。
`ui-chat` 接受已展示的用户内容(包括待接纳 steering 和本地发送回显)、或最终完成的 Assistant 文本与 reasoning 中的一个非空浏览器选区。流式及中断的 Assistant 输出、tool、注入上下文和 Markdown 控件被排除。选区提取遵循可见的行与 block 换行,通过当前 Session 的 input actions 插入。
文本交互属于共享基础能力与 Chat 拥有的 list Slot,quote 只注册手势接管与插入操作。本地绝对定位让操作处于内容坐标系;外部按下与 Escape 对所有使用方关闭同一个区域拥有的交互。关闭展示与清除选区是独立操作,因此插入失败时保留摘录。右键菜单使用方同步取消来源浏览器事件;渲染 Slot 无法事后取消事件。
[文本派生 blockquote 决策](../simplification/2026-10-13-text-derived-message-quotes.zh.md)拥有发送后的消息识别与展示。[UserText 计算器](../architecture/2026-10-10-user-text-json-nodes.zh.md)通过现有 matcher registry 渲染派生的 quote 节点。
Quote 显示紧凑的单行预览;截断的摘录通过共享行内 HoverCard 展示完整文本。文本不提供来源导航操作,只有可编辑草稿提供整节点删除。消息持久化只保留所选文本:不保留来源 Session、消息或选区坐标。
## 考虑过的替代方案
**持久化 Lexical JSON 或引入另一套草稿格式。** 编辑器序列化会成为产品存储义务,第二套草稿格式则会重复拥有恢复与转移逻辑。现有语义 snapshot 已携带 reference 范围,也可扩展到 quote。
**保留来源导航。** 可用性检查需要来源坐标、订阅、历史分页、焦点路由和失败状态。未来若添加导航功能,必须先定义其持久坐标与目标拥有的操作。
**存储 UserText 节点或 callback。** 文本节点是派生的展示,callback 是 runtime 状态;二者都可以独立于记录的输入和语义草稿变化。
## 验证
Composer 测试覆盖原子删除与撤销、语义存储往返、Markdown 序列化和发送失败恢复。UserText 测试覆盖文本识别、字面摘录展示、字面 reference 与凭据原文。无密钥 Web 场景通过已发布组合验证选区、截断预览、草稿切换与刷新、持久化和普通 Markdown 提交。
## 后果
草稿与 Session 保留足以支持原子编辑和 replay 的数据,不保留对来源历史的依赖。UserText 仍是展示投影;其 JSON 节点不会替代权威 content。浏览器本地存储按现有草稿策略保留未发送的所选文本。
@@ -9,14 +9,14 @@
en: c8e926bfc65d7195
zh: e0e007960b68fe80
/agent-note-in-tree-persistence-type-history/decision:
en: 94be3d6c7df3063b
zh: 7e8fb08d7fd95f85
en: 3d4b2935c4db54b2
zh: eb0da7083e301ef4
/agent-note-in-tree-persistence-type-history/decision/finalized-checkpoints:
en: b508035a163340a3
zh: ba5b2b7e8cef4b2f
/agent-note-in-tree-persistence-type-history/alternatives-considered:
en: 1cf6d0a5555ae50a
zh: 70ea1ba535e09442
en: a581d63455788d48
zh: 0a3781d2f827e35d
/agent-note-in-tree-persistence-type-history/consequences:
en: 672c8cb59d291dc2
zh: cbec90fbca92320a
@@ -16,6 +16,8 @@ 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.
Every schema file stores one node table keyed by a 16-hexadecimal-digit prefix of each node's canonical subgraph digest; roots and types locate their graphs through their own digests. A type change therefore rewrites only the changed nodes, their ancestors, and the affected digests. Parsers accept only the exact generated text, and all committed files share this layout.
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 readable and records the older-reader limitation. Other structural changes require an explicit same-version choice in the recording CLI or an increasing header version. Every structural difference needs an acknowledgement; its Compatibility and Verification sections explain both reader directions and executed checks. CI validates saved decisions without inferring how they were authored. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) owns adjacent migration work when a bump is required.
The persistence-change document kind preserves 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.
@@ -44,6 +46,8 @@ Finalization records acceptance independently of publication. The [status record
**Infer behavior from types or require proof of human review.** A type graph cannot establish replay semantics, and a recorded choice cannot establish how it was authored. The checker validates declared transitions and header-version constraints. Reviewers inspect actual reader behavior and the acknowledgement’s explanation independently.
**Keep positional node indices or also read the previous layout.** Positional indices renumber every later node when a union gains a variant, and per-root graphs repeat shared types, so a one-variant change rewrote thousands of lines. Digest-keyed nodes keep diffs proportional to the change. Reading two layouts would leave two parsers to maintain and two accepted texts per inventory; migrating every committed file once keeps a single format.
## Consequences
Authors retain one snapshot per affected root per accepted transition and resolve history forks when integrating competing changes to that root. Independent roots can advance without rewriting unrelated acknowledgements. The [cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.md) provides the local authoring and verification procedure.
@@ -16,6 +16,8 @@ Status: implemented
专用的[持久化变更记录](../../../../docs/persistence-changes/README.zh.md)将确认绑定到每个受影响根的变更后摘要。生成的伴随文件保存完整的变更后 schema。初始记录覆盖所有根;后续记录为每个根指定前驱。前驱的变更后 schema 提供下一次的变更前 schema。验证拒绝有歧义的历史,并要求当前源码匹配最终记录状态,无需查询 Git 历史或远端服务。
每个 schema 文件保存一张节点表,以每个节点规范子图摘要的 16 位十六进制前缀为键;根与类型通过各自的摘要定位其图。因此类型变更只改写发生变化的节点、其祖先节点以及受影响的摘要。解析器只接受精确生成的文本,所有已提交文件共用此布局。
自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。当双方保存的 schema 均声明支持时,受限的[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)也允许添加符合条件的生产者 kind。[事件载荷版本规则](../../../../docs/persistence-changes/README.zh.md#compatibility-rules)允许在每个旧分支仍可读取时增加更高的数字载荷版本,并记录旧读取器限制。其他结构变更要求在记录 CLI 中显式选择同版本,或递增头版本。每个结构差异都需要确认;其兼容性与验证部分说明两个方向的读取器行为及已执行检查。CI 验证保存的决策,不推断其编写方式。需要升版本时,[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)负责相邻迁移工作。
持久化变更文档类型保留历史类型转换的兼容性说明和证据。Agent Note 保留机制层面的决策,不积累单次确认清单。双语对中的机器声明保持相同,且只解析一次。
@@ -44,6 +46,8 @@ Status: implemented
**从类型推断行为,或要求人类评审证明。** 类型图无法证明回放语义,所记录的选择也无法证明其编写方式。检查器验证声明的转换与头版本约束。评审者独立检查实际读取器行为与确认说明。
**保留按位置编号的节点索引,或同时读取旧布局。** 联合类型新增分支时,按位置编号的索引会为其后的每个节点重新编号,而逐根图会重复共享类型,因此单个分支变更曾改写数千行。以摘要为键的节点使差异规模与变更成正比。同时读取两种布局需要维护两个解析器,且每个清单会有两种可接受文本;将所有已提交文件一次性迁移后只保留单一格式。
## 影响
作者为每次已接受转换中的每个受影响根保留一份快照,并在集成该根的竞争变更时解决历史分叉。独立根可以演进而不改写无关确认。[实操手册](../../../../docs/cookbook/reviewing-persistence-type-changes.zh.md)提供本地编写与验证流程。
@@ -9,8 +9,8 @@
en: dc43ba84345e1405
zh: dbc03572ab7710d2
/agent-note-ship-optional-bundles-with-the-installation/decision:
en: 5210b3b746cfee78
zh: f1907f4807ae0098
en: 1362533f342b31f4
zh: 90689262acefb43d
/agent-note-ship-optional-bundles-with-the-installation/alternatives-considered:
en: 95b48b61757f1f13
zh: e9fb2d6ea492a4bc
@@ -10,7 +10,7 @@ The Web plugin page manages only the bundles a person installed into the profile
## Decision
The launcher names in `OPTIONAL_BUNDLES` (`packages/boot/app-boot/src/profile.ts`, beside the profile templates) the bundles the installation ships for a person to switch on. Each must be a runtime dependency of `apps/cli` that declares `dsh.bundle.patch`, and no shipped profile template selects it. The plugin manager's `listBundles` reports such a bundle as `optional`: switched off until selected, never removable, resolved from the installation like any installation-supplied bundle. The Web plugin page opens its Official group with the optional bundles, tagged experimental when the package is experimental, ahead of the profile's own installed bundles.
The launcher names in `OPTIONAL_BUNDLES` (`packages/boot/app-boot/src/profile.ts`, beside the profile templates) the bundles the installation ships for a person to switch on. Each must be a runtime dependency of `apps/cli` that declares `dsh.bundle.patch`, and no shipped profile template selects it. The plugin manager's `listBundles` reports such a bundle as `optional`: switched off until selected, never removable, resolved from the installation like any installation-supplied bundle. The Web plugin page presents offered optional bundles in its Official group, tagged experimental when the package is experimental, ahead of the profile's own installed bundles. The [persistent-terminal bundle](../../../../packages/experimental/terminal-bundle/README.md) remains available to the Host but has no page switch, even when enabled; its existing profile selection remains intact.
Default-product isolation keeps its rules with one declared exception: an optional bundle's dependency graph is outside the default product. The static gate skips the `dependencies` edge from `@deepseek-ai/dsh` to a listed bundle and still rejects a runtime import, a shipped composition, a preset, or a default template that names it, an experimental dependency the list does not name, and a listed name that is not a runtime dependency or not a bundle. The workspace-constraints check accepts the same `dependencies` edges and no other runtime section, and the packed-install release check skips them from the installed entry package while requiring each listed bundle to be installed.
@@ -10,7 +10,7 @@ Web 插件页只管理用户装进 profile 的组合包。像 Agent Teams 这样
## 决策
启动器在 `OPTIONAL_BUNDLES`(`packages/boot/app-boot/src/profile.ts`,与 profile 模板并列)里点名安装随附、供用户开启的组合包。每一个都必须是 `apps/cli` 声明了 `dsh.bundle.patch` 的运行时依赖,且不被任何随附 profile 模板选中。插件管理器的 `listBundles` 把这类组合包报告为 `optional`:选中前保持关闭、永不可卸载、像其他安装提供的组合包一样从安装目录解析。Web 插件页以可选组合包开启「官方」分组,属于实验包的带实验性标签,排在 profile 自己已安装的组合包之前。
启动器在 `OPTIONAL_BUNDLES`(`packages/boot/app-boot/src/profile.ts`,与 profile 模板并列)里点名安装随附、供用户开启的组合包。每一个都必须是 `apps/cli` 声明了 `dsh.bundle.patch` 的运行时依赖,且不被任何随附 profile 模板选中。插件管理器的 `listBundles` 把这类组合包报告为 `optional`:选中前保持关闭、永不可卸载、像其他安装提供的组合包一样从安装目录解析。Web 插件页在「官方」分组展示可供选择的可选组合包,属于实验包的带实验性标签,排在 profile 自己已安装的组合包之前。[持久终端组合包](../../../../packages/experimental/terminal-bundle/README.zh.md)仍可供 Host 使用,但即使已启用也不提供页面开关;已有的 profile 选择保持不变。
默认产品隔离的规则保持不变,只声明一个例外:可选组合包的依赖图在默认产品之外。静态门禁跳过从 `@deepseek-ai/dsh` 到列表中组合包的 `dependencies` 边,仍然拒绝运行时 import、随附组合、preset 或默认模板对它的引用,拒绝列表没有点名的实验依赖,也拒绝不是运行时依赖或不是组合包的列表项。workspace 约束检查接受同样的 `dependencies` 边而不接受其他运行时依赖段;发布时的 packed-install 检查对已安装入口包跳过这些边,并要求列表中的每个组合包都已安装。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-10-20-weighted-approval-status-gate-with-check-run-label.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-10-20-weighted-approval-status-gate-with-check-run-label.md
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run:
en: a1f0f9fd657c7d35
zh: e8fa02fb5bde6075
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/problem:
en: 8e854872edfc0729
zh: 6f3ed495fcb690a4
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/decision:
en: 03de94946d7532d7
zh: cf2f585f715d3295
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/verification:
en: 8298cff64cce6467
zh: a121d509c2302c00
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/alternatives-considered:
en: 197255cd82aabef7
zh: dc03f4bf77150f22
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/consequences:
en: 25e1ac1c8cab089a
zh: 84e10cccb1b9eda7
@@ -0,0 +1,37 @@
# Agent Note: Weighted approval keeps its commit status gate and adds a decision check run
Status: implemented
English | [中文](2026-10-20-weighted-approval-status-gate-with-check-run-label.zh.md)
## Problem
The `weighted approval` commit status is the record this repository's branch rules require, and below the approval threshold it stays `pending`. A commit status has no action-required state, so readers cannot tell an approval wait from running work. Moving the gate to a check run does not work: after that change, the check run completed as `success` on the pull-request head and both the REST API and `gh pr checks` listed it, while the merge box kept reporting the same `weighted approval decision` context as `Expected — Waiting for status to be reported`, and two pull requests needed an administrator bypass. That change was reverted.
## Decision
The publisher keeps writing the `weighted approval` commit status exactly as before: `pending` while the policy is unmet, `success` when it holds, and `error` when setup or evaluation fails. Branch rules keep requiring that status. That behavior, including keeping an unmet policy `pending`, is what the [archived decision](../../archived/process/2026-09-09-blocked-weighted-approvals-remain-pending.md) records; this change adds the label beside it instead of changing the gate.
The publisher additionally writes a `weighted approval decision` check run for the same evaluation: `action_required` while the policy is unmet, `success` when it holds, and `failure` when setup or evaluation fails. The check run's title carries the score or the blocker. Branch rules do not require it, so it never blocks a merge; it names the blocked state beside the status that does.
A head keeps one GitHub Actions run with the check-run name: the publisher updates the head's existing run and creates one only when none exists. The setup phase creates the run as `in_progress` on a head without one and deals with a published success by rewriting its conclusion to `action_required`, because GitHub accepts but does not apply a return to `in_progress`. A failed check-run write is logged and never changes the status, so a token without the checks permission, or a check-run API failure, leaves the gate as the status wrote it. The publisher identifies its own runs by the `external_id` marker it sets on creation, which excludes check runs that workflow jobs produce for a same-named job. It revokes a published `success` before dependency setup, because GitHub accepts but does not apply a return to `in_progress`.
## Verification
[Approval policy tests](../../../../.github/review-ownership/check-approval.test.mjs) pin the unchanged status states, the check-run conclusion for a met and an unmet policy, the phase writes, revoking a published success through its conclusion, one run per head, and invalid list responses. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the `checks: write` and `statuses: write` permissions and the setup phase commands.
The check-run limit was measured on 2026-10-20 with the reverted attempt on this repository: the `weighted approval decision` check run completed as `success` on the pull-request head, the REST API and `gh pr checks` listed it, and the merge box kept reporting the same context as expected.
## Alternatives considered
**Require the check run instead of the commit status.** Measured above: the ruleset does not match a check run published through the default-branch workflow's token, so the gate cannot be satisfied.
**Publish the commit status as `failure` while the policy is unmet.** The status would complete, but this changes the required record's meaning and would report an unmet, reversible condition as a malfunction.
**Publish only the check run and leave the status untouched.** The status would keep its last state from an earlier evaluation, so the gate and the label could disagree.
**Publish the required check run from a dedicated GitHub App.** A check run whose app the ruleset names would let the required entry carry the action-required conclusion itself. The repository has no App with the checks permission; with one, the label could move onto the required record and the status could be retired.
## Consequences
A job that ends between the `pending` write and the fallback step, such as a lost runner or a cancellation that skips the final step, leaves the status `pending`; that is the original symptom of this status, not a regression from the added label. While reviewers are pending, the merge box reports `weighted approval` as pending (the gate) and the `weighted approval decision` entry as `action_required`, with the score or blocker in its title; `gh pr checks` lists both. A published success is revoked by rewriting the check run's conclusion, and the status follows the unchanged policy states.
@@ -0,0 +1,37 @@
# Agent Note: Weighted approval 保留其 commit status 门禁并新增决策 check run
Status: implemented
[English](2026-10-20-weighted-approval-status-gate-with-check-run-label.md) | 中文
## 问题
`weighted approval` commit status 是本仓库分支规则要求的记录,在批准阈值下方时保持 `pending`。commit status 没有 action-required 状态,读者无法区分等待批准与正在运行的工作。把门禁改成 check run 并不可行:该改动后 check run 在 PR head 上以 `success` 完成,REST API 与 `gh pr checks` 都能列出它,而合并框始终把同一上下文报告为 `Expected — Waiting for status to be reported`,两个 PR 需要管理员绕过才能合并。该改动已回退。
## 决定
publisher 继续完全按原样写 `weighted approval` commit status:策略未满足时为 `pending`,满足时为 `success`,安装或评估失败时为 `error`。分支规则继续要求该 status。该行为——包括未满足的策略保持 `pending`——正是[已归档决定](../../archived/process/2026-09-09-blocked-weighted-approvals-remain-pending.md)记录的;本次变更在该门禁旁边新增标签,而不改变门禁。
publisher 为同一次评估额外写一个 `weighted approval decision` check run:策略未满足时为 `action_required`,满足时为 `success`,安装或评估失败时为 `failure`。check run 的标题携带分数或 blocker。分支规则不要求它,因此它从不阻止合并;它在会阻止合并的那条 status 旁边命名被阻止的状态。
每个 head 只保留一个同名 GitHub Actions run:publisher 更新 head 上已有的 run,仅在不存在时创建。setup 阶段对没有 run 的 head 以 `in_progress` 创建,并把已发布的 success 的 conclusion 改写为 `action_required`,因为 GitHub 接受但不执行改回 `in_progress` 的更新。check run 写入失败会被记录,且从不改变 status,因此没有 checks 权限的 token 或 check-run API 失败时,门禁保持 status 写入的结果。publisher 用创建时设置的 `external_id` 标记识别自己的 run,从而排除 workflow job 为同名 job 产生的 check run。它在依赖安装前撤销已发布的 `success`,因为 GitHub 接受但不执行改回 `in_progress` 的更新。
## 验证
[批准策略测试](../../../../.github/review-ownership/check-approval.test.mjs)锁定未改动的 status 状态、策略满足与未满足两种情况下 check run 的 conclusion、阶段写入、通过 conclusion 撤销已发布的成功、每个 head 只有一个 run,以及无效的列表响应。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定 `checks: write` 与 `statuses: write` 权限以及 setup 阶段命令。
check run 的限制于 2026-10-20 在本仓库用被回退的尝试实测:`weighted approval decision` check run 在 PR head 上以 `success` 完成,REST API 与 `gh pr checks` 都能列出它,而合并框始终把同一个 `weighted approval decision` 上下文报告为 expected。
## 考虑过的替代方案
**把 check run 作为必需检查。** 见上面的实测:ruleset 不匹配默认分支 workflow 的 token 发布的 check run,门禁无法满足。
**策略未满足时把 commit status 发布为 `failure`。** status 会完成,但改变了必需记录的含义,并把尚未满足且可撤销的条件报告为故障。
**只发布 check run 而不动 status。** status 会保留上一次评估的状态,门禁与标签可能不一致。
**由专用 GitHub App 发布必需 check run。** 由 ruleset 指定 app 的 check run 可以让必需条目本身携带 action-required 结论。本仓库没有具备 checks 权限的 App;有了之后可以把标签移到必需记录上并停用 status。
## 后果
job 在 `pending` 写入与兜底步骤之间结束(例如 runner 丢失,或取消导致最后一步未执行)会让 status 保持 `pending`;这是该 status 既有的表现,不是新增标签带来的回归。评审未完成时,合并框把 `weighted approval` 报告为 pending(门禁),并把 `weighted approval decision` 条目报告为 `action_required`,标题里是分数或 blocker;`gh pr checks` 会列出两者。已发布的 success 通过改写 check run 的 conclusion 撤销,status 继续按未改动的策略状态变化。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-10-13-text-derived-message-quotes.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-10-13-text-derived-message-quotes.md
/agent-note-recognize-message-quotes-from-markdown-text:
en: a2abd7f693937d51
zh: f9e4bb817536dbbe
/agent-note-recognize-message-quotes-from-markdown-text/problem:
en: d71ebbacbd9bd8a3
zh: 45c1f7fd1d797f61
/agent-note-recognize-message-quotes-from-markdown-text/decision:
en: 0abb09d5ba396f69
zh: dd067b0ca5fd376f
/agent-note-recognize-message-quotes-from-markdown-text/alternatives-considered:
en: 05c6009796927583
zh: 9f08229767c07b31
/agent-note-recognize-message-quotes-from-markdown-text/verification:
en: 790598fd0f3f0c67
zh: 0399e0e322ffca40
/agent-note-recognize-message-quotes-from-markdown-text/consequences:
en: 21f96c04f6edcc1d
zh: 8ea3f42a8e981592
@@ -0,0 +1,29 @@
# Agent Note: Recognize message quotes from Markdown text
Status: implemented
English | [中文](2026-10-13-text-derived-message-quotes.zh.md)
## Problem
Quote-only content indexes duplicate information in Markdown text and require coordinated prompt, source, echo, and queue metadata. The same visible blockquote can acquire different presentation and direct-input behavior depending on how it was created.
## Decision
Messages contain ordinary Markdown text. The shared blockquote matcher recognizes consecutive explicitly marked lines for UserText presentation. Hand-written and composer-created blockquotes have identical sent-message behavior. Quote previews display decoded text without running UserText rules over their contents. Session-reference and skill processing applies normally to quoted text; recognized Session labels retain the same presentation as the surrounding quote text.
The [rich-text quote feature](../feature/2026-08-26-web-rich-text-quotes.md) retains atomic draft nodes and semantic restoration spans. This decision supersedes only its durable quote-index distinction. UserText nodes and recognition ranges remain derived values.
## Alternatives considered
**Keep quote-only indexes.** Indexes preserve a distinction between typed and inserted quotes, but that distinction is unnecessary for sent messages and requires metadata coordination across every producer and renderer.
**Remove atomic draft spans.** Plain text cannot restore whole-node editing, undo, Session switching, or failed-send restoration. Draft spans remain browser-owned editing state.
## Verification
Matcher tests cover original offsets, explicit line markers, blank excerpts, and literal nested quotes. Composer tests assert ordinary text submission. The shipped Web scenario checks text-only persistence and quote display after reload.
## Consequences
Messages need no quote fields or content indexes. Typed blockquotes also display as quotes without changing direct-input processing. Recognition deliberately covers explicit quote lines rather than Markdown lazy continuation or fenced-code parsing. Source navigation still requires a separate durable-coordinate design.
@@ -0,0 +1,29 @@
# Agent Note: 从 Markdown 文本识别消息 quote
Status: implemented
[English](2026-10-13-text-derived-message-quotes.md) | 中文
## 问题
纯 quote content 索引重复了 Markdown 文本中的信息,并要求 prompt、source、回显与队列协同携带元数据。同样可见的 blockquote 会因创建方式不同而获得不同的展示与直接输入行为。
## 决策
消息包含普通 Markdown 文本。共享 blockquote 匹配器识别连续的显式标记行,用于 UserText 展示。手写与输入框创建的 blockquote 在发送后具有相同行为。Quote 预览直接展示还原后的文本,不对正文再次运行 UserText 规则。Session-reference 与 skill 处理照常作用于引用文本;已识别的 Session 标签与周围引用文字保持相同样式。
[富文本 quote 功能](../feature/2026-08-26-web-rich-text-quotes.zh.md)保留原子草稿节点与语义恢复范围。本决策仅取代其持久 quote 索引区分。UserText 节点与识别范围仍是派生值。
## 考虑过的替代方案
**保留纯 quote 索引。** 索引保留手写与插入 quote 的区别,但发送后的消息无需这一区分,且要求每个生产方与渲染器协同处理元数据。
**移除原子草稿范围。** 纯文本无法恢复整节点编辑、撤销、Session 切换或发送失败恢复。草稿范围仍是浏览器拥有的编辑状态。
## 验证
匹配器测试覆盖原始偏移、显式行标记、空白摘录与字面的嵌套 quote。Composer 测试断言普通文本提交。已发布 Web 场景检查仅文本的持久化与刷新后的 quote 展示。
## 后果
消息无需 quote 字段或 content 索引。手写 blockquote 也显示为 quote,不改变直接输入处理。识别有意限定为显式引用行,不涵盖 Markdown 懒惰续行或代码围栏解析。来源导航仍需独立设计持久坐标。
+1 -1
View File
@@ -55,7 +55,7 @@ This is not a one-way shortening pass. Add or restore prose when code, types, an
- **Postmortems:** retain the incident sequence, evidence, causal chain, impact, and prevention. Remove repeated persuasion or implementation detail that does not establish causality.
- **Skills and agent instructions:** state behavioral guardrails and explicit scope limitations such as “guidance, not a script/checklist.” Keep the workflow concise and link its source of truth.
- **Examples and configuration comments:** explain access limits, non-obvious wiring or load order, security stance, replay behavior, exceptions, and likely misuse. Do not narrate entries that the configuration already shows.
- **Prompts and visible strings:** treat wording as behavior. Client UI copy belongs in typed locale dictionaries and reaches Cordis-free primitives as explicit localized props; inspect text, accessibility names, tooltips, placeholders, and format templates together, then run `verify-client-ui-i18n`. Update the owning runnable snapshot for model-visible text and repository-required GUI evidence. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral; do not silently fold it into a prose-only edit.
- **Prompts and visible strings:** treat wording as behavior. Client UI copy belongs in typed locale dictionaries and reaches Cordis-free primitives as explicit localized props; inspect text, accessibility names, tooltips, placeholders, and format templates together, then run `verify-client-ui-i18n`. English UI copy, including plugin `meta.title` and `meta.description`, uses sentence case: capitalize only the first word, proper nouns, and product names such as `Chrome DevTools`, `Git`, or `MCP` (`Git worktrees`, not `Git Worktrees`). Chinese copy keeps its own conventions. Update the owning runnable snapshot for model-visible text and repository-required GUI evidence. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral; do not silently fold it into a prose-only edit.
- **Diagnostics:** name the failing subject or path, violated rule, and correction when it is non-obvious. Remove internal execution narration.
Preserve searchable mechanism names and meaningful modal, temporal, or negative emphasis. Normalize decorative emphasis only.
+1 -1
View File
@@ -16,7 +16,7 @@ The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes
## Approval scoring
The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and status publication completed, while the `weighted approval` commit status carries the approval decision on the pull request head. Branch rules must require only the commit status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. The publisher marks the head pending before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. Setup and evaluation failures publish an error status. A completed evaluation returns `pending` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps the status pending even when counted approvals reach the threshold. It returns `success` only when the threshold is met, the pull request is ready, and no such blocker exists. If evaluation fails, the publisher writes an `error` status.
The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and status publication completed, while the `weighted approval` commit status carries the approval decision on the pull request head. Branch rules must require only the commit status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. The publisher marks the head pending before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. Setup and evaluation failures publish an error status. A completed evaluation returns `pending` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps the status pending even when counted approvals reach the threshold. It returns `success` only when the threshold is met, the pull request is ready, and no such blocker exists. If evaluation fails, the publisher writes an `error` status. The publisher also writes a `weighted approval decision` check run that reports the same decision as a conclusion — `action_required` while the policy is unmet, `success` when it holds, and `failure` on setup or evaluation failures — with the score or blocker in its title. Branch rules do not require that check run, so it never blocks a merge; it names the blocked state beside the status that gates the merge. A failed check-run write is logged and never changes the status.
Reviewers whose calculated base repository permission is `write` or `admin` count. The [approval policy](approval-policy.json) gives `@07akioni`, `@imccyu`, `@tianyicui`, `@tianyicui-bot`, `@turtle1999`, and `@turtle2099` two points each; every other write-capable reviewer gets one point. The pull-request author’s own review and reviewers without write permission do not count.
+89 -6
View File
@@ -11,6 +11,8 @@ const API_VERSION = '2026-03-10'
const MAX_PULL_REQUEST_RECORDS = 3_000
const PAGE_SIZE = 100
const STATUS_CONTEXT = 'weighted approval'
const CHECK_NAME = 'weighted approval decision'
const CHECK_RUN_MARKER = 'weighted-approval-decision'
const WRITABLE_PERMISSIONS = new Set(['admin', 'write'])
const REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING'])
@@ -332,6 +334,7 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
}
} catch (error) {
await publishStatus(api, pull, 'error', 'Approval evaluation failed.', runUrl)
await tryPublishCheck(api, pull, 'failure', 'Approval evaluation failed.', runUrl, write)
throw error
}
write(result.authorCredit
@@ -343,6 +346,7 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
writeList(write, 'Blocking change requests', result.blockers.map(login => `@${login}`))
writeList(write, 'Ignored reviewers without write access', result.ignoredReviewers.map(login => `@${login}`))
await publishStatus(api, pull, result.state, result.description, runUrl)
await tryPublishCheck(api, pull, result.state === 'success' ? 'success' : 'action_required', result.description, runUrl, write)
write(`Published ${JSON.stringify(STATUS_CONTEXT)} status ${JSON.stringify(result.state)}.`)
try {
await requestDelegatedReviews(result, api, write)
@@ -354,13 +358,25 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
/**
* Revoke a previous success before dependency installation, or report its failure.
* @param {{event: unknown, api: (path: string, options: object) => Promise<unknown>, runUrl: string, phase: string}} options Publication inputs.
* @returns {Promise<void>} Completion of the status write.
* @param {{event: unknown, api: (path: string, options: object) => Promise<unknown>, runUrl: string, phase: string, write?: (line: string) => void}} options Publication inputs.
* @returns {Promise<void>} Completion of the status write and the corresponding check-run write.
*/
export async function publishApprovalPhase({ event, api, runUrl, phase }) {
if (!['pending', 'error'].includes(phase)) throw new Error('invalid approval setup phase')
await publishStatus(api, pullRequestFromEvent(event), phase,
phase === 'pending' ? 'Preparing approval evaluation.' : 'Approval setup or evaluation failed.', runUrl)
export async function publishApprovalPhase({ event, api, runUrl, phase, write = line => process.stdout.write(`${line}\n`) }) {
const pull = pullRequestFromEvent(event)
if (phase === 'pending') {
await publishStatus(api, pull, 'pending', 'Preparing approval evaluation.', runUrl)
try {
await revokeApproval(api, pull, runUrl)
} catch (error) {
write(`Decision check run not revoked; the commit status is unchanged: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`)
}
return
}
if (phase === 'error') {
await publishStatus(api, pull, 'error', 'Approval setup or evaluation failed.', runUrl)
return tryPublishCheck(api, pull, 'failure', 'Approval setup or evaluation failed.', runUrl, write)
}
throw new Error('invalid approval setup phase')
}
/**
@@ -467,6 +483,73 @@ async function reviewerPermission(api, repository, login) {
return response.permission.toLowerCase()
}
// The required gate is the commit status; this check run reports the same decision as a conclusion
// label and is not required. A failed label write is reported and never changes the gate, including
// for a replayed workflow whose token has no checks permission.
async function tryPublishCheck(api, pull, conclusion, title, runUrl, write) {
try {
await publishCheck(api, pull, conclusion, title, runUrl)
} catch (error) {
write(`Decision check run not published; the commit status is unchanged: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`)
}
}
async function publishCheck(api, pull, conclusion, title, runUrl) {
requireHttpsRunUrl(runUrl)
const body = { status: 'completed', conclusion, details_url: runUrl, output: { title, summary: `${title} [Workflow run](${runUrl})` } }
const runs = await decisionRuns(api, pull)
if (runs.length === 0) {
await api(`/repos/${pull.repository}/check-runs`, {
method: 'POST',
body: { name: CHECK_NAME, head_sha: pull.headSha, external_id: CHECK_RUN_MARKER, ...body },
})
return
}
for (const run of runs) {
if (!Number.isSafeInteger(run.id)) throw new Error('check run has no valid id')
await api(`/repos/${pull.repository}/check-runs/${run.id}`, { method: 'PATCH', body })
}
}
// A head without a decision starts in progress; a published success becomes action_required, so an
// interrupted evaluation cannot leave a success that its current inputs no longer support.
async function revokeApproval(api, pull, runUrl) {
requireHttpsRunUrl(runUrl)
const title = 'Preparing approval evaluation.'
const output = { title, summary: `${title} [Workflow run](${runUrl})` }
const runs = await decisionRuns(api, pull)
if (runs.length === 0) {
await api(`/repos/${pull.repository}/check-runs`, {
method: 'POST',
body: { name: CHECK_NAME, head_sha: pull.headSha, status: 'in_progress', details_url: runUrl, output },
})
return
}
for (const run of runs) {
if (!Number.isSafeInteger(run.id)) throw new Error('check run has no valid id')
// The label is not a gate; only a published success has to stop claiming the policy holds.
if (run.conclusion !== 'success') continue
await api(`/repos/${pull.repository}/check-runs/${run.id}`, {
method: 'PATCH',
body: { status: 'completed', conclusion: 'action_required', details_url: runUrl, output },
})
}
}
// A completed check run never returns to in_progress: GitHub answers 200 and leaves it completed.
async function decisionRuns(api, pull) {
const listed = await api(`/repos/${pull.repository}/commits/${pull.headSha}/check-runs?check_name=${encodeURIComponent(CHECK_NAME)}&filter=all&per_page=100`)
if (!isRecord(listed) || !Array.isArray(listed.check_runs) || listed.total_count !== listed.check_runs.length) {
throw new Error('check-run list response is invalid or incomplete')
}
return listed.check_runs.filter(run => isRecord(run) && isRecord(run.app) && run.app.slug === 'github-actions'
&& (run.external_id === CHECK_RUN_MARKER || run.external_id === ''))
}
function requireHttpsRunUrl(runUrl) {
if (!/^https:\/\/[^\s]+$/u.test(runUrl)) throw new Error('workflow run URL must use HTTPS')
}
async function publishStatus(api, pull, state, description, runUrl) {
if (!/^https:\/\/[^\s]+$/u.test(runUrl)) throw new Error('workflow run URL must use HTTPS')
if (description.length > 140) throw new Error('commit status description exceeds 140 characters')
@@ -11,13 +11,18 @@ import {
evaluateApproval as evaluateWithHistory,
listPullRequestReviews,
parseApprovalPolicy,
runApprovalCheck as runWithHistory,
runApprovalCheck as runPublisher,
publishApprovalPhase,
} from './check-approval.mjs'
const policySource = readFileSync(new URL('approval-policy.json', import.meta.url), 'utf8')
const withoutComments = api => (path, options) => path.includes('/comments?') ? [] : api(path, options)
const emptyCheckRuns = { total_count: 0, check_runs: [] }
const isCheckRunsList = path => path.includes('/commits/') && path.includes('/check-runs?')
const withoutChecks = api => (path, options) =>
isCheckRunsList(path) ? emptyCheckRuns : path.includes('/check-runs') ? {} : api(path, options)
const withoutComments = api => withoutChecks((path, options) => path.includes('/comments?') ? [] : api(path, options))
const evaluateApproval = options => evaluateWithHistory({ getMergedCount: async () => 0, ...options, api: withoutComments(options.api) })
const runWithHistory = options => runPublisher({ ...options, api: withoutChecks(options.api) })
const runApprovalCheck = options => runWithHistory({ getMergedCount: async () => 0, ...options, api: withoutComments(options.api) })
const HEAD_SHA = '1234567890abcdef1234567890abcdef12345678'
@@ -475,16 +480,94 @@ test('uses policy endpoints and formats only the displayed score', async () => {
test('publishes setup phases without evaluating or installing dependencies', async () => {
const states = []
const phases = []
const options = {
event: pullRequestEvent(), runUrl: 'https://github.example/run/1',
api: async (path, { body }) => { assert.match(path, /\/statuses\//u); states.push(body.state) },
api: async (path, options = {}) => {
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) { phases.push(options.body?.conclusion ?? options.body?.status); return {} }
assert.match(path, /\/statuses\//u)
states.push(options.body.state)
},
}
await publishApprovalPhase({ ...options, phase: 'pending' })
await publishApprovalPhase({ ...options, phase: 'error' })
assert.deepEqual(states, ['pending', 'error'])
assert.deepEqual(phases, ['in_progress', 'failure'])
await assert.rejects(publishApprovalPhase({ ...options, phase: 'success' }), /invalid approval setup phase/u)
})
test('keeps the status gate when the label write fails', async () => {
const writes = []
const states = []
await runPublisher({
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1',
write: line => writes.push(line),
getOwnership: async () => ({ totalLines: 100, reviewerLines: { turtle2099: 25 } }),
api: async (path, options = {}) => {
if (path.includes('/reviews?')) return [review('turtle2099', 'APPROVED')]
if (path.includes('/collaborators/turtle2099/permission')) return { permission: 'write' }
if (path.includes('/comments?')) return []
if (path.includes('/statuses/')) { states.push(options.body.state); return {} }
if (path.includes('/check-runs')) throw new Error('check runs unavailable')
throw new Error(`unexpected API path ${path}`)
},
})
assert.deepEqual(states, ['pending', 'success'])
assert.ok(writes.some(line => /Decision check run not published/.test(line)))
})
test('reports the decision on a non-required check run beside the status', async () => {
const calls = []
const api = async (path, options = {}) => {
calls.push({ path, method: options.method, body: options.body })
if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
if (path.includes('/collaborators/writer/permission')) return { permission: 'write' }
if (path.includes('/comments?')) return []
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs') || path.includes('/statuses/')) return {}
throw new Error(`unexpected API path ${path}`)
}
const result = await runPublisher({
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {},
getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
getMergedCount: async () => 0,
api,
})
const status = calls.filter(call => call.path.includes('/statuses/')).at(-1)
const check = calls.filter(call => call.path.endsWith('/check-runs')).at(-1)
assert.equal(result.state, 'pending')
assert.equal(status.body.state, 'pending')
assert.equal(check.body.name, 'weighted approval decision')
assert.equal(check.body.conclusion, 'action_required')
})
test('revokes a published success check run and updates one run per head', async () => {
const calls = []
const checkRuns = [
{ id: 7, app: { slug: 'github-actions' }, conclusion: 'success', external_id: '' },
{ id: 8, app: { slug: 'github-actions' }, conclusion: 'action_required', external_id: 'weighted-approval-decision' },
{ id: 9, app: { slug: 'other-app' }, conclusion: 'success', external_id: '' },
{ id: 10, app: { slug: 'github-actions' }, conclusion: 'success', external_id: 'a-job-check-run' },
]
await publishApprovalPhase({
event: pullRequestEvent(), runUrl: 'https://github.example/run/1', phase: 'pending',
api: async (path, options = {}) => {
calls.push({ path, method: options.method, body: options.body })
return isCheckRunsList(path) ? { total_count: checkRuns.length, check_runs: checkRuns } : {}
},
})
assert.deepEqual(calls.map(call => call.path), [
`/repos/deepseek-harness/deepseek-harness/statuses/${HEAD_SHA}`,
`/repos/deepseek-harness/deepseek-harness/commits/${HEAD_SHA}/check-runs?check_name=weighted%20approval%20decision&filter=all&per_page=100`,
'/repos/deepseek-harness/deepseek-harness/check-runs/7',
])
for (const call of calls.slice(2)) {
assert.equal(call.method, 'PATCH')
assert.equal(call.body.conclusion, 'action_required')
}
})
for (const [mergedCount, credit] of [[0, 0], [1, 0.011], [50, 0.55], [99, 1.089], [100, 1.1], [101, 1.1], [200, 1.1]]) {
test(`author with ${mergedCount} merged PRs contributes ${credit} points but cannot approve alone`, async () => {
const result = await evaluateApproval({
+1
View File
@@ -10,6 +10,7 @@ on:
types: [completed]
permissions:
checks: write
contents: read
pull-requests: write
statuses: write
+1 -1
View File
@@ -155,7 +155,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and
- **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR.
- **Create Agent Notes only for durable decision rationale;** mechanical/local edits are exempt, including local UI changes ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)).
- **Official plugin artwork:** use [dsh-official-plugin-artwork](.agents/skills/dsh-official-plugin-artwork/SKILL.md) for official and official experimental plugin icons; obtain designer approval before landing new glyphs.
- **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)).
- **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). English copy, including plugin titles, uses sentence case ([rule](.agents/skills/dsh-prose-standard/SKILL.md#required-coverage-by-prose-location)).
- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless recorded-session snapshot; [snapshot ownership](snapshots/AGENTS.md) reserves the top-level tree for session-driven cases and keeps other expected output owner-local. Fixtures replay on macOS/Linux; fix fixtures, not normalizers.
- **Design each tool's UI presentation up front.** Host presenters stay pure; Web cards derive from raw events and persisted result metadata ([cookbook](docs/cookbook/adding-a-tool.md)).
- **Plan unit, e2e, and snapshot coverage** for capability seams, lifecycle paths, and transcript output; include missing snapshot-harness support in the same change.
+1
View File
@@ -78,6 +78,7 @@ External packages installed for runtime use or embedded in distributed Host or b
| [`ajv`](https://github.com/ajv-validator/ajv) | MIT |
| [`anser`](https://github.com/IonicaBizau/anser) | MIT |
| [`big.js`](https://github.com/MikeMcl/big.js) | MIT |
| [`birpc`](https://github.com/antfu-collective/birpc) | MIT |
| [`buffer`](https://github.com/feross/buffer) | MIT |
| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
| [`chrome-devtools-frontend`](https://github.com/ChromeDevTools/devtools-frontend) | BSD-3-Clause |
+3 -2
View File
@@ -6,7 +6,7 @@ import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
import { SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-skill'
import type {} from '@deepseek-ai/dsh-tools'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
import { unsupportedModel, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
const overlayPath = process.argv[2]
if (overlayPath === undefined) throw new Error('dsh-badge snapshot requires an overlay path')
@@ -32,6 +32,7 @@ try {
id: agentId,
options: {},
session,
model: unsupportedModel(),
inbox: unsupportedInbox(),
status: 'idle',
send: () => {},
@@ -44,7 +45,7 @@ try {
}
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
{ messages: [], turn: 1, step: 1, signal: new AbortController().signal },
{ modelSelection: { provider: 'mock', model: 'mock' }, messages: [], turn: 1, step: 1, signal: new AbortController().signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
const catalog = decision.kind === 'enter'
@@ -17,6 +17,7 @@
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}
{"type":"job/registered","data":{"jobId":"{{id:1}}","owner":"{{sessionId}}","kind":"bash","label":"printf CLI_TOOL_ROUND_TRIP","startedAt":0,"toolCall":{"callId":"cli-smoke-call","rootCallId":"cli-smoke-call"}}}
{"type":"job/settled","data":{"jobId":"{{id:1}}","owner":"{{sessionId}}","kind":"bash","label":"printf CLI_TOOL_ROUND_TRIP","startedAt":0,"toolCall":{"callId":"cli-smoke-call","rootCallId":"cli-smoke-call"},"finishedAt":0,"status":"completed","cause":"producer","producerReported":true,"detail":"exit code: 0"}}
{"type":"model/selection","data":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"off"}}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cli-smoke-call"},"toolCallId":"cli-smoke-call","content":[{"type":"text","text":"CLI_TOOL_ROUND_TRIP"}],"isError":false,"id":"{{sessionId}}"},"meta":{"cwd":"{{cwd}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"step/start","data":{"turn":1,"step":2}}
+1 -1
View File
@@ -301,7 +301,7 @@ describe('the shipped Web composition', () => {
// depend on ripgrep being present on the machine.
expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_subagent_models', 'present', 'read', 'read_image',
'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_subagent_models', 'plugin_manager', 'present', 'read', 'read_image',
'schedule_create', 'schedule_delete', 'schedule_list', 'schedule_update',
'send_message', 'skill',
'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_fetch', 'web_search',
+4 -4
View File
@@ -33,8 +33,8 @@
en: 372297005b57a08b
zh: 074a635ac17e4362
/deepseek-harness-desktop/installation-ownership:
en: 22153a298a6662e1
zh: 3600803c8c9d0274
en: b3cf09d7c901d727
zh: 2ec34045a201d8e9
/deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation:
en: a1a47b09f4a72ba4
zh: 7b1cf27f0b72cf31
@@ -45,8 +45,8 @@
en: faf7676ba18bbf2f
zh: f1d7b75ad348c548
/deepseek-harness-desktop/develop/startup-onboarding:
en: a51346b8d880ddba
zh: 7913214c1c66063c
en: d87d51b901b72ecd
zh: 525da1c4d56af659
/deepseek-harness-desktop/develop/welcome-window-appearance:
en: 617a89aeb28131b9
zh: 4d84bd5d86501c86
+2 -2
View File
@@ -150,7 +150,7 @@ The product UI retains Web actions, including "Open In..." through the shared au
Update copy preserves the complete version, including prerelease suffixes, without adding v or V. When no update is available, the dialog title reports the result and the body shows the current version.
Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. The macOS application bundle declares English and Simplified Chinese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources.
Electron chooses typed English, Chinese, or Japanese shell copy from its application locale and falls back to English. The macOS application bundle declares English, Simplified Chinese, and Japanese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources.
Windows and macOS relay native fullscreen transitions and the state after each page load to the renderer, so overlays can adapt their caption clearance.
@@ -218,7 +218,7 @@ Repeated launches and `dsh://open` keep the workspace hidden until the startup c
Desktop checks configured model API-key references after the Host starts and before opening the workspace. With no configured key, the welcome window offers the API-key page. Save and continue writes through the existing credential service using the official DeepSeek provider's configured reference, then opens the workspace. Set up later opens the workspace without saving the draft or a completion setting; the next process launch checks credentials again. Back to sign in returns to the entry and clears the unsaved key and validation message. Buttons keep their labels and block competing actions while saving or opening the workspace. The Desktop preload marker suppresses the Web credential dialog, while retaining the Models settings page and the welcome notice.
The welcome window reads the shared `locale.preference` before it appears. An explicit English or Chinese choice wins; otherwise Desktop picks the first supported OS language and falls back to English. The main UI reads the same preference and OS language order through its isolated preload before mounting. Language changes in Settings update the shell’s current dictionary and menu; automatic selection writes no preference. The welcome window has no language selector.
The welcome window reads the shared `locale.preference` before it appears. An explicit English, Chinese, or Japanese choice wins; otherwise Desktop picks the first supported OS language and falls back to English. The main UI reads the same preference and OS language order through its isolated preload before mounting. Language changes in Settings update the shell’s current dictionary and menu; automatic selection writes no preference. The welcome window has no language selector.
The browser-login waiting page offers a copy-link action for the current pending authorization, a loading indicator, and cancellation. Clipboard failures leave the copy action available for retry. Copy feedback resets after two seconds; the copied state disables the link until it resets. Welcome text uses Montserrat Light with system fallbacks; large action buttons keep the system font, while text buttons use Montserrat Light. English introductory copy is 24px throughout. Chinese introductory copy is 24px with a 26px product name. Login action buttons are 240px wide with 14px labels. Authorization status headings use 20px Montserrat Regular text. The API-key page uses a 20px heading and a 14px back action, with 84px between the secondary button’s bottom edge and the window bottom.
+2 -2
View File
@@ -150,7 +150,7 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm
升级文案完整展示版本号(包括预发布后缀),不额外添加 v 或 V。无可用更新时,弹窗标题显示检查结果,正文显示当前版本。
Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语和简体中文,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
Electron 根据应用语言选择类型化的英文、中文或日文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语、简体中文和日语,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
Windows 和 macOS 会在原生全屏切换及每次页面加载后向渲染器同步全屏状态,让浮层调整顶栏避让距离。
@@ -218,7 +218,7 @@ API Key 输入框初始为空,并通过 `autocomplete="new-password"` 请求 C
Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否已配置。没有已配置的密钥时,欢迎窗口提供 API Key 页面。“保存并继续”通过现有凭证服务写入 DeepSeek 官方提供方配置的引用,然后打开工作区。“稍后配置”打开工作区,但不保存草稿或完成标记;下次进程启动时会重新检查凭证。“返回登录”回到入口并清空未保存的密钥和校验提示。保存或打开工作区期间,按钮保持原文案并禁用竞争操作。Desktop preload 标记使 Web 凭证弹窗不再显示,同时保留模型设置页和欢迎须知。
欢迎窗口在显示前读取共享的 `locale.preference`。用户明确选择的英文或中文优先;否则 Desktop 按系统语言顺序匹配支持的语言,并以英文兜底。主界面在挂载前通过隔离 preload 读取同一偏好和系统语言顺序。在设置中切换语言会更新桌面壳的当前词典和菜单;自动选择不会写入偏好。欢迎窗口不提供语言切换入口。
欢迎窗口在显示前读取共享的 `locale.preference`。用户明确选择的英文、中文或日文优先;否则 Desktop 按系统语言顺序匹配支持的语言,并以英文兜底。主界面在挂载前通过隔离 preload 读取同一偏好和系统语言顺序。在设置中切换语言会更新桌面壳的当前词典和菜单;自动选择不会写入偏好。欢迎窗口不提供语言切换入口。
等待浏览器登录时,欢迎页提供当前待授权请求的链接复制入口、加载指示和取消操作;剪贴板写入失败后可以重试复制,复制结果提示在两秒后恢复;已复制状态下链接禁用,恢复后可再次点击。Welcome 文字使用 Montserrat Light 并回退到系统字体,底部大按钮保留系统字体,文字按钮使用 Montserrat Light。英文欢迎正文及产品名均为 24px,中文欢迎正文为 24px、产品名为 26px。登录操作按钮宽 240px,文字为 14px。授权状态标题使用 20px Montserrat Regular 字重。API Key 页的标题为 20px,返回操作为 14px,次级按钮底边距窗口底部 84px。
+4 -1
View File
@@ -22,7 +22,10 @@ export interface DesktopElectronBuilderConfig {
...{ readonly from: string, readonly to: 'tray.ico' }[],
]
readonly mac: {
readonly extendInfo: { readonly NSMicrophoneUsageDescription: string }
readonly extendInfo: {
readonly CFBundleLocalizations: readonly string[]
readonly NSMicrophoneUsageDescription: string
}
readonly entitlements: string
readonly entitlementsInherit: string
readonly identity: string | undefined
@@ -162,12 +162,14 @@ export function createElectronBuilderConfig(
icon: fileURLToPath(new URL('../resources/icon-macos.png', import.meta.url)),
category: 'public.app-category.developer-tools',
// macOS matches the application locale against this bundle, not Electron Framework resources.
extendInfo: { CFBundleLocalizations: ['en', 'zh_CN'] },
extendInfo: {
CFBundleLocalizations: ['en', 'zh_CN', 'ja'],
NSMicrophoneUsageDescription: 'DeepSeek Harness uses your microphone to transcribe speech into message drafts.',
},
// Ad-hoc signing keeps the modified Electron executable runnable without a Developer ID.
identity: unsigned ? '-' : macOSSigning?.signingIdentity,
forceCodeSigning: !unsigned,
hardenedRuntime: true,
extendInfo: { NSMicrophoneUsageDescription: 'DeepSeek Harness uses your microphone to transcribe speech into message drafts.' },
entitlements: fileURLToPath(new URL('./macos-entitlements.plist', import.meta.url)),
entitlementsInherit: fileURLToPath(new URL('./macos-entitlements.plist', import.meta.url)),
// Prepared runtime files retain their signatures; PAK resources are sealed by their enclosing bundle.
+177 -5
View File
@@ -1,4 +1,4 @@
/** Typed English and Chinese copy owned by the Electron shell. */
/** Typed English, Chinese, and Japanese copy owned by the Electron shell. */
export const en = {
cliCommandMenu: 'Manage dsh Command…',
@@ -341,15 +341,187 @@ export const zh = {
mandatoryNotification: '返回应用确认安装并重启。',
} as const satisfies DesktopMessages
/** Japanese copy, checked against the English keys. */
export const ja = {
cliCommandMenu: 'dsh コマンドを管理…',
cliCommandTitle: 'dsh コマンドを管理',
cliCommandLocation: 'デスクトップコマンド: {path}',
cliCommandSelected: '現在の dsh コマンド: {path}',
cliCommandTarget: '現在のランチャーターゲット: {path}',
cliCommandShadowed: '別の dsh が優先されています。デフォルトでデスクトップコマンドを使用するには、そのインストールを削除するか順序を変更してください。デスクトップコマンドはフルパスでも実行できます。',
cliCommandSelectionUnknown: 'シェルコマンドを確認できませんでした。エイリアスや別の dsh インストールが優先されている可能性があります。',
cliCommandInstalled: 'デスクトップコマンドがインストールされています。',
cliCommandNotInstalled: 'デスクトップコマンドをターミナルに追加します。',
cliCommandBroken: 'デスクトップコマンドの修復が必要です。',
cliCommandInstall: 'インストール',
cliCommandRepair: '修復',
cliCommandRemove: '削除',
cliCommandClose: '閉じる',
cliCommandSwitch: 'デスクトップコマンドで続行しますか?',
cliCommandPreserve: '既存のコマンドは保持されます。他のインストールやシェルの起動ファイルは変更されません。',
cliCommandContinue: '続行',
cliCommandInstallApp: 'dsh コマンドを管理する前に、デスクトップをアプリケーションフォルダにインストールしてください。',
cliCommandUpdating: 'アップデートをインストール中です。インストール完了後にコマンドを管理してください。',
cliCommandNewTerminal: '新しいターミナルを開いて dsh --version を実行してください。',
cliCommandRemoved: 'デスクトップコマンドの登録を削除しました。',
cliCommandPreviousRestored: '以前のランチャーを復元しました。',
cliCommandOtherKept: '他のコマンドインストールはそのまま残されています。',
cliCommandBackupKept: '以前のランチャーは次の場所に保持されています: {path}',
cliCommandChanged: 'このダイアログを開いている間にコマンドまたは PATH が変更されました。現在の状態を確認するには、dsh コマンドを管理を再度開いてください。',
cliCommandOwnershipError: 'コマンド登録またはバックアップが変更されました。無関係なコマンドは削除されていません。',
cliCommandFailed: 'コマンドを更新できませんでした。アプリケーションがインストールされ、保存先に書き込み権限があることを確認してから再試行してください。',
application: 'アプリケーション',
fileMenu: 'ファイル',
closePage: 'ページまたはウィンドウを閉じる',
aboutMenu: 'DeepSeek Harness について',
aboutProduct: 'DeepSeek Harness',
aboutVersion: 'バージョン V{version}',
hideApplication: 'DeepSeek Harness を隠す',
hideOtherApplications: 'ほかを隠す',
showAllApplications: 'すべて表示',
quitApplication: 'DeepSeek Harness を終了',
openApplication: 'DeepSeek Harness を開く',
quit: '終了',
cancel: 'キャンセル',
quitTitle: 'DeepSeek Harness を終了しますか?',
quitActiveTasks: '実行中のタスクは中断されます。',
quitScheduledTasks: 'アプリの終了中はスケジュールされたタスクが実行されません。',
quitActiveAndScheduledTasks: '実行中のタスクは中断され、アプリの終了中はスケジュールされたタスクが実行されません。',
backgroundNoticeBody: '実行中のタスクは継続されます。システムトレイからウィンドウを再度開けます。',
backgroundNoticeConfirm: '確認',
edit: '編集',
menuBar: 'アプリケーションメニュー',
delete: '削除',
undo: '元に戻す',
redo: 'やり直す',
cut: '切り取り',
copy: 'コピー',
paste: '貼り付け',
selectAll: 'すべて選択',
startupFailed: 'DeepSeek Harness を利用できません',
fatalSummary: 'アプリケーションを起動できなかったか、予期せず停止しました。',
startupAddressInUse: '別の DSH インスタンス (dsh web やデスクトップアプリなど) が実行中です。同時に起動することはできません。実行中の他の DSH インスタンスを終了してから再起動してください。',
diagnosticTruncated: '… エラー詳細は省略されています。',
reportWrittenTo: '診断レポート: {path}',
startupReinstallAdvice: 'アプリケーションファイルが見つからないか破損している場合は、アプリケーションを閉じて再インストールしてください。タスクは別途保存されています。',
exitApplication: '終了',
restartApplication: '再起動',
recoveryOperationFailed: '復旧操作に失敗しました',
disableThirdPartyPlugins: 'サードパーティのプラグインを無効化し、プロファイルパッチをバックアップして再起動',
welcomeTitle: 'DeepSeek Harness',
welcomeBrand: 'DeepSeek Harness',
welcomeTaglineBefore: 'ようこそ ',
welcomeTaglineBrand: 'DeepSeek Harness',
welcomeTaglineAfter: '',
welcomeDescription: '可能性を築く。知能を探求する。',
welcomeAuthStarting: 'ログインを開いています…',
welcomeAuthWaiting: 'ブラウザが自動で開きませんでしたか?',
welcomeAuthWaitingDescription: 'ログインリンクをコピーしてブラウザで開き、ログインしてください。',
welcomeAuthExchanging: 'ログインを完了しています…',
welcomeAuthExpired: 'ログインがタイムアウトしました',
welcomeAuthExpiredDescription: '続行するには再度ログインしてください',
welcomeAuthFailed: 'ログインを完了できませんでした。もう一度お試しください。',
welcomeAuthCopyLink: 'ログインリンクをコピー',
welcomeAuthCopied: 'コピーしました',
welcomeAuthCopyFailed: 'コピーできませんでした。もう一度お試しください。',
welcomeAuthCancel: 'キャンセル',
welcomeAuthRetry: '再度ログイン',
welcomeSignIn: 'ログイン',
welcomeApiKey: 'APIキーを追加',
welcomeKeyTitle: 'APIキーを追加して開始',
welcomeKeyDescription: '公式のDeepSeekモデルを設定してHarnessを使い始めましょう',
welcomeKeyPlaceholder: 'APIキーを入力',
welcomeKeySave: '保存して続行',
welcomeKeyLater: '後で設定',
welcomeKeyBack: 'ログインに戻る',
welcomeSessionExpired: 'アカウントからログアウトしました。再度ログインしてください。',
welcomeKeyBlank: 'APIキーを入力してください。',
welcomeKeyInvalid: '引用符、スペース、環境変数の代入を含めず、APIキーそのものを入力してください。',
welcomeKeyFailed: 'APIキーを保存できませんでした。もう一度お試しください。',
welcomeContinueFailed: 'ワークスペースを開けませんでした。もう一度お試しください。',
checkUpdatesMenu: 'アップデートを確認…',
reloadPageMenu: 'ページを再読み込み',
restartAppHostMenu: 'アプリとホストを再起動',
updateCheckFailedTitle: 'アップデートの確認に失敗しました',
updateCheckFailed: 'アップデートを確認できませんでした。後でもう一度お試しください。',
updateDownloadFailed: 'アップデートをダウンロードできませんでした。もう一度お試しください。',
updateInstallFailed: 'アップデートをインストールできませんでした。後でもう一度お試しください。',
updateCheckNetworkFailed: 'アップデートを確認できませんでした。接続を確認してもう一度お試しください。',
updateDownloadNetworkFailed: 'アップデートをダウンロードできませんでした。接続を確認してもう一度お試しください。',
updateInstallNetworkFailed: 'アップデートをインストールできませんでした。接続を確認してもう一度お試しください。',
unknownError: '不明なエラー',
updateCheckTitle: 'アップデートを確認',
updateCurrentDetail: '現在のバージョン: {version}',
updateCurrent: '最新です!',
updateChecking: 'アップデートを確認中…',
updateDownload: 'アップデートをダウンロード',
updateDownloadedTitle: 'バージョン {version} をインストールする準備ができました',
updateDownloadedDetail: 'アップデート中はアプリが終了し、完了後に自動的に再起動します。',
updateDownloadedDetailWindows: 'アップデート中はアプリが一時的に終了し、完了後に自動的に再起動します。\n\nアップデートには時間がかかる場合があります。インストール中はアプリを再度起動せずにお待ちください。',
updateClose: '閉じる',
updateAcknowledge: 'OK',
updateLater: '後でアップデート',
updateDownloading: 'ダウンロード中 {percent}%…',
updateVerifying: 'アップデートファイルを検証中…',
updateInstalling: '再起動の準備中…',
updateRetry: 'アップデートを再試行',
updateActiveTasks: 'タスクがまだ実行中です',
updateActiveTasksDetail: 'アップデートすると実行中のタスクが停止し、アプリが再起動します。続行しますか?',
updateStopTasks: 'タスクを停止してアップデート',
updateTasksChanged: '新しいタスクが開始されました。タスクを停止してアップデートするには再度確認してください。',
updateTasksUnavailable: 'タスクの状態を取得できません。ワークスペースの準備ができてから再度アップデートをお試しください。',
updateFullscreenTimeout: 'フルスクリーンを終了できませんでした。フルスクリーンを終了してからアップデートを再試行してください。',
updateStopFailed: 'タスクを安全に停止できませんでした。アップデートはインストールされていません。後でもう一度お試しください。',
updateTechnicalDetails: '技術的な詳細を表示',
updateTitle: 'DeepSeek Harness アップデート',
updateAvailable: '新しいバージョンがあります: {version}',
updateDetail: 'ダウンロードが完了したら、アップデートをインストールしてアプリを再起動できます。',
installAndRestart: 'インストールして再起動',
later: '後で',
updateFailedTitle: 'アップデートに失敗しました',
mandatoryTitle: '続行するにはアップデートしてください',
mandatoryDetail: 'このバージョンはサポートされなくなりました。続行するにはアップデートしてください。実行中のタスクは、インストールの確認と再起動を行うまで実行を継続します。',
mandatoryUnavailable: 'アップデートの要件を確認できませんでした。後でもう一度お試しください。',
policyLoginTitle: 'テスト環境にログイン',
policyLoginRequired: 'Feishuでログインして、このテストビルドのアップデート要件を確認します。ログインしてもアップデートのダウンロードやインストールは行われません。',
policyLogin: 'Feishuでログイン',
policyLoginFailed: 'Feishuのログインが完了しませんでした。もう一度お試しください。',
policyLoginLoading: 'ログインページを読み込み中…',
mandatoryNoRelease: '互換性のあるアップデートが見つかりませんでした。再度確認するか、サポートにお問い合わせください。',
mandatoryRefresh: '再確認',
mandatoryPage: '公式サイトからダウンロード',
mandatoryCopy: 'ダウンロードリンクをコピー',
mandatoryPageFailed: '公式ダウンロードページを開けませんでした。リンクをコピーしてブラウザで開いてください。',
mandatoryActionFailed: '操作に失敗しました。もう一度お試しください。アップデートが完了すると、アプリを再度使用できます。',
mandatoryReady: 'アップデートの準備完了',
mandatoryVersion: '新しいバージョン: {version}',
mandatoryReadyDetail: 'アップデート中にアプリが終了し、完了後に自動的に再起動します。',
mandatoryDeferred: '実行中のタスクはそのまま続行されます。アプリを再度使用する前にアップデートを完了してください。',
mandatoryContinue: 'アップデートを続行',
mandatoryInspecting: 'タスクを確認中…',
mandatoryStopping: 'タスクを停止中…',
mandatoryRestarting: 'まもなくアプリが再起動します。しばらくお待ちください。',
mandatoryDownloadFailed: 'アップデートファイルをダウンロードまたは準備できませんでした。再試行してください。',
mandatoryInstallFailed: 'アップデートはインストールされていません。タスクを再度確認して再試行してください。',
mandatoryOpenHelp: 'ページが開きませんでしたか?',
mandatoryReopen: '公式ダウンロードページを再度開く',
mandatoryCopied: 'リンクをコピーしました',
mandatoryCopyFailed: 'リンクをコピーできませんでした。以下から選択してコピーしてください。',
mandatoryAddress: 'ダウンロードリンク',
mandatoryNotification: 'アプリに戻り、インストールと再起動を確認してください。',
} satisfies Record<keyof typeof en, string>
/** Locale payload exposed to the Desktop-owned renderer. */
export interface DesktopLocale {
readonly id: 'en' | 'zh-CN'
readonly id: 'en' | 'zh-CN' | 'ja'
readonly messages: DesktopMessages
}
/** Resolve Electron's locale to one shipped Desktop dictionary. */
export function resolveDesktopLocale(locale: string): DesktopLocale {
return locale.toLowerCase().startsWith('zh')
const primary = locale.toLowerCase().split('-')[0]
if (primary === 'ja') return { id: 'ja', messages: ja }
return primary === 'zh'
? { id: 'zh-CN', messages: zh }
: { id: 'en', messages: en }
}
@@ -362,10 +534,10 @@ export function resolveDesktopLocale(locale: string): DesktopLocale {
*/
export function resolveDesktopStartupLocale(preference: string | null, languages: readonly string[]): DesktopLocale {
const selected = preference?.toLowerCase()
if (selected === 'zh' || selected === 'en') return resolveDesktopLocale(selected)
if (selected === 'zh' || selected === 'en' || selected === 'ja') return resolveDesktopLocale(selected)
for (const language of languages) {
const primary = language.toLowerCase().split('-')[0]
if (primary === 'zh' || primary === 'en') return resolveDesktopLocale(primary)
if (primary === 'zh' || primary === 'en' || primary === 'ja') return resolveDesktopLocale(primary)
}
return resolveDesktopLocale('en')
}
+7 -5
View File
@@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest'
import { en, formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale, zh } from '../src/locale.ts'
import { en, formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale, ja, zh } from '../src/locale.ts'
describe('desktop locale dictionaries', () => {
it('ships the same key set in English and Chinese', () => {
it('ships the same key set in English, Chinese, and Japanese', () => {
expect(Object.keys(zh)).toEqual(Object.keys(en))
expect(Object.keys(ja)).toEqual(Object.keys(en))
expect(resolveDesktopLocale('JA-jp')).toEqual({ id: 'ja', messages: ja })
expect(resolveDesktopLocale('zh-Hans-CN').messages).toEqual(zh)
expect(resolveDesktopLocale('en-US').messages).toEqual(en)
expect(resolveDesktopLocale('fr-FR').messages).toEqual(en)
@@ -17,11 +19,11 @@ describe('desktop locale dictionaries', () => {
it('prefers an explicit supported choice, then the first supported system language', () => {
expect(resolveDesktopStartupLocale('zh', ['en-US']).id).toBe('zh-CN')
expect(resolveDesktopStartupLocale('EN', ['zh-CN']).id).toBe('en')
expect(resolveDesktopStartupLocale(null, ['ja-JP', 'zh-Hant', 'en-US']).id).toBe('zh-CN')
expect(resolveDesktopStartupLocale(null, ['ja-JP', 'zh-Hant', 'en-US']).id).toBe('ja')
expect(resolveDesktopStartupLocale(null, ['en-US', 'zh-CN']).id).toBe('en')
expect(resolveDesktopStartupLocale(null, ['ja-JP']).id).toBe('en')
expect(resolveDesktopStartupLocale(null, ['ja-JP']).id).toBe('ja')
expect(resolveDesktopStartupLocale(null, []).id).toBe('en')
expect(resolveDesktopStartupLocale('ja', ['zh-CN']).id).toBe('zh-CN')
expect(resolveDesktopStartupLocale('ja', ['zh-CN']).id).toBe('ja')
})
})
@@ -51,6 +51,7 @@ describe('desktop macOS release signature', () => {
expect(config.protocols).toEqual([{ name: 'DeepSeek Harness', schemes: ['dsh'] }])
expect(portablePath(config.directories.output)).toContain('/.desktop-build/targets/mac-arm64/artifacts')
expect(config.mac.extendInfo.NSMicrophoneUsageDescription).toContain('microphone')
expect(config.mac.extendInfo.CFBundleLocalizations).toEqual(['en', 'zh_CN', 'ja'])
expect(config.mac.entitlementsInherit).toBe(config.mac.entitlements)
const entitlements = readFileSync(config.mac.entitlements, 'utf8')
for (const key of ['com.apple.security.cs.allow-jit', 'com.apple.security.cs.allow-unsigned-executable-memory',
+3
View File
@@ -73,5 +73,8 @@
"@deepseek-ai/dsh-experimental-cot-translation-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-worktree": "workspace:*",
"@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*"
},
"dependencies": {
"@deepseek-ai/dsh-message-quotes": "workspace:*"
}
}
+8 -8
View File
@@ -85,8 +85,8 @@ describe('web e2e: Agent Teams panel', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel'))
const projectionReads = vi.spyOn(scaffold.ctx.sessionController, 'projections')
onTestFinished(() => { projectionReads.mockRestore() })
await page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu }).click()
const action = page.getByRole('dialog', { name: 'Agent Team', exact: true })
await page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true }).click()
const action = page.getByRole('dialog', { name: 'Agent team', exact: true })
await action.getByText('No shared tasks yet. Create them through the conversation.').waitFor()
await action.getByText('lead').waitFor()
expect(projectionReads).not.toHaveBeenCalled()
@@ -119,8 +119,8 @@ describe('web e2e: Agent Teams panel', () => {
it('keeps the panel inside the viewport and closes on outside click or Escape', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel-keyboard'))
const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true })
const trigger = page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu })
const panel = page.getByRole('dialog', { name: 'Agent team', exact: true })
const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true })
const viewport = page.viewportSize()!
await trigger.click()
await panel.getByText('lead', { exact: true }).waitFor()
@@ -162,8 +162,8 @@ describe('web e2e: Agent Teams panel', () => {
})
it('keeps the hovered panel open when the pointer returns directly to its trigger', async () => {
const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent Team', exact: true })
const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true })
const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true })
const panel = page.getByRole('dialog', { name: 'Agent team', exact: true })
await trigger.hover()
await panel.waitFor()
await panel.hover()
@@ -182,8 +182,8 @@ describe('web e2e: Agent Teams panel', () => {
writeScopes: [],
})
const viewport = page.viewportSize()!
const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent Team', exact: true })
const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true })
const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true })
const panel = page.getByRole('dialog', { name: 'Agent team', exact: true })
const card = panel.locator('article').filter({ hasText: 'Responsive task' })
await trigger.click()
await card.waitFor()
+11
View File
@@ -112,6 +112,17 @@ describe.each(MODE === 'record' ? ['button'] as const : ['button', 'keyboard'] a
await panel.waitFor({ timeout: MODE === 'record' ? 180_000 : 60_000 })
const scroll = panel.locator('[data-approval-scroll]')
await expect.poll(() => scroll.getByText(/tok/).count(), { timeout: 15_000 }).toBeGreaterThan(0)
const defaults = await panel.evaluate((root: HTMLElement) => {
const before = root.style.textAlign
root.style.textAlign = 'center'
try {
return {
alignment: getComputedStyle(root.querySelector('[data-approval-scroll]')!).textAlign,
wrapping: getComputedStyle(root.querySelector('button')!.parentElement!).flexWrap,
}
} finally { root.style.textAlign = before }
})
expect(defaults).toEqual({ alignment: 'center', wrapping: 'nowrap' })
if (MODE !== 'record') {
const snapshot = await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd)
+7 -1
View File
@@ -26,6 +26,10 @@ it('keeps settings and expanded plugin dialogs clear of the Windows caption acro
await openSettings(page, 'zh')
const settings = page.getByRole('dialog', { name: '设置', exact: true })
const settingsMask = settings.locator('..').locator(':scope > [aria-hidden="true"]')
const permissionText = settings.getByText('权限', { exact: true }).locator('..')
expect(await permissionText.evaluate(el => getComputedStyle(el).paddingRight)).toBe('48px')
const cubes = settings.getByRole('button', { name: /浅色|深色|跟随系统/ })
expect(await cubes.first().evaluate(el => getComputedStyle(el).flexBasis)).toBe('180px')
for (const fullscreen of [false, true, false]) {
await page.evaluate(value => document.documentElement.toggleAttribute('data-fullscreen', value), fullscreen)
const margin = fullscreen ? 24 : 60
@@ -86,8 +90,10 @@ it('keeps settings and expanded plugin dialogs clear of the Windows caption acro
}, platform)
if (await reopenSidebar.isVisible()) await reopenSidebar.click()
await openSettings(page, 'zh')
await expect.poll(async () => (await settings.boundingBox())?.y).toBe(platform === 'darwin' ? 68 : 24)
await expect.poll(async () => (await settings.boundingBox())?.y).toBe(platform === 'darwin' ? 68 : 0)
expect((await settingsMask.boundingBox())?.y).toBe(0)
expect(await permissionText.evaluate(el => getComputedStyle(el).paddingRight)).toBe(platform === 'darwin' ? '48px' : '16px')
expect(await cubes.first().evaluate(el => getComputedStyle(el).flexBasis)).toBe(platform === 'darwin' ? '180px' : '0px')
await page.keyboard.press('Escape')
await settings.waitFor({ state: 'hidden' })
}
+18 -9
View File
@@ -59,13 +59,20 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
calls: [{ type: 'tool-call', id: ToolCallId('cua-app-approval'), name: 'run_code',
arguments: JSON.stringify({ code: 'return await tools.cua({ method: "getApp", params: { selector: "Fixture" } })',
description: 'Request application access' }) }] })
const approval = page.locator('[data-question-key]')
const approval = page.locator('[data-approval-key]')
await Promise.race([approval.waitFor(), grant.then((result) => {
throw new Error(`Application selection completed before consent: ${JSON.stringify(result)}`)
})])
expect(await approval.textContent()).toContain('Fixture')
await approval.getByRole('radio', { name: 'Always allow', exact: true }).click()
await approval.getByRole('button', { name: 'Submit', exact: true }).click()
expect(await approval.getByText('Fixture', { exact: true }).count()).toBe(1)
expect(await approval.getByRole('textbox').count()).toBe(0)
expect(await approval.locator('[data-approval-scroll]').evaluate(element => getComputedStyle(element).textAlign)).toBe('left')
expect(await approval.getByRole('button', { name: 'Always allow', exact: true })
.evaluate(button => getComputedStyle(button.parentElement!).flexWrap)).toBe('wrap')
await expect.poll(() => approval.locator('img').evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1)
expect(await approval.getByText('/Applications/Fixture.app', { exact: true }).count()).toBe(0)
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/cua-permissions/application-approval.expected.md', import.meta.url)),
await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd), webSnapshotMode())
await approval.getByRole('button', { name: 'Always allow', exact: true }).click()
expect((await grant).results).toMatchObject([{ result: { isError: false } }])
await approval.waitFor({ state: 'hidden' })
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
@@ -73,15 +80,15 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
expect(await card.getByRole('button', { name: /Accessibility permission|Screen Recording permission/ }).count()).toBe(0)
await page.setViewportSize({ width: 1680, height: 600 })
const plugins = page.locator('[data-plugin-panel]')
const openDetails = page.getByRole('button', { name: 'View Computer Use', exact: true })
const openDetails = page.getByRole('button', { name: 'View Computer use', exact: true })
await openDetails.scrollIntoViewIfNeeded()
expect(await plugins.evaluate(element => element.scrollTop)).toBeGreaterThan(0)
await openDetails.click()
const panel = page.locator('[data-cua-settings]')
const accessibility = panel.getByRole('group', { name: 'Accessibility', exact: true })
await accessibility.getByRole('button', { name: 'Request permission', exact: true }).waitFor()
expect(await panel.getByRole('region', { name: 'Authorize Computer Use', exact: true }).getByRole('group').count()).toBe(4)
expect(await panel.getByRole('region', { name: 'Manage applications Computer Use can access', exact: true }).count()).toBe(1)
expect(await panel.getByRole('region', { name: 'Authorize computer use', exact: true }).getByRole('group').count()).toBe(4)
expect(await panel.getByRole('region', { name: 'Manage applications computer use can access', exact: true }).count()).toBe(1)
await expect.poll(() => panel.locator('li img').evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1)
expect(await panel.getByText('/fixture/app', { exact: true }).count()).toBe(0)
expect(await accessibility.getByText('Visual Studio Code.app', { exact: true }).count()).toBe(1)
@@ -146,7 +153,9 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
{ method: 'openPermissionSettings', params: { permission: 'accessibility' } },
])
expect(events.every(event => ['permissionStatus', 'requestPermission', 'openPermissionSettings', 'applicationIcons', 'getApp'].includes(event.method))).toBe(true)
await plugins.locator('[data-detail-document] h1').waitFor()
const preview = plugins.locator('[data-detail-document] img')
await preview.waitFor()
await preview.evaluate(async (image: HTMLImageElement) => { await image.decode() })
const trace = await page.evaluateHandle(() => {
const errors: string[] = []
const stable = [...document.querySelectorAll('[data-cua-settings] [role="alert"]')].map(node => node.textContent?.trim())
@@ -177,7 +186,7 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
return { errors, layouts, documents, stop: () => { observer.disconnect(); cancelAnimationFrame(frame) } }
})
try {
const toggle = page.locator('[data-plugin-panel]').getByRole('switch', { name: 'Enable Computer Use', exact: true })
const toggle = page.locator('[data-plugin-panel]').getByRole('switch', { name: 'Enable Computer use', exact: true })
for (const width of [1680, 390]) {
await page.setViewportSize({ width, height: 1000 })
if (width === 390) await page.locator('[data-sidebar-collapsed]').waitFor({ state: 'attached' })
@@ -95,6 +95,8 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett
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' })
const patchPath = join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml')
expect(await readFile(patchPath, 'utf8')).toContain('model: deepseek-v4-flash')
await page.reload({ waitUntil: 'load' })
const input = page.locator('[data-composer-input]').first()
const trigger = page.getByRole('button', { name: '请选择模型', exact: true })
@@ -109,8 +111,8 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett
await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection()).toMatchObject({
provider: 'deepseek-official', model: 'deepseek-flash',
})
const settings = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')
expect(settings).toContain('provider: deepseek-official')
const settings = await readFile(patchPath, 'utf8')
expect(settings).not.toContain('id: agent-default-model')
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
})
+47 -2
View File
@@ -21,6 +21,7 @@ describe.skipIf(MODE === 'record')('web e2e: native and Client locale preference
let page: Page
let tripwire: ReturnType<typeof watchConsole>
const reported: string[] = []
let languages = ['fr-FR', 'zh-CN', 'en-US']
beforeAll(async () => {
scaffold = await launchWebScaffold()
@@ -29,7 +30,7 @@ describe.skipIf(MODE === 'record')('web e2e: native and Client locale preference
tripwire = watchConsole(page)
await page.exposeFunction('nativeLocaleRead', () => {
const section = scaffold.ctx.settings.describe().find(row => row.ns === 'locale')!.value as { preference?: string }
return { languages: ['ja-JP', 'zh-CN', 'en-US'], preference: section.preference ?? null }
return { languages, preference: section.preference ?? null }
})
await page.exposeFunction('nativeLocaleChanged', (locale: string) => { reported.push(locale) })
await page.addInitScript(() => {
@@ -61,7 +62,17 @@ describe.skipIf(MODE === 'record')('web e2e: native and Client locale preference
expect(scaffold.ctx.settings.describe().find(row => row.ns === 'locale')!.value).toEqual({})
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'automatic.expected.md'),
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, versionCapture), MODE)
await zhDialog.getByRole('button', { name: '中文', exact: true }).click()
languages = ['ja-JP', 'zh-CN', 'en-US']
const automaticWarnings = tripwire.warnings.length
reported.length = 0
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, automaticWarnings)
await openSettings(page, 'ja')
const jaDialog = page.getByRole('dialog', { name: '設定', exact: true })
await jaDialog.getByRole('button', { name: '日本語', exact: true }).waitFor()
await expect.poll(() => reported).toEqual(['ja'])
expect(scaffold.ctx.settings.describe().find(row => row.ns === 'locale')!.value).toEqual({})
await jaDialog.getByRole('button', { name: '日本語', exact: true }).click()
await page.getByRole('menuitem', { name: 'English', exact: true }).click()
const enDialog = page.getByRole('dialog', { name: 'Settings', exact: true })
await enDialog.getByRole('button', { name: 'English', exact: true }).waitFor()
@@ -79,6 +90,40 @@ describe.skipIf(MODE === 'record')('web e2e: native and Client locale preference
await expect.poll(() => reported).toEqual(['en'])
expect(await page.evaluate(() => document.documentElement.lang)).toBe('en')
expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
await enDialog.getByRole('button', { name: 'English', exact: true }).click()
await page.getByRole('menuitem', { name: '日本語', exact: true }).click()
await jaDialog.getByRole('button', { name: '日本語', exact: true }).waitFor()
await expect.poll(() => readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'))
.toContain('preference: ja')
const japaneseReloadWarnings = tripwire.warnings.length
reported.length = 0
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, japaneseReloadWarnings)
await openSettings(page, 'ja')
await jaDialog.getByRole('button', { name: '日本語', exact: true }).waitFor()
await expect.poll(() => reported).toEqual(['ja'])
expect(await page.evaluate(() => document.documentElement.lang)).toBe('ja')
const overflows = (): Promise<number> => jaDialog.evaluate((dialog) => {
const containers = [dialog, ...dialog.querySelectorAll<HTMLElement>('*')]
.filter(element => element === dialog || ['auto', 'scroll'].includes(getComputedStyle(element).overflowX))
return containers.filter(element => element.clientWidth > 0 && element.scrollWidth > element.clientWidth + 1).length
})
await expect.poll(overflows).toBe(0)
await jaDialog.evaluate((dialog) => {
const options = dialog.querySelector('nav')!.nextElementSibling!.lastElementChild!
const probe = document.createElement('div')
probe.dataset.localeOverflowProbe = ''
probe.style.width = '2000px'
probe.style.height = '1px'
options.append(probe)
})
try {
expect(await overflows()).toBeGreaterThan(0)
} finally {
await jaDialog.locator('[data-locale-overflow-probe]').evaluate((element) => { element.remove() })
}
await expect.poll(overflows).toBe(0)
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['automatic.expected.md', 'selected.expected.md'])
@@ -1,6 +1,6 @@
- dialog "设置":
- text: 设置
- navigation:
- text: 设置
- button "通用设置"
- button "模型"
- button "内置插件"
@@ -0,0 +1,8 @@
- text: Waiting for approval
- group "Approval details":
- strong: Fixture
- text: Allow computer use to launch, read, capture and control this application?
- paragraph: Session access lasts until DSH restarts or computer use is disabled.
- button "Reject"
- button "Allow for this session"
- button "Always allow"
@@ -1,8 +1,8 @@
- region "Authorize Computer Use":
- heading "Authorize Computer Use" [level=3]
- region "Authorize computer use":
- heading "Authorize computer use" [level=3]
- group "Default mode for new sessions":
- strong: Default mode for new sessions
- paragraph: Computer Use is available only in PTC mode.
- paragraph: Computer use is available only in PTC mode.
- button "Set to PTC"
- group "PTC execution mode":
- strong: PTC execution mode
@@ -17,8 +17,8 @@
- paragraph: In System Settings, allow Visual Studio Code.app to capture application windows.
- button "Request permission"
- paragraph: Manage permissions in System Settings for the application named by macOS.
- region "Manage applications Computer Use can access":
- heading "Manage applications Computer Use can access" [level=3]
- region "Manage applications computer use can access":
- heading "Manage applications computer use can access" [level=3]
- heading "Always-allowed applications" [level=4]
- list:
- listitem:
@@ -1,6 +1,6 @@
- dialog "设置":
- text: 设置
- navigation:
- text: 设置
- button "通用设置"
- button "模型"
- button "内置插件"
@@ -1,6 +1,6 @@
- dialog "设置":
- text: 设置
- navigation:
- text: 设置
- button "通用设置"
- button "模型"
- button "内置插件"
@@ -1,6 +1,6 @@
- dialog "Settings":
- text: Settings
- navigation:
- text: Settings
- button "General"
- button "Models"
- button "Built-in plugins"
@@ -1,6 +1,6 @@
- dialog "设置":
- text: 设置
- navigation:
- text: 设置
- button "通用设置"
- button "模型"
- button "内置插件"
@@ -1,6 +1,6 @@
- dialog "设置":
- text: 设置
- navigation:
- text: 设置
- button "通用设置"
- button "模型"
- button "内置插件"
@@ -1,6 +1,6 @@
- dialog "设置":
- text: 设置
- navigation:
- text: 设置
- button "通用设置"
- button "模型"
- button "内置插件"
@@ -1,6 +1,6 @@
- dialog "设置":
- text: 设置
- navigation:
- text: 设置
- button "通用设置"
- button "模型"
- button "内置插件"
@@ -1,6 +1,6 @@
- dialog "设置":
- text: 设置
- navigation:
- text: 设置
- button "通用设置"
- button "模型"
- button "内置插件"
@@ -1,6 +1,6 @@
- dialog "设置":
- text: 设置
- navigation:
- text: 设置
- button "通用设置"
- button "模型"
- button "内置插件"
@@ -1,6 +1,6 @@
- dialog "设置":
- text: 设置
- navigation:
- text: 设置
- button "通用设置"
- button "模型"
- button "内置插件"
@@ -1,6 +1,6 @@
- dialog "设置":
- text: 设置
- navigation:
- text: 设置
- button "通用设置"
- button "模型"
- button "内置插件"
@@ -1,6 +1,6 @@
- dialog "设置":
- text: 设置
- navigation:
- text: 设置
- button "通用设置"
- button "模型"
- button "内置插件"
@@ -1,6 +1,6 @@
- dialog "设置":
- text: 设置
- navigation:
- text: 设置
- button "通用设置"
- button "模型"
- button "内置插件"
@@ -1,6 +1,6 @@
- dialog "设置":
- text: 设置
- navigation:
- text: 设置
- button "通用设置"
- button "模型"
- button "内置插件"
@@ -1,6 +1,6 @@
- dialog "设置":
- text: 设置
- navigation:
- text: 设置
- button "通用设置"
- button "模型"
- button "内置插件"
@@ -1,6 +1,6 @@
- dialog "设置":
- text: 设置
- navigation:
- text: 设置
- button "通用设置"
- button "模型"
- button "内置插件"
@@ -32,16 +32,16 @@
- text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
- status
- switch "启用 语音输入"
- listitem:
- button "查看 自定义插件市场": 自定义插件市场
- text: 实验性 在插件页添加「发现」标签页,从你添加的插件市场索引浏览和安装插件。
- status
- switch "启用 自定义插件市场"
- listitem:
- button "查看 自动授权审查": 自动授权审查
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
- status
- switch "启用 自动授权审查"
- listitem:
- button "查看 开发者工具": 开发者工具
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
- status
- switch "启用 开发者工具"
- heading "已安装插件" [level=3]
- list:
- listitem:
@@ -32,16 +32,16 @@
- text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
- status
- switch "启用 语音输入"
- listitem:
- button "查看 自定义插件市场": 自定义插件市场
- text: 实验性 在插件页添加「发现」标签页,从你添加的插件市场索引浏览和安装插件。
- status
- switch "启用 自定义插件市场"
- listitem:
- button "查看 自动授权审查": 自动授权审查
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
- status
- switch "启用 自动授权审查"
- listitem:
- button "查看 开发者工具": 开发者工具
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
- status
- switch "启用 开发者工具"
- heading "已安装插件" [level=3]
- list:
- listitem:
@@ -32,16 +32,16 @@
- text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
- status
- switch "启用 语音输入"
- listitem:
- button "查看 自定义插件市场": 自定义插件市场
- text: 实验性 在插件页添加「发现」标签页,从你添加的插件市场索引浏览和安装插件。
- status
- switch "启用 自定义插件市场"
- listitem:
- button "查看 自动授权审查": 自动授权审查
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
- status
- switch "启用 自动授权审查"
- listitem:
- button "查看 开发者工具": 开发者工具
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
- status
- switch "启用 开发者工具"
- heading "已安装插件" [level=3]
- list:
- listitem:
@@ -1,6 +1,6 @@
- dialog "Settings":
- text: Settings
- navigation:
- text: Settings
- button "General"
- button "Models"
- button "Built-in plugins"
@@ -1,6 +1,6 @@
- dialog "Settings":
- text: Settings
- navigation:
- text: Settings
- button "General"
- button "Models"
- button "Built-in plugins"
@@ -1,6 +1,6 @@
- dialog "设置":
- text: 设置
- navigation:
- text: 设置
- button "通用设置"
- button "模型"
- button "内置插件"
@@ -1,9 +1,9 @@
- bundle: Developer Tools
- bundle: Developer tools
description: Inspect and debug raw session data, grouped chat messages, and the NodeJS backend.
- component: NodeJS Inspector
- component: NodeJS inspector
description: Press Ctrl/Cmd+Shift+. to open Chrome DevTools in the bottom panel for development and debugging.
- component: Session Log
- component: Session log
description: Enable analysis of the current session's raw data and grouped chat messages in the session sidebar.
- session sidebar: Session Log
- session sidebar: Session log
description: Analyze raw logs and grouped chat messages for the current session in the sidebar.
- NodeJS bottom: NodeJS Inspector
- NodeJS bottom: NodeJS inspector
@@ -72,7 +72,7 @@
- region "Approval area":
- heading "Approval area" [level=3]
- list:
- listitem: Allow once Enter
- listitem: Allow Enter
- listitem: Reject Esc
- contentinfo:
- button "Restore all defaults" [disabled]
@@ -72,7 +72,7 @@
- region "Approval area":
- heading "Approval area" [level=3]
- list:
- listitem: Allow once Enter
- listitem: Allow Enter
- listitem: Reject Esc
- contentinfo:
- button "Restore all defaults" [disabled]
@@ -72,7 +72,7 @@
- region "审批区域":
- heading "审批区域" [level=3]
- list:
- listitem: 允许一次 Enter
- listitem: 允许 Enter
- listitem: 拒绝 Esc
- contentinfo:
- button "恢复全部默认" [disabled]
+6 -1
View File
@@ -35,7 +35,8 @@ it('opens bottom DevTools after the Developer Tools bundle is enabled on an alre
await page.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click()
const plugins = page.locator('[data-plugin-panel]')
const toggle = plugins.getByRole('switch', { name: 'Enable Developer Tools', exact: true })
await plugins.getByRole('button', { name: 'More', exact: true }).click()
const toggle = plugins.getByRole('switch', { name: 'Enable Developer tools', exact: true })
await toggle.waitFor()
expect(await toggle.getAttribute('aria-checked')).toBe('false')
const panel = page.locator('[data-inspector-panel]')
@@ -43,6 +44,10 @@ it('opens bottom DevTools after the Developer Tools bundle is enabled on an alre
await toggle.click()
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true')
// Enabling a More-page bundle also pins it to the main list.
await plugins.getByRole('button', { name: 'Back to plugins', exact: true }).click()
await plugins.locator('[data-plugin-group="extensions"]').getByRole('switch', { name: 'Enable Developer tools', exact: true }).waitFor()
expect(await toggle.getAttribute('aria-checked')).toBe('true')
await panel.waitFor({ state: 'attached', timeout: 20_000 }).catch((error: unknown) => {
throw new Error(`Bottom panel did not mount; ${BOOTSTRAP_PATH} responses: ${JSON.stringify(bootstraps)}`, { cause: error })
})
+2 -1
View File
@@ -112,7 +112,8 @@ describe('web e2e: plugin configuration pages', () => {
await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor()
const bundles = await scaffold.ctx.pluginManager.listBundles()
expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count())
.toBe(bundles.filter(bundle => bundle.official && (bundle.listedByDefault !== false || bundle.enabled || bundle.installed)).length)
.toBe(bundles.filter(bundle => bundle.name !== '@deepseek-ai/dsh-experimental-terminal-bundle'
&& bundle.official && (bundle.listedByDefault !== false || bundle.enabled || bundle.installed)).length)
expect(await basic.count()).toBe(0)
expect(await extensions.count()).toBe(0)
expect(await panel.locator('[data-plugin-group="bundles"]').count()).toBe(0)
+12 -9
View File
@@ -659,14 +659,18 @@ describe('web e2e: plugin manager', () => {
expect(await panel.locator('[data-plugin-group="basic"] [data-plugin-item]').count()).toBe(4)
expect(await panel.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-inspector"]').count()).toBe(0)
expect(await panel.getByRole('switch', { name: '启用 语音输入', exact: true }).getAttribute('aria-checked')).toBe('false')
expect(await panel.getByRole('switch', { name: '启用 开发者工具', exact: true }).getAttribute('aria-checked')).toBe('false')
expect(await panel.getByText('查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端', { exact: true }).count()).toBe(1)
expect(await panel.getByRole('switch', { name: '启用 自定义插件市场', exact: true }).getAttribute('aria-checked')).toBe('false')
expect(await panel.getByRole('switch', { name: '启用 开发者工具', exact: true }).count()).toBe(0)
expect(await panel.getByText(/Cordis|Chrome DevTools/).count()).toBe(0)
await panel.getByRole('button', { name: '更多', exact: true }).click()
await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor()
expect(await panel.getByText('查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端', { exact: true }).count()).toBe(1)
const bundles = await scaffold.ctx.pluginManager.listBundles()
expect(bundles.some(bundle => bundle.name === '@deepseek-ai/dsh-experimental-terminal-bundle')).toBe(true)
expect(await panel.getByRole('switch', { name: '启用 持久终端', exact: true }).count()).toBe(0)
expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count())
.toBe(bundles.filter(bundle => bundle.official && (bundle.listedByDefault !== false || bundle.enabled || bundle.installed)).length)
.toBe(bundles.filter(bundle => bundle.name !== '@deepseek-ai/dsh-experimental-terminal-bundle'
&& bundle.official && (bundle.listedByDefault !== false || bundle.enabled || bundle.installed)).length)
for (const name of ['@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-ralph-bundle']) {
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name))
.toMatchObject({ enabled: false, optional: true, availability: 'installation', listedByDefault: false })
@@ -679,15 +683,14 @@ describe('web e2e: plugin manager', () => {
]))).toEqual([
['@deepseek-ai/dsh-experimental-agent-team-profile', 1],
['@deepseek-ai/dsh-experimental-voice-input-bundle', 1],
['@deepseek-ai/dsh-experimental-plugin-market-bundle', 1],
['@deepseek-ai/dsh-experimental-auto-review', 1],
['@deepseek-ai/dsh-experimental-inspector-profile', 1],
['@deepseek-ai/dsh-cua-bundle', 1],
['@deepseek-ai/dsh-experimental-tool-worktree', 1],
['@deepseek-ai/dsh-experimental-cot-translation-bundle', 1],
['@deepseek-ai/dsh-experimental-terminal-bundle', 1],
['@deepseek-ai/dsh-experimental-session-titles-bundle', 1],
['@deepseek-ai/dsh-experimental-badge-skill-bundle', 1],
['@deepseek-ai/dsh-experimental-plugin-market-bundle', 1],
['@deepseek-ai/dsh-subagent-codex', 0],
['@deepseek-ai/dsh-subagent-claude-code', 0],
])
@@ -921,14 +924,14 @@ describe('web e2e: plugin manager', () => {
expect(await child.getByText('为智能体提供成员协调、消息通信和共享任务管理工具。', { exact: true }).count()).toBe(1)
try {
await setLanguage('en')
await panel.getByRole('heading', { name: 'Agent Teams', exact: true }).waitFor()
await panel.getByRole('heading', { name: 'Agent teams', exact: true }).waitFor()
expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile')
expect(await panel.getByText('Enable team collaboration, team tools, the member roster, and the shared task board.').count()).toBe(1)
await child.getByText('Team Tools', { exact: true }).waitFor()
await child.getByText('Team tools', { exact: true }).waitFor()
expect(await child.getByText('Give agents tools to coordinate members, exchange messages, and manage shared tasks.', { exact: true }).count()).toBe(1)
await panel.getByRole('button', { name: 'Back to plugins' }).click()
await panel.getByRole('button', { name: 'View Agent Teams', exact: true }).waitFor()
expect(await panel.getByRole('switch', { name: 'Enable Agent Teams', exact: true }).count()).toBe(1)
await panel.getByRole('button', { name: 'View Agent teams', exact: true }).waitFor()
expect(await panel.getByRole('switch', { name: 'Enable Agent teams', exact: true }).count()).toBe(1)
// Schedule ships in the delivered composition, so no bundle card carries it in either language.
expect(await panel.getByRole('button', { name: 'View Automation tasks', exact: true }).count()).toBe(0)
// The official configuration pages follow the language too, from their own dictionary.
@@ -0,0 +1,57 @@
/** A delivery declared in the closing reply's step reaches the completed turn's cards when later steps add no text. */
import { readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Browser, type Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/present-after-closing-reply', import.meta.url))
const MODE = webSnapshotMode()
describe('web e2e: delivery after the closing reply', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await writeFile(join(scaffold.workspaceCwd, 'report.txt'), 'DRAFT_REPORT\n')
await seedSession(scaffold, await readFile(join(SNAPSHOT_DIR, 'session.v4.jsonl'), 'utf8'), 'present-after-closing-reply')
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.locator('[role="treeitem"]').first().click()
await page.locator('[role="treeitem"]').nth(1).click()
await page.getByText('REPORT_READY', { exact: true }).waitFor()
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('shows the delivery card under the closing reply and previews the file', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-present-after-closing-reply'))
const row = page.locator('[data-turn-tail] [data-presented-files-row]')
await row.waitFor()
const card = row.locator('[data-presented-file]')
expect(await card.count()).toBe(1)
expect(await card.innerText()).toContain('report.txt')
expect(await card.innerText()).toContain('Draft report')
// The grid excludes the Host-desktop notice, which depends on the test platform.
const cards = await captureStableAria(page, '[data-turn-tail] [data-presented-files-row]', scaffold.workspaceCwd)
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'ui.expected.md'), cards, MODE)
await card.getByRole('button', { name: `Preview ${join(scaffold.workspaceCwd, 'report.txt')} in sidebar`, exact: true }).click()
await page.locator('[data-rightbar-col] [data-document-preview]').getByText('DRAFT_REPORT', { exact: true }).waitFor()
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['session.v4.jsonl', 'ui.expected.md'])
})
})
+1 -1
View File
@@ -776,7 +776,7 @@ async function openSessionInspector(page: Page): Promise<void> {
// The showcase opens with a file tab in the right sidebar; a new tab shows the guide entries.
await page.getByRole('button', { name: 'New tab', exact: true }).click()
await page.locator('[data-sidebar-right-guide-entry="session-inspector-log"]').click({ timeout: 30_000 })
await page.getByRole('table', { name: 'Raw Log', exact: true }).waitFor({ timeout: 15_000 })
await page.getByRole('table', { name: 'Raw log', exact: true }).waitFor({ timeout: 15_000 })
expect(await setBundle(page, INSPECTOR_BUNDLE, false)).toBe('applied')
}
+22 -1
View File
@@ -11,7 +11,7 @@ import {
acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, openSettings, saveFailureShot } from './support.ts'
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/ptc-round/session.v3.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.expected.md', import.meta.url))
@@ -275,6 +275,27 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
expect(await wrap.getAttribute('aria-pressed')).toBe('true')
})
it.skipIf(MODE === 'record')('renders the same recorded tool round in Japanese without translating model or tool data', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-japanese'))
await openSettings(page, 'en')
const settings = page.getByRole('dialog', { name: 'Settings', exact: true })
await settings.getByRole('button', { name: 'English', exact: true }).click()
await page.getByRole('menuitem', { name: '日本語', exact: true }).click()
await page.getByRole('dialog', { name: '設定', exact: true }).waitFor()
await page.keyboard.press('Escape')
await page.getByRole('tab', { name: 'チャット', exact: true }).click()
await page.getByText('DONE', { exact: true }).waitFor()
expect(await page.evaluate(() => document.documentElement.lang)).toBe('ja')
const row = page.locator('[data-subcalls] [data-sample="bash"]').first()
await expandOwningTurnProcess(page, row)
if (await row.getAttribute('aria-expanded') !== 'true') await row.click()
await row.locator('xpath=..').getByText('echo CODE_ROUND_OK', { exact: true }).waitFor()
const snapshot = await captureExpandedTurnProcessAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(
fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui-ja.expected.md', import.meta.url)), snapshot, MODE,
)
})
it.skipIf(MODE === 'record')('stayed clean: no page errors, no reconnect churn', () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
+35 -1
View File
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { TIMED_WAIT_PARAMETER } from '@deepseek-ai/dsh-user-questions'
@@ -700,3 +700,37 @@ describe.skipIf(MODE === 'record')('web e2e: late-answered question transcript',
])
})
})
it.each(['another-feature', 'cua-app-access'])('answers %s approval without a specialized composer', async (id) => {
const resources: { scaffold?: WebScaffold; browser?: Browser; waiting?: Promise<unknown> } = {}
const controller = new AbortController()
onTestFinished(async () => {
controller.abort()
await resources.waiting
await resources.browser?.close()
await resources.scaffold?.close()
})
const scaffold = await launchWebScaffold()
resources.scaffold = scaffold
const browser = await chromium.launch()
resources.browser = browser
const page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
onTestFailed(() => saveFailureShot(page, `web-approval-fallback-${id}`))
await page.goto(scaffold.authenticatedUrl)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const agent = scaffold.ctx.agents.list()[0]!
const result = scaffold.ctx.userQuestions.ask({ agent, signal: controller.signal, questions: [{
id, question: 'Allow fixture access?', detail: 'Only this request.',
options: [{ label: 'Allow' }, { label: 'Deny' }],
intent: { kind: 'approval', approve: 'Allow', reject: 'Deny' },
}] })
resources.waiting = Promise.allSettled([result])
const question = page.locator('[data-question-key]')
await question.getByRole('radio', { name: 'Deny', exact: true }).click()
await question.getByRole('button', { name: 'Submit', exact: true }).click()
expect(await result).toEqual({ answers: [{ id, selected: ['Deny'] }] })
await question.waitFor({ state: 'hidden' })
expect(tripwire.pageErrors).toEqual([])
})
+646
View File
@@ -0,0 +1,646 @@
/** Keyless real-Web coverage for quote selection, persistence, and uniform reference presentation. */
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import { SessionId } from '@deepseek-ai/dsh-session'
import { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
import { formatQuoteText } from '@deepseek-ai/dsh-client-ui-primitives'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold,
parseSeedFixture, renderSeedFixture, seedSession, selectedSessionFixture, watchConsole, webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/rich-text-quotes', import.meta.url))
const BORROWED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/rich-text-quotes/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const SESSION_ID = 'rich-text-quotes-web-e2e'
const OTHER_ID = 'rich-text-quotes-other'
const LEGACY_DRAFT_KEY = `dsh.conversation.${SESSION_ID}`
const CURRENT_DRAFT_KEY = `dsh.conversation.${SESSION_ID}`
const LEGACY_DRAFT = JSON.stringify({ draft: 'Imported plain draft', view: 'chat', viewRequest: null })
const SOURCE_TEXT = 'Durable source phrase selected from an earlier message and preserved as structured quote metadata.'
const THINKING_TEXT = 'Settled reasoning source'
const LITERAL_CREDENTIAL = 'sk-1234567890abcdefghijklmnopqrstuvwxyz'
const SESSION_MENTION = formatSessionReferenceMention({ sessionId: SessionId(OTHER_ID), label: 'Quote source' })
const DIRECT_SYNTAX_TEXT = [
`/quoted-demo ${SESSION_MENTION} ${LITERAL_CREDENTIAL} **literal Markdown**`,
...Array.from({ length: 80 }, (_, index) => `Quote line ${String(index + 1)} contains preserved text.`),
'End of long quote.',
].join('\n')
const SELECTED_SYNTAX_TEXT = DIRECT_SYNTAX_TEXT.replaceAll('\n', ' ')
const SENT_SYNTAX_TEXT = SELECTED_SYNTAX_TEXT.replace(SESSION_MENTION, '@Quote source')
const REPLY = 'Quote received.'
async function quoteAppearance(quote: Locator): Promise<Record<string, string>> {
return quote.evaluate((element) => {
const style = getComputedStyle(element)
return {
alignItems: style.alignItems,
color: style.color,
display: style.display,
fontSize: style.fontSize,
gap: style.gap,
lineHeight: style.lineHeight,
margin: style.margin,
padding: style.padding,
}
})
}
const REPLAY: ReplayOverrideDoc = [{
kind: 'chunks',
chunks: [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: REPLY },
{ type: 'block-end', index: 0, block: { type: 'text', text: REPLY } },
{ type: 'usage', usage: { inputTokens: 128, outputTokens: 8 } },
{ type: 'finish', reason: { kind: 'stop' } },
],
}]
function quoteHistoryFixture(raw: string): string {
const { headerLine } = parseSeedFixture(raw)
let seq = 0
let time = 0
const events: Array<Record<string, unknown> & { seq: number; time: number }> = []
const at = (event: Record<string, unknown>): number => {
const assigned = seq++
events.push({ ...event, seq: assigned, time: time++ })
return assigned
}
at({ type: 'turn/start', data: { turn: 1 } })
at({
type: 'user/message',
data: {
id: '{{message:1}}',
role: 'user',
content: [{ type: 'text', text: SOURCE_TEXT }],
source: { kind: 'user', rpcId: '{{rpc:1}}' },
},
surfaceOp: 'append',
})
for (let ordinal = 2; ordinal <= 51; ordinal++) {
at({
type: 'user/message',
data: {
id: `{{message:${String(ordinal)}}}`,
role: 'user',
content: [{
type: 'text',
text: ordinal === 2 ? formatQuoteText(DIRECT_SYNTAX_TEXT) : `Filler message ${String(ordinal)}`,
}],
source: { kind: 'user', rpcId: `{{rpc:${String(ordinal)}}}` },
},
surfaceOp: 'append',
})
}
at({
type: 'user/message',
data: {
id: '{{message:52}}',
role: 'user',
content: [
{ type: 'text', text: `> ${SOURCE_TEXT}` },
{ type: 'text', text: '\n\nUse that excerpt.' },
],
source: {
kind: 'user',
rpcId: '{{rpc:52}}',
},
},
surfaceOp: 'append',
})
at({
type: 'session/title',
data: {
title: 'Quote history',
messageSeqs: Array.from({ length: 52 }, (_value, index) => index + 1),
source: { kind: 'fallback' },
},
})
at({ type: 'step/start', data: { turn: 1, step: 1 } })
at({
type: 'assistant/message',
data: {
turn: 1,
step: 1,
message: {
id: '{{message:53}}',
role: 'assistant',
content: [
{ type: 'reasoning', text: THINKING_TEXT },
{ type: 'text', text: 'Quote fixture ready.' },
],
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
},
stream: [
{ type: 'chunk', time: 0, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } },
{ type: 'reasoning-chunks', time0: 0, index: 0, dt: [], texts: [THINKING_TEXT] },
{ type: 'chunk', time: 0, chunk: { type: 'block-end', index: 0, block: { type: 'reasoning', text: THINKING_TEXT } } },
{ type: 'chunk', time: 0, chunk: { type: 'block-start', index: 1, blockType: 'text' } },
{ type: 'text-chunks', time0: 0, index: 1, dt: [], texts: ['Quote fixture ready.'] },
{ type: 'chunk', time: 0, chunk: { type: 'block-end', index: 1, block: { type: 'text', text: 'Quote fixture ready.' } } },
{ type: 'chunk', time: 0, chunk: { type: 'finish', reason: { kind: 'stop' } } },
],
},
surfaceOp: 'append',
})
at({ type: 'step/end', data: { turn: 1, step: 1 } })
at({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } })
return renderSeedFixture(headerLine, events)
}
function otherSessionFixture(raw: string): string {
const { headerLine } = parseSeedFixture(raw)
let seq = 0
const events: Array<Record<string, unknown> & { seq: number; time: number }> = []
const at = (event: Record<string, unknown>): void => {
events.push({ ...event, seq, time: seq })
seq += 1
}
at({ type: 'turn/start', data: { turn: 1 } })
at({
type: 'user/message', surfaceOp: 'append',
data: {
id: '{{message:1}}', role: 'user',
content: [{ type: 'text', text: 'Other session.' }],
source: { kind: 'user', rpcId: '{{rpc:1}}' },
},
})
at({ type: 'session/title', data: { title: 'Other session', messageSeqs: [1], source: { kind: 'fallback' } } })
at({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } })
return renderSeedFixture(headerLine, events)
}
async function selectAllText(locator: Locator): Promise<void> {
await locator.evaluate((element) => {
const range = document.createRange()
range.selectNodeContents(element)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
document.dispatchEvent(new Event('selectionchange'))
})
}
async function expectEllipsizedQuote(box: Locator, text: Locator, container: Locator): Promise<number> {
const [quoteBounds, containerBounds, presentation] = await Promise.all([
box.boundingBox(),
container.boundingBox(),
text.evaluate((element) => {
const style = getComputedStyle(element)
return {
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
overflow: style.overflow,
textOverflow: style.textOverflow,
whiteSpace: style.whiteSpace,
}
}),
])
expect(quoteBounds).not.toBeNull()
expect(containerBounds).not.toBeNull()
const expectedMaximum = Math.min(containerBounds!.width * 0.8, 300)
expect(quoteBounds!.width).toBeLessThanOrEqual(expectedMaximum + 1)
expect(presentation.overflow).toBe('hidden')
expect(presentation.textOverflow).toBe('ellipsis')
expect(presentation.whiteSpace).toBe('nowrap')
expect(presentation.scrollWidth).toBeGreaterThan(presentation.clientWidth)
return quoteBounds!.width
}
async function openQuoteSession(page: Page): Promise<void> {
await page.locator(`[data-row-key="session:${SESSION_ID}"]`).click()
}
async function expectScrollableQuote(page: Page, quote: Locator, text: string): Promise<void> {
await quote.hover()
const content = page.locator('[data-quote-popover]')
await expect.poll(() => content.textContent()).toBe(text)
const panel = content.locator('..')
await expect.poll(async () => {
const bounds = await panel.boundingBox()
return bounds !== null && bounds.y >= 8 && bounds.y + bounds.height <= page.viewportSize()!.height - 8
}).toBe(true)
await panel.hover()
await page.mouse.wheel(0, 10_000)
await expect.poll(() => panel.evaluate(element => ({
scrolled: element.scrollTop > 0,
atEnd: element.scrollTop + element.clientHeight >= element.scrollHeight - 1,
}))).toEqual({ scrolled: true, atEnd: true })
expect(await content.evaluate((element) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
let last: Node | null = null
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) last = node
if (last === null) return false
const length = last.textContent?.length ?? 0
const range = document.createRange()
range.setStart(last, length - 'End of long quote.'.length)
range.setEnd(last, length)
const end = range.getBoundingClientRect()
const panelBounds = element.parentElement!.getBoundingClientRect()
return end.top >= panelBounds.top && end.bottom <= panelBounds.bottom
})).toBe(true)
await page.mouse.move(0, 0)
await expect.poll(() => content.count()).toBe(0)
}
async function crossQuoteGap(page: Page, quote: Locator, text: string, placement: 'above' | 'below'): Promise<void> {
await quote.hover()
const content = page.locator('[data-quote-popover]')
await expect.poll(() => content.count()).toBe(1)
await expect.poll(() => content.textContent()).toBe(text)
const anchor = (await quote.locator('..').boundingBox())!
const panel = (await content.locator('..').boundingBox())!
expect(panel.width).toBeLessThan(300)
const above = panel.y < anchor.y
expect(above).toBe(placement === 'above')
const x = Math.max(anchor.x, panel.x) + Math.min(anchor.width, panel.width) / 2
const y = above ? (panel.y + panel.height + anchor.y) / 2 : (anchor.y + anchor.height + panel.y) / 2
expect(await page.evaluate(point =>
Boolean(document.elementFromPoint(point.x, point.y)?.closest('[data-hovercard-bridge]')), { x, y })).toBe(true)
await page.mouse.move(x, y, { steps: 8 })
await page.evaluate(() => new Promise<void>((resolve) => { requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) }) }))
await expect.poll(() => content.count()).toBe(1)
expect(await content.textContent()).toBe(text)
await content.hover()
const body = (await content.boundingBox())!
await page.mouse.move(body.x + 12, body.y + 15)
await page.mouse.down()
await page.mouse.move(body.x + 60, body.y + 15, { steps: 5 })
await page.mouse.up()
expect(await page.evaluate(() => window.getSelection()?.toString().trim())).not.toBe('')
expect(await content.count()).toBe(1)
await page.keyboard.press('Escape')
await expect.poll(() => content.count()).toBe(0)
}
describe.skipIf(MODE === 'record')('web e2e: rich-text quotes', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let replayDir: string
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
replayDir = await mkdtemp(join(tmpdir(), 'dsh-rich-text-quotes-replay-'))
const replayOverride = join(replayDir, 'replay.override.json')
await writeFile(replayOverride, JSON.stringify(REPLAY))
scaffold = await launchWebScaffold({
replayFixture: join(replayDir, 'override-only.jsonl'),
replayOverride,
})
const skillDir = join(scaffold.workspaceCwd, '.agents', 'skills', 'quoted-demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), [
'---',
'name: quoted-demo',
'description: Example user-invoked skill',
'disable-model-invocation: true',
'---',
'',
'An example skill instruction.',
'',
].join('\n'))
const borrowed = await readFile(await selectedSessionFixture(BORROWED), 'utf8')
const createdAt = Date.now() - 60_000
await seedSession(scaffold, quoteHistoryFixture(borrowed), SESSION_ID, undefined, { createdAt })
await seedSession(scaffold, otherSessionFixture(borrowed), OTHER_ID, undefined, { createdAt: createdAt + 1_000 })
browser = await chromium.launch()
page = await newEnglishPage(browser)
await page.addInitScript(({ key, value }) => {
if (localStorage.getItem(key) === null) localStorage.setItem(key, value)
}, { key: LEGACY_DRAFT_KEY, value: LEGACY_DRAFT })
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const group = page.getByRole('treeitem', { name: /Ungrouped/ })
await group.waitFor({ timeout: 15_000 })
if (await group.getAttribute('aria-expanded') !== 'true') await group.click()
await openQuoteSession(page)
}, 120_000)
afterAll(async () => {
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
if (replayDir !== undefined) {
await rm(replayDir, { recursive: true, force: true })
.catch((error: unknown) => failures.push(error))
}
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'rich-text-quotes e2e cleanup failed')
})
it('renders and restores rich quotes with uniform styling after reference recognition', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-rich-text-quotes'))
const input = page.locator('[data-composer-input]').first()
await expect.poll(() => input.textContent()).toBe('Imported plain draft')
await input.click()
await input.press('ControlOrMeta+A')
await input.press('Backspace')
await expect.poll(() => input.textContent()).toBe('')
const sentQuote = page.locator('[data-message-bubble] [data-quote-preview]').filter({ hasText: SOURCE_TEXT }).first()
await sentQuote.waitFor({ timeout: 15_000 })
await expect.poll(() => sentQuote.getAttribute('title')).toBeNull()
await expect.poll(() => sentQuote.getAttribute('role')).toBeNull()
await expect.poll(() => sentQuote.getAttribute('tabindex')).toBeNull()
await expect.poll(() => sentQuote.locator('xpath=ancestor::*[@data-message-bubble][1]').textContent())
.toBe(`${SOURCE_TEXT} Use that excerpt.`)
const sentText = sentQuote.locator('[data-quote-preview-text]')
const sentWidth = await expectEllipsizedQuote(
sentQuote,
sentText,
sentQuote.locator('xpath=ancestor::*[@data-message-bubble][1]'),
)
const sentAppearance = await quoteAppearance(sentQuote)
expect(sentAppearance.gap).toBe('4px')
expect(sentAppearance.margin).toBe('0px')
await sentQuote.hover()
const quotePopover = page.locator('[data-quote-popover]')
await expect.poll(() => quotePopover.textContent()).toBe(SOURCE_TEXT)
const hoveredSentBounds = await sentQuote.boundingBox()
expect(hoveredSentBounds).not.toBeNull()
expect(hoveredSentBounds!.width).toBeCloseTo(sentWidth, 0)
await quotePopover.hover()
// Exceed the pointer grace to prove the panel itself keeps the popover open.
await page.waitForTimeout(300)
await expect.poll(() => quotePopover.count()).toBe(1)
await page.mouse.move(0, 0)
await expect.poll(() => quotePopover.count()).toBe(0)
const sourceBlocks = page.locator('[data-text-interaction-source]').filter({ hasText: SOURCE_TEXT })
const olderSource = page.getByRole('button', { name: 'Load earlier' })
if (await sourceBlocks.count() < 2 && await olderSource.isVisible()) await olderSource.click()
await expect.poll(() => sourceBlocks.count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(2)
const source = sourceBlocks.filter({ hasNot: page.locator('[data-quote-preview]') }).first()
await source.waitFor()
const quoteAction = page.getByRole('button', { name: 'Quote text', exact: true })
await source.scrollIntoViewIfNeeded()
await source.dispatchEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', buttons: 1 })
await selectAllText(source)
await expect.poll(() => quoteAction.count()).toBe(0)
await source.dispatchEvent('pointerup', { pointerId: 1, pointerType: 'mouse', buttons: 0 })
await expect.poll(() => quoteAction.count()).toBe(1)
expect(await quoteAction.evaluate(element => element.closest('[data-chat-flow]') !== null)).toBe(true)
expect(await quoteAction.evaluate(element => getComputedStyle(element.closest('[data-text-interaction-surface]')!).position)).toBe('absolute')
await page.keyboard.press('Escape')
await expect.poll(() => quoteAction.count()).toBe(0)
await page.evaluate(() => window.dispatchEvent(new Event('resize')))
await expect.poll(() => quoteAction.count()).toBe(0)
await source.dispatchEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 0, buttons: 1 })
await selectAllText(source)
await source.dispatchEvent('pointerup', { pointerId: 1, pointerType: 'mouse', button: 0, buttons: 0 })
await expect.poll(() => quoteAction.count()).toBe(1)
await input.click()
await source.scrollIntoViewIfNeeded()
await expect.poll(() => quoteAction.count()).toBe(0)
await source.dispatchEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 0, buttons: 1 })
await selectAllText(source)
await source.dispatchEvent('pointerup', { pointerId: 1, pointerType: 'mouse', button: 0, buttons: 0 })
await expect.poll(() => quoteAction.count()).toBe(1)
await page.locator('[data-conversation-scroll]').evaluate((element) => {
element.scrollTop = element.scrollHeight
element.dispatchEvent(new Event('scroll'))
})
await expect.poll(() => quoteAction.isVisible()).toBe(false)
expect(await page.locator('[data-text-interaction-surface] button').count()).toBe(1)
await source.scrollIntoViewIfNeeded()
await expect.poll(() => quoteAction.isVisible()).toBe(true)
await quoteAction.click()
const draftQuote = page.locator('[data-composer-quote]')
const draftPreview = draftQuote.locator('[data-quote-preview]')
const draftPreviewText = draftPreview.locator('[data-quote-preview-text]')
await expect.poll(() => draftQuote.count()).toBe(1)
await expect.poll(() => draftQuote.textContent()).toContain(SOURCE_TEXT)
const draftText = draftPreviewText
await expect.poll(() => draftPreview.getAttribute('role')).toBeNull()
await expect.poll(() => draftPreview.getAttribute('tabindex')).toBeNull()
const draftWidth = await expectEllipsizedQuote(
draftPreview,
draftPreviewText,
input,
)
expect(await quoteAppearance(draftPreview)).toEqual(sentAppearance)
await draftText.hover()
await expect.poll(() => quotePopover.textContent()).toBe(SOURCE_TEXT)
const hoveredDraftBounds = await draftPreview.boundingBox()
expect(hoveredDraftBounds).not.toBeNull()
expect(hoveredDraftBounds!.width).toBeCloseTo(draftWidth, 0)
await page.mouse.move(0, 0)
await expect.poll(() => quotePopover.count()).toBe(0)
await input.press('Backspace')
await expect.poll(() => draftQuote.count()).toBe(0)
await expect.poll(() => input.textContent()).toBe('')
await input.press('ControlOrMeta+z')
await expect.poll(() => draftQuote.count()).toBe(1)
const removeQuote = draftQuote.getByRole('button', { name: 'Remove quote' })
const removeIcons = removeQuote.locator('svg')
await expect.poll(() => removeIcons.first().evaluate(element => getComputedStyle(element).opacity)).toBe('1')
await expect.poll(() => removeIcons.last().evaluate(element => getComputedStyle(element).opacity)).toBe('0')
await input.click()
await page.keyboard.press('ControlOrMeta+A')
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString() ?? '')).toContain(SOURCE_TEXT)
await draftQuote.hover()
await expect.poll(() => removeIcons.first().evaluate(element => getComputedStyle(element).opacity)).toBe('0')
await expect.poll(() => removeIcons.last().evaluate(element => getComputedStyle(element).opacity)).toBe('1')
await removeQuote.focus()
await expect.poll(() => removeIcons.last().evaluate(element => getComputedStyle(element).opacity)).toBe('1')
await expectEllipsizedQuote(draftPreview, draftPreviewText, input)
await removeQuote.click()
await expect.poll(() => draftQuote.count()).toBe(0)
await expect.poll(() => input.textContent()).toBe('')
await expect.poll(() => page.evaluate(() => window.getSelection()?.isCollapsed ?? true)).toBe(true)
await page.keyboard.press('ControlOrMeta+z')
await expect.poll(() => draftQuote.count()).toBe(1)
await input.click()
await page.keyboard.press('ControlOrMeta+A')
await page.keyboard.press('ArrowRight')
await page.keyboard.press('Backspace')
await page.keyboard.press('Backspace')
await expect.poll(() => draftQuote.count()).toBe(0)
await page.keyboard.press('ControlOrMeta+z')
await expect.poll(() => draftQuote.count()).toBe(1)
await page.keyboard.press('ControlOrMeta+A')
await page.keyboard.press('ArrowLeft')
await page.keyboard.press('Delete')
await expect.poll(() => draftQuote.count()).toBe(0)
await page.keyboard.press('ControlOrMeta+z')
await expect.poll(() => draftQuote.count()).toBe(1)
await page.locator(`[data-row-key="session:${OTHER_ID}"]`).click()
await openQuoteSession(page)
await expect.poll(() => draftQuote.count()).toBe(1)
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const restoredQuote = page.locator('[data-composer-quote]')
await expect.poll(() => restoredQuote.count(), { timeout: 15_000 }).toBe(1)
const restoredPreview = restoredQuote.locator('[data-quote-preview]')
const restoredText = restoredQuote.locator('[data-quote-preview-text]')
await expect.poll(() => restoredPreview.getAttribute('role')).toBeNull()
await restoredText.click()
await restoredQuote.getByRole('button', { name: 'Remove quote' }).click()
await expect.poll(() => input.textContent()).toBe('')
const process = page.locator('[data-turn-process]').first()
if (await process.getAttribute('aria-expanded') === 'false') await process.click()
await page.getByRole('button', { name: 'Analysis completed', exact: true }).click()
const think = page.getByRole('button', { name: /Think/u })
await think.click()
const reasoningSource = page.locator('[data-text-interaction-source]').filter({ hasText: THINKING_TEXT })
await reasoningSource.waitFor()
await selectAllText(reasoningSource)
await expect.poll(() => quoteAction.isVisible()).toBe(true)
const beforeComposerScroll = await reasoningSource.evaluate((element) => {
const scroller = element.closest<HTMLElement>('[data-conversation-scroll]')!
const composer = scroller.querySelector<HTMLElement>('[data-composer-seat]')!
const previous = scroller.scrollTop
scroller.scrollTop -= composer.getBoundingClientRect().top - element.getBoundingClientRect().top + 16
return previous
})
await expect.poll(() => reasoningSource.evaluate((element) => {
const composer = element.closest('[data-conversation-scroll]')!.querySelector('[data-composer-seat]')!
return element.getBoundingClientRect().top >= composer.getBoundingClientRect().top
})).toBe(true)
await expect.poll(() => quoteAction.isVisible()).toBe(false)
expect(await page.locator('[data-text-interaction-surface] button').count()).toBe(1)
await page.locator('[data-conversation-scroll]').evaluate((element, top) => { element.scrollTop = top }, beforeComposerScroll)
await expect.poll(() => quoteAction.isVisible()).toBe(true)
await quoteAction.click()
await expect.poll(() => draftQuote.textContent()).toContain(THINKING_TEXT)
await think.click()
await expect.poll(() => reasoningSource.isVisible()).toBe(false)
const reasoningQuote = draftQuote.locator('[data-quote-preview-text]')
await expect.poll(() => draftQuote.locator('[data-quote-preview]').getAttribute('role')).toBeNull()
await reasoningQuote.click()
await expect.poll(() => reasoningSource.isVisible()).toBe(false)
await expect.poll(() => think.getAttribute('aria-expanded')).toBe('false')
const backToBottom = page.getByRole('button', { name: 'Back to bottom', exact: true })
if (await backToBottom.isVisible()) await backToBottom.click()
await expect.poll(() => backToBottom.isVisible()).toBe(false)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await draftQuote.getByRole('button', { name: 'Remove quote' }).click()
const loadEarlier = page.getByRole('button', { name: 'Load earlier' })
if (await loadEarlier.isVisible()) await loadEarlier.click()
await page.getByText('Filler message 3', { exact: true }).waitFor({ timeout: 15_000 })
await page.locator('[data-conversation-scroll]').evaluate((element) => {
element.scrollTop = 0
element.dispatchEvent(new Event('scroll'))
})
const syntaxSource = page.locator('[data-text-interaction-source]').filter({ hasText: DIRECT_SYNTAX_TEXT }).first()
await syntaxSource.waitFor({ timeout: 15_000 })
await syntaxSource.scrollIntoViewIfNeeded()
await selectAllText(syntaxSource)
await quoteAction.click()
const viewport = page.viewportSize()!
try {
await expectScrollableQuote(page, draftPreview, SELECTED_SYNTAX_TEXT)
await page.setViewportSize({ width: 420, height: 420 })
await expectScrollableQuote(page, draftPreview, SELECTED_SYNTAX_TEXT)
} finally {
await page.setViewportSize(viewport)
}
const beforeOffset = scaffold.ctx.agents.get(SessionId(SESSION_ID))!.session.seq
const settled = scaffold.whenTurnSettled()
await page.getByRole('button', { name: 'Send message' }).click()
const settledSessionId = await settled
await page.getByText(REPLY, { exact: true }).waitFor({ timeout: 20_000 })
const longSentQuote = page.locator('[data-message-bubble] [data-quote-preview]').filter({ hasText: SENT_SYNTAX_TEXT }).last()
try {
await expectScrollableQuote(page, longSentQuote, SENT_SYNTAX_TEXT)
await page.setViewportSize({ width: 420, height: 420 })
await expectScrollableQuote(page, longSentQuote, SENT_SYNTAX_TEXT)
} finally {
await page.setViewportSize(viewport)
}
expect(await longSentQuote.textContent()).toContain(LITERAL_CREDENTIAL)
const sentSource = longSentQuote.locator('[data-quote-preview-text]')
expect(await sentSource.textContent()).toBe(SENT_SYNTAX_TEXT)
expect(await sentSource.locator('*').count()).toBe(0)
const textAppearance = await quoteAppearance(sentSource)
const quoteStyle = await quoteAppearance(longSentQuote)
expect(textAppearance.color).toBe(quoteStyle.color)
expect(textAppearance.fontSize).toBe(quoteStyle.fontSize)
expect(textAppearance.lineHeight).toBe(quoteStyle.lineHeight)
expect(await longSentQuote.locator('[data-secret], [data-ref-chip], strong').count()).toBe(0)
const events = scaffold.ctx.agents.get(settledSessionId)!.session.snapshotEvents(beforeOffset)
expect(events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'skill-invocation')).toBe(true)
expect(events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'session-reference')).toBe(true)
const submitted = events.find(event => event.type === 'user/message'
&& event.data.source.kind === 'user')
expect(submitted).toMatchObject({
data: {
content: [{ type: 'text', text: formatQuoteText(SENT_SYNTAX_TEXT) }],
},
})
expect(submitted).not.toHaveProperty('data.source.quotes')
await expect.poll(() => input.textContent()).toBe('')
const persisted = await page.evaluate((key): unknown => JSON.parse(localStorage.getItem(key)!), CURRENT_DRAFT_KEY)
expect(persisted).toMatchObject({ draft: { text: '', references: [] } })
await page.reload({ waitUntil: 'load' })
await page.getByText(REPLY, { exact: true }).waitFor({ timeout: 20_000 })
await expectScrollableQuote(page, longSentQuote, SENT_SYNTAX_TEXT)
const literalQuote = await captureStableAria(
page, `[data-message-bubble]:has([data-quote-preview-text]):has-text("${LITERAL_CREDENTIAL}") >> nth=-1`, scaffold.workspaceCwd,
)
await compareOrRefreshGolden(UI_EXPECTED, `${snapshot}\n\n## Literal quote body\n\n${literalQuote}`, MODE)
await expect.poll(() => input.textContent()).toBe('')
expect(await page.locator('[data-slot-error]').count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
}, 120_000)
it('keeps adjacent quote previews exclusive while crossing their hover gaps', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-quote-hover-gap'))
const texts = ['First excerpt', 'Second excerpt'].map(line => Array.from({ length: 6 }, () => line).join('\n'))
const quoted = texts.map(formatQuoteText)
const separator = '\nx\n'
const draft = {
text: quoted[0]! + separator + quoted[1]!, references: [],
quotes: texts.map((text, index) => ({
text, offset: index === 0 ? 0 : quoted[0]!.length + separator.length, length: quoted[index]!.length,
})),
}
const viewport = page.viewportSize()!
try {
await page.evaluate(({ key, draft }) => {
localStorage.setItem(key, JSON.stringify({ draft, view: 'chat', viewRequest: null }))
}, { key: CURRENT_DRAFT_KEY, draft })
await page.reload({ waitUntil: 'load' })
const draftQuotes = page.locator('[data-composer-quote] [data-quote-preview]')
await expect.poll(() => draftQuotes.count()).toBe(2)
await crossQuoteGap(page, draftQuotes.last(), texts[1]!, 'above')
const settled = scaffold.whenTurnSettled()
await page.getByRole('button', { name: 'Send message' }).click()
await settled
await page.setViewportSize({ width: 600, height: 1000 })
const sentQuotes = page.locator('[data-message-bubble] [data-quote-preview]').filter({ hasText: 'First excerpt' })
await sentQuotes.last().waitFor()
await sentQuotes.last().evaluate((element) => { element.scrollIntoView({ block: 'center' }) })
await crossQuoteGap(page, sentQuotes.last(), texts[0]!, 'below')
expect(tripwire.pageErrors).toEqual([])
} finally {
await page.keyboard.press('Escape')
await page.setViewportSize(viewport)
}
})
})
+130
View File
@@ -0,0 +1,130 @@
// UI-only phone geometry for the Automation tasks page: no text field it owns
// sits under the 16px touch step, so the page never reads wider than the screen
// — iOS Safari zooms the visual viewport for a focused field below it.
import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
import { expect, it } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-schedule'
import { launchWebScaffold, watchConsole } from './scaffold.ts'
const PHONE_TASK_SESSION = SessionId('schedule-phone-web-e2e')
const PHONE_TASK_TITLE = 'Review overdue deployment'
const PHONE_PAGE_WIDTH = 390
/** The page's box against the screen, plus the font steps of its text fields. */
interface ViewFacts {
width: number
documentWidth: number
smallestField: number
name: string
search: string
instruction: string
}
/** Open the Automation tasks page and return its panel. */
async function openTaskManager(page: Page, url: string): Promise<Locator> {
await page.goto(url, { waitUntil: 'load' })
await page.getByRole('button', { name: '自动化任务', exact: true }).click()
const panel = page.getByTestId('task-manager-page')
await panel.getByRole('heading', { name: '自动化任务', exact: true }).waitFor({ timeout: 30_000 })
await page.evaluate(() => document.fonts.ready)
return panel
}
/** Read the facts above from the open view. */
async function viewFacts(page: Page): Promise<ViewFacts> {
return page.evaluate(() => {
const panel = document.querySelector('[data-testid="task-manager-page"]')
const fields = Array.from(panel?.querySelectorAll('input, textarea, select') ?? [])
const steps = fields.map(element => Number.parseFloat(getComputedStyle(element).fontSize))
const step = (selector: string): string => {
const element = document.querySelector(selector)
return element === null ? 'absent' : getComputedStyle(element).fontSize
}
return {
width: window.innerWidth,
documentWidth: document.documentElement.scrollWidth,
smallestField: steps.length === 0 ? -1 : Math.min(...steps),
name: step('[class*="_editName"]'),
search: step('[class*="_searchField"] input'),
instruction: step('[class*="_instruction"]'),
}
})
}
it('keeps the automation page on screen with its text fields at the touch step', async () => {
const scaffold = await launchWebScaffold()
const url = scaffold.authenticatedUrl
try {
await scaffold.ctx.schedule.create(PHONE_TASK_SESSION, {
prompt: 'Check the deployment log', title: PHONE_TASK_TITLE, at: '2099-08-25T12:08:00.000Z',
})
const browser: Browser = await chromium.launch()
try {
// A coarse pointer is the touch device, so the phone context takes the
// 16px step.
const phone = await browser.newContext({
viewport: { width: PHONE_PAGE_WIDTH, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true, locale: 'zh-CN',
})
const phonePage = await phone.newPage()
const phoneConsole = watchConsole(phonePage)
const phonePanel = await openTaskManager(phonePage, url)
await expect.poll(() => viewFacts(phonePage)).toEqual({
width: PHONE_PAGE_WIDTH,
documentWidth: PHONE_PAGE_WIDTH,
smallestField: 16,
name: 'absent',
search: '16px',
instruction: 'absent',
})
// The detail keeps the floor too, and the name control keeps the 20px step
// the floor must not flatten.
await phonePanel.getByRole('button', { name: PHONE_TASK_TITLE, exact: true }).click()
await phonePanel.locator('[class*="_detailScroll"]').waitFor({ timeout: 10_000 })
await expect.poll(() => viewFacts(phonePage)).toEqual({
width: PHONE_PAGE_WIDTH,
documentWidth: PHONE_PAGE_WIDTH,
smallestField: 16,
name: '20px',
search: '16px',
instruction: '16px',
})
await phone.close()
// A fine pointer keeps the compact steps: the floor is pointer-scoped.
const desktop = await browser.newContext({ viewport: { width: 1024, height: 800 }, locale: 'zh-CN' })
const desktopPage = await desktop.newPage()
const desktopConsole = watchConsole(desktopPage)
const desktopPanel = await openTaskManager(desktopPage, url)
await expect.poll(() => viewFacts(desktopPage)).toEqual({
width: 1024,
documentWidth: 1024,
smallestField: 14,
name: 'absent',
search: '14px',
instruction: 'absent',
})
await desktopPanel.getByRole('button', { name: PHONE_TASK_TITLE, exact: true }).click()
await desktopPanel.locator('[class*="_detailScroll"]').waitFor({ timeout: 10_000 })
await expect.poll(() => viewFacts(desktopPage)).toEqual({
width: 1024,
documentWidth: 1024,
smallestField: 14,
name: '20px',
search: '14px',
instruction: '14px',
})
await desktop.close()
expect(phoneConsole.pageErrors).toEqual([])
expect(phoneConsole.warnings).toEqual([])
expect(desktopConsole.pageErrors).toEqual([])
expect(desktopConsole.warnings).toEqual([])
} finally {
await browser.close()
}
} finally {
await scaffold.close()
}
}, 180_000)
+7 -7
View File
@@ -46,12 +46,12 @@ it.skipIf(webSnapshotMode() === 'record')('inspects a replayed Session log and C
await page.locator('[data-sidebar-right-guide-entry="session-inspector-log"]').click()
const mode = page.getByRole('combobox', { name: 'Presentation', exact: true })
expect(await mode.inputValue()).toBe('session-log')
const raw = page.getByRole('table', { name: 'Raw Log', exact: true })
const raw = page.getByRole('table', { name: 'Raw log', exact: true })
await raw.getByRole('button', { name: 'Filter types', exact: true }).click()
await page.getByRole('combobox', { name: 'Type keyword', exact: true }).fill('user/message')
await page.getByRole('option', { name: 'user/message', exact: true }).click()
await raw.getByRole('button', { name: 'user/message', exact: true }).first().click()
const panel = page.locator('section[aria-label="Raw Log"]')
const panel = page.locator('section[aria-label="Raw log"]')
const details = panel.getByRole('separator').locator('..').locator('ul').first()
expect(await details.innerText()).toContain(prompts[0])
expect(await details.locator('details:not([open])').count()).toBe(0)
@@ -64,14 +64,14 @@ it.skipIf(webSnapshotMode() === 'record')('inspects a replayed Session log and C
const replacements = [...new Set(times)].filter(value => /^\d{2}:\d{2}:\d{2}\.\d{3}$/.test(value))
.map(value => [value, '{{time}}'] as const)
await compareOrRefreshGolden(join(directory, 'log.expected.md'),
await captureStableAria(page, 'section[aria-label="Raw Log"] table', scaffold.workspaceCwd, { replacements }), webSnapshotMode())
await captureStableAria(page, 'section[aria-label="Raw log"] table', scaffold.workspaceCwd, { replacements }), webSnapshotMode())
await mode.selectOption('chat-node')
const groups = page.getByRole('table', { name: 'Chat Group', exact: true })
const groups = page.getByRole('table', { name: 'Chat group', exact: true })
expect(await groups.getByRole('columnheader', { name: 'Time (UTC)' }).count()).toBe(0)
await groups.getByRole('button', { name: 'assistant-step', exact: true }).first().click()
await page.getByRole('navigation', { name: 'Raw data navigation', exact: true }).waitFor()
const groupPanel = page.locator('section[aria-label="Chat Group"]')
await groupPanel.locator('summary').filter({ hasText: 'Node Data' }).click()
const groupPanel = page.locator('section[aria-label="Chat group"]')
await groupPanel.locator('summary').filter({ hasText: 'Node data' }).click()
const blocks = groupPanel.getByText('blocks:', { exact: true }).locator('..')
await blocks.click()
await blocks.locator('..').getByText('Other properties:', { exact: true }).waitFor()
@@ -104,7 +104,7 @@ it.skipIf(webSnapshotMode() === 'record')('highlights nearby Chat content when t
await page.getByText('DONE', { exact: true }).waitFor()
await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click()
await page.locator('[data-sidebar-right-guide-entry="session-inspector-log"]').click()
const table = page.getByRole('table', { name: 'Raw Log', exact: true })
const table = page.getByRole('table', { name: 'Raw log', exact: true })
await table.getByRole('button', { name: 'Filter types', exact: true }).click()
await page.getByRole('combobox', { name: 'Type keyword', exact: true }).fill('tool/call')
await page.getByRole('button', { name: 'Apply filter', exact: true }).click()
+105 -2
View File
@@ -84,6 +84,17 @@ describe('web e2e: settings modal and General preferences', () => {
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
const panelBox = await dialog.boundingBox()
const optionsBox = await dialog.locator('[class*="_options"]').boundingBox()
const firstSectionBox = await dialog.getByRole('navigation').getByRole('button').first().boundingBox()
expect(optionsBox!.y - panelBox!.y).toBe(54)
expect(firstSectionBox!.y - panelBox!.y).toBe(64)
const lastSection = dialog.getByRole('navigation').getByRole('button').last()
await lastSection.focus()
await page.keyboard.press('Tab')
expect(await dialog.getByRole('button', { name: '打开配置文件' }).evaluate(el => el === document.activeElement)).toBe(true)
await page.keyboard.press('Tab')
expect(await dialog.getByRole('button', { name: '关闭', exact: true }).evaluate(el => el === document.activeElement)).toBe(true)
// General is active by default; Permission, Language and Appearance are functional.
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
await dialog.getByRole('button', { name: '工作区内修改' }).waitFor({ timeout: 10_000 })
@@ -210,7 +221,7 @@ describe('web e2e: settings modal and General preferences', () => {
await page.keyboard.press('Escape')
await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
// Close path 2: the header close button (focus lands there on open).
// Close path 2: the header close button.
await openSettings(page, 'zh')
await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '关闭' }).click()
await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
@@ -227,7 +238,7 @@ describe('web e2e: settings modal and General preferences', () => {
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 title = dialog.getByText('设置', { exact: true })
const options = dialog.locator('[class*="_options"]')
const last = navigation.getByRole('button').last()
await last.waitFor()
@@ -261,6 +272,98 @@ describe('web e2e: settings modal and General preferences', () => {
expect(console.warnings).toEqual([])
})
it('fills a phone viewport and turns the rail into the horizontal section strip', async () => {
const phone = await browser.newPage({ viewport: { width: 390, height: 844 }, locale: ZH_BROWSER_LOCALE })
onTestFinished(() => phone.close())
onTestFailed(() => saveSettingsFailureShot(phone, 'web-e2e-settings-phone'))
const console = watchConsole(phone)
await phone.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await openSettings(phone, 'zh')
const dialog = phone.getByRole('dialog', { name: '设置', exact: true })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '通用设置', exact: true }).waitFor({ timeout: 10_000 })
// The panel is the viewport: no centered card, no side margins, no rail.
await expect.poll(() => dialog.boundingBox()).toEqual({ x: 0, y: 0, width: 390, height: 844 })
// The rail reads left to right on one line instead of stacking.
const navigation = dialog.getByRole('navigation')
const list = navigation.locator('[class*="_navList"]')
const cells = navigation.getByRole('button')
const first = await cells.first().boundingBox()
const second = await cells.nth(1).boundingBox()
expect(first).not.toBeNull()
expect(second).not.toBeNull()
expect(second!.x).toBeGreaterThanOrEqual(first!.x + first!.width - 1)
expect(Math.abs(second!.y - first!.y)).toBeLessThan(1)
expect(await list.evaluate(el => getComputedStyle(el).overflowX)).toBe('auto')
// The shipped sections overflow the strip, which scrolls sideways; the
// section column starts below it and keeps the panel's width.
const strip = await list.boundingBox()
const options = dialog.locator('[class*="_options"]')
const optionsBox = await options.boundingBox()
expect(strip).not.toBeNull()
expect(optionsBox).not.toBeNull()
expect(await list.evaluate(el => el.scrollWidth)).toBeGreaterThan(await list.evaluate(el => el.clientWidth))
expect(optionsBox!.y).toBeGreaterThanOrEqual(strip!.y + strip!.height - 1)
expect(Math.round(optionsBox!.width)).toBe(390)
expect(await phone.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
// Tapping a cell swaps the page, and a General row keeps its label and
// control on one line rather than stacking.
await cells.nth(1).click()
await expect.poll(() => cells.nth(1).getAttribute('aria-current')).toBe('true')
await expect.poll(() => dialog.getByText('权限', { exact: true }).count()).toBe(0)
await cells.first().click()
await expect.poll(() => dialog.getByText('权限', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
const permissionRow = dialog.getByText('权限', { exact: true }).locator('..')
const permissionBox = await permissionRow.boundingBox()
const controlBox = await dialog.getByRole('button', { name: '工作区内修改' }).boundingBox()
expect(controlBox).not.toBeNull()
expect(permissionBox).not.toBeNull()
expect(controlBox!.y).toBeLessThan(permissionBox!.y + permissionBox!.height)
// The Appearance cubes share one row instead of wrapping to two.
const cubes = dialog.getByRole('button', { name: /浅色|深色|跟随系统/ })
const cubeBoxes = await cubes.evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect().top))
expect(new Set(cubeBoxes.map(top => Math.round(top))).size).toBe(1)
await cells.first().focus()
for (let index = 1; index < await cells.count(); index += 1) {
await phone.keyboard.press('Tab')
}
const last = cells.last()
expect(await last.evaluate(el => el === document.activeElement)).toBe(true)
const lastBox = await last.boundingBox()
// Scroll offsets can round while glyph widths remain fractional.
expect(Math.round(lastBox!.x)).toBeGreaterThanOrEqual(0)
expect(Math.round(lastBox!.x + lastBox!.width)).toBeLessThanOrEqual(390)
await last.click()
await expect.poll(() => last.getAttribute('aria-current')).toBe('true')
await phone.keyboard.press('Escape')
await dialog.waitFor({ state: 'hidden' })
await openSettings(phone, 'zh')
const active = dialog.locator('[aria-current="true"]')
expect(await active.textContent()).toBe('通用设置')
expect(await active.evaluate(el => el === document.activeElement)).toBe(true)
const close = dialog.getByRole('button', { name: '关闭', exact: true })
const closeBox = await close.boundingBox()
expect(closeBox!.width).toBe(28)
expect(closeBox!.x).toBeGreaterThanOrEqual(0)
expect(closeBox!.y).toBeGreaterThanOrEqual(0)
expect(closeBox!.x + closeBox!.width).toBeLessThanOrEqual(390)
expect(closeBox!.y + closeBox!.height).toBeLessThanOrEqual(844)
await close.click()
await dialog.waitFor({ state: 'hidden' })
const trigger = phone.getByRole('button', { name: '账号菜单', exact: true })
await expect.poll(() => trigger.evaluate(el => el === document.activeElement)).toBe(true)
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'))
+5 -5
View File
@@ -42,7 +42,7 @@ it('toggles bottom DevTools from the page and iframe without a selected Session'
const close = panel.getByRole('button', { name: 'Collapse', exact: true })
expect(await close.textContent()).toBe('')
expect(await close.locator('svg').count()).toBe(1)
const divider = panel.getByRole('separator', { name: 'Resize NodeJS Inspector panel' })
const divider = panel.getByRole('separator', { name: 'Resize NodeJS inspector panel' })
const drag = async (deltaY: number): Promise<void> => {
const box = (await divider.boundingBox())!
const x = box.x + box.width / 2
@@ -93,10 +93,10 @@ it.each([
openSidebar: '打开右侧边栏', sidebarDescription: '在侧边栏分析当前会话原始日志和聊天分组数据',
},
{
locale: 'en-US', file: 'en', plugins: 'Plugins', open: 'View Developer Tools', bundle: 'Developer Tools',
locale: 'en-US', file: 'en', plugins: 'Plugins', open: 'View Developer tools', bundle: 'Developer tools',
description: 'Inspect and debug raw session data, grouped chat messages, and the NodeJS backend.',
inspector: 'NodeJS Inspector', inspectorDescription: 'Press Ctrl/Cmd+Shift+. to open Chrome DevTools in the bottom panel for development and debugging.',
session: 'Session Log', sessionDescription: "Enable analysis of the current session's raw data and grouped chat messages in the session sidebar.",
inspector: 'NodeJS inspector', inspectorDescription: 'Press Ctrl/Cmd+Shift+. to open Chrome DevTools in the bottom panel for development and debugging.',
session: 'Session log', sessionDescription: "Enable analysis of the current session's raw data and grouped chat messages in the session sidebar.",
openSidebar: 'Open right sidebar', sidebarDescription: 'Analyze raw logs and grouped chat messages for the current session in the sidebar.',
},
])('shows the Inspector bundle and component copy in $locale', async (copy) => {
@@ -150,7 +150,7 @@ it.each([
` description: ${await sessionGuide.getByText(copy.sidebarDescription, { exact: true }).innerText()}`,
`- NodeJS bottom: ${await bottomTitle.innerText()}`)
await sessionGuide.click()
await page.getByRole('table', { name: copy.locale === 'zh-CN' ? '原始数据' : 'Raw Log', exact: true }).waitFor()
await page.getByRole('table', { name: copy.locale === 'zh-CN' ? '原始数据' : 'Raw log', exact: true }).waitFor()
await compareOrRefreshGolden(fileURLToPath(new URL(`./expected/shared-inspector/metadata-${copy.file}.expected.md`, import.meta.url)),
snapshot.join('\n'), webSnapshotMode())
})
@@ -485,6 +485,7 @@ const EXPECTED_TOOLS = [
'job_output',
'list_agents',
'list_subagent_models',
'plugin_manager',
'present',
'read',
'read_image',
@@ -1,4 +1,4 @@
- dialog "Agent Team":
- dialog "Agent team":
- heading "Members" [level=3]
- button "lead Current chat Inactive" [disabled]
- heading "Shared tasks 2" [level=3]
@@ -1,4 +1,4 @@
- dialog "Agent Team":
- dialog "Agent team":
- heading "Members" [level=3]
- button "lead Current chat Inactive" [disabled]
- heading "Shared tasks 1" [level=3]
+4 -3
View File
@@ -333,7 +333,8 @@ export function conversationContextKey(kind: string, id: string): string {
* @param page - browser page with the mounted sidebar.
* @param locale - current UI language.
*/
export async function openSettings(page: Page, locale: 'en' | 'zh'): Promise<void> {
await page.getByRole('button', { name: locale === 'zh' ? '账号菜单' : 'Account menu', exact: true }).click()
await page.getByRole('menuitem', { name: locale === 'zh' ? '设置' : 'Settings', exact: true }).click()
export async function openSettings(page: Page, locale: 'en' | 'zh' | 'ja'): Promise<void> {
const labels = { en: ['Account menu', 'Settings'], zh: ['账号菜单', '设置'], ja: ['アカウントメニュー', '設定'] } as const
await page.getByRole('button', { name: labels[locale][0], exact: true }).click()
await page.getByRole('menuitem', { name: labels[locale][1], exact: true }).click()
}
+4 -4
View File
@@ -70,9 +70,9 @@ it('persists the Git Worktrees switch and removes its capability while retaining
}
let panel = await openPlugins()
let card = panel.locator(`[data-plugin-group="more"] [data-plugin-package="${BUNDLE}"]`)
await card.getByRole('button', { name: 'View Git Worktrees', exact: true }).waitFor()
await card.getByRole('button', { name: 'View Git worktrees', exact: true }).waitFor()
expect(await card.getByText('Experimental', { exact: true }).count()).toBe(1)
let toggle = card.getByRole('switch', { name: 'Enable Git Worktrees', exact: true })
let toggle = card.getByRole('switch', { name: 'Enable Git worktrees', exact: true })
expect(await toggle.getAttribute('aria-checked')).toBe('false')
expect(await selectedBundles()).not.toContain(BUNDLE)
expect(rows()).toEqual([])
@@ -92,7 +92,7 @@ it('persists the Git Worktrees switch and removes its capability while retaining
await page.reload({ waitUntil: 'load' })
panel = await openPlugins()
card = panel.locator(`[data-plugin-group="more"] [data-plugin-package="${BUNDLE}"]`)
toggle = card.getByRole('switch', { name: 'Enable Git Worktrees', exact: true })
toggle = card.getByRole('switch', { name: 'Enable Git worktrees', exact: true })
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true')
expect(tools()).toContain('create_worktree')
await toggle.click()
@@ -108,7 +108,7 @@ it('persists the Git Worktrees switch and removes its capability while retaining
await page.reload({ waitUntil: 'load' })
panel = await openPlugins()
await expect.poll(() => panel.getByRole('switch', { name: 'Enable Git Worktrees', exact: true })
await expect.poll(() => panel.getByRole('switch', { name: 'Enable Git worktrees', exact: true })
.getAttribute('aria-checked')).toBe('false')
expect(tools()).not.toContain('create_worktree')
expect(await selectedBundles()).not.toContain(BUNDLE)
+3
View File
@@ -23,6 +23,7 @@
// this client-registered project must not also hold them (one program
// cannot see both sides of the cordis Context merges).
"exclude": [
"tests/rich-text-quotes.e2e.ts",
"tests/hover-input.e2e.ts",
"tests/collapse-timing.e2e.ts",
"tests/notice-wake-tail.e2e.ts",
@@ -89,6 +90,7 @@
"tests/cua-optin.e2e.ts",
"tests/cua-permissions.e2e.ts",
"tests/schedule-header.e2e.ts",
"tests/schedule-phone.e2e.ts",
"tests/worktree-opt-in.e2e.ts",
"tests/session-inspector.e2e.ts",
"tests/shared-inspector.e2e.ts",
@@ -150,6 +152,7 @@
"tests/ptc-round.e2e.ts",
"tests/present.e2e.ts",
"tests/present-svg.e2e.ts",
"tests/present-after-closing-reply.e2e.ts",
"tests/composer-draft-scroll.e2e.ts",
"tests/composer-focus.e2e.ts",
"tests/composer-placeholder.e2e.ts",
@@ -30,7 +30,6 @@ import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline
import TokenMeter from '@deepseek-ai/dsh-token-meter'
// These Host-only adapters have no public Node export and are compiled into the benchmark worker.
import { SessionHistoryController } from '../../packages/api/session-controller/src/history.ts'
import { modelSelectionProjection } from '../../packages/api/session-controller/src/model-selection-projection.ts'
import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts'
import { SYNTHETIC_SESSION_ID } from './session-open.constants.ts'
@@ -131,7 +130,6 @@ function memoryDelta(
async function installProjectionSet(ctx: Context): Promise<void> {
// Mirrors projection owners mounted by the base and web-app bundles without timing profile boot.
await ctx.sessionProjections.register(agentPresetProjectionDefinition)
await ctx.sessionProjections.register(modelSelectionProjection)
await ctx.plugin(SessionTitleService, {
fallbackMaxWords: 5,
fallbackMaxBytes: 40,
+4 -4
View File
@@ -24,11 +24,11 @@
en: e89802f066e4955d
zh: ea2fb4bef94e086d
/deepseek-harness-architecture/turn-flow:
en: 5ba2d921295f4254
zh: a09fa21b8882fb25
en: 53db218880e07b3d
zh: 17cffbc6258b57a8
/deepseek-harness-architecture/session-log:
en: f1729d805c20df36
zh: 6b71b6039663e2e7
en: 8a7f19b95d426c99
zh: d5bb0538404e20ea
/deepseek-harness-architecture/capability-seams:
en: a5bc20c3f2a1b370
zh: 91773a281410b2f4
+3 -3
View File
@@ -83,7 +83,7 @@ A **step** is one model request plus the tools it calls. A **turn** is zero or m
```text
turn/start
claim next-step input plus one queued message
assemble prompt sections + tool schemas
snapshot Session model selection; assemble prompt sections + tool schemas
-> agent/pre-step; project runtime context at fallback
reject | enter(messages, startsRequestSeries?)
reject, or a first enter rewritten empty -> close the turn with no step
@@ -107,7 +107,7 @@ turn/end
One inbox feeds the driver; injected context waits for a waking message. AgentLoop’s durable `inbox` projection exposes pending input without live Agents.
`agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Each attempt refreshes registered runtime facts, reconciles the bound prompt, admits users once, and restores removed runtime context, logs header/context as needed, and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes append non-empty updates after cached history, including supported tool updates; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)).
The loop snapshots Session model selection before assembly and shares it through `AssembleContext.modelSelection` and `agent/pre-step.modelSelection`; retries retain that selection, and omitted reasoning effort uses adapter defaults. `agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, the deprecated `agent/request` hook adjusts controls without changing the provider, model, or effort selection, and `prepareCall()` binds the adapter before the system prompt and accepted users are committed; cancellation during either async phase commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Each attempt refreshes registered runtime facts, reconciles the bound prompt, admits users once, and restores removed runtime context, logs header/context as needed, and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The system prompt travels as `system/message` history: empty text clears it, capable routes append updates, and other routes consolidate it at the first system node. [Agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) owns prompt admission and replacement details.
The loop sends immutable requests with live cancellation and reuses freeze evidence for frozen identities; [agent-loop](../packages/core/agent-loop/README.md) owns construction and cancellation causes.
@@ -125,7 +125,7 @@ Session consumers know only the current logical format. Header-only `stat` and `
**Model-visible means logged.** Every model request must be reconstructable from the log. New model-visible inputs require session events. Plugins changing existing message content register [pure message projections](subsystems/session.md#plugin-owned-message-projections); detached readers supply the same definitions explicitly. Tool changes are capability-independent; [Session tool history](../packages/core/session/README.md) supplies provider declarations.
**Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, and carriers batch cropped client views with `snapshot()`. Domain services await registration during activation, retain private handles, and expose business methods for other plugins. Releasing registration removes global participation; retained handles continue folding Session events. `AgentRegistry` owns turn and step state and exposes `turnBoundaryOf(session)` ([registration lifecycle](../packages/session/session-projection/README.md)).
**Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, and carriers batch cropped client views with `snapshot()`. Domain services await registration during activation, retain private handles, and expose business methods for other plugins. Releasing registration removes global participation; retained handles continue folding Session events. `AgentRegistry` owns turn and step state and exposes `turnBoundaryOf(session)` ([registration lifecycle](../packages/session/session-projection/README.md)). `AgentLoop` owns `modelSelection` and constructs each Agent’s `model` instance alongside its inbox.
[Working directories](subsystems/working-directory.md) supply user context and execution paths without changing original project identity, sandbox write roots, or existing process directories.
+3 -3
View File
@@ -87,7 +87,7 @@ AgentLoop 在启动已排队工作前等待串行 `agent/created`,初始化失
```text
turn/start
claim next-step input plus one queued message
assemble prompt sections + tool schemas
snapshot Session model selection; assemble prompt sections + tool schemas
-> agent/pre-step; project runtime context at fallback
reject | enter(messages, startsRequestSeries?)
reject, or a first enter rewritten empty -> close the turn with no step
@@ -111,7 +111,7 @@ turn/end
输入通过同一个 inbox 到达驱动器;注入的上下文等待一条唤醒消息。AgentLoop 的持久 `inbox` 投影使待处理输入在没有活跃 Agent 时仍可读取。
`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试刷新已注册运行时事实、协调绑定的提示词、仅接纳一次用户消息并恢复被移除的运行时上下文、按需记录 header/context,再冻结请求并通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新,包括同时发生的受支持工具更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。
循环在组装前快照 Session 模型选择,并通过 `AssembleContext.modelSelection` 与 `agent/pre-step.modelSelection` 传递该选择;重试保留该选择,省略推理强度时使用适配器默认值。`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,已弃用的 `agent/request` hook 在不改变提供方、模型或推理强度选择的前提下调整控制参数,`prepareCall()` 绑定适配器,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试刷新已注册运行时事实、协调绑定的提示词、仅接纳一次用户消息并恢复被移除的运行时上下文、按需记录 header/context,再冻结请求并通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。系统提示词通过 `system/message` 历史传递:空文本清除提示词,具备能力的路由追加更新,其他路由将其归并到首个系统节点。[Agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation) 说明提示词准入与替换的细节。
循环发送不可变请求,同时保持取消有效,仅对完全冻结的对象复用冻结证据;[agent-loop](../packages/core/agent-loop/README.zh.md) 负责请求构建与取消原因。
@@ -129,7 +129,7 @@ Session 消费方只了解当前逻辑格式。仅 header 的 `stat` 与 `list`
**模型可见即已记录。** 每个模型请求都必须能从日志重建。新增模型可见输入需要会话事件。修改现有消息内容的插件注册[纯消息投影](subsystems/session.zh.md#plugin-owned-message-projections),独立读取器显式传入相同的处理器。 工具变更不依赖能力;[Session 工具历史](../packages/core/session/README.zh.md)提供提供方声明。
**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。领域服务在激活时等待注册完成,在内部保留句柄,并向其他插件提供业务方法。释放注册会移除全局参与关系;已有句柄仍可继续折叠 Session 事件。`AgentRegistry` 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`([注册生命周期](../packages/session/session-projection/README.zh.md))。
**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。领域服务在激活时等待注册完成,在内部保留句柄,并向其他插件提供业务方法。释放注册会移除全局参与关系;已有句柄仍可继续折叠 Session 事件。`AgentRegistry` 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`([注册生命周期](../packages/session/session-projection/README.zh.md))。 `AgentLoop` 持有 `modelSelection`,并与 inbox 一起构建每个 Agent 的 `model` 实例。
[工作目录](subsystems/working-directory.zh.md) 提供用户上下文和执行路径,不改变原始项目标识、沙箱写入根目录或已有进程目录。
Loaded 100 of 917 files, more files were not shown because too many files have changed in this diff. Show more