mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
Merge latest master into xtr/ptc-vm-script
This commit is contained in:
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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、配置与诊断:未变化的已有故障保留为警告,本次影响到的新故障使操作失败。显式启用的目标必须成功激活。
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
@@ -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.
|
||||
|
||||
+2
@@ -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 预览不能证明应用内结果。
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
en: 2ba6c43b20a3d347
|
||||
zh: 5647179ddaa38ad9
|
||||
/agent-note-ship-experimental-capabilities-as-optional-bundles/decision:
|
||||
en: d6328ba2b218f2ac
|
||||
zh: 3731561313b1ad5e
|
||||
en: 191ca8bffb101db2
|
||||
zh: 1365bef5cd830a5c
|
||||
/agent-note-ship-experimental-capabilities-as-optional-bundles/alternatives-considered:
|
||||
en: d6f40bf02df37ab7
|
||||
zh: e5b85ce9d41e8754
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ The Web plugin page offered two optional bundles, Agent Teams and voice input. A
|
||||
|
||||
## Decision
|
||||
|
||||
`OPTIONAL_BUNDLES` owns the installation's default-off selections, including maintained optional capabilities and experimental capabilities. Every bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`; the Official group displays that metadata. A bundle's presentation does not determine its [experimental status](../../../../packages/experimental/README.md#status). The isolation check validates admission and still rejects experimental dependencies of nonexperimental bundles.
|
||||
`OPTIONAL_BUNDLES` owns the installation's default-off selections, including maintained optional capabilities and experimental capabilities. Every bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`; the [plugin manager page](../../../../packages/client/ui-plugin-manager/README.md#use-this-package) displays that metadata for eligible bundles and retains explicit activation for default-hidden ones. A bundle's presentation does not determine its [experimental status](../../../../packages/experimental/README.md#status). The isolation check validates admission and still rejects experimental dependencies of nonexperimental bundles.
|
||||
|
||||
An optional bundle's dependencies are downloaded with every `dsh` installation. [Reasoning translation](2026-10-05-anonymous-reasoning-translation.md) uses three experimental packages without adding third-party runtime dependencies. Admission therefore considers installation cost and whether a switch supplies a usable composition without an additional configuration form. External browser and Cua Driver providers remain explicit compositions because they add substantial runtimes or require an external executable. Stagehand requires model credentials; Python PTC replaces a core provider and conflicts with TypeScript workflow consumers. Demonstrative hooks, webhooks, and mods stay outside the list. [Session Inspector](../feature/2026-09-24-session-inspector.md) owns the Inspector's distinct activation behavior.
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r
|
||||
|
||||
## 决策
|
||||
|
||||
`OPTIONAL_BUNDLES` 管理安装随附且默认关闭的选项,包括维护中的可选能力与实验性能力。每个 bundle 声明 `icon`,并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`;官方分组显示这些元数据。Bundle 的展示方式不决定其[实验性状态](../../../../packages/experimental/README.zh.md#status)。隔离检查校验准入,并继续拒绝非实验 bundle 的实验性依赖。
|
||||
`OPTIONAL_BUNDLES` 管理安装随附且默认关闭的选项,包括维护中的可选能力与实验性能力。每个 bundle 声明 `icon`,并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`;[插件管理页](../../../../packages/client/ui-plugin-manager/README.zh.md#use-this-package)为符合展示条件的组合包显示这些元数据,并保留默认隐藏项的显式启用入口。Bundle 的展示方式不决定其[实验性状态](../../../../packages/experimental/README.zh.md#status)。隔离检查校验准入,并继续拒绝非实验 bundle 的实验性依赖。
|
||||
|
||||
可选 bundle 的依赖随每次 `dsh` 安装下载。[思考过程翻译](2026-10-05-anonymous-reasoning-translation.zh.md)使用三个实验包,不增加第三方运行时依赖。因此准入需要考虑安装成本,以及开关能否在不增加配置表单的情况下提供可用组合。外部浏览器与 Cua Driver 提供方仍使用显式组合,因为它们会增加较大的运行时或要求外部可执行文件。Stagehand 需要模型凭据;Python PTC 替换核心提供方,并与 TypeScript workflow 消费方冲突。演示性钩子、webhook 与模组不进入名单。[Session Inspector](../feature/2026-09-24-session-inspector.zh.md) 管理 Inspector 特有的激活行为。
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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 检查对已安装入口包跳过这些边,并要求列表中的每个组合包都已安装。
|
||||
|
||||
|
||||
+22
@@ -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
|
||||
+37
@@ -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.
|
||||
+37
@@ -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 继续按未改动的策略状态变化。
|
||||
+22
@@ -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 懒惰续行或代码围栏解析。来源导航仍需独立设计持久坐标。
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
checks: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -79,6 +79,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
@@ -5,7 +5,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')
|
||||
@@ -31,6 +31,7 @@ try {
|
||||
id: agentId,
|
||||
options: {},
|
||||
session,
|
||||
model: unsupportedModel(),
|
||||
inbox: unsupportedInbox(),
|
||||
status: 'idle',
|
||||
send: () => {},
|
||||
@@ -43,7 +44,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'
|
||||
|
||||
+1
@@ -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}}
|
||||
|
||||
@@ -300,7 +300,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',
|
||||
|
||||
@@ -72,5 +72,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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -59,13 +59,20 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
|
||||
callId: ToolCallId('cua-app-approval'), name: 'run_code',
|
||||
arguments: { 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).isError).toBe(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)
|
||||
})
|
||||
|
||||
@@ -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 "内置插件"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -35,7 +35,7 @@ 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 })
|
||||
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]')
|
||||
|
||||
@@ -9,7 +9,6 @@ import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { ON_DEMAND_BUNDLES, OPTIONAL_BUNDLES } from '@deepseek-ai/dsh-app-boot'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
@@ -111,8 +110,10 @@ describe('web e2e: plugin configuration pages', () => {
|
||||
await more.focus()
|
||||
await page.keyboard.press('Enter')
|
||||
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(OPTIONAL_BUNDLES.length + ON_DEMAND_BUNDLES.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)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { FiberState } from '@deepseek-ai/cordis'
|
||||
import { ON_DEMAND_BUNDLES, OPTIONAL_BUNDLES } from '@deepseek-ai/dsh-app-boot'
|
||||
import { readProfileManifest } from '@deepseek-ai/dsh-app-boot'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
@@ -664,8 +664,17 @@ describe('web e2e: plugin manager', () => {
|
||||
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()
|
||||
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(OPTIONAL_BUNDLES.length + ON_DEMAND_BUNDLES.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 })
|
||||
expect(await panel.locator(`[data-plugin-package="${name}"]`).count()).toBe(0)
|
||||
}
|
||||
const cards = await panel.locator('[data-plugin-group="more"] [data-plugin-package]').all()
|
||||
expect(await Promise.all(cards.map(async card => [
|
||||
await card.getAttribute('data-plugin-package'),
|
||||
@@ -678,11 +687,8 @@ describe('web e2e: plugin manager', () => {
|
||||
['@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-search', 1],
|
||||
['@deepseek-ai/dsh-experimental-session-titles-bundle', 1],
|
||||
['@deepseek-ai/dsh-experimental-badge-skill-bundle', 1],
|
||||
['@deepseek-ai/dsh-experimental-ralph-bundle', 1],
|
||||
['@deepseek-ai/dsh-experimental-plugin-market-bundle', 1],
|
||||
['@deepseek-ai/dsh-subagent-codex', 0],
|
||||
['@deepseek-ai/dsh-subagent-claude-code', 0],
|
||||
@@ -703,6 +709,36 @@ describe('web e2e: plugin manager', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('enables both default-hidden shipped bundles by name without adding a profile dependency', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-bundled-enable'))
|
||||
const profileDir = join(scaffold.harnessHome, 'profiles', 'scaffold')
|
||||
const before = readProfileManifest('dsh', profileDir).dependencies
|
||||
for (const [name, title] of [
|
||||
['@deepseek-ai/dsh-experimental-session-search', '会话搜索'],
|
||||
['@deepseek-ai/dsh-experimental-ralph-bundle', 'Ralph 循环'],
|
||||
] as const) {
|
||||
const panel = await openPluginsPanel()
|
||||
expect(await panel.locator(`[data-plugin-package="${name}"]`).count()).toBe(0)
|
||||
await panel.getByRole('button', { name: '添加插件', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '添加插件', exact: true })
|
||||
await dialog.getByRole('textbox', { name: '包名或地址', exact: true }).fill(name)
|
||||
await dialog.getByRole('button', { name: '安装', exact: true }).click()
|
||||
const ready = page.getByRole('dialog', { name: '插件已随 DSH 提供', exact: true })
|
||||
await ready.waitFor()
|
||||
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name)?.enabled).toBe(false)
|
||||
expect(await ready.getByText('无需下载,启用即可使用', { exact: true }).count()).toBe(1)
|
||||
await ready.getByRole('button', { name: '立即启用', exact: true }).click()
|
||||
const card = panel.locator(`[data-plugin-package="${name}"]`)
|
||||
await card.waitFor()
|
||||
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name)?.enabled).toBe(true)
|
||||
expect(readProfileManifest('dsh', profileDir).dependencies).toEqual(before)
|
||||
await card.getByRole('switch', { name: `启用 ${title}`, exact: true }).click()
|
||||
await card.waitFor({ state: 'hidden' })
|
||||
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name)?.enabled).toBe(false)
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps off-state switch thumbs light in both themes', async () => {
|
||||
// The off-state thumb must stay light so an operable toggle cannot read as
|
||||
// disabled, whose only other difference is reduced opacity.
|
||||
@@ -887,14 +923,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.
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -484,6 +484,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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
en: e89802f066e4955d
|
||||
zh: ea2fb4bef94e086d
|
||||
/deepseek-harness-architecture/turn-flow:
|
||||
en: e6402b28c0ed15fa
|
||||
zh: 6c07be9047b42056
|
||||
en: 4151c0b69f563f4e
|
||||
zh: 32c62b6326446c10
|
||||
/deepseek-harness-architecture/session-log:
|
||||
en: f1729d805c20df36
|
||||
zh: 6b71b6039663e2e7
|
||||
en: 8a7f19b95d426c99
|
||||
zh: d5bb0538404e20ea
|
||||
/deepseek-harness-architecture/capability-seams:
|
||||
en: a5bc20c3f2a1b370
|
||||
zh: 91773a281410b2f4
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -123,7 +123,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.
|
||||
|
||||
|
||||
@@ -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) 负责请求构建与取消原因。
|
||||
|
||||
@@ -127,7 +127,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) 提供用户上下文和执行路径,不改变原始项目标识、沙箱写入根目录或已有进程目录。
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Each package entry labels its data with three identifiers: `inject` lists the se
|
||||
|
||||
- `inject`: `agents` · `llm` · `sessionPersistence` · `sessions`
|
||||
- `refs`: `Stream` (`@agentclientprotocol/sdk`)
|
||||
- `source`: [`packages/acp/acp/src/index.ts:75`](../packages/acp/acp/src/index.ts)
|
||||
- `source`: [`packages/acp/acp/src/index.ts:74`](../packages/acp/acp/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: the provider/model selection used for each ACP-created agent. */
|
||||
@@ -96,7 +96,7 @@ export interface Config {
|
||||
|
||||
- `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections`
|
||||
- `refs`: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md) · `Volatile` (`@deepseek-ai/cosmokit`)
|
||||
- `source`: [`packages/core/agent-loop/src/index.ts:250`](../packages/core/agent-loop/src/index.ts)
|
||||
- `source`: [`packages/core/agent-loop/src/index.ts:253`](../packages/core/agent-loop/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Agent-loop plugin configuration. */
|
||||
@@ -106,7 +106,7 @@ export interface Config {
|
||||
* omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
|
||||
*/
|
||||
maxParallelToolCalls: Volatile<number>
|
||||
/** Agents created or resumed at plugin startup. */
|
||||
/** Agents created or resumed at plugin startup; a resumed agent records its configured route as the Session's model selection. */
|
||||
agents: (AgentOptions & {
|
||||
/** Stable config label used in logs and as the fresh combined-id prefix. */
|
||||
id: string
|
||||
@@ -2606,7 +2606,7 @@ export interface Config {
|
||||
maxOutputBytes?: number
|
||||
/** V8 old-generation heap limit in MiB; native allocations are excluded. */
|
||||
maxOldGenerationSizeMb?: number
|
||||
/** Maximum control frame, outstanding argument and queued control-output bytes. */
|
||||
/** Maximum control message, outstanding binding-argument and queued control-output bytes. */
|
||||
maxMessageBytes?: number
|
||||
/** Maximum simultaneous host binding calls accepted from a program. */
|
||||
maxPendingCalls?: number
|
||||
@@ -4787,6 +4787,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
| `@deepseek-ai/dsh-llm` | — | [`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-lsp` | — | [`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-mcp-resources` | `tools` | [`packages/mcp/mcp-resources/src/index.ts`](../packages/mcp/mcp-resources/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-message-quotes` | — | [`packages/session/message-quotes/src/index.ts`](../packages/session/message-quotes/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-otel` | — | [`packages/telemetry/otel/src/index.ts`](../packages/telemetry/otel/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-sandbox-ssh` | `ssh` | [`packages/ssh/sandbox-ssh/src/index.ts`](../packages/ssh/sandbox-ssh/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-session` | — | [`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts) |
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
- `inject`: `agents` · `llm` · `sessionPersistence` · `sessions`
|
||||
- `refs`: `Stream` (`@agentclientprotocol/sdk`)
|
||||
- `source`: [`packages/acp/acp/src/index.ts:75`](../packages/acp/acp/src/index.ts)
|
||||
- `source`: [`packages/acp/acp/src/index.ts:74`](../packages/acp/acp/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: the provider/model selection used for each ACP-created agent. */
|
||||
@@ -98,7 +98,7 @@ export interface Config {
|
||||
|
||||
- `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections`
|
||||
- `refs`: [`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md) · `Volatile` (`@deepseek-ai/cosmokit`)
|
||||
- `source`: [`packages/core/agent-loop/src/index.ts:250`](../packages/core/agent-loop/src/index.ts)
|
||||
- `source`: [`packages/core/agent-loop/src/index.ts:253`](../packages/core/agent-loop/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Agent-loop plugin configuration. */
|
||||
@@ -108,7 +108,7 @@ export interface Config {
|
||||
* omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
|
||||
*/
|
||||
maxParallelToolCalls: Volatile<number>
|
||||
/** Agents created or resumed at plugin startup. */
|
||||
/** Agents created or resumed at plugin startup; a resumed agent records its configured route as the Session's model selection. */
|
||||
agents: (AgentOptions & {
|
||||
/** Stable config label used in logs and as the fresh combined-id prefix. */
|
||||
id: string
|
||||
@@ -2608,7 +2608,7 @@ export interface Config {
|
||||
maxOutputBytes?: number
|
||||
/** V8 old-generation heap limit in MiB; native allocations are excluded. */
|
||||
maxOldGenerationSizeMb?: number
|
||||
/** Maximum control frame, outstanding argument and queued control-output bytes. */
|
||||
/** Maximum control message, outstanding binding-argument and queued control-output bytes. */
|
||||
maxMessageBytes?: number
|
||||
/** Maximum simultaneous host binding calls accepted from a program. */
|
||||
maxPendingCalls?: number
|
||||
@@ -4789,6 +4789,7 @@ export interface Config {
|
||||
| `@deepseek-ai/dsh-llm` | — | [`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-lsp` | — | [`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-mcp-resources` | `tools` | [`packages/mcp/mcp-resources/src/index.ts`](../packages/mcp/mcp-resources/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-message-quotes` | — | [`packages/session/message-quotes/src/index.ts`](../packages/session/message-quotes/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-otel` | — | [`packages/telemetry/otel/src/index.ts`](../packages/telemetry/otel/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-sandbox-ssh` | `ssh` | [`packages/ssh/sandbox-ssh/src/index.ts`](../packages/ssh/sandbox-ssh/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-session` | — | [`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts) |
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 0894bd9f35430753
|
||||
zh: 2d28090e71134edd
|
||||
/cookbook-adding-a-workspace-package/5-add-optional-plugin-display-metadata:
|
||||
en: ed501a72ab8ba6e7
|
||||
zh: 8ad775b735887a10
|
||||
en: cb799f5d561dd917
|
||||
zh: 7739af36ef8ffb3f
|
||||
/cookbook-adding-a-workspace-package/6-verify:
|
||||
en: 80c7fb90b1175aae
|
||||
zh: 2ba37962dbc57299
|
||||
|
||||
@@ -136,7 +136,7 @@ Merge these entries into `package.json`, retaining existing runtime exports and
|
||||
}
|
||||
```
|
||||
|
||||
Keep language files together in `locale/`, with `en.json` as the discovery entry. Fields are optional; present values must be non-empty strings. Missing files or fields permit fallback, while malformed JSON or invalid fields produce a per-plugin diagnostic.
|
||||
Keep language files together in `locale/`, with `en.json` as the discovery entry. Fields are optional; present values must be non-empty strings. English `meta.title` and `meta.description` use sentence case: capitalize only the first word, proper nouns, and product names, as in `Git worktrees` ([rule](../../.agents/skills/dsh-prose-standard/SKILL.md#required-coverage-by-prose-location)). Missing files or fields permit fallback, while malformed JSON or invalid fields produce a per-plugin diagnostic.
|
||||
|
||||
Fields fall back independently before view-specific name formatting, using the existing locale language chain first:
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex
|
||||
}
|
||||
```
|
||||
|
||||
语言文件统一放在 `locale/`,并保留 `en.json` 作为发现入口。字段可选,填写时必须是非空字符串。文件或字段缺失时允许回退,JSON 损坏或字段无效时显示单插件诊断。
|
||||
语言文件统一放在 `locale/`,并保留 `en.json` 作为发现入口。字段可选,填写时必须是非空字符串。英文 `meta.title` 和 `meta.description` 使用 sentence case:只大写首个单词、专有名词和产品名,例如 `Git worktrees`([rule](../../.agents/skills/dsh-prose-standard/SKILL.md#required-coverage-by-prose-location))。文件或字段缺失时允许回退,JSON 损坏或字段无效时显示单插件诊断。
|
||||
|
||||
各字段先独立回退,再按页面规则格式化技术名称;回退优先经过现有 locale 语言链:
|
||||
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
en: f0a28d07c0bb4d1b
|
||||
zh: 840962f512cb4031
|
||||
/cookbook-extension-plugin-shapes/the-feature-mechanism-map:
|
||||
en: fa917d46c071fad2
|
||||
zh: 4062805237e2c847
|
||||
en: af09d0fbb5f39df9
|
||||
zh: 189166baa84868bc
|
||||
|
||||
@@ -103,7 +103,7 @@ Every product feature maps to a listener on a documented extension point — the
|
||||
|
||||
| Product feature | Plugin mechanism |
|
||||
|---|---|
|
||||
| Hook system (user + project level) | listeners on `agent/created`, `agent/pre-step`, `agent/request`, `tools/pre-execute`, `tools/post-execute`, and `agent/turn-stopping`; the waterfalls return typed decisions, while `agent/turn-stopping` may steer another step; the `dsh-hooks-claude-code` / `dsh-hooks-codex` bridges map hook config files onto these extension points |
|
||||
| Hook system (user + project level) | listeners on `agent/created`, `agent/pre-step`, `tools/pre-execute`, `tools/post-execute`, and `agent/turn-stopping`; the waterfalls return typed decisions, while `agent/turn-stopping` may steer another step; the `dsh-hooks-claude-code` / `dsh-hooks-codex` bridges map hook config files onto these extension points |
|
||||
| `/goal` | `ctx.goals` owns durable state, `dsh-goal-round-driver` schedules same-session rounds through the public `Agent`, and separate command/tool producers expose human/model control |
|
||||
| `/loop` | on the `turn/end` session event, `followup()` the next iteration; or force-continue |
|
||||
| Dynamic workflow | `ctx.workflowEngine` + the PTC workflow engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and the structured-output execution's monotonic `concludeTurn()` marker |
|
||||
|
||||
@@ -107,7 +107,7 @@ export function apply(ctx: Context) {
|
||||
|
||||
| 产品功能 | 插件机制 |
|
||||
|---|---|
|
||||
| 钩子系统(用户级 + 项目级) | `agent/created`、`agent/pre-step`、`agent/request`、`tools/pre-execute`、`tools/post-execute` 和 `agent/turn-stopping` 上的监听器;waterfall 返回类型化决策,`agent/turn-stopping` 则可通过 steering(中途引导)触发下一步;`dsh-hooks-claude-code` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些扩展点上 |
|
||||
| 钩子系统(用户级 + 项目级) | `agent/created`、`agent/pre-step`、`tools/pre-execute`、`tools/post-execute` 和 `agent/turn-stopping` 上的监听器;waterfall 返回类型化决策,`agent/turn-stopping` 则可通过 steering(中途引导)触发下一步;`dsh-hooks-claude-code` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些扩展点上 |
|
||||
| `/goal` | `ctx.goals` 管理持久状态,`dsh-goal-round-driver` 通过公共 `Agent` 调度同会话 Round,独立的命令/工具生产方分别提供人类/模型控制 |
|
||||
| `/loop` | 在 `turn/end` 会话事件上 `followup()` 下一次迭代;或强制继续 |
|
||||
| 动态工作流 | `ctx.workflowEngine` + PTC 工作流引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的提示词/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和结构化输出执行的单调 `concludeTurn()` 标记来强制输出 |
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 5fca19ac2bb1ddac
|
||||
zh: 00d78fe7e80ba4f7
|
||||
/cookbook-reviewing-persistence-type-changes/optional-inspect-the-change:
|
||||
en: 14049617e3de750d
|
||||
zh: 200456cf72bef77b
|
||||
en: 841a75ed33db17bd
|
||||
zh: ed4e80aae708bfb9
|
||||
/cookbook-reviewing-persistence-type-changes/1-record-the-change:
|
||||
en: de16b6e1ac883317
|
||||
zh: 21d367bb78e799d1
|
||||
|
||||
@@ -43,7 +43,7 @@ A recorded decision does not hide the transition from this comparison. `--check`
|
||||
|
||||
Record the commits supplying those files with the report. Add `--json` for structured output. This read-only comparison groups shared changes with their affected roots and uses actual literal `kind`/`form` values instead of union positions. Ambiguous alternatives remain separate additions and removals. Its compatibility section copies every root's authoritative classifier result; the structural explanation does not replace acknowledgement checks. Current catalog labels and declaration names are descriptive metadata; structural anchors and fingerprints identify types.
|
||||
|
||||
The current machine inventory stores complete graphs in `roots`. A `types` entry contains `digest`, `names`, and `sources`, plus an explicit `schema` when roots cannot reconstruct that graph exactly. Readers resolve omitted graphs from root subgraphs by digest and validate explicit graphs directly, preserving every type and its metadata. Historical full entries remain readable. `formatVersion` identifies normalization rules; storage compaction does not change root fingerprints or require an acknowledgement.
|
||||
Every inventory uses the [node-table layout](../persistence-changes/README.md#files-and-ownership); both comparison inputs must be generated by the current tooling. `formatVersion` identifies normalization rules; the storage layout does not change root fingerprints or require an acknowledgement.
|
||||
|
||||
<a id="acknowledge"></a>
|
||||
## 1. Record the change
|
||||
|
||||
@@ -43,7 +43,7 @@ pnpm --silent run persistence-review --before .artifacts/base.schema.json --afte
|
||||
|
||||
在报告旁记录这些文件对应的 commit。添加 `--json` 可获取结构化输出。此只读比较将共享变更与受影响的根类型归组,使用实际字面量 `kind`/`form` 值代替联合类型位置。无法唯一匹配的候选项保留为独立的新增与删除。兼容性部分复制每个根类型的权威分类结果;结构说明不替代确认检查。当前目录标签和声明名称是描述元数据;结构锚点和指纹标识类型。
|
||||
|
||||
当前机器清单在 `roots` 中保存完整图。每个 `types` 条目包含 `digest`、`names` 和 `sources`;若根无法精确重建该图,条目还会保存显式 `schema`。读取器按摘要从根的子图恢复省略的图,并直接验证显式图,保留每个类型及其元数据。历史完整条目仍然可读。`formatVersion` 标识规范化规则;存储压缩不改变根指纹,也不需要确认记录。
|
||||
所有清单都使用[节点表布局](../persistence-changes/README.zh.md#files-and-ownership);两个比较输入都必须由当前工具生成。`formatVersion` 标识规范化规则;存储布局不改变根指纹,也不需要确认记录。
|
||||
|
||||
<a id="acknowledge"></a>
|
||||
## 1. 记录变更
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
en: 24799f579b04b2d4
|
||||
zh: 97d4d82dc3a02c75
|
||||
/4-events/waterfall-transform-or-short-circuit:
|
||||
en: 8309aea415b395c3
|
||||
zh: 480ee4a163543dd0
|
||||
en: a0027cc61e674138
|
||||
zh: c96dd1e7d559b748
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user