mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
Merge master into fix/image-drag-plugin-ownership
保留文本交互和文件拖放注入,合并依赖及生成目录。
This commit is contained in:
commit
a0b45086da
2247 files changed
+55393
-676404
No files matched your search
+2
-2
@@ -24,8 +24,8 @@
|
||||
en: 26e4b71366bebd1b
|
||||
zh: 10598e12b28c586e
|
||||
/agent-note-cooperative-tool-cancellation-at-the-registry-boundary/verification:
|
||||
en: fbd101d4576afa92
|
||||
zh: 35f5d3c2f6cad6a8
|
||||
en: 983da6d0c09f5c10
|
||||
zh: ca7c86117a1a4d5f
|
||||
/agent-note-cooperative-tool-cancellation-at-the-registry-boundary/alternatives-considered:
|
||||
en: c4d86feb135d2ac4
|
||||
zh: 67c56c0bc2213949
|
||||
|
||||
@@ -46,7 +46,7 @@ This decision requires cancellation at the tool invocation boundary only. Making
|
||||
|
||||
## Verification
|
||||
|
||||
[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`ptc.spec.ts`](../../../../packages/core/tools/tests/ptc.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership.
|
||||
[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`ptc.spec.ts`](../../../../packages/core/tool-executor-ptc/tests/ptc.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership.
|
||||
|
||||
No registry test can prove that arbitrary third-party same-process code observes the signal or stops in bounded time. Capability tests continue to prove cancellation and quiescence at the boundary that owns each side effect.
|
||||
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ Status: implemented
|
||||
|
||||
## 验证
|
||||
|
||||
[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`ptc.spec.ts`](../../../../packages/core/tools/tests/ptc.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。
|
||||
[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`ptc.spec.ts`](../../../../packages/core/tool-executor-ptc/tests/ptc.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。
|
||||
|
||||
任何注册表测试都无法证明任意第三方同进程代码会观察信号或在有界时间内停止。各能力的测试仍需在拥有相应副作用的边界证明取消与完全停稳。
|
||||
|
||||
|
||||
+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 特有的激活行为。
|
||||
|
||||
|
||||
+4
-4
@@ -54,8 +54,8 @@
|
||||
en: 7865e159528e2d30
|
||||
zh: 1399d54632f4d926
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/access-snapshot-stream:
|
||||
en: 41d7ac1faf2448d7
|
||||
zh: 6d523895a9abae68
|
||||
en: a928a63e8c36177c
|
||||
zh: c943c1fdc8c4bb45
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/client-face:
|
||||
en: 7be28cd7e19e5377
|
||||
zh: 30b5aee957271d6c
|
||||
@@ -81,8 +81,8 @@
|
||||
en: 89452c3c3f042e75
|
||||
zh: 9bcad00a60c65303
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/consequences/verification:
|
||||
en: 048067eb02e6bf8a
|
||||
zh: 12508b59f6b206b8
|
||||
en: 2279a8c153cc5b89
|
||||
zh: 63c0b6462722f0a3
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/consequences/costs-and-limits:
|
||||
en: 1e254cec680b1a37
|
||||
zh: 06a31e057ba965aa
|
||||
|
||||
+2
-2
@@ -446,7 +446,7 @@ export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__'
|
||||
|
||||
| Behavior | Detail |
|
||||
|---|---|
|
||||
| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to the owner baseline `{ type: 'baseline', owner: true }`, to every index the webServer renders, through `webserver/index-inject`. A business package that renders a non-owner page (such as liveshare on the owner Host) places an assignment of that global to `snapshotFor(principal)` after every index injection row; the later assignment wins, and the Client reads the global when it mounts |
|
||||
| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. The webServer renders each index for the Peer the page speaks for, and `webserver/index-inject` passes that Peer to listeners; remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to `snapshotFor(peer.principal)`, to every index, so the owner's page receives the owner baseline `{ type: 'baseline', owner: true }` and a page rendered for a non-owner Peer (such as a liveshare viewer page on the owner Host) receives that principal's grants. The Client reads the global when it mounts |
|
||||
| Subscription | Each Peer subscribes to one access snapshot stream, whose baseline replaces the injected one |
|
||||
| Computation | All bindings of the policy for the principal's kind are merged by equal resource; each resource's `actions` are the endpoint actions of those bindings' roles that the same decision as a call allows (one resource, without args, conditions called without args); only a resource with at least one allowed endpoint action gets a grant. Decisions and snapshots share one source: for the same principal, action, and resource (without arguments), the snapshot answer equals `decide()` |
|
||||
| Endpoint actions only | `event:<name>` actions do not enter the snapshot; the frontend decides entry visibility from endpoint actions only |
|
||||
@@ -638,7 +638,7 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R
|
||||
| Resource resolution | A default field (top-level or first object argument) yields its resource; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, so an undeclared nested field changes no resource and no condition sees it, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) |
|
||||
| Resource registry | `typert.resources` registers one default field per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec |
|
||||
| Forwarded events | A non-owner `$events` stream receives an event, whole, only when `event:<name>` is granted on its resources; an event that declares no resources is decided on `global`; an event whose reader returns `undefined` does not reach non-owners, and `decide()` is not asked; the owner receives all | Gateway host spec (gateway-access) |
|
||||
| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the owner baseline into every rendered index; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs |
|
||||
| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the snapshot of the Peer each index speaks for; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs |
|
||||
| Type inference | The parameter types of `resources` readers are inferred from the method; lookup positions have wire types; a missing property, more parameters than the method has, and a `global` key fail compilation; a binding whose resource kind differs from its role's fails compilation | typert-protocol spec, remote-access spec (type tests) |
|
||||
| List filtering | `session/list`, `session/search`, `session/control`, and `workspace/follow` return non-owners only items for which `allows()` is true; `workspace/follow` sends a `replace` increment when a delivered Workspace loses visibility, and the Workspace reappears once access returns; owner results are unchanged | session-controller / workspace-controller host spec |
|
||||
| Access snapshots | The allowed set in a snapshot equals the Host decision for the same principal, action, and resource without arguments; it contains endpoint actions only; a resource with no allowed action gets no grant, and one whose allowed set becomes empty is removed; `invalidate()` and policy registration or removal push increments; a wait that a change ends leaves no abort listener behind; unloading the service ends every open snapshot stream; the owner receives only `{ owner: true }` | remote-access host spec |
|
||||
|
||||
+2
-2
@@ -446,7 +446,7 @@ export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__'
|
||||
|
||||
| 行为 | 说明 |
|
||||
|---|---|
|
||||
| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。remote-access 经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如 owner Host 上的 liveshare)在所有 index 注入行之后放置一条把该全局变量赋值为 `snapshotFor(principal)` 的语句;后写的赋值生效,Client 在挂载时读取该全局变量 |
|
||||
| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。webServer 按页面所代表的 Peer 渲染 index,`webserver/index-inject` 把该 Peer 传给监听方;remote-access 为每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 `snapshotFor(peer.principal)`,owner 页面因此得到 owner baseline `{ type: 'baseline', owner: true }`,为非 owner Peer 渲染的页面(例如 owner Host 上的 liveshare 读者页)得到该 principal 的 grant。Client 在挂载时读取该全局变量 |
|
||||
| 订阅 | 每个 Peer 订阅一条访问快照流,其 baseline 取代注入的快照 |
|
||||
| 计算 | 取该 principal kind 的 policy 的全部 binding,按资源相等归并;每个资源的 `actions` 为这些 binding 的 role 中、经与调用相同的判定(单个资源、不带 args,condition 以无 args 调用)放行的端点 action;只为至少放行一个端点 action 的资源给出 grant。判定与快照同源:同一 principal、action 与资源(不带参数)上,快照答案与 `decide()` 一致 |
|
||||
| 只含端点 action | `event:<name>` action 不进入快照;前端只据端点 action 决定入口显隐 |
|
||||
@@ -638,7 +638,7 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot
|
||||
| 资源解析 | 默认字段(顶层或首个对象参数)得到对应资源;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,未声明的嵌套字段不改变资源、condition 也读不到它,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) |
|
||||
| 资源注册表 | `typert.resources` 每个种类登记一个默认字段,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec |
|
||||
| 转发事件 | 非 owner 的 `$events` 流只在 `event:<name>` 于其资源上被授权时整条收到事件;未声明资源的事件按 `global` 判定;读取函数返回 `undefined` 的事件不送达非 owner,且不询问 `decide()`;owner 全部收到 | Gateway host spec(gateway-access) |
|
||||
| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为渲染的每个 index 注入 owner baseline;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec |
|
||||
| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为每个 index 注入其所代表 Peer 的快照;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec |
|
||||
| 类型推断 | `resources` 读取函数的参数类型从方法推断;lookup 位为 wire 类型;访问不存在的属性、参数多于方法参数与 `global` 键编译失败;binding 的资源种类与 role 不符编译失败 | typert-protocol spec、remote-access spec(类型测试) |
|
||||
| 列表过滤 | `session/list`、`session/search`、`session/control`、`workspace/follow` 对非 owner 只返回 `allows()` 为 true 的项;已下发工作区失去可见性时 `workspace/follow` 发送 `replace` 增量帧,访问恢复后该工作区重新出现;owner 结果不变 | session-controller / workspace-controller host spec |
|
||||
| 访问快照 | 快照中的允许集合与 Host 对同一 principal、action、资源(不带参数)的判定一致;只含端点 action;没有放行 action 的资源不出 grant,放行集合变空时推送 `remove`;`invalidate()` 与 policy 注册、撤回后推送增量;被变化结束的等待不残留 abort 监听器;卸载服务即结束所有打开的快照流;owner 只收 `{ owner: true }` | remote-access host spec |
|
||||
|
||||
+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 元数据必须与解码后的原图尺寸一致。
|
||||
|
||||
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
# Bilingual-pair consistency record for 2026-10-17-agent-controller-message-delivery.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/architecture/2026-10-17-agent-controller-message-delivery.md
|
||||
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller:
|
||||
en: 9105f8148e107f43
|
||||
zh: a160e87bb0605da4
|
||||
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/problem:
|
||||
en: 5071232d9743096c
|
||||
zh: b553a626538b3466
|
||||
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/decision:
|
||||
en: 1b61d351c4d2bdf1
|
||||
zh: d6f8180b30d97b34
|
||||
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/alternatives-considered:
|
||||
en: acc465cfe02b624c
|
||||
zh: db1d06574f647130
|
||||
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/consequences:
|
||||
en: 9d8486586d044709
|
||||
zh: 24c7249421fd2541
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
# Agent Note: Delivering messages from outside an Agent through the Agent controller
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-17-agent-controller-message-delivery.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Plugins need to decide, with Host context, whether a message someone sends may enter a Session now. The experimental forge panel must create a requested worktree before the first message reaches the Agent, and must stop a message after the Session's worktree was removed. The Host had no point for that decision. `session/prompt` appended a prompt to the Agent inbox synchronously, and the only seam every sender passes, `agent/pre-step`, runs after the inbox accepted the message and the client already showed it as sent. A pre-step rejection ends the turn without returning the message to its sender, and a queued message reaches pre-step only when its turn starts. A gate in the Web composer, tried first, had no Host context and was bypassed by every other client.
|
||||
|
||||
## Decision
|
||||
|
||||
The core package `dsh-agent-controller` owns how a message from outside an Agent enters its inbox. The base bundle mounts it as `ctx.agentController`, so every base-backed profile can deliver through it; `dsh-agent` and `agent-loop` keep their inbox methods unchanged.
|
||||
|
||||
| Part | Contract |
|
||||
|---|---|
|
||||
| Entry | `ctx.agentController.deliver(agent, { mode, source, signal, prepare, beforeInsert })` prepares the content, builds the message, asks the `agent-controller/admission` waterfall for an `AdmissionDecision`, checks the sender's cancellation and that the Agent is still registered, calls `beforeInsert`, then `followup()` or `steer()`, and resolves with the message id once the message is in the inbox |
|
||||
| Sender work | `prepare` turns the sender's content into durable message content inside the Agent's delivery order; the optional `beforeInsert` runs synchronously immediately before the append, for bookkeeping that must precede the Agent's wake |
|
||||
| Listeners | Run in registration order with Agent scope filtering and receive the Agent, the message, and a signal that aborts when the sender cancels or the controller stops; return the decision of `next()` to admit, await before `next()` to hold, and return `{ kind: 'reject', reason, message, presented }` to refuse without asking later listeners; throw only when the check itself fails |
|
||||
| Delivery mode | Stays with the sender; listeners do not see it, because no admission policy depends on it |
|
||||
| Order | Messages to one Agent are prepared, decided, and appended one at a time in arrival order; a sender that cancels before the append is not admitted and stops waiting at once, even behind a held message; disposing the controller stops every message that has not entered an inbox and finishes once those deliveries settled |
|
||||
| Failures | A rejection raises `AgentAdmissionRejected`, a `HarnessError` with code `AGENT_ADMISSION_REJECTED`, with its reason and `presented`; senders recognize it by that code rather than its class and import only types from this package; a thrown listener error raises it with `agent-controller/admission-failed`, and an Agent that left the registry with `agent-controller/agent-gone`; errors from `prepare`, `beforeInsert`, `followup()`, and `steer()` pass through; no listener runs after the message entered the inbox |
|
||||
|
||||
Only senders that deliver work on someone's behalf call `deliver()`. Messages the Agent's own machinery produces, such as job results, goal rounds, plan transitions, and question answers, keep `followup()`, `steer()`, and `inject()`: they belong to work that was already admitted. A listener must not wait for a person when the message source is unattended, such as a schedule or a webhook; it passes or refuses such a message.
|
||||
|
||||
`presented` tells an interactive client that the refusing plugin explains the refusal in its own interface, so the client adds no generic notice.
|
||||
|
||||
The Web Session Remote adopts the controller first. `session/prompt` resolves or resumes the Agent, acknowledges a repeated `requestId` itself, and delivers with a `prepare` that checks image input against the Session model, resolves upload receipts, and stores attachments, and a `beforeInsert` that binds the receipts to the request. It maps a refusal to `session/prompt-rejected` with `reason` and `presented`. The call stays open while a listener holds the message, and the client's cancellation stops it. The SDK server, ACP, headless runs, schedules, webhooks, and subagent prompts move to `deliver()` in later changes, each mapping a refusal to its own protocol. Until they do, a plugin that must cover them keeps an `agent/pre-step` check.
|
||||
|
||||
Request deduplication stays in the Session Remote. It reads the Session history synchronously, and the [synchronous-read decision](2026-09-09-deprecate-synchronous-session-event-reads.md) prohibits copying such a read into new production code; the controller can take over deduplication once a projection records accepted request ids.
|
||||
|
||||
The Web client keeps its existing echo: Enter clears the composer and shows the message at once, and a refusal reaches the client as an ordinary prompt failure, which retires the echo and restores the draft unless new text was typed meanwhile.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **An admission stage in `dsh-agent`.** `ctx.agents.admit()` with an `agent/admission` event reaches every sender as well, but it adds a delivery concern and a new extension point to the Agent core, whose contract the loop implements. A package composed in the base bundle reaches the same senders without changing that contract.
|
||||
- **A Session Remote event, or a layer inside the Session Controller.** It fits the Web path, but the Session Controller is composed only in the Web profile, so ACP, SDK, and headless senders could never reach it.
|
||||
- **`agent/pre-step`.** It covers every sender, but it runs after the inbox accepted the message and the client showed it as sent, a refusal drops the message instead of returning it, and a queued message is checked only when its turn starts.
|
||||
- **Gated, asynchronous `followup()` and `steer()`.** Every internal sender would pass listeners meant for outside input, and their callers rely on synchronous enqueueing.
|
||||
- **A composer gate in the Web client.** It has no Host context, and other clients and `ctx.conversation.send` bypass it.
|
||||
- **Appending in the innermost step, with refusals thrown.** Listeners could act once `next()` returned with the message already in the inbox, but that capability, which no listener uses, cost three special cases: a listener failure after the append could only be logged, the inbox's own refusal had to be told apart from a listener failure, and a refusal thrown from another module copy of the error class needed a structural check. A returned decision keeps the stage a pure decision, as `agent/pre-step` is; a plugin that reacts to admitted messages listens to `agent/inbox/inserted`.
|
||||
|
||||
The liveshare proposal in PR #3854 also introduced an admission hook, for caller identity. [Peer principals](2026-10-12-peer-principal-and-remote-access.md) replaced it; this stage decides whether the Agent can take a message now, independent of who sent it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A listener that holds one message keeps later messages to the same Agent waiting; other Agents proceed.
|
||||
- While a listener holds a Web prompt, the client already shows its echo; a refusal then retires the echo and returns the draft.
|
||||
- `prepare` stores attachments before the decision, so a refused prompt can leave a stored attachment unreferenced.
|
||||
- Image prompts deliver inside the Session Remote's image admission chain, so a model switch waits while a listener holds an image prompt.
|
||||
- Until every outside sender adopts `deliver()`, the stage is not a complete enforcement point.
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
# Agent Note:经 Agent 控制器投递来自 Agent 外部的消息
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-17-agent-controller-message-delivery.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
插件需要借助 Host 侧的上下文,判断别人发来的一条消息现在能否进入 Session。实验性的 forge 面板必须在第一条消息到达 Agent 之前建好所请求的工作树,并在 Session 的工作树被删除后拦下消息。Host 此前没有做这个判断的位置:`session/prompt` 同步地把提示词追加进 Agent 收件箱,而所有发送方都会经过的唯一扩展点 `agent/pre-step` 运行在收件箱接受消息、客户端已显示为已发送之后。pre-step 拒绝会结束本轮,却不会把消息退回给发送方;排队的消息也要等到它的轮次开始才会到达 pre-step。最初尝试的 Web 输入框闸门拿不到 Host 上下文,其他客户端也都会绕过它。
|
||||
|
||||
## 决策
|
||||
|
||||
core 包 `dsh-agent-controller` 负责来自 Agent 外部的消息如何进入其收件箱。base 组合包把它挂载为 `ctx.agentController`,因此每个基于 base 的 profile 都能经它投递;`dsh-agent` 与 `agent-loop` 的收件箱方法保持不变。
|
||||
|
||||
| 部分 | 约定 |
|
||||
|---|---|
|
||||
| 入口 | `ctx.agentController.deliver(agent, { mode, source, signal, prepare, beforeInsert })` 准备内容、构造消息、向 `agent-controller/admission` waterfall 要一个 `AdmissionDecision`,检查发送方是否取消、Agent 是否仍在注册表中,调用 `beforeInsert`,再调用 `followup()` 或 `steer()`,消息进入收件箱后返回消息 id |
|
||||
| 发送方的工作 | `prepare` 在该 Agent 的投递顺序内把发送方的内容转成持久的消息内容;可选的 `beforeInsert` 紧挨在放入之前同步运行,用于必须先于 Agent 唤醒完成的登记 |
|
||||
| 监听器 | 按注册顺序运行,按 Agent 作用域过滤,拿到 Agent、消息和一个在发送方取消或控制器停止时中止的信号;返回 `next()` 的决定即放行,在 `next()` 前 await 即暂留,返回 `{ kind: 'reject', reason, message, presented }` 即拒绝且不再询问后面的监听器;只在检查本身执行失败时抛出异常 |
|
||||
| 投递方式 | 留在发送方,监听器看不到,因为目前没有按投递方式区分的准入策略 |
|
||||
| 顺序 | 同一个 Agent 的消息按到达顺序逐条准备、决定并放入;在放入之前取消的发送方不会被接纳,并立即结束等待,即使排在被暂留的消息之后;销毁控制器会停止所有尚未进入收件箱的消息,并在这些投递结束后才完成 |
|
||||
| 失败 | 拒绝时抛出带其 reason 与 `presented` 的 `AgentAdmissionRejected`,它是 code 为 `AGENT_ADMISSION_REJECTED` 的 `HarnessError`;发送方按这个 code 而不是按类识别拒绝,对本包只导入类型;监听器抛出异常时 reason 为 `agent-controller/admission-failed`,Agent 离开注册表时为 `agent-controller/agent-gone`;`prepare`、`beforeInsert`、`followup()` 与 `steer()` 的错误原样抛出;消息进入收件箱之后不再有监听器运行 |
|
||||
|
||||
只有代表他人送来工作的发送方调用 `deliver()`。Agent 自身机制产生的消息,例如后台任务结果、goal 轮次、plan 切换和问题答复,继续使用 `followup()`、`steer()` 与 `inject()`:它们属于已经接纳的工作。消息来源无人值守时(例如定时任务或 webhook),监听器不得等待人来处理,只能放行或拒绝。
|
||||
|
||||
`presented` 告诉交互式客户端,拒绝它的插件会在自己的界面里给出说明,客户端因此不再追加通用提示。
|
||||
|
||||
Web Session Remote 首先接入控制器。`session/prompt` 自己解析或恢复 Agent、确认重复的 `requestId`,再调用投递:`prepare` 按 Session 模型检查图片输入、解析上传凭证并存储附件,`beforeInsert` 把凭证绑定到该请求。它把拒绝映射为带 `reason` 与 `presented` 的 `session/prompt-rejected`。监听器暂留消息期间调用保持未完成,客户端取消会让它停止。SDK 服务端、ACP、headless 运行、定时任务、webhook 与子代理提示词在后续改动中改用 `deliver()`,各自把拒绝映射到自己的协议。在此之前,必须覆盖它们的插件仍保留一个 `agent/pre-step` 检查。
|
||||
|
||||
请求去重留在 Session Remote。它同步读取 Session 历史,而[同步读取决策](2026-09-09-deprecate-synchronous-session-event-reads.zh.md)禁止把这类读取复制到新的生产代码;等有投影记录已接收的请求 id 后,控制器才能接管去重。
|
||||
|
||||
Web 客户端保留现有的回显方式:Enter 立即清空 composer 并显示消息;拒绝在客户端表现为一次普通的提示词失败,回显撤下,草稿回到 composer,除非期间已经输入了新内容。
|
||||
|
||||
## 备选方案
|
||||
|
||||
- **在 `dsh-agent` 里加接纳阶段。** `ctx.agents.admit()` 加 `agent/admission` 事件同样能覆盖所有发送方,但它把投递职责和一个新扩展点加进了 Agent 核心,而 Agent 的约定由循环实现。组合在 base 组合包里的独立包能覆盖同样的发送方,又不改变这项约定。
|
||||
- **Session Remote 自己的事件,或 Session Controller 内部的一层。** 它适合 Web 路径,但 Session Controller 只在 Web profile 中组合,ACP、SDK 与 headless 发送方永远无法使用它。
|
||||
- **`agent/pre-step`。** 它覆盖所有发送方,但运行在收件箱接受消息、客户端显示为已发送之后;拒绝会丢掉消息而不是退回;排队的消息要到轮次开始才会被检查。
|
||||
- **让 `followup()` 与 `steer()` 异步并经过闸门。** 每个内部发送方都会经过为外部输入准备的监听器,而它们的调用方依赖同步入队。
|
||||
- **Web 客户端的输入框闸门。** 它拿不到 Host 上下文,其他客户端和 `ctx.conversation.send` 都会绕过它。
|
||||
- **在最内层步骤放入消息,并以抛出异常表示拒绝。** 监听器能在 `next()` 返回、消息已进入收件箱之后继续执行,但这项没有监听器使用的能力带来三处特殊处理:放入之后的监听器失败只能记录日志;要把收件箱自身的拒绝与监听器失败区分开;另一个模块副本抛出的错误类需要按结构识别。返回决定让这个阶段和 `agent/pre-step` 一样只做决定;需要对已接纳消息作出反应的插件监听 `agent/inbox/inserted`。
|
||||
|
||||
PR #3854 中的 liveshare 方案也引入过一个接纳钩子,用于调用方身份。[Peer principals](2026-10-12-peer-principal-and-remote-access.zh.md) 取代了它;本阶段判断 Agent 现在能否接收一条消息,与发送者是谁无关。
|
||||
|
||||
## 影响
|
||||
|
||||
- 监听器暂留一条消息时,同一 Agent 之后的消息都要等待;其他 Agent 不受影响。
|
||||
- 监听器暂留一条 Web 提示词期间,客户端已经显示其回显;随后被拒绝时,回显撤下,草稿回到 composer。
|
||||
- `prepare` 在决定之前存储附件,因此被拒绝的提示词可能留下一个无人引用的已存附件。
|
||||
- 图片提示词在 Session Remote 的图片接纳链内投递,因此监听器暂留图片提示词期间,切换模型要等待。
|
||||
- 在所有外部发送方都改用 `deliver()` 之前,这个阶段还不是完整的强制执行点。
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-19-pi-ai-system-reminder-fallback.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/architecture/2026-10-19-pi-ai-system-reminder-fallback.md
|
||||
/agent-note-pi-ai-system-reminder-fallback:
|
||||
en: f43b5c6974289180
|
||||
zh: abc0a86b1d137189
|
||||
/agent-note-pi-ai-system-reminder-fallback/problem:
|
||||
en: 5247278483ca4e7a
|
||||
zh: ca711671a43fae76
|
||||
/agent-note-pi-ai-system-reminder-fallback/decision:
|
||||
en: 604eb72704c73328
|
||||
zh: 6661d1065f65ecf9
|
||||
/agent-note-pi-ai-system-reminder-fallback/alternatives-considered:
|
||||
en: 0e2aaf897587ebb0
|
||||
zh: 0a7f9a9cb7ddffaf
|
||||
/agent-note-pi-ai-system-reminder-fallback/consequences:
|
||||
en: 8a0c53289a04c64a
|
||||
zh: 591e602eb2036a1a
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: Pi-ai system reminder fallback
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-19-pi-ai-system-reminder-fallback.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Pi-ai concatenates later system messages into the initial prompt for models without native mid-conversation system support. Later system messages supplied to the adapter therefore invalidate the preceding conversation cache prefix.
|
||||
|
||||
## Decision
|
||||
|
||||
The pi-ai adapter uses the prepared model's existing `supportsMidConvoSystemMessages` capability mapping. It preserves native system messages on capable models and converts each non-empty later system message into a user message wrapped in `<system-reminder>` otherwise, before pi-ai receives the context. Leading system text remains in the initial prompt. Message order, repeated updates, and existing text-block concatenation remain intact. Durable messages retain their original roles.
|
||||
|
||||
The fallback belongs to the adapter because it depends on the selected protocol capability. Prompt admission remains owned by the agent loop, which consolidates system-prompt updates on routes without native support; this fallback only handles later system messages present in the supplied request.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Let pi-ai concatenate updates.** Retains system-role priority but changes the initial prompt whenever later system text changes, defeating prefix reuse.
|
||||
|
||||
**Advertise native support or send developer messages.** Misstates model capability or relies on another unsupported instruction role. The fallback uses ordinary user messages and does not change capability metadata.
|
||||
|
||||
## Consequences
|
||||
|
||||
The wrapper does not confer system-role authority, so reminders have user-role priority. Cache hits remain provider-dependent; tool changes and compaction can still invalidate prefixes. Both text-only and attachment-aware conversion apply the same fallback, and wire tests inspect the real pi-ai serializer output.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Agent Note: Pi-ai 系统提醒回退
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-19-pi-ai-system-reminder-fallback.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
对于不原生支持对话中系统消息的模型,pi-ai 会把后续系统消息拼接进初始提示词。因此,传给适配器的后续系统消息会使此前的对话缓存前缀失效。
|
||||
|
||||
## 决策
|
||||
|
||||
pi-ai 适配器使用已准备模型的现有 `supportsMidConvoSystemMessages` 能力映射。对于具备该能力的模型,适配器保留原生系统消息;否则,在 pi-ai 接收上下文之前,将每条非空的后续系统消息转换为以 `<system-reminder>` 包裹的用户消息。开头的系统文本仍位于初始提示词中。消息顺序、重复更新和现有文本块拼接方式均保持不变。持久消息保留原始角色。
|
||||
|
||||
回退由适配器负责,因为它取决于所选协议的能力。提示词纳入仍由 Agent loop 负责,它在不支持原生更新的路由上合并系统提示词更新;此回退仅处理传入请求中已有的后续系统消息。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**让 pi-ai 拼接更新。** 保留系统角色优先级,但后续系统文本每次变化都会改变初始提示词,破坏前缀复用。
|
||||
|
||||
**宣称原生支持或发送 developer 消息。** 错报模型能力,或依赖另一种不受支持的指令角色。回退使用普通用户消息,不改变能力元数据。
|
||||
|
||||
## 后果
|
||||
|
||||
包裹文本不会赋予系统角色权限,因此提醒具有用户角色优先级。缓存命中仍取决于提供方;工具变化和压缩仍可能使前缀失效。纯文本与附件感知转换使用相同的回退,线协议测试检查真实 pi-ai 序列化器的输出。
|
||||
@@ -0,0 +1,28 @@
|
||||
# Bilingual-pair consistency record for 2026-10-21-when-access.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/architecture/2026-10-21-when-access.md
|
||||
/agent-note-whenaccess-registration-that-follows-access:
|
||||
en: 2ef7cfef514c138a
|
||||
zh: 064e3aecbfafcd9c
|
||||
/agent-note-whenaccess-registration-that-follows-access/problem:
|
||||
en: 4f88d3ee7868eaa5
|
||||
zh: 72a4ce2dde13bef7
|
||||
/agent-note-whenaccess-registration-that-follows-access/decision:
|
||||
en: a15c7c13f167e5ac
|
||||
zh: 63bcc2ef60c2f344
|
||||
/agent-note-whenaccess-registration-that-follows-access/decision/whenaccess:
|
||||
en: c168de20989cf9a4
|
||||
zh: bf3b0361beea083d
|
||||
/agent-note-whenaccess-registration-that-follows-access/decision/plugin-migration:
|
||||
en: 73577ed282c656f3
|
||||
zh: 2f60a6824218e693
|
||||
/agent-note-whenaccess-registration-that-follows-access/alternatives-considered:
|
||||
en: ddc432b693608163
|
||||
zh: 620294d8812b0a31
|
||||
/agent-note-whenaccess-registration-that-follows-access/consequences:
|
||||
en: de44fa92c49bc335
|
||||
zh: 34436fe971ab013f
|
||||
/agent-note-whenaccess-registration-that-follows-access/related:
|
||||
en: 89073ffbfcff0641
|
||||
zh: 736a180d13a08981
|
||||
@@ -0,0 +1,88 @@
|
||||
# Agent Note: whenAccess registration that follows access
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-21-when-access.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Client plugins need to switch their own registrations on and off with the current Peer's access: sidebar entries, main panels, overlays, and data streams. The existing Client access surface covers only rendering:
|
||||
|
||||
| Where | Existing capability | Gap |
|
||||
|---|---|---|
|
||||
| Components | `useRemoteAccess(action)` (global), `useSessionAccess(sessionId, action)`, `useWorkspaceAccess(workspaceId, action)`, and the Session-scope `useAccess(action)` | Readable only during render; a changed answer re-renders automatically |
|
||||
| JS (`apply`, services) | `ctx.remoteAccess.source(kind, id, action)` returns a `HostObservable<boolean>` | A plugin must read `getSnapshot` and subscribe to changes itself, then register and dispose by hand |
|
||||
|
||||
Without a JS entry point, a plugin can only decide its registrations once by identity at `apply`, and they do not follow an access change.
|
||||
|
||||
## Decision
|
||||
|
||||
### whenAccess
|
||||
|
||||
The JS side has a `whenAccess` for each of the three access Hooks, from the package that publishes that Hook:
|
||||
|
||||
| Interface | Provider | Matching Hook |
|
||||
|---|---|---|
|
||||
| `ctx.remoteAccess.whenAccess(action, callback)` | remote-access | `useRemoteAccess(action)` |
|
||||
| `ctx.uiSession.whenAccess(sessionId, action, callback)` | ui-session | `useSessionAccess(sessionId, action)` |
|
||||
| `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` | ui-workspace | `useWorkspaceAccess(workspaceId, action)` |
|
||||
|
||||
```ts ignore-check
|
||||
import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
|
||||
/** The global-resource member; the Session and Workspace versions take the resource id first. */
|
||||
export interface WhenAccess {
|
||||
/**
|
||||
* Keep `callback`'s effects installed while the current Peer may call `action`
|
||||
* on the resource: run it when the answer becomes true, dispose its effects in
|
||||
* reverse order when it becomes false, and run it again on the next true.
|
||||
* @returns disposer for the observation and any installed effects.
|
||||
*/
|
||||
whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void
|
||||
}
|
||||
```
|
||||
|
||||
`AccessEffect` is `(() => void) | Iterable<() => void, void, void>`.
|
||||
|
||||
| Rule | Content |
|
||||
|---|---|
|
||||
| Semantics | Same as `ctx.slots.inject`: the callback returns one disposer or an iterable of disposers; an iterable installs as one transaction and disposes in reverse order; the controller belongs to the caller's fiber and disposes when the plugin unloads |
|
||||
| Synchronous run | When the answer is already true, the callback runs synchronously; the owner's answer is always true, so registration order matches direct registration |
|
||||
| Stream failure | A non-owner's answer turns false and the installed effects dispose |
|
||||
| Callback failure | A setup failure at the call throws synchronously; a setup failure on a later grant stops the controller and is rethrown in a microtask |
|
||||
| Layering | remote-access knows only `global`; the Session and Workspace versions are built over remote-access's lower-level `whenResourceAccess(kind, id, action, callback)`, as their Hooks are built over `source(kind, id, action)`. Only the package that owns a resource kind calls `source` and `whenResourceAccess`; other plugins use only the access Hooks and `whenAccess` and pass no kind. All four entry points share [`when-allowed.ts`](../../../../packages/api/remote-access/src/client/when-allowed.ts) |
|
||||
|
||||
### Plugin migration
|
||||
|
||||
| Plugin | Change | Action |
|
||||
|---|---|---|
|
||||
| ui-schedule | The task catalog source, delete toast overlay, right-sidebar task tab and title, main panel, sidebar entry, turn-tail card, Session-header reminder catalog, and Session row mark and hover list move into `remoteAccess.whenAccess`; the Session-header catalog reads the per-Session `schedule.list`, so its component also decides through the Session-scope `useAccess` whether to render; the task tab type and the turn definition stay registered outside | `schedule/catalog`, `schedule/list` |
|
||||
| ui-plugin-manager | Host change subscriptions, refresh toast overlay, main panel (including `pluginNavigation`), and sidebar entry move into `remoteAccess.whenAccess`, and each grant refreshes a list already loaded; the controller and `pluginChanges` stay outside, and `pluginChanges` receives no updates while access is withdrawn. ui-entry-customization and voice-input use `pluginNavigation` optionally: without the service they hide the browse-plugins and open/details buttons and still start | `pluginManager/listBundles` |
|
||||
| ui-settings-general | The settings shell `sidebar.settings` and the `settings.open` shortcut move into `remoteAccess.whenAccess` | `settings/describe` |
|
||||
| ui-settings-account | The account state stream (including default model initialization), quota notice, settings launcher, account settings section, and `settings.models.sign-in` move into `remoteAccess.whenAccess`. On revocation the stream's disposer synchronously invalidates the account reads this grant started, closes the pending authorization tab, clears the account view, details, picture, and sign-in dialog state, ends this generation's bonus notices, which a frame of that stream begins, and then closes the stream; a frame delivered before the revocation but not yet handled is dropped. The notice controller stays outside and begins again under the next grant; the account section appears once the new stream's first frame arrives | `account/watch` |
|
||||
| ui-directory-picker-native | Both directory flows move into `remoteAccess.whenAccess` | `directoryPicker/pick` |
|
||||
| ui-message-feedback | The message feedback buttons stay registered and the component decides whether to render through the Session-scope `useAccess`; `messageFeedback/*` requests carry `sessionId`, which the Gateway resolves to that Session through the `session` resource's default field, so they declare nothing. The `/feedback` decoration has no Client check: the decoration registry is global while `sessionFeedback/record` is decided per Session, so without access the dialog still opens and the Host refuses the submission, which the dialog reports with its failure code | `messageFeedback/put` |
|
||||
| ui-deliverables, ui-open-in-app | Unchanged: an owner-only HTTP route that cannot be read falls back as before | — |
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Return after an owner check at the start of `apply` (the #6486 approach)**: fixed on identity; does not follow an access change.
|
||||
|
||||
**Trim the plugin list per connection**: a plugin cannot reload at once when access changes.
|
||||
|
||||
**Declare access in the plugin package manifest**: defines an access-control concept in the package manifest.
|
||||
|
||||
**One generic method with a resource parameter, or all three versions on remote-access**: business code would pass a kind, and remote-access would name the Session and Workspace resource kinds.
|
||||
|
||||
## Consequences
|
||||
|
||||
| Consequence | Note |
|
||||
|---|---|
|
||||
| Registrations follow access changes | Without a grant, a migrated plugin registers none of the corresponding entries for a non-owner; they appear after a grant and disappear after a revocation; the owner's answer is always true, so its registrations match direct registration |
|
||||
| The callback must hand back disposers | An effect registered through the plugin's `ctx` inside the callback also hangs on the plugin fiber; a registration whose disposer is not yielded does not dispose on access loss, only when the plugin unloads |
|
||||
| Readers receive all plugin code | Plugins load as before; the Gateway decides each call, and `whenAccess` and the Hooks control the UI |
|
||||
| Test stand-ins | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.md) replaces remote-access with fixed answers through `StaticRemoteAccess`, `staticAccessPlugin`, and `TestClientOptions.access`, so plugin specs cover the refused path |
|
||||
|
||||
## Related
|
||||
|
||||
- [Peer, principal, and remote access](2026-10-12-peer-principal-and-remote-access.md): this Note extends its Client access face.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Agent Note: 按访问状态注册的 whenAccess
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-21-when-access.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Client 插件需要按当前 Peer 的访问状态开关自己的注册:侧栏入口、主面板、浮层、数据流。现有 Client 访问面只覆盖渲染:
|
||||
|
||||
| 位置 | 现有能力 | 缺口 |
|
||||
|---|---|---|
|
||||
| 组件 | `useRemoteAccess(action)`(global)、`useSessionAccess(sessionId, action)`、`useWorkspaceAccess(workspaceId, action)`、会话作用域的 `useAccess(action)` | 只能在渲染时读,答案变化时自动重渲染 |
|
||||
| JS(`apply`、服务) | `ctx.remoteAccess.source(kind, id, action)` 返回 `HostObservable<boolean>` | 插件要自己读 `getSnapshot`、订阅变化,再手动注册与撤销 |
|
||||
|
||||
没有 JS 侧入口时,插件只能在 `apply` 时按身份一次性决定注册与否,授权变化后注册不跟随。
|
||||
|
||||
## 决策
|
||||
|
||||
### whenAccess
|
||||
|
||||
JS 侧有与三个访问 Hook 一一对应的 `whenAccess`,由各自 Hook 的发布者提供:
|
||||
|
||||
| 接口 | 提供者 | 对应 Hook |
|
||||
|---|---|---|
|
||||
| `ctx.remoteAccess.whenAccess(action, callback)` | remote-access | `useRemoteAccess(action)` |
|
||||
| `ctx.uiSession.whenAccess(sessionId, action, callback)` | ui-session | `useSessionAccess(sessionId, action)` |
|
||||
| `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` | ui-workspace | `useWorkspaceAccess(workspaceId, action)` |
|
||||
|
||||
```ts ignore-check
|
||||
import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
|
||||
/** The global-resource member; the Session and Workspace versions take the resource id first. */
|
||||
export interface WhenAccess {
|
||||
/**
|
||||
* Keep `callback`'s effects installed while the current Peer may call `action`
|
||||
* on the resource: run it when the answer becomes true, dispose its effects in
|
||||
* reverse order when it becomes false, and run it again on the next true.
|
||||
* @returns disposer for the observation and any installed effects.
|
||||
*/
|
||||
whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void
|
||||
}
|
||||
```
|
||||
|
||||
`AccessEffect` 为 `(() => void) | Iterable<() => void, void, void>`。
|
||||
|
||||
| 规则 | 内容 |
|
||||
|---|---|
|
||||
| 语义 | 与 `ctx.slots.inject` 相同:回调返回一个 disposer 或一组 disposer;一组时按事务安装、逆序撤销;控制器属于调用方 fiber,插件卸载时撤销 |
|
||||
| 同步执行 | 调用时答案为 true 则回调同步执行;owner 的答案恒为 true,注册顺序与直接注册相同 |
|
||||
| 快照流失败 | 非 owner 的答案变为 false,已安装的效果撤销 |
|
||||
| 回调失败 | 调用时安装失败同步抛出;之后重新授权时安装失败,控制器停止并在微任务中抛出 |
|
||||
| 分层 | remote-access 只认识 global;会话版与工作区版经 remote-access 的底层方法 `whenResourceAccess(kind, id, action, callback)` 实现,与它们用 `source(kind, id, action)` 构造 Hook 相同。`source` 与 `whenResourceAccess` 只由拥有该资源种类的包调用,其他插件只用访问 Hook 与 `whenAccess`,不传 kind。四个入口共用 [`when-allowed.ts`](../../../../packages/api/remote-access/src/client/when-allowed.ts) |
|
||||
|
||||
### 插件迁移
|
||||
|
||||
| 插件 | 改法 | action |
|
||||
|---|---|---|
|
||||
| ui-schedule | 任务列表数据源、删除提示浮层、右栏任务 tab 与标题、主面板、侧栏入口、turn 尾部卡片、会话头部提醒、会话行标记与悬浮列表包进 `remoteAccess.whenAccess`;会话头部提醒读取按会话判定的 `schedule.list`,组件另在会话作用域里用 `useAccess` 决定是否渲染;任务 tab 类型与 turn 定义的注册留在外面 | `schedule/catalog`、`schedule/list` |
|
||||
| ui-plugin-manager | Host 变更订阅、刷新提示浮层、主面板(含 `pluginNavigation`)、侧栏入口包进 `remoteAccess.whenAccess`,每次授权时刷新已加载过的列表;controller 与 `pluginChanges` 留在外面,撤权期间 `pluginChanges` 不更新。依赖 `pluginNavigation` 的 ui-entry-customization 与 voice-input 改为可选使用:服务不在时隐藏「浏览插件」与「打开 / 查看详情」按钮,插件照常启动 | `pluginManager/listBundles` |
|
||||
| ui-settings-general | 设置外壳 `sidebar.settings` 与 `settings.open` 快捷键包进 `remoteAccess.whenAccess` | `settings/describe` |
|
||||
| ui-settings-account | 账号状态流(含默认模型初始化)、额度提醒、设置启动器、账号设置分区与 `settings.models.sign-in` 包进 `remoteAccess.whenAccess`。撤权时账号流的 disposer 同步作废这一轮发出的账号读取,关闭待授权的浏览器标签页,清空账号视图、详情、头像与登录对话框状态,结束本轮奖励提示(提示由该流的帧开始),再关闭流;撤权前已交付但尚未处理的帧被丢弃。提示控制器留在外面,下次授权时重新开始;账号分区等新流的第一帧到达后才出现 | `account/watch` |
|
||||
| ui-directory-picker-native | 两个目录选择流程包进 `remoteAccess.whenAccess` | `directoryPicker/pick` |
|
||||
| ui-message-feedback | 消息反馈按钮照常注册,组件在会话作用域里用 `useAccess` 判断是否渲染;`messageFeedback/*` 的请求带 `sessionId`,Gateway 按 `session` 资源的默认字段自动解析为该 Session,无需声明。`/feedback` 装饰不做前端判断:装饰注册表是全局的,而 `sessionFeedback/record` 按会话判定;没有权限时对话框照常打开,提交被 Host 拒绝并显示失败码 | `messageFeedback/put` |
|
||||
| ui-deliverables、ui-open-in-app | 不动:owner 专用 HTTP 路由读不到时走已有回退 | — |
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**插件 `apply` 开头判断 owner 后 return(#6486 的做法)**:按身份写死,授权变化后不跟随。
|
||||
|
||||
**按连接裁剪插件列表**:授权变化后插件无法立即重新加载。
|
||||
|
||||
**插件包清单声明访问性**:在包清单上定义访问控制概念。
|
||||
|
||||
**一个带资源参数的通用方法,或三个版本都挂在 remote-access 上**:业务代码要传 kind,且 remote-access 会出现会话、工作区的资源种类名。
|
||||
|
||||
## 后果
|
||||
|
||||
| 后果 | 说明 |
|
||||
|---|---|
|
||||
| 注册跟随授权变化 | 迁移的插件在非 owner 无授权时不注册相应入口,授予后出现,撤销后消失;owner 的答案恒为 true,注册与直接注册相同 |
|
||||
| 回调必须交还 disposer | 回调里经插件 `ctx` 注册的效果也挂在插件 fiber 上;不 yield 其 disposer 的注册在失权时不会撤销,只在插件卸载时撤销 |
|
||||
| 读者拿到全部插件代码 | 插件照常加载;访问由 Gateway 逐次判定,界面由 `whenAccess` 与 Hook 控制 |
|
||||
| 测试替身 | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.zh.md) 的 `StaticRemoteAccess`、`staticAccessPlugin` 与 `TestClientOptions.access` 以固定答案替换 remote-access,供插件测试无权限路径 |
|
||||
|
||||
## 相关
|
||||
|
||||
- [Peer、principal 与 remote-access](2026-10-12-peer-principal-and-remote-access.zh.md):本 Note 扩展其 Client 访问面。
|
||||
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-09-17-multiline-display-dollar-math.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/bug-fix/2026-09-17-multiline-display-dollar-math.md
|
||||
/agent-note-multi-line-display-dollar-math:
|
||||
en: 8f57c77aa43fbd6c
|
||||
zh: ef5192184e4923d5
|
||||
/agent-note-multi-line-display-dollar-math/problem:
|
||||
en: e4b73644a982844c
|
||||
zh: 48f86358242d6ccc
|
||||
/agent-note-multi-line-display-dollar-math/decision:
|
||||
en: d6b136b1f62285b7
|
||||
zh: aba323bfdb800162
|
||||
/agent-note-multi-line-display-dollar-math/alternatives-considered:
|
||||
en: e0833b5674e648e7
|
||||
zh: b0472020b07099b9
|
||||
/agent-note-multi-line-display-dollar-math/consequences:
|
||||
en: 70f16597bd640e93
|
||||
zh: 0e0c9005f331e514
|
||||
/agent-note-multi-line-display-dollar-math/testing:
|
||||
en: f17a1453df91ce88
|
||||
zh: 4f15be0139e8b694
|
||||
@@ -0,0 +1,66 @@
|
||||
# Agent Note: Multi-line display-dollar math
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-17-multiline-display-dollar-math.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Authors and models write `$$` display math in three shapes. The settled grammar rendered two of them: `$$math$$` on one line, and an opening `$$` alone on its line closed by `$$` alone on its line. The third shape — math on the opening line, a closing `$$` at the end of the last math line — did not:
|
||||
|
||||
```markdown
|
||||
$$f(x)=x
|
||||
\text{continued}$$
|
||||
```
|
||||
|
||||
`micromark-extension-math` treats text after an opening `$$` as fence meta and accepts a closing fence only on a line whose content is exactly `$$` (after optional whitespace). The [markdown compatibility layer](../../archived/feature/2026-07-23-web-assistant-markdown.md) claimed only the same-line shape, and the settled renderer passes `node.value` to KaTeX while ignoring `node.meta`. The block therefore never closed: it consumed the document text that followed, `value` retained the literal `$$`, KaTeX raised `Can't use function '$' in math mode`, and [katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx)'s error arm painted the TeX source plus the swallowed prose in red.
|
||||
|
||||
In the session that surfaced this, one chat reply lost lines 22–55 of 55 to a single error span, and six note files lost every line after such a block (up to 500 lines) to one. A second defect shared the mechanism: when a later lone `$$` did close the flow, the opening line had already been assigned to `meta`, so the renderer silently dropped the first line of the formula.
|
||||
|
||||
## Decision
|
||||
|
||||
[mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) gains multi-line display-dollar blocks. Its dollar construct keeps the opening line's text in `mathFlowValue` instead of fence meta and closes at a trailing `$$` on any later content line, so `$$a+b` continued by `c+d$$` becomes one math node.
|
||||
|
||||
A `$$` sequence that fails to close ends the candidate: immediately on a later line, and at that line's ending on the opening line (`$$x$$ trailing` fails there). Within the opening line the scan continues, so a later `$$` there still closes (`$$\verb*a$$a$$`). Scanning past either point would consume a later block's closing fence. A solitary `$` is content rather than a failed fence, so a formula that uses one — `$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$` — keeps the block open and closes normally wherever this flow owns the block. A trailing backslash escapes the line ending, not the next line's first character, so the escape run ends at the line ending instead of reaching into the next candidate. An unescaped `%` starts a TeX comment that runs to the line ending, so a `$$` at the end of a later line closes nothing when the comment holds text before it. A `%` whose text before the fence is empty or whitespace (`95%$$`, `95 %$$`) holds no comment text, and a block that opens and closes on one line keeps closing at that line's `$$`, as it did before this flow spanned lines. That state and its gate on the closing check belong to the dollar flow. The characters right after `\verb` or `\verb*` are that command's delimiter rather than a fence, so the delimiter and, when the region is empty, the `$` closing it are content; that tail is cleared at a line ending and after an escaped backslash (`\\verb` is a line break followed by letters), and a comment holds no commands. Otherwise verbatim regions are not parsed, which keeps `\verb`-bearing formulas on the same value path as every other input.
|
||||
|
||||
Its paragraph-interruption check declines instead of consuming the line ending, for the dollar construct only. Consuming it there keeps the paragraph open across the line boundary, so the document layer keeps the container and counts the next line lazy for every construct tried afterwards; that made upstream's `mathFlow`, the construct tried after this one, end empty inside a list item. Declining also leaves the interruption decision to that upstream flow, whose own check short-circuits without consuming and claims only the fences it accepts, so a candidate neither construct accepts (`**before` followed by `$$a\$b`) leaves its paragraph intact. The backslash construct has no competing upstream construct and keeps consuming there.
|
||||
|
||||
[parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts) registers `mathCompatibility()` after `math()`: micromark dispatches the extension registered last first, so the dollar flow above takes precedence over upstream's `mathFlow` while `math()` still owns inline `$…$`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the compatibility layer at same-line `$$…$$`.** The multi-line shape is what GitHub, KaTeX's auto-render, VS Code, and Typora accept, and the form models emit for multi-case formulas; leaving it unparsed means a red error span in place of the formula plus the rest of the document.
|
||||
|
||||
**Repair the parsed tree afterwards.** The flow consumes the swallowed text into `mathFlowValue`, where it is indistinguishable from math source; `meta` and `value` cannot be reassembled into the intended block, and the following blocks were never parsed at all.
|
||||
|
||||
**Rewrite `$$` fences in the source text before parsing.** The compatibility layer is a grammar extension by design; a text pass would need to locate code spans, fences, and escapes itself, and would change author text rather than parse it.
|
||||
|
||||
**Continue the dollar flow after a failed close.** That is what a plain `multiline` flag produces: `$$x$$ trailing\n\n$$y$$` then scans to the second block's `$$` and renders one math node holding both lines' text, where upstream inline math renders `x`, the prose, and `y` separately. Existing behavior for `$$x$$ trailing` already pinned the fallback, so the flow declines the candidate instead. Declining on any `$` rather than a full `$$` sequence also broke formulas that use a solitary `$` inside `\text{}`.
|
||||
|
||||
**End the attempt as soon as one line resists parsing.** Declining every failed same-line close swallows nothing but turns `$$a$b` lines into a quadratic scan and drops the solitary-`$` formulas above. The condition is the full `$$` sequence, not the character.
|
||||
|
||||
**Claim the paragraph interruption in the check.** Returning success there, as upstream's flow does for the fences it accepts, prevents the lazy bookkeeping that broke the list case, but it also ends the paragraph for candidates that later fail (`**before\n$$a\$b\nafter**` split in two and exposed its `**`). Declining instead lets upstream's own check decide.
|
||||
|
||||
**Apply the interruption guard to the backslash construct too.** The guard exists to protect the construct tried after this one; `\[`/`\(` have no second construct, and an unconditional guard dropped their multi-line blocks (`Prose line` followed by `\[`).
|
||||
|
||||
## Consequences
|
||||
|
||||
The three shapes now produce the same math node, so formulas stop truncating at the opening line and stop swallowing the prose after them. `$$` fence meta is no longer honored: `$$asciimath$$` parses as a formula whose value is `asciimath`, which upstream's flow treats as fence meta and drops. That spelling has no user in this repository, and the renderer never read `meta` anyway.
|
||||
|
||||
Six inputs still reach upstream's flow or stay in the surrounding paragraph, because the compatibility flow declines them. Trailing text after a closing `$$` (`$$x$$ trailing`, or a CJK quote mark closing a line of prose) resolves through upstream inline math, as the existing `markdown.client.spec.tsx` case pins — including when a later `$$` block follows. A `$$` sequence the line does not end after declines the block: immediately on a later line, and at that line's ending on the opening line. A `$$` block left open runs to the next lone `$$` or to EOF exactly as it did before this change, because the upstream flow closes at EOF.
|
||||
|
||||
The fourth is a multi-line block whose opening line carries a solitary `$` and that must interrupt a paragraph, with no blank line before it. Both flows decline the interruption there — this one by design, upstream because its fence meta stops at the `$` — so the block stays inside the paragraph and renders as inline math or not at all. Separating the block from the paragraph with a blank line makes this flow own it and render the display block.
|
||||
|
||||
The fifth follows from the comment gate: a block whose only closing fence sits behind a comment that holds text keeps scanning instead of closing there, so when nothing else closes it the block reaches the upstream flow as the shapes above do. A fence behind a whitespace-only comment closes the block there, which leaves a following lone `$$` as its own empty block where the flow it replaced kept one node.
|
||||
|
||||
The sixth is a multi-line block inside a container whose continuation line falls back to the container's indentation. `- Define:` followed by `$$f(x)=x` at the item's indentation and then a de-indented continuation line gives upstream's flow a fence meta and no value, so the reader sees an empty display block and the continuation as prose — the opening line is gone. Keeping the continuation at the same indentation, or moving the block out of the list, renders it normally. Both flow behaviors match the grammar this replaced; only the multi-line blocks this flow owns are new.
|
||||
|
||||
One further limitation is not a fallback: the flow reads the six characters before a fence and does not parse verbatim regions, so a fence inside an unterminated region (`\verb|$$`) closes the block there, and a region whose text ends in `\verb` (`\verb**\verb*$$`) has its fence read as that text's delimiter. The six characters before either fence are locally identical, so no rule that reads only that tail separates them.
|
||||
|
||||
Repeated unclosed candidates keep linear parser work: an open candidate declines at the next line that starts with a full `$$` it cannot close, rather than scanning each candidate to EOF, which would make 9.6 KB of `$$a$b` lines take ~4 s instead of ~16 ms. KaTeX-level failures in a formula's own source, such as `\arccot`, remain errors by design.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-primitives/tests/markdown.client.spec.tsx` adds the multi-line case — opening-line math closed on a content line, closed by a lone fence, after a paragraph, inside a list item, and at column zero after a list item — and that case fails without the change. The same file also pins a multi-line formula that uses a solitary `$`, which like the case above renders only with this change. Further cases pin one condition each: a trailing-text candidate ahead of a later block (the flow must decline a full `$$` the line does not end after), a solitary `$` inside a single-line display formula (the flow must not decline on a single `$`), an escaped-dollar candidate that leaves its paragraph intact (the interruption check must not claim the paragraph), an unclosed `\[` candidate that leaves its paragraph intact (the guard must not apply to the backslash construct), a candidate ending in an odd backslash (the escape run must end at the line ending), a closing fence inside a TeX comment, a `%` the fence follows immediately or after whitespace, a second `%` inside an open comment, a `%` inside `\verb|…|`, the delimiter right after `\verb` and `\verb*` together with its empty region, an escaped backslash before `verb`, a verb tail that crosses no line ending, a comment ending in `verb`, a later-line trailing fence whose following prose upstream takes, a failed fence inside a comment, and bounded-work cases for repeated candidates with that backslash, without it, and ending in a verb tail.
|
||||
|
||||
The DOM compatibility corpus adds `math-multiline-display` with its `.settled.txt` and `.streaming.txt` fixtures, which also pin that a column-zero `$$` after a list item stays outside the list item. `apps/web/tests/math-rendering.e2e.ts` renders the multi-line shape in the browser lane.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Agent Note: Multi-line display-dollar math
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-17-multiline-display-dollar-math.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
作者和模型写 `$$` 行间公式时有三种写法。定稿语法只渲染其中两种:一行内开闭的 `$$math$$`,以及 `$$` 独占一行、由独占一行的 `$$` 闭合。第三种——开围栏同一行就带公式内容,闭合 `$$` 落在最后一行公式行行尾——不渲染:
|
||||
|
||||
```markdown
|
||||
$$f(x)=x
|
||||
\text{continued}$$
|
||||
```
|
||||
|
||||
`micromark-extension-math` 把开围栏 `$$` 之后的同一行文本当作围栏 meta,并且只接受内容恰为 `$$`(后接可选空白)的行作为闭合围栏。[markdown 兼容层](../../archived/feature/2026-07-23-web-assistant-markdown.md)只认同一行开闭这一种形状,而定稿渲染器把 `node.value` 交给 KaTeX,不读 `node.meta`。于是该块始终不闭合:它吞掉后续正文,`value` 里残留字面 `$$`,KaTeX 抛出 `Can't use function '$' in math mode`,[katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx) 的错误分支把 TeX 原文连同被吞掉的正文一起显示为红色。
|
||||
|
||||
在暴露该问题的会话中,一条聊天回复 55 行里有 34 行(第 22–55 行)变成同一个错误块,六个笔记文件从出错点起的所有行(最多约 500 行)同样变成一个错误块。同一机制还带出第二个缺陷:当更靠后的独占行 `$$` 恰好闭合了这个流时,开围栏那一行已经归给 `meta`,渲染器于是静默丢掉公式的第一行。
|
||||
|
||||
## Decision
|
||||
|
||||
[mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) 现在支持跨行的显示美元块。dollar 构造把开围栏同一行的文本留在 `mathFlowValue` 里而不是当作围栏 meta,并在后续任意内容行行尾的 `$$` 处闭合,于是 `$$a+b` 接 `c+d$$` 成为一个 math 节点。
|
||||
|
||||
闭合失败的 `$$` 序列会结束该候选:出现在后续行时立即结束;出现在开围栏行时在该行行尾结束(`$$x$$ trailing` 即在此放弃)。在开围栏行内扫描继续,因此同一行更靠后的 `$$` 仍能闭合(`$$\verb*a$$a$$`)。越过这两点继续扫描都会吞掉更靠后一个块的闭合围栏。孤立的一个 `$` 属于公式内容而不是失败的围栏,所以凡是本 flow 拥有该块的场合,用到它的公式——`$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$`——保持块打开并正常闭合。行尾反斜杠转义的是换行本身,不是下一行的第一个字符,因此转义状态在换行处结束,不会延续到下一个候选。未转义的 `%` 会开启延续到行尾的 TeX 注释:当注释在围栏之前还有文本时,块后续行行尾的 `$$` 不闭合任何东西;而围栏之前只有空白或什么都没有的 `%`(`95%$$`、`95 %$$`)不含注释文本,且在一行内开闭的块仍在该行的 `$$` 处闭合,与它跨行之前的行为一致。该状态及其对闭合判定的门控属于 dollar flow。`\verb` 或 `\verb*` 之后的那个字符是该命令的分隔符而不是围栏,因此该分隔符、以及区域为空时紧随其后的那个 `$` 都属于内容;这段尾部状态在换行处、以及在被转义的反斜杠之后清空(`\\verb` 是换行加字母,不是该命令),注释里也不识别命令。除此之外不解析原样文本区域,使含 `\verb` 的公式与其它输入走同一条 value 路径。
|
||||
|
||||
它的段落打断检查改为「不认领」,且只作用于 dollar 构造。在那里消耗换行会让段落跨行边界保持打开,document 层因此保留容器并把下一行对随后尝试的所有构造计为 lazy;这正是上游 `mathFlow`(本构造之后被尝试的构造)在列表项内以空块结束的原因。不认领同时把打断决定交给上游 flow:它自己的检查不消耗换行就短路返回,并且只认领它接受的围栏,因此两个构造都不接受的候选(`**before` 后接 `$$a\$b`)保持原段落。反斜杠构造没有竞争的上游构造,仍然照旧消耗换行。
|
||||
|
||||
[parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts) 把 `mathCompatibility()` 注册在 `math()` 之后:micromark 先派发最后注册的扩展,上面的 dollar flow 因此优先于上游 `mathFlow`,而行内 `$…$` 仍由 `math()` 负责。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**兼容层维持只处理同一行 `$$…$$`。** 多行写法是 GitHub、KaTeX auto-render、VS Code、Typora 都接受的形状,也是模型写多分支公式时实际采用的形状;不解析它意味着公式位置显示为红色错误块,并连带吞掉后文。
|
||||
|
||||
**解析后修补语法树。** 被吞掉的正文已经进入 `mathFlowValue`,与数学源码无法区分;`meta` 和 `value` 无法还原成原意的块,而且后面的块根本没有被解析。
|
||||
|
||||
**解析前用文本替换改写 `$$` 围栏。** 兼容层的设计就是语法扩展;文本替换需要自己定位代码 span、围栏和转义,而且改的是作者原文而不是解析它。
|
||||
|
||||
**闭合失败后继续扫描。** 这正是单纯打开 `multiline` 会得到的行为:`$$x$$ trailing\n\n$$y$$` 会一路扫到第二个块的 `$$`,渲染成一个同时含着两行文本的 math 节点;而上游行内数学会把 `x`、正文和 `y` 分开渲染。`$$x$$ trailing` 的既有行为已经固定了这条回退,因此该构造放弃这个候选。对任意一个 `$`(而不是完整的 `$$` 序列)就放弃,还会破坏在 `\text{}` 内使用单个 `$` 的公式。
|
||||
|
||||
**只要有一行解析不下去就结束尝试。** 对每次同一行闭合失败都放弃虽然不再吞文,却会让 `$$a$b` 这类行变成平方级扫描,并丢掉上面那些含单个 `$` 的公式。判据是完整的 `$$` 序列,不是那个字符。
|
||||
|
||||
**在检查阶段认领段落打断。** 像上游 flow 那样返回成功可以避免破坏列表用例的 lazy 记账,但也会让最终失败的候选提前结束段落(`**before\n$$a\$b\nafter**` 被拆成两段并暴露 `**`)。改为不认领,把决定交给上游自己的检查。
|
||||
|
||||
**把打断守卫也用在反斜杠构造上。** 这个守卫是为了保护随后被尝试的构造;`\[`/`\(` 没有第二个构造,无条件套用会丢掉它们的跨行块(`Prose line` 后接 `\[`)。
|
||||
|
||||
## Consequences
|
||||
|
||||
三种写法现在产生同一个 math 节点,公式不再在开围栏那一行被截断,也不再吞掉其后的正文。`$$` 围栏 meta 不再生效:`$$asciimath$$` 会解析成 value 为 `asciimath` 的公式,而上游 flow 会把该段当作 fence meta 丢弃。这种写法在本仓库没有使用者,且渲染器本来就从不读 `meta`。
|
||||
|
||||
有六种输入仍会走到上游 flow,或留在所属段落里,因为兼容 flow 主动放弃它们。闭合 `$$` 之后还有文本时(`$$x$$ trailing`,或用中文引号收尾的散文行)走上游行内数学,这一点由 `markdown.client.spec.tsx` 中已有用例固定,后面还有别的 `$$` 块时同样如此。「后面不是行尾」的 `$$` 序列会让块放弃:出现在后续行时立即放弃,出现在开围栏行时在该行行尾放弃。未闭合的 `$$` 块仍像本次改动之前那样延伸到下一个独占行 `$$` 或文件结尾,因为上游 flow 在 EOF 处闭合。
|
||||
|
||||
第四种是「开围栏行含孤立 `$`、且需要打断段落、前面又没有空行」的多行块。两个 flow 都不认领这次打断——本 flow 出于设计,上游则因为它的围栏 meta 在 `$` 处即停止——该块因此留在段落里,渲染成行内数学或根本不渲染。用空行把块与段落分开,本 flow 就会拥有它并渲染 display。
|
||||
|
||||
第五种来自注释门控:当块唯一的闭合围栏位于「带文本的注释」之后时,块继续扫描而不是在那里闭合,若再没有别的围栏闭合它,就会像上面几种一样走到上游 flow。若围栏之前只有空白,则块在该处闭合,此后单独一行的 `$$` 会成为它自己的空块——而被替换掉的语法那时只给出一个节点。
|
||||
|
||||
第六种是容器内续行退回容器缩进的多行块。`- Define:` 之后以列表项缩进写 `$$f(x)=x`,再接一行不缩进的续行,上游 flow 会得到有 meta、无 value 的结果:读者看到一个空的 display 块加一行散文,公式首行无声消失。续行保持同样缩进,或把整块移到列表之外,即可正常渲染。这两种 flow 行为都与本次替换掉的语法一致;真正新增的只是「本 flow 拥有的多行块」。
|
||||
|
||||
另有一项不属于回退的限制:flow 只读取围栏之前的六个字符、不解析原样文本区域,因此未闭合区域内部的围栏(`\verb|$$`)会在那里闭合块,而内容以文本 `\verb` 结尾的区域(`\verb**\verb*$$`)其围栏会被读成该文本的分隔符。两种情况下围栏前的六个字符在局部完全相同,仅凭这段尾部无法区分。
|
||||
|
||||
重复的未闭合候选保持线性解析开销:未闭合的候选会在下一行遇到无法闭合的完整 `$$` 时放弃,而不是把每个候选都扫到 EOF——否则 9.6 KB 的 `$$a$b` 行会花约 4 秒而不是约 16 毫秒。公式自身源码层面的 KaTeX 失败,例如 `\arccot`,仍按设计报错。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-primitives/tests/markdown.client.spec.tsx` 新增多行用例——开围栏行内容由内容行行尾 `$$` 闭合、由独占行 `$$` 闭合、出现在段落之后、出现在列表项内,以及列表项之后的零缩进围栏——该用例去掉改动即失败。同一文件还固定了「使用单个 `$` 的多行公式」,它与上面的多行用例一样只有本次改动才会渲染成 display。另有一组用例各自对应一个条件:后面还有块时的尾随文本候选(flow 必须放弃「后面不是行尾」的完整 `$$`)、单行显示公式内的单个 `$`(不得因为一个 `$` 就放弃)、保持段落完整性的转义美元候选(打断检查不得认领段落)、保持段落完整性的未闭合 `\[` 候选(守卫不得作用于反斜杠构造)、以奇数个反斜杠结尾的候选(转义状态必须在换行处结束)、TeX 注释内的闭合围栏、紧邻围栏或只隔空白的 `%`、已开启注释内的第二个 `%`、`\verb|…|` 内的 `%`、`\verb` 与 `\verb*` 之后的分隔符及其空区域、`verb` 之前的转义反斜杠、不跨行的 verb 尾部、以 `verb` 结尾的注释、后续行尾随文本围栏(其后正文由上游接管)、注释内失败的围栏,以及带该反斜杠、不带该反斜杠、以 verb 尾部结尾三种重复候选的有界解析开销。
|
||||
|
||||
DOM 兼容语料新增 `math-multiline-display` 及其 `.settled.txt`、`.streaming.txt`,同时固定了「列表项之后的零缩进 `$$` 不进入列表项内」。`apps/web/tests/math-rendering.e2e.ts` 在浏览器 lane 渲染多行写法。
|
||||
@@ -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。浏览器本地存储按现有草稿策略保留未发送的所选文本。
|
||||
@@ -15,8 +15,8 @@
|
||||
en: db0a2deb9193a00d
|
||||
zh: dab5a65b88957464
|
||||
/agent-note-experimental-auto-review-before-each-tool-call/decision/one-complete-reviewer-request:
|
||||
en: 3a07a59b6c5ec26c
|
||||
zh: e4f3290dc6d26368
|
||||
en: 7bd9fef90e8639b9
|
||||
zh: e2a096885c9c1e83
|
||||
/agent-note-experimental-auto-review-before-each-tool-call/decision/result-and-cancellation:
|
||||
en: 647fe68c52b907e0
|
||||
zh: 267a739bad44ad65
|
||||
|
||||
@@ -30,19 +30,20 @@ The reviewer derives authority from existing Session facts. Text with `source.ki
|
||||
|
||||
### One complete reviewer request
|
||||
|
||||
The integration uses only the latest `request/header.config` provider/model and the shipped adapter's default reasoning. It neither compares redundant route metadata nor copies the main agent's request. The request has five fixed partitions:
|
||||
The integration uses only the latest `request/header.config` provider/model and the shipped adapter's default reasoning. It neither compares redundant route metadata nor copies the main agent's request. The request is a fixed system prompt followed by user messages:
|
||||
|
||||
| Partition | Retained input |
|
||||
| Part | Retained input |
|
||||
| --- | --- |
|
||||
| `REVIEW_POLICY` | Fixed classification, source authority, and strict result rules; allow executes immediately with Full access and no later confirmation |
|
||||
| `ENVIRONMENT` | Existing Session header `cwd` only |
|
||||
| `PROJECT_INSTRUCTIONS` | Visible project instructions with their original sources and constraint role |
|
||||
| `FILTERED_HISTORY` | Current compaction surface's sourced user/direct-parent messages, checkpoints, image/attachment facts, and historical call names with logged arguments |
|
||||
| `PENDING_ACTION` | Tool name, description, parameter schema, and parsed arguments |
|
||||
| System prompt `REVIEW_POLICY` | Fixed classification, source authority, message layout, and strict result rules; allow executes immediately with Full access and no later confirmation |
|
||||
| First user message `ENVIRONMENT` | Existing Session header `cwd` only |
|
||||
| One user message per retained item | Current compaction surface's sourced user/direct-parent messages, project instructions with the constraint role, checkpoints, image/attachment facts, historical call names with logged arguments, and a `tool-schema` item before the first call that used each reviewed schema, in surface order |
|
||||
| Final user message | The pending call with its logged arguments; the latest earlier `tool-schema` item with its mode and name gives the tool description and parameter schema |
|
||||
|
||||
The reviewer builds those two action sections from the Session's complete action history: an authorization is the earlier call the `REVIEW_POLICY` source rules make eligible, and a duplicate or conflicting identity has to be visible anywhere in the log rather than inside a recent window, so neither the Session projections nor a bounded read serves the decision. The read is the deprecated synchronous `snapshotEvents()` under a line-scoped `typescript/no-deprecated` waiver.
|
||||
Each item is its own message and keeps the same text in later reviews: the pending call becomes a historical call, and the integration remembers, for each live Session, the schema each reviewed call showed so that it reappears at the same position. A review's messages therefore begin with all messages of the previous review. Providers that cache only at message ends, such as OpenAI implicit breakpoints on GPT-5.6 and later, or at a final marker with lookback, such as Anthropic `cache_control`, reuse the previous review; one growing user message let them reuse only the policy. The schema memory is process-local, so the first review after a restart omits earlier schemas and misses the cache once.
|
||||
|
||||
The main agent's V3 `system/message` nodes, assistant text/reasoning, and tool results are excluded. The current call must belong to the open step recorded by `step/start`; missing step ownership fails closed. It appears only in `PENDING_ACTION`; an unstarted sibling has no historical call fact. Native schema comes from the latest request header. PTC captures a frozen schema at binding construction and passes it through the scheduler into `ToolExecution`; descriptions and parameter schemas never enter start/settle events or the Session/SDK wire. Missing, inconsistent, or ambiguous action facts reject the call without consulting the live registry. An oversized request fails closed without summarization, truncation, another compaction pass, or a small output-token budget.
|
||||
The reviewer builds the history and the pending call from the Session's complete action history: an authorization is the earlier call the `REVIEW_POLICY` source rules make eligible, and a duplicate or conflicting identity has to be visible anywhere in the log rather than inside a recent window, so neither the Session projections nor a bounded read serves the decision. The read is the deprecated synchronous `snapshotEvents()` under a line-scoped `typescript/no-deprecated` waiver.
|
||||
|
||||
The main agent's V3 `system/message` nodes, assistant text/reasoning, and tool results are excluded. The current call must belong to the open step recorded by `step/start`; missing step ownership fails closed. It appears only as the final message; an unstarted sibling has no historical call fact. Native schema comes from the latest request header. PTC captures a frozen schema at binding construction and passes it through the scheduler into `ToolExecution`; descriptions and parameter schemas never enter start/settle events or the Session/SDK wire. Missing, inconsistent, or ambiguous action facts reject the call without consulting the live registry. An oversized request fails closed without summarization, truncation, another compaction pass, or a small output-token budget.
|
||||
|
||||
### Result and cancellation
|
||||
|
||||
|
||||
@@ -30,19 +30,20 @@ Reviewer 从既有 Session 事实派生权威。`source.kind === 'user'` 的文
|
||||
|
||||
### 一次完整 reviewer 请求
|
||||
|
||||
Integration 只使用最新 `request/header.config` 的 provider/model 与 shipped adapter 默认 reasoning。它既不比较冗余 route metadata,也不复制主 agent 请求。请求固定包含五个分区:
|
||||
Integration 只使用最新 `request/header.config` 的 provider/model 与 shipped adapter 默认 reasoning。它既不比较冗余 route metadata,也不复制主 agent 请求。请求由固定 system prompt 和其后的 user 消息组成:
|
||||
|
||||
| 分区 | 保留输入 |
|
||||
| 部分 | 保留输入 |
|
||||
| --- | --- |
|
||||
| `REVIEW_POLICY` | 固定分类、来源权威与严格结果规则;allow 后立即以 Full access 执行,没有后续确认 |
|
||||
| `ENVIRONMENT` | 仅既有 Session header `cwd` |
|
||||
| `PROJECT_INSTRUCTIONS` | 可见项目指令,保留原始来源并赋予约束角色 |
|
||||
| `FILTERED_HISTORY` | 当前 compaction surface 中带来源的用户/直接父级消息、checkpoint、图片/附件事实,以及历史调用名称与日志参数 |
|
||||
| `PENDING_ACTION` | 工具名称、描述、参数 schema 与解析后的 arguments |
|
||||
| System prompt `REVIEW_POLICY` | 固定分类、来源权威、消息布局与严格结果规则;allow 后立即以 Full access 执行,没有后续确认 |
|
||||
| 第一条 user 消息 `ENVIRONMENT` | 仅既有 Session header `cwd` |
|
||||
| 每个保留条目一条 user 消息 | 当前 compaction surface 中带来源的用户/直接父级消息、带约束角色的项目指令、checkpoint、图片/附件事实、历史调用名称与日志参数,以及在首次使用某个已审 schema 的调用之前出现的 `tool-schema` 条目,按 surface 顺序排列 |
|
||||
| 最后一条 user 消息 | 待审调用及其日志参数;其之前同 mode 与名称的最新 `tool-schema` 条目给出工具描述与参数 schema |
|
||||
|
||||
reviewer 从 Session 的完整动作历史构建这两个动作分节:授权是符合 `REVIEW_POLICY` 来源规则的那个更早调用,而重复或冲突的身份必须在整份日志的任何位置都可见,而不是只在近期窗口内可见,因此 Session 投影与有界读取都无法支撑该判定。这次读取使用已废弃的同步 `snapshotEvents()`,并带有行级 `typescript/no-deprecated` 豁免。
|
||||
每个条目各占一条消息,并在后续评审中保持相同文本:待审调用会变成历史调用,integration 为每个存活 Session 记住每次已审调用展示的 schema,使其在同一位置再次出现。因此一次评审的消息以上一次评审的全部消息开头。只在消息结尾缓存的 provider(如 GPT-5.6 及以后的 OpenAI 隐式断点),或在末尾标记处向前回溯的 provider(如 Anthropic `cache_control`),都能复用上一次评审;单条不断增长的 user 消息只能让它们复用策略。Schema 记忆只在进程内,因此重启后的第一次评审会缺少先前的 schema,并有一次缓存未命中。
|
||||
|
||||
主 agent 的 V3 `system/message` 节点、assistant 正文/reasoning 与 tool results 全部排除。当前调用必须属于 `step/start` 记录的开放 step;缺少 step 归属时拒绝执行。该调用只在 `PENDING_ACTION` 出现;尚未开始的 sibling 没有历史调用事实。原生 schema 来自最新 request header。PTC 在 binding 构造时捕获冻结 schema,经由调度器传入 `ToolExecution`;描述与参数 schema 不进入开始/结算事件或 Session/SDK wire。动作事实缺失、不一致或有歧义时拒绝调用,不查询 live registry。超窗请求直接拒绝,不做摘要、截断、额外 compaction 或设置小型输出 token 预算。
|
||||
reviewer 从 Session 的完整动作历史构建历史与待审调用:授权是符合 `REVIEW_POLICY` 来源规则的那个更早调用,而重复或冲突的身份必须在整份日志的任何位置都可见,而不是只在近期窗口内可见,因此 Session 投影与有界读取都无法支撑该判定。这次读取使用已废弃的同步 `snapshotEvents()`,并带有行级 `typescript/no-deprecated` 豁免。
|
||||
|
||||
主 agent 的 V3 `system/message` 节点、assistant 正文/reasoning 与 tool results 全部排除。当前调用必须属于 `step/start` 记录的开放 step;缺少 step 归属时拒绝执行。该调用只作为最后一条消息出现;尚未开始的 sibling 没有历史调用事实。原生 schema 来自最新 request header。PTC 在 binding 构造时捕获冻结 schema,经由调度器传入 `ToolExecution`;描述与参数 schema 不进入开始/结算事件或 Session/SDK wire。动作事实缺失、不一致或有歧义时拒绝调用,不查询 live registry。超窗请求直接拒绝,不做摘要、截断、额外 compaction 或设置小型输出 token 预算。
|
||||
|
||||
### 结果与取消
|
||||
|
||||
|
||||
@@ -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
@@ -1,22 +0,0 @@
|
||||
# Bilingual-pair consistency record for 2026-09-29-weighted-approval-decision-check-run.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-09-29-weighted-approval-decision-check-run.md
|
||||
/agent-note-weighted-approval-decisions-are-completed-check-runs:
|
||||
en: 1b120e96b0f5f340
|
||||
zh: ff20c20fe0558964
|
||||
/agent-note-weighted-approval-decisions-are-completed-check-runs/problem:
|
||||
en: 6ba6f6a15a1562ec
|
||||
zh: 1cbef3e736c258e6
|
||||
/agent-note-weighted-approval-decisions-are-completed-check-runs/decision:
|
||||
en: 7dcebfed221053cc
|
||||
zh: 84346547d1b95ee3
|
||||
/agent-note-weighted-approval-decisions-are-completed-check-runs/verification:
|
||||
en: 3771d95af79c69bb
|
||||
zh: d4053338d878511f
|
||||
/agent-note-weighted-approval-decisions-are-completed-check-runs/alternatives-considered:
|
||||
en: b8daea66b0a90d26
|
||||
zh: fb7079c815b38281
|
||||
/agent-note-weighted-approval-decisions-are-completed-check-runs/consequences:
|
||||
en: 5708f2112ee1e647
|
||||
zh: 02d29ce5b18db06d
|
||||
@@ -1,61 +0,0 @@
|
||||
# Agent Note: Weighted approval decisions are completed check runs
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-29-weighted-approval-decision-check-run.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The weighted approval policy must block merging until its conditions hold, distinguish an unmet condition from a failed evaluation, and let tools that wait for pull-request checks to finish terminate. A draft pull request, insufficient approval points, and an effective `CHANGES_REQUESTED` review from a write-capable reviewer are reversible review states rather than evaluation failures.
|
||||
|
||||
Commit statuses offer only `pending`, `success`, `failure`, and `error`. A `pending` commit status blocks merging, but `gh pr checks --watch` and other CI watchers treat it as running work and wait until someone approves. A `failure` commit status ends the wait but reports an unmet, reversible condition as a malfunction and conflates the policy outcome with publisher health.
|
||||
|
||||
## Decision
|
||||
|
||||
The [publisher](../../../../.github/review-ownership/check-approval.mjs) reports the decision as the `weighted approval decision` check run on the pull-request head, written with the job's GitHub Actions token. Branch rules require that check run with GitHub Actions as its expected source.
|
||||
|
||||
| Condition | Check run |
|
||||
|---|---|
|
||||
| First evaluation of a head | `in_progress` until the decision is written |
|
||||
| Re-evaluating a head that published `success` | conclusion `action_required` until the decision is written |
|
||||
| Draft, fewer than the required points, or an effective write-capable `CHANGES_REQUESTED` review | `completed`, conclusion `action_required` |
|
||||
| Ready, threshold met, and no blocking review | `completed`, conclusion `success` |
|
||||
| Setup, evaluation, or cancellation failure | `completed`, conclusion `failure` |
|
||||
|
||||
A check run that reached `completed` never returns to `in_progress` or `queued`: GitHub answers `200` to that update and leaves the run completed, so only the conclusion is mutable. The publisher therefore revokes a published success by writing the blocking conclusion before dependency setup, which covers the setup and history fetch that dominate a cold evaluation. The head keeps one GitHub Actions run with the required name: the publisher updates the head's existing run and creates one only when none exists. An incomplete list or a run without a valid ID fails the publication. The publish step records the conclusion it wrote in a step output, and the setup-failure step runs only when no conclusion was recorded: a published decision survives a later failure or cancellation, while a run cancelled before that write concludes `failure`. A run cancelled before the publish step records its conclusion, including a cancellation whose file-command outputs the runner drops, therefore concludes `failure`, which still blocks the merge. During a re-evaluation, a previous decision other than a published success keeps its conclusion and title until the new decision is written; a published success is replaced by `action_required` and the preparation title.
|
||||
|
||||
A blocking review dominates the point total, so the conclusion remains `action_required` even when counted approvals reach the threshold. GitHub accepts only `success`, `neutral`, and `skipped` for a required check, so `action_required` blocks merging while completing the check.
|
||||
|
||||
## Verification
|
||||
|
||||
[Approval policy tests](../../../../.github/review-ownership/check-approval.test.mjs) pin the threshold-reaching blocker case, the exact published check-run payloads, revoking a published success through its conclusion, updating an existing run instead of creating another, recording the conclusion that the setup-failure step reads, and invalid list responses. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the `checks: write` permission, the `revoke` and `failure` phase commands, the publish step identity, and the fallback that runs when a run fails or is cancelled before publishing.
|
||||
|
||||
The transition matrix and the fallback condition were measured on 2026-09-30 with throwaway tag runs in this repository, using the same GitHub Actions token as the publisher. A run created as `in_progress` and completed as `success` accepted `status: in_progress` and `status: queued` with `200` and stayed `completed`, while conclusion-only updates moved it to `action_required`, back to `success`, and to `failure`. Those runs measured the fallback condition as it stood on 2026-09-30 (`failure() || (cancelled() && steps.publish.outcome != 'success')`): the step ran for a failed publish and for a cancellation during the publish step, and did not run when the job was cancelled after the decision was published. The current condition also requires an empty `concluded` output, so a job cancelled after the publish step recorded its conclusion skips the fallback in either condition. A `timeout-minutes` cancellation was measured separately on 2026-10-20: a job that exceeded its one-minute timeout on a throwaway `push` branch still ran its `cancelled()` step, so the step executes on that path; whether a cancelled step's file-command outputs are exposed was not measured, and dropping them only makes that cancellation conclude `failure`.
|
||||
|
||||
The list response's `total_count` counts the `check_name`-filtered runs rather than every check on the head: on 2026-10-20 a head carrying 33 check runs reported `total_count` 0 for a name that matches none and 2 for an existing name, so an equality between `total_count` and the returned array length rejects a truncated page.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the `pending` commit status.** Branch rules block correctly, but CI watchers cannot distinguish an approval wait from running checks and do not finish.
|
||||
|
||||
**Publish `failure` for unmet conditions.** This ends the wait, but it represents an unmet, reversible merge condition as a malfunction and conflates the policy outcome with publisher health.
|
||||
|
||||
**Revoke by returning the completed run to `in_progress`.** GitHub accepts that update without applying it, so the published success would stand through dependency setup and the history fetch.
|
||||
|
||||
**Publish nothing until success.** A required check that was never reported remains expected and is invisible to many watchers. Revoking a success already published on the same head still requires a non-success write, and each watcher's treatment of a missing required check is unspecified.
|
||||
|
||||
**Keep the `weighted approval` name.** When a check and a commit status share a required name, GitHub requires both to pass. Heads that carry a `pending` commit status from the earlier publisher would stay blocked until a new push.
|
||||
|
||||
**Create a new check run on every evaluation.** Several same-name check runs on one head make the result depend on how GitHub and each watcher select among them.
|
||||
|
||||
**Let approval points override a blocking review.** This makes the score the only success condition, but it permits success while a write-capable reviewer's effective decision still requests changes.
|
||||
|
||||
## Consequences
|
||||
|
||||
CI watchers finish while the decision waits on reviewers. `gh pr checks` groups `action_required` with failed checks and exits non-zero; consumers distinguish it by the check name, the `ACTION_REQUIRED` conclusion, or the output title, which carries the score or blocker. The `weighted approval publisher` Actions job and the `failure` conclusion carry operational failures.
|
||||
|
||||
Re-evaluating a head that already published `success` reports `action_required` until the new decision is written, so a watcher that polls inside that window sees a failed check.
|
||||
|
||||
The branch-rule switch leaves two gaps on heads the publisher has not evaluated since it. A check run's name does not identify its publisher. Any workflow in the pushed branch can name a job `weighted approval decision`: on 2026-10-20 a job with `if: false` on a throwaway branch produced a `github-actions` check run with that name and conclusion `skipped` on the pushed commit, and a required check accepts `skipped`. The publisher therefore revokes every passing conclusion it finds before dependency setup and adopts every same-named run when it publishes, which covers a run that exists at either write; a same-named passing run created after the publisher's last write on the head is outside its control. Requiring the check from a dedicated GitHub App is the only complete binding, and it replaces the job token with an app-installed identity.
|
||||
|
||||
A head without the decision check run reports the required check as expected until the next publisher event creates it; re-running a pre-switch run does not, because a re-run replays that run's earlier workflow file, which calls the removed `pending` phase against the new script. A re-run of a pre-switch run on 2026-10-20 executed the pre-switch step names and no check-run write. A pull-request edit triggers the publisher's `edited` event, so the rollout can create the run on a head that has none. A head that also carries the earlier `weighted approval` commit status keeps that pending status until the same event: the combined status resolves each context to its latest status, so the rollout posts a terminal `success` status for that context on every open head instead of waiting for its next push.
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
# Agent Note: 加权批准决策使用已完成的 check run
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-29-weighted-approval-decision-check-run.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
加权批准策略必须在条件满足之前阻止合并,区分尚未满足的条件与失败的评估,并让等待 PR 检查结束的工具能够退出。draft PR、批准点数不足,以及具有写权限的评审人仍然生效的 `CHANGES_REQUESTED` 评审,都是可撤销的评审状态,而不是评估故障。
|
||||
|
||||
commit status 只提供 `pending`、`success`、`failure` 和 `error`。`pending` commit status 会阻止合并,但 `gh pr checks --watch` 和其他 CI 监视工具把它当作仍在运行的工作,会一直等到有人批准。`failure` commit status 能结束等待,但会把尚未满足且可撤销的条件报告为故障,并混淆策略结果与 publisher 的健康状态。
|
||||
|
||||
## 决策
|
||||
|
||||
[publisher](../../../../.github/review-ownership/check-approval.mjs) 使用该 job 的 GitHub Actions token,在 PR head 上以 `weighted approval decision` check run 报告决策。分支规则要求该 check run,并把 GitHub Actions 设为预期来源。
|
||||
|
||||
| 条件 | Check run |
|
||||
|---|---|
|
||||
| head 的首次评估 | 写入决策前为 `in_progress` |
|
||||
| 重新评估已发布 `success` 的 head | 写入决策前 conclusion 为 `action_required` |
|
||||
| draft、点数少于要求,或存在具有写权限的评审人仍然生效的 `CHANGES_REQUESTED` 评审 | `completed`,conclusion 为 `action_required` |
|
||||
| 已 ready、达到阈值且没有阻塞性评审 | `completed`,conclusion 为 `success` |
|
||||
| 安装、评估或取消导致的失败 | `completed`,conclusion 为 `failure` |
|
||||
|
||||
已经到达 `completed` 的 check run 不会再回到 `in_progress` 或 `queued`:GitHub 对该更新返回 `200`,但让 run 保持已完成,因此只有 conclusion 可变。publisher 因此在依赖安装前用阻塞性 conclusion 撤销已发布的成功,覆盖冷启动中占主要耗时的安装与历史拉取。同一 head 上只保留一个同名 GitHub Actions run:publisher 更新 head 上已有的 run,仅在不存在时创建一个。列表不完整或 run 没有有效 ID 时发布失败。publish 步骤把自己写入的 conclusion 记录到 step output,安装失败步骤只在该 output 未记录时运行:已发布的决策在之后的失败或取消中保留,而在该写入之前被取消的 run 以 `failure` 结束。在 publish 步骤记录 conclusion 之前被取消的 run——包括 runner 丢弃其 file-command 输出的取消——因此以 `failure` 结束,仍然阻止合并。重新评估期间,先前决策若不是已发布的 `success`,其 conclusion 与标题会一直显示到新决策写入为止;已发布的 `success` 会被替换为 `action_required` 与准备中的标题。
|
||||
|
||||
阻塞性评审的优先级高于点数总和,因此即使计入的批准已经达到阈值,conclusion 仍为 `action_required`。GitHub 只接受 `success`、`neutral` 和 `skipped` 作为必需检查的通过结果,因此 `action_required` 会阻止合并,同时让该检查处于完成状态。
|
||||
|
||||
## 验证
|
||||
|
||||
[批准策略测试](../../../../.github/review-ownership/check-approval.test.mjs)锁定已达到阈值但仍有 blocker 的场景、准确发布的 check run payload、通过 conclusion 撤销已发布的成功、更新已有 run 而不是另建一个、记录安装失败步骤所读的 conclusion,以及无效的列表响应。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定 `checks: write` 权限、`revoke` 和 `failure` 阶段命令、publish 步骤的身份,以及运行失败或在发布前被取消时执行的兜底步骤。
|
||||
|
||||
转换矩阵与该兜底条件于 2026-09-30 在本仓库用一次性 tag 运行实测,使用的是与 publisher 相同的 GitHub Actions token。以 `in_progress` 创建、以 `success` 完成的 run,接受 `status: in_progress` 和 `status: queued` 的更新时都返回 `200` 并保持 `completed`;只改 conclusion 的更新把它依次改为 `action_required`、`success` 和 `failure`。这些运行测的是 2026-09-30 当时的兜底条件(`failure() || (cancelled() && steps.publish.outcome != 'success')`):兜底步骤在发布失败以及发布步骤执行期间被取消时运行,在决策发布之后才取消 job 时不运行。当前条件还要求 `concluded` output 为空,因此两种条件下,publish 步骤已记录结论后才取消的 job 都跳过兜底。`timeout-minutes` 取消路径于 2026-10-20 单独实测:一个在一次性 `push` 分支上超过一分钟超时的 job 仍然执行了 `cancelled()` 步骤,因此该步骤在这条路径上会执行;取消步骤的 file-command 输出是否会被暴露未实测,若被丢弃只会让该次取消以 `failure` 结束。
|
||||
|
||||
列表响应中的 `total_count` 统计的是经 `check_name` 过滤后的 run,而不是该 head 上的全部检查:2026-10-20 实测,一个带有 33 个 check run 的 head,对不匹配任何 run 的名称返回 `total_count` 0,对已存在的名称返回 2,因此用 `total_count` 与返回数组长度相等来拒绝被截断的分页是有效的。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**保留 `pending` commit status。** 分支规则能正确阻止合并,但 CI 监视工具无法区分等待批准与仍在运行的检查,因此不会结束。
|
||||
|
||||
**为尚未满足的条件发布 `failure`。** 该方案能结束等待,但会把尚未满足且可撤销的合并条件表示为故障,并混淆策略结果与 publisher 的健康状态。
|
||||
|
||||
**通过把已完成的 run 改回 `in_progress` 来撤销。** GitHub 接受该更新但不执行,因此已发布的成功会在依赖安装与历史拉取期间继续存在。
|
||||
|
||||
**在成功之前不发布任何结果。** 从未上报的必需检查保持 expected 状态,许多监视工具看不到它。撤销同一 head 上已发布的成功仍然需要写入非成功结果,而各监视工具如何对待缺失的必需检查没有规定。
|
||||
|
||||
**保留 `weighted approval` 名称。** 当 check 和 commit status 使用同一个必需名称时,GitHub 要求两者都通过。带有旧 publisher 所写 `pending` commit status 的 head 会一直被阻止,直到出现新的 push。
|
||||
|
||||
**每次评估都创建新的 check run。** 同一 head 上存在多个同名 check run 时,结果取决于 GitHub 和各监视工具如何在其中选择。
|
||||
|
||||
**允许批准点数覆盖阻塞性评审。** 该方案会让分数成为唯一的成功条件,但也允许在具有写权限的评审人仍然有效地要求修改时发布成功结果。
|
||||
|
||||
## 后果
|
||||
|
||||
决策等待评审人时,CI 监视工具会结束。`gh pr checks` 把 `action_required` 与失败的检查归为一组并以非零状态退出;消费方可以通过 check 名称、`ACTION_REQUIRED` conclusion 或携带分数或 blocker 的输出标题加以区分。`weighted approval publisher` Actions job 和 `failure` conclusion 承载运行故障信号。
|
||||
|
||||
重新评估已经发布 `success` 的 head 时,在新决策写入前会报告 `action_required`,因此在该窗口内轮询的监视工具会看到失败的检查。
|
||||
|
||||
分支规则切换后,对切换以来 publisher 尚未评估过的 head 存在两个缺口。check run 的名称并不标识发布者。被推送分支里的任何 workflow 都可以把一个 job 命名为 `weighted approval decision`:2026-10-20 在一次性分支上,带 `if: false` 的 job 在被推送的 commit 上产生了 `github-actions` 来源、该名称、conclusion 为 `skipped` 的 check run,而必需检查接受 `skipped`。因此 publisher 在依赖安装前撤销它找到的所有可通过的 conclusion,并在发布时接管所有同名 run,覆盖两次写入时已存在的 run;在 publisher 对该 head 最后一次写入之后新出现的同名可通过 run 不在其控制范围内。把该检查绑定到专用 GitHub App 是唯一完整的绑定方式,它会用 App 安装身份替换 job token。
|
||||
|
||||
没有该决策 check run 的 head 会把必需检查报告为 expected,直到下一次 publisher 事件创建它;重跑切换前的 run 不能完成迁移,因为重跑会回放该 run 当时的 workflow 文件,而它调用的 `pending` 阶段已被新脚本拒绝。2026-10-20 对切换前 run 的重跑执行的是切换前的步骤名,没有写入 check run。编辑 PR 会触发 publisher 的 `edited` 事件,因此 rollout 可以对没有该 run 的 head 制造这次事件。同时也带有先前 `weighted approval` commit status 的 head 会一直保留那个 pending status 直到同一事件:combined status 对每个 context 取最新的 status,因此 rollout 对每个开放 head 发布该 context 的终态 `success` status,而不是等它下一次 push。
|
||||
+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 懒惰续行或代码围栏解析。来源导航仍需独立设计持久坐标。
|
||||
@@ -56,7 +56,7 @@ Scrollbars stay inside their container: use the shared scrollbar styles rather t
|
||||
|
||||
## Responsive layouts
|
||||
|
||||
Use [dsh-responsive-ui](../dsh-responsive-ui/SKILL.md) for responsive layouts, text truncation, title wrapping, icon-only controls, and focus during width changes.
|
||||
Use [dsh-responsive-ui](../dsh-responsive-ui/SKILL.md) for responsive layouts, mobile and touch detection, text truncation, title wrapping, icon-only controls, and focus during width changes.
|
||||
|
||||
## Platform window adaptation
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo —
|
||||
- [dsh-ci-test-reliability](../dsh-ci-test-reliability/SKILL.md): isolation and regression-proof rules for resource-owning, asynchronous, or flaky tests and fixtures.
|
||||
- [dsh-client-ui-ux](../dsh-client-ui-ux/SKILL.md): feedback-surface choice, overlay safety, platform window adaptation, and visual token discipline for product-user-visible GUI changes.
|
||||
- [dsh-error-handling](../dsh-error-handling/SKILL.md): failure classification, handling ownership, containment, state guarantees, and actionable reports.
|
||||
- [dsh-session-projection](../dsh-session-projection/SKILL.md): projection ownership, state size, historical reads, and migrations.
|
||||
- [docs/testing.md](../../../docs/testing.md) and the [quality-gates reference](../../../docs/testing.md): required test tiers and gates.
|
||||
- [Agent Notes](../../notes/README.md): design rationale. Treat disagreement with an Agent Note as a design discussion, not an automatic veto.
|
||||
- For bilingual changes, read [translation-rules.md](../../../docs/i18n/translation-rules.md) and [terminology.md](../../../docs/i18n/terminology.md); the extended translation skill is outside automatic review and runs only on explicit user invocation.
|
||||
|
||||
@@ -124,7 +124,7 @@ For GitHub PRs, inspect remote CI after the push:
|
||||
gh pr checks
|
||||
```
|
||||
|
||||
Report pending checks as pending. Inspect failures before attributing them to the branch or the environment. A `weighted approval decision` check that `gh pr checks` lists as failed means the approval gate is waiting on reviewers only when its conclusion is `ACTION_REQUIRED`; the same check concluded `FAILURE` is a publisher fault, so read the `weighted approval publisher` job before attributing it to the branch.
|
||||
Report pending checks as pending. Inspect failures before attributing them to the branch or the environment.
|
||||
|
||||
When `gh pr checks` reports "no checks reported" and `/actions/runs?head_sha=<sha>` returns `total_count: 0`, read mergeability before suspecting the push or a dropped GitHub event:
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ This is not a one-way shortening pass. Add or restore prose when code, types, an
|
||||
- **Postmortems:** retain the incident sequence, evidence, causal chain, impact, and prevention. Remove repeated persuasion or implementation detail that does not establish causality.
|
||||
- **Skills and agent instructions:** state behavioral guardrails and explicit scope limitations such as “guidance, not a script/checklist.” Keep the workflow concise and link its source of truth.
|
||||
- **Examples and configuration comments:** explain access limits, non-obvious wiring or load order, security stance, replay behavior, exceptions, and likely misuse. Do not narrate entries that the configuration already shows.
|
||||
- **Prompts and visible strings:** treat wording as behavior. Client UI copy belongs in typed locale dictionaries and reaches Cordis-free primitives as explicit localized props; inspect text, accessibility names, tooltips, placeholders, and format templates together, then run `verify-client-ui-i18n`. Update the owning runnable snapshot for model-visible text and repository-required GUI evidence. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral; do not silently fold it into a prose-only edit.
|
||||
- **Prompts and visible strings:** treat wording as behavior. Client UI copy belongs in typed locale dictionaries and reaches Cordis-free primitives as explicit localized props; inspect text, accessibility names, tooltips, placeholders, and format templates together, then run `verify-client-ui-i18n`. English UI copy, including plugin `meta.title` and `meta.description`, uses sentence case: capitalize only the first word, proper nouns, and product names such as `Chrome DevTools`, `Git`, or `MCP` (`Git worktrees`, not `Git Worktrees`). Chinese copy keeps its own conventions. Update the owning runnable snapshot for model-visible text and repository-required GUI evidence. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral; do not silently fold it into a prose-only edit.
|
||||
- **Diagnostics:** name the failing subject or path, violated rule, and correction when it is non-obvious. Remove internal execution narration.
|
||||
|
||||
Preserve searchable mechanism names and meaningful modal, temporal, or negative emphasis. Normalize decorative emphasis only.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dsh-responsive-ui
|
||||
description: Use when designing, implementing, or reviewing responsive client layouts, text truncation, title wrapping, icon-only controls, mobile hover, tooltips and keyboard behavior, and focus during width changes in DeepSeek Harness.
|
||||
description: Use when designing, implementing, or reviewing responsive client layouts, mobile and touch detection, text truncation, title wrapping, icon-only controls, mobile hover, tooltips and keyboard behavior, and focus during width changes in DeepSeek Harness.
|
||||
---
|
||||
|
||||
# Responsive client UI
|
||||
@@ -39,10 +39,10 @@ Use a viewport width of 390 CSS px as the primary acceptance minimum. There must
|
||||
|
||||
## 6. Mobile hover behavior
|
||||
|
||||
1. Enable mouse-hover effects only on devices that support hovering. Use `@media (hover: hover)`, not viewport width, to determine this.
|
||||
2. Tapping on a phone must not leave behind backgrounds, highlights, or action-button visibility intended only for hover.
|
||||
3. Preserve normal selected, enabled, and pressed states, as well as focus indicators for keyboard interaction.
|
||||
4. Try to preserve focus when the width changes, unless the focused element disappears from the page as a result.
|
||||
1. Enable hover effects under `@media (hover: hover)`, independently of viewport width. This queries the primary pointing device, not the current event; JavaScript hover handlers must ignore touch events on mixed-input devices.
|
||||
2. Tapping on a phone must not leave behind backgrounds, highlights, or action-button visibility intended only for hover. Without hover, actions remain visible or reachable through an explicit control. Keyboard users can reach and reveal the same actions.
|
||||
3. Preserve normal selected, expanded, enabled, and pressed states, as well as focus indicators. Keep these styles independent of hover capability. Do not clear focus to remove a hover effect; `:focus-visible` can also apply to text fields focused by touch.
|
||||
4. Preserve focus during width changes when the control remains visible and interactive. Avoid remounting controls only to change layout. If the focused control is hidden or removed, move focus to a suitable remaining control without opening the mobile keyboard unnecessarily.
|
||||
|
||||
## 7. Mobile tooltips
|
||||
|
||||
@@ -52,3 +52,8 @@ Use a viewport width of 390 CSS px as the primary acceptance minimum. There must
|
||||
## 8. Mobile keyboard
|
||||
|
||||
On mobile, do not raise the input keyboard automatically unless it is explicitly intended.
|
||||
|
||||
## 9. Input capabilities and mobile text fields
|
||||
|
||||
1. Prefer CSS media features for presentation: `(pointer: coarse)` describes the primary pointer's accuracy, `(hover: none)` describes its inability to hover, and width queries select layout breaks. These queries do not detect a physical keyboard. Never branch layout on a parsed user agent.
|
||||
2. Text fields take a 16px floor under `(pointer: coarse)` to avoid iOS Safari's focus zoom. Fine pointers keep the existing compact field sizes. Use the field rules in `packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css` as the reference.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: dsh-session-projection
|
||||
description: Use when creating, extending, or reviewing a DeepSeek Harness session projection, or migrating a fold helper to one.
|
||||
---
|
||||
|
||||
# DeepSeek Harness Session Projections
|
||||
|
||||
Follow the [projection API](../../../packages/session/session-projection/README.md) for registration and lifecycle details.
|
||||
|
||||
1. Decide whether to extend an existing projection or create one; check whether an existing owner already maintains the needed state.
|
||||
2. Follow the README's [service ownership rules](../../../packages/session/session-projection/README.md#register-and-read): keep projection definitions and handles private to the owning service's implementation, and expose domain methods to consumers.
|
||||
3. Require `sessionProjections` when the service needs projected state to function. Optional registration is for contributions the service can operate without.
|
||||
4. Evaluate retained state size as the session grows. Do not accumulate state proportional to session length, including collections of event sequence numbers. Growth is acceptable only when slow enough to remain negligible. When the data would keep growing, consider incremental loading instead of retaining the complete state.
|
||||
5. `apply` is synchronous; historical reads belong in asynchronous consumers. When later reads need event payloads, consider retaining sequence numbers within rule 4's state-size limits and letting consumers resolve them asynchronously.
|
||||
6. When replacing a helper such as `foldSessionTitle` with a projection, remove the superseded helper.
|
||||
7. After migrating to Session persistence, stop using `fold` in names and descriptions of the migrated logic.
|
||||
8. Do not add calls to deprecated synchronous readers (`snapshotEvents`, `eventAt`, `ownEvents`) or wrappers exposing the same access ([decision](../../notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md)). Assume the full event log is not resident in memory and random reads may require asynchronous disk access.
|
||||
9. Consider state size, replay cost, and per-event work; add complexity only for a concrete performance need.
|
||||
10. Asynchronous `register()` is usually called in `[Service.init]`. If event listeners are registered afterward, check initialization ordering: when needed, install listeners before awaiting registration; if a listener needs the returned handle, await its readiness in a callback that supports asynchronous waiting. Otherwise, an event may fire before the listener is installed and be missed. Determine whether this race exists from the actual initialization flow and event trigger paths; do not move listeners earlier or add waits mechanically.
|
||||
@@ -16,11 +16,11 @@ 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 check publication completed, while the `weighted approval decision` check run carries the approval decision on the pull request head. Branch rules must require that check run with GitHub Actions as its expected source; a name-only requirement can accept a same-named check or status from another integration. The publisher updates the head's existing GitHub Actions decision check run and creates one only when none exists; a head without one starts `in_progress` and a re-evaluated head that published `success` is concluded `action_required` before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. A completed check run only ever changes conclusion, so every later write completes it again. Failures and cancellations that happen before the publish step records a conclusion conclude it as `failure`; a cancellation after that record keeps the published decision. A completed evaluation concludes `action_required` 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 that conclusion even when counted approvals reach the threshold. It concludes `success` only when the threshold is met, the pull request is ready, and no such blocker exists. `action_required` blocks merging but completes the check, so tools that wait for checks to finish do not wait for reviewers; the check-run title carries the score or blocker.
|
||||
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.
|
||||
|
||||
The PR author contributes `min(100, mergedPRCount) × 11 / 1000` points: 0 merged PRs → 0 points, 50 → 0.55, and 100 or more → 1.1. The publisher counts only merged PRs in this repository using the author’s immutable account ID, excluding the current PR. It stops at 100 matches and rejects incomplete history responses. Counts refresh at the next subscribed evaluation event. Author credit is separate from reviewer approvals and cannot satisfy the two-point requirement alone; the author’s own review remains excluded. Human and bot authors, including Dependabot, use the same rule. Drafts, blocking reviews, and sufficient reviewer points skip history lookup; logs mark credit as not evaluated. Otherwise, logs show author credit and the capped count separately. Below the cap, counting may scan the repository’s entire merged history; a failed required lookup concludes the check as `failure`.
|
||||
The PR author contributes `min(100, mergedPRCount) × 11 / 1000` points: 0 merged PRs → 0 points, 50 → 0.55, and 100 or more → 1.1. The publisher counts only merged PRs in this repository using the author’s immutable account ID, excluding the current PR. It stops at 100 matches and rejects incomplete history responses. Counts refresh at the next subscribed evaluation event. Author credit is separate from reviewer approvals and cannot satisfy the two-point requirement alone; the author’s own review remains excluded. Human and bot authors, including Dependabot, use the same rule. Drafts, blocking reviews, and sufficient reviewer points skip history lookup; logs mark credit as not evaluated. Otherwise, logs show author credit and the capped count separately. Below the cap, counting may scan the repository’s entire merged history; a failed required lookup publishes an error.
|
||||
|
||||
A one-point approval receives weight `min(2, 1 + 4 × ownedLines / totalLines)` from modified or deleted old production-code lines, attributed by `git blame` at the merge base of the live base branch and exact reviewed head. Ownership of 0%, 12.5%, and 25% gives 1, 1.5, and 2 points; higher ownership remains capped at 2. The success threshold remains 2 total points, without rounding the score. New lines do not enter the denominator, and an empty denominator gives no boost. Existing two-point weights remain unchanged. GitHub commit-author accounts identify reviewers across author emails; unlinked authors remain in the denominator without contributing to a reviewer. The publisher logs measured ownership. It skips attribution when reviewer points plus author credit already meet the threshold or a blocking review exists. Displayed scores use at most three decimal places; the decision uses unrounded scores with a tolerance of `1e-12` points for floating-point error. The curve endpoints come from the policy’s default and required points.
|
||||
|
||||
@@ -28,13 +28,13 @@ Production source means supported code files under `src/` in `packages/`, `apps/
|
||||
|
||||
Each reviewer contributes only the current `APPROVED` or `CHANGES_REQUESTED` decision that GitHub returns. A `DISMISSED` record clears that reviewer's standing decision, including earlier approvals. Comment-only and pending records do not replace a decision. Reviews from deleted accounts and reviewers without current repository access do not count. The workflow does not invalidate an approval by its review commit; the repository's native pull-request rules own stale-review and latest-push requirements.
|
||||
|
||||
The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, becomes a draft, or is edited, including a base-branch change. Conversation comment events run the publisher only when the current or previous body contains `/delegate`. Closed or merged PRs and stale review heads are skipped before check-run writes or dependency setup; the workflow does not subscribe to master pushes. GitHub associates `workflow_run` publisher runs with the default branch even though decision check runs target the open PR head. Pull-request, review, and comment events share one concurrency group per PR. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review and conversation comment, and resolves current repository permission before publishing the check run. Permission changes take effect on the next subscribed event.
|
||||
The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, becomes a draft, or is edited, including a base-branch change. Conversation comment events run the publisher only when the current or previous body contains `/delegate`. Closed or merged PRs and stale review heads are skipped before status writes or dependency setup; the workflow does not subscribe to master pushes. GitHub associates `workflow_run` publisher runs with the default branch even though approval statuses target the open PR head. Pull-request, review, and comment events share one concurrency group per PR. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review and conversation comment, and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed event.
|
||||
|
||||
<a id="delegating-points"></a>
|
||||
|
||||
## Delegating points
|
||||
|
||||
Post `/delegate @username` as the entire text of a PR conversation comment to transfer your reviewer points to that user's effective `APPROVED` decision on this PR. For example, `/delegate @turtle1999` lets @turtle1999's approval count your points alongside their own. Until they approve, your points do not count, even if your own approval remains active. Each evaluation reconciles every active eligible command: it dismisses the sender's existing `APPROVED` and `CHANGES_REQUESTED` reviews, then requests review from recipients who have neither approved nor already been requested. This also handles commands posted on drafts once the PR becomes ready and commands whose original event was replaced in the concurrency queue. Other requested reviewers remain unchanged. The command does not submit a review; drafts do not dismiss or request reviews. Comment-only and pending reviews cannot be dismissed. A failed dismissal concludes the check as `failure` and prevents review requests; scores are refreshed after successful dismissal. Request failures are logged without changing the published score, and subsequent evaluations retry outstanding requests.
|
||||
Post `/delegate @username` as the entire text of a PR conversation comment to transfer your reviewer points to that user's effective `APPROVED` decision on this PR. For example, `/delegate @turtle1999` lets @turtle1999's approval count your points alongside their own. Until they approve, your points do not count, even if your own approval remains active. Each evaluation reconciles every active eligible command: it dismisses the sender's existing `APPROVED` and `CHANGES_REQUESTED` reviews, then requests review from recipients who have neither approved nor already been requested. This also handles commands posted on drafts once the PR becomes ready and commands whose original event was replaced in the concurrency queue. Other requested reviewers remain unchanged. The command does not submit a review; drafts do not dismiss or request reviews. Comment-only and pending reviews cannot be dismissed. A failed dismissal publishes an error and prevents review requests; scores are refreshed after successful dismissal. Request failures are logged without changing the published score, and subsequent evaluations retry outstanding requests.
|
||||
|
||||
Both accounts must currently have write or admin permission, and neither may be the PR author. Commands involving ineligible accounts are ignored. A sender's newest surviving eligible, author-authorized command wins, in comment creation order; editing an older comment does not move it after newer comments. Submitting any review after delegation automatically takes the points back, including an approval, change request, or comment-only review. Submitting an inline diff comment with “Add single comment” also creates a comment-only review and reclaims the points. Pending reviews do not count; a later dismissal does not restore the delegation. When timestamps are equal, the review takes precedence. A new command after the review can delegate again; editing an older command cannot reactivate it. Post `/delegate @your-own-username` to restore your own review decision. Editing or deleting a command recomputes delegation from remaining comments, so deleting the latest command can restore an older one. Quoted commands, code blocks, review bodies, inline review comments, and commands mixed with other text do not count. Surrounding whitespace is allowed; account matching is case-insensitive. An edited command is accepted only when GitHub identifies the original author as its latest editor; edits by other accounts cannot delegate or cancel that author’s points.
|
||||
|
||||
@@ -44,7 +44,7 @@ Each sender contributes at most once, using their own fixed weight or production
|
||||
|
||||
## Security
|
||||
|
||||
All actions in the check-writing job are pinned to commit SHAs. The job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Only when there is no blocker, reviewer points plus author credit are insufficient, and an approval has the policy’s default weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews and comments are treated as API data and escaped in logs. All publisher triggers and steps, including dependency setup, have pull-request write permission; the policy uses it to dismiss eligible senders' prior decision reviews and request recipients. Every evaluation verifies command authors and latest editors by immutable account ID through GraphQL, matching the body to the REST comment history before counting points or acting. Missing or inconsistent editor metadata fails evaluation. Non-author edits are ignored. Only active eligible delegations are reconciled; superseded or revoked commands have no effects. Dismissal failures conclude the check as `failure`; request failures remain separate from the approval decision.
|
||||
All actions in the status-writing job are pinned to commit SHAs. The job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Only when there is no blocker, reviewer points plus author credit are insufficient, and an approval has the policy’s default weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews and comments are treated as API data and escaped in logs. All publisher triggers and steps, including dependency setup, have pull-request write permission; the policy uses it to dismiss eligible senders' prior decision reviews and request recipients. Every evaluation verifies command authors and latest editors by immutable account ID through GraphQL, matching the body to the REST comment history before counting points or acting. Missing or inconsistent editor metadata fails evaluation. Non-author edits are ignored. Only active eligible delegations are reconciled; superseded or revoked commands have no effects. Dismissal failures publish an error; request failures remain separate from the approval decision.
|
||||
|
||||
Approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the program or policy for its own run.
|
||||
|
||||
@@ -52,7 +52,7 @@ Approval policy changes take effect only after they merge into the default branc
|
||||
|
||||
## Verification
|
||||
|
||||
Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, merged-history pagination and account matching, lazy author credit, permission filtering, weighted scoring, delegation conservation and revocation, editor authorization, comment pagination, dismissal and review-request reconciliation, blockers, drafts, closed-PR skips, check-run publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs the approval policy and workflow tests in CI. The Python SDK job runs `uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'` for real Git histories, lexers, renames, shallow-history rejection, and the publisher’s fetch/analysis integration.
|
||||
Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, merged-history pagination and account matching, lazy author credit, permission filtering, weighted scoring, delegation conservation and revocation, editor authorization, comment pagination, dismissal and review-request reconciliation, blockers, drafts, closed-PR skips, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs the approval policy and workflow tests in CI. The Python SDK job runs `uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'` for real Git histories, lexers, renames, shallow-history rejection, and the publisher’s fetch/analysis integration.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
|
||||
@@ -10,8 +10,9 @@ import { authorCreditPoints, countMergedAuthorPulls } from './author-weight.mjs'
|
||||
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 PASSING_CONCLUSIONS = new Set(['success', 'neutral', 'skipped'])
|
||||
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'])
|
||||
|
||||
@@ -210,13 +211,13 @@ function timestamp(value, subject) {
|
||||
/**
|
||||
* Evaluate approval points from current reviews and repository permissions.
|
||||
* @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs.
|
||||
* @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'action_required' | 'success', description: string, points: number, authorCredit: {mergedCount: number, points: number} | null, requiredPoints: number, approvals: Array<{login: string, points: number, delegatedTo?: string, ownership?: {ownedLines: number, totalLines: number}}>, delegations: Array<{login: string, delegatedTo: string, commentId: number, reviewIds: number[], approved: boolean}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and check-run output fields; approval login owns the points, delegatedTo supplies its decision, delegations contains eligible active commands and their superseded decision review IDs, and null author credit means history was not evaluated.
|
||||
* @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'pending' | 'success', description: string, points: number, authorCredit: {mergedCount: number, points: number} | null, requiredPoints: number, approvals: Array<{login: string, points: number, delegatedTo?: string, ownership?: {ownedLines: number, totalLines: number}}>, delegations: Array<{login: string, delegatedTo: string, commentId: number, reviewIds: number[], approved: boolean}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields; approval login owns the points, delegatedTo supplies its decision, delegations contains eligible active commands and their superseded decision review IDs, and null author credit means history was not evaluated.
|
||||
*/
|
||||
export async function evaluateApproval({ event, policySource, api, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls }) {
|
||||
const pull = pullRequestFromEvent(event)
|
||||
const policy = parseApprovalPolicy(policySource)
|
||||
if (pull.draft) {
|
||||
return approvalResult(pull, policy.requiredPoints, [], [], [], 'action_required', 'draft pull request')
|
||||
return approvalResult(pull, policy.requiredPoints, [], [], [], 'pending', 'draft pull request')
|
||||
}
|
||||
|
||||
const reviews = await listPullRequestReviews(api, pull.repository, pull.number)
|
||||
@@ -287,11 +288,11 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
|
||||
return next
|
||||
}, authorCredit?.points ?? 0)
|
||||
if (blockers.length > 0) {
|
||||
return approvalResult(pull, policy.requiredPoints, approvals, blockers, ignoredReviewers, 'action_required',
|
||||
return approvalResult(pull, policy.requiredPoints, approvals, blockers, ignoredReviewers, 'pending',
|
||||
`${blockers.length} blocking change request${blockers.length === 1 ? '' : 's'}`, authorCredit, activeDelegations)
|
||||
}
|
||||
// Tolerate floating-point addition error without rounding approval scores.
|
||||
const state = points + 1e-12 >= policy.requiredPoints ? 'success' : 'action_required'
|
||||
const state = points + 1e-12 >= policy.requiredPoints ? 'success' : 'pending'
|
||||
return approvalResult(
|
||||
pull,
|
||||
policy.requiredPoints,
|
||||
@@ -306,12 +307,13 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate and publish the required check run, concluding it as failure when evaluation fails.
|
||||
* @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, runUrl: string, write?: (line: string) => void, record?: (conclusion: string) => void, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs; record receives each conclusion the run publishes.
|
||||
* Evaluate and publish the required commit status, publishing an error status when evaluation fails.
|
||||
* @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, runUrl: string, write?: (line: string) => void, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs.
|
||||
* @returns {Promise<Awaited<ReturnType<typeof evaluateApproval>>>} Published approval decision.
|
||||
*/
|
||||
export async function runApprovalCheck({ event, policySource, api, runUrl, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls, write = line => process.stdout.write(`${line}\n`), record = () => {} }) {
|
||||
export async function runApprovalCheck({ event, policySource, api, runUrl, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls, write = line => process.stdout.write(`${line}\n`) }) {
|
||||
const pull = pullRequestFromEvent(event)
|
||||
await publishStatus(api, pull, 'pending', 'Evaluating approval points.', runUrl)
|
||||
let result
|
||||
try {
|
||||
result = await evaluateApproval({ event, policySource, api, getOwnership, getMergedCount })
|
||||
@@ -331,8 +333,8 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
|
||||
result = await evaluateApproval({ event, policySource, api, getOwnership, getMergedCount })
|
||||
}
|
||||
} catch (error) {
|
||||
await publishCheck(api, pull, 'failure', 'Approval evaluation failed.', runUrl)
|
||||
record('failure')
|
||||
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,9 +345,9 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
|
||||
`@${login}: ${points}${delegatedTo ? ` (delegated to @${delegatedTo})` : ''}${ownership ? ` (${ownership.ownedLines}/${ownership.totalLines} old production lines)` : ''}`))
|
||||
writeList(write, 'Blocking change requests', result.blockers.map(login => `@${login}`))
|
||||
writeList(write, 'Ignored reviewers without write access', result.ignoredReviewers.map(login => `@${login}`))
|
||||
await publishCheck(api, pull, result.state, result.description, runUrl)
|
||||
record(result.state)
|
||||
write(`Published ${JSON.stringify(CHECK_NAME)} check run ${JSON.stringify(result.state)}.`)
|
||||
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)
|
||||
} catch (error) {
|
||||
@@ -355,14 +357,25 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a published success before dependency installation, or conclude a failed setup.
|
||||
* @param {{event: unknown, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, runUrl: string, phase: string}} options Publication inputs.
|
||||
* @returns {Promise<void>} Completion of the check-run write.
|
||||
* 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, 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 }) {
|
||||
export async function publishApprovalPhase({ event, api, runUrl, phase, write = line => process.stdout.write(`${line}\n`) }) {
|
||||
const pull = pullRequestFromEvent(event)
|
||||
if (phase === 'revoke') return revokeApproval(api, pull, runUrl)
|
||||
if (phase === 'failure') return publishCheck(api, pull, 'failure', 'Approval setup or evaluation failed.', runUrl)
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -417,7 +430,7 @@ export async function approvalEventFromComment({ event, api }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a pull-request event before any check-run write; closed PRs and stale heads are skipped.
|
||||
* Refresh a pull-request event before any status write; closed PRs and stale heads are skipped.
|
||||
* @param {{event: unknown, api: (path: string) => Promise<unknown>}} options Pull-request event and API caller.
|
||||
* @returns {Promise<Record<string, unknown> | null>} Live event, or null for a closed PR or superseded head.
|
||||
*/
|
||||
@@ -470,19 +483,31 @@ async function reviewerPermission(api, repository, login) {
|
||||
return response.permission.toLowerCase()
|
||||
}
|
||||
|
||||
// One decision per head keeps branch rules and CI watchers unambiguous; the head's existing GitHub
|
||||
// Actions run is updated and a run is created only when none exists.
|
||||
// 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, ...body } })
|
||||
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 { id } of runs) {
|
||||
if (!Number.isSafeInteger(id)) throw new Error('check run has no valid id')
|
||||
await api(`/repos/${pull.repository}/check-runs/${id}`, { method: 'PATCH', body })
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,32 +525,45 @@ async function revokeApproval(api, pull, runUrl) {
|
||||
})
|
||||
return
|
||||
}
|
||||
for (const { id, conclusion } of runs) {
|
||||
if (!Number.isSafeInteger(id)) throw new Error('check run has no valid id')
|
||||
// Revoke every conclusion a required check accepts, not only the success this publisher writes:
|
||||
// a workflow job added by the pull request can produce a same-named passing check run.
|
||||
if (!PASSING_CONCLUSIONS.has(conclusion)) continue
|
||||
await api(`/repos/${pull.repository}/check-runs/${id}`, {
|
||||
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 check run that reached completed never returns to in_progress: GitHub answers 200 and leaves the
|
||||
// run completed, so a published decision can only be replaced by another conclusion.
|
||||
// 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=${PAGE_SIZE}`)
|
||||
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')
|
||||
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')
|
||||
await api(`/repos/${pull.repository}/statuses/${pull.headSha}`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
state,
|
||||
context: STATUS_CONTEXT,
|
||||
description,
|
||||
target_url: runUrl,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function pullRequestFromEvent(event) {
|
||||
const repository = repositoryFromEvent(event)
|
||||
if (!isRecord(event.pull_request) || !isRecord(event.pull_request.user)
|
||||
@@ -616,7 +654,7 @@ async function main() {
|
||||
const phase = process.argv[2]
|
||||
if (phase) {
|
||||
await publishApprovalPhase({ event, api, runUrl: process.env.GITHUB_RUN_URL ?? '', phase })
|
||||
if (phase === 'revoke' && process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'active=true\n')
|
||||
if (phase === 'pending' && process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'active=true\n')
|
||||
return
|
||||
}
|
||||
await runApprovalCheck({
|
||||
@@ -624,9 +662,6 @@ async function main() {
|
||||
policySource,
|
||||
api,
|
||||
runUrl: process.env.GITHUB_RUN_URL ?? '',
|
||||
record: (conclusion) => {
|
||||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `concluded=${conclusion}\n`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,13 +18,12 @@ import {
|
||||
const policySource = readFileSync(new URL('approval-policy.json', import.meta.url), 'utf8')
|
||||
const emptyCheckRuns = { total_count: 0, check_runs: [] }
|
||||
const isCheckRunsList = path => path.includes('/commits/') && path.includes('/check-runs?')
|
||||
const withoutCheckRuns = api => (path, options) =>
|
||||
isCheckRunsList(path) ? emptyCheckRuns : api(path, options)
|
||||
const withEmptyReads = api => withoutCheckRuns((path, options) => path.includes('/comments?') ? [] : api(path, options))
|
||||
const evaluateApproval = options => evaluateWithHistory({ getMergedCount: async () => 0, ...options, api: withEmptyReads(options.api) })
|
||||
const runWithHistory = options => runPublisher({ ...options, api: withoutCheckRuns(options.api) })
|
||||
const runApprovalCheck = options => runWithHistory({ getMergedCount: async () => 0, ...options, api: withEmptyReads(options.api) })
|
||||
const publishedState = body => body.conclusion ?? body.status
|
||||
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'
|
||||
|
||||
const pullRequestEvent = ({ author = 'author', draft = false } = {}) => ({
|
||||
@@ -204,7 +203,7 @@ test('accepts two one-point approvals and ignores reviews without write access',
|
||||
assert.deepEqual(result.ignoredReviewers, ['reader'])
|
||||
})
|
||||
|
||||
test('requires action for one one-point approval without failing the check', async () => {
|
||||
test('keeps one one-point approval pending without failing the status', async () => {
|
||||
const result = await evaluateApproval({
|
||||
event: pullRequestEvent(),
|
||||
policySource,
|
||||
@@ -215,7 +214,7 @@ test('requires action for one one-point approval without failing the check', asy
|
||||
throw new Error(`unexpected API path ${path}`)
|
||||
},
|
||||
})
|
||||
assert.equal(result.state, 'action_required')
|
||||
assert.equal(result.state, 'pending')
|
||||
assert.equal(result.points, 1)
|
||||
})
|
||||
|
||||
@@ -234,12 +233,12 @@ test('ignores a reviewer whose collaborator permission lookup returns 404', asyn
|
||||
},
|
||||
})
|
||||
const result = await evaluateApproval({ event: pullRequestEvent(), policySource, api })
|
||||
assert.equal(result.state, 'action_required')
|
||||
assert.equal(result.state, 'pending')
|
||||
assert.deepEqual(result.ignoredReviewers, ['former-writer'])
|
||||
})
|
||||
|
||||
test('requires action on a write-capable change request while ignoring the author and read-only reviewers', async () => {
|
||||
const writes = []
|
||||
test('keeps the status pending on a write-capable change request while ignoring the author and read-only reviewers', async () => {
|
||||
const statuses = []
|
||||
const result = await runApprovalCheck({
|
||||
event: pullRequestEvent({ author: 'author' }),
|
||||
policySource,
|
||||
@@ -257,43 +256,45 @@ test('requires action on a write-capable change request while ignoring the autho
|
||||
if (path.includes('/collaborators/turtle1999/permission')) return { permission: 'admin' }
|
||||
if (path.includes('/collaborators/blocker/permission')) return { permission: 'write' }
|
||||
if (path.includes('/collaborators/reader/permission')) return { permission: 'read' }
|
||||
if (isCheckRunsList(path)) return emptyCheckRuns
|
||||
if (path.includes('/check-runs')) {
|
||||
writes.push(options.body)
|
||||
if (path.includes('/statuses/')) {
|
||||
statuses.push(options.body)
|
||||
return {}
|
||||
}
|
||||
throw new Error(`unexpected API path ${path}`)
|
||||
},
|
||||
write: () => {},
|
||||
})
|
||||
assert.equal(result.state, 'action_required')
|
||||
assert.equal(result.state, 'pending')
|
||||
assert.equal(result.description, '1 blocking change request.')
|
||||
assert.equal(result.points, 2)
|
||||
assert.deepEqual(result.blockers, ['blocker'])
|
||||
assert.deepEqual(result.ignoredReviewers, ['reader'])
|
||||
assert.deepEqual(writes, [{
|
||||
name: 'weighted approval decision',
|
||||
head_sha: HEAD_SHA,
|
||||
status: 'completed',
|
||||
conclusion: 'action_required',
|
||||
details_url: 'https://github.example/actions/runs/1',
|
||||
output: { title: '1 blocking change request.', summary: '1 blocking change request. [Workflow run](https://github.example/actions/runs/1)' },
|
||||
assert.deepEqual(statuses, [{
|
||||
state: 'pending',
|
||||
context: 'weighted approval',
|
||||
description: 'Evaluating approval points.',
|
||||
target_url: 'https://github.example/actions/runs/1',
|
||||
}, {
|
||||
state: 'pending',
|
||||
context: 'weighted approval',
|
||||
description: '1 blocking change request.',
|
||||
target_url: 'https://github.example/actions/runs/1',
|
||||
}])
|
||||
})
|
||||
|
||||
test('requires action on drafts without reading reviews', async () => {
|
||||
test('keeps drafts pending without reading reviews', async () => {
|
||||
const result = await evaluateApproval({
|
||||
event: pullRequestEvent({ draft: true }),
|
||||
policySource,
|
||||
getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
|
||||
api: async () => { throw new Error('draft evaluation must not call GitHub') },
|
||||
})
|
||||
assert.equal(result.state, 'action_required')
|
||||
assert.equal(result.state, 'pending')
|
||||
assert.equal(result.points, 0)
|
||||
assert.match(result.description, /draft pull request/u)
|
||||
})
|
||||
|
||||
test('publishes the decision check run and concludes failure when evaluation fails', async () => {
|
||||
test('publishes the required status and replaces stale success with error on evaluation failure', async () => {
|
||||
const calls = []
|
||||
const output = []
|
||||
const result = await runApprovalCheck({
|
||||
@@ -305,24 +306,21 @@ test('publishes the decision check run and concludes failure when evaluation fai
|
||||
calls.push({ path, options })
|
||||
if (path.includes('/reviews?')) return [review('turtle2099', 'APPROVED')]
|
||||
if (path.includes('/collaborators/turtle2099/permission')) return { permission: 'write' }
|
||||
if (isCheckRunsList(path)) return emptyCheckRuns
|
||||
if (path.includes('/check-runs')) return {}
|
||||
if (path.includes('/statuses/')) return {}
|
||||
throw new Error(`unexpected API path ${path}`)
|
||||
},
|
||||
write: line => output.push(line),
|
||||
})
|
||||
assert.equal(result.state, 'success')
|
||||
assert.deepEqual(calls.at(-1), {
|
||||
path: '/repos/deepseek-harness/deepseek-harness/check-runs',
|
||||
path: `/repos/deepseek-harness/deepseek-harness/statuses/${HEAD_SHA}`,
|
||||
options: {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'weighted approval decision',
|
||||
head_sha: HEAD_SHA,
|
||||
status: 'completed',
|
||||
conclusion: 'success',
|
||||
details_url: 'https://github.example/actions/runs/1',
|
||||
output: { title: '2/2 approval points.', summary: '2/2 approval points. [Workflow run](https://github.example/actions/runs/1)' },
|
||||
state: 'success',
|
||||
context: 'weighted approval',
|
||||
description: '2/2 approval points.',
|
||||
target_url: 'https://github.example/actions/runs/1',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -337,8 +335,7 @@ test('publishes the decision check run and concludes failure when evaluation fai
|
||||
runUrl: 'https://github.example/actions/runs/2',
|
||||
api: async (path, options = {}) => {
|
||||
if (path.includes('/reviews?')) throw new Error('reviews unavailable')
|
||||
if (isCheckRunsList(path)) return emptyCheckRuns
|
||||
if (path.includes('/check-runs')) {
|
||||
if (path.includes('/statuses/')) {
|
||||
failures.push({ path, options })
|
||||
return {}
|
||||
}
|
||||
@@ -346,10 +343,9 @@ test('publishes the decision check run and concludes failure when evaluation fai
|
||||
},
|
||||
write: () => {},
|
||||
}), /reviews unavailable/u)
|
||||
assert.deepEqual(failures.map(({ path }) => path), ['/repos/deepseek-harness/deepseek-harness/check-runs'])
|
||||
assert.equal(failures[0].options.body.status, 'completed')
|
||||
assert.equal(failures[0].options.body.conclusion, 'failure')
|
||||
assert.equal(failures[0].options.body.output.title, 'Approval evaluation failed.')
|
||||
assert.equal(failures[0].options.body.state, 'pending')
|
||||
assert.equal(failures[1].options.body.state, 'error')
|
||||
assert.equal(failures[1].options.body.description, 'Approval evaluation failed.')
|
||||
})
|
||||
|
||||
test('sends authenticated JSON and escapes an API error body', async () => {
|
||||
@@ -392,7 +388,7 @@ for (const [ownedLines, totalLines, expectedPoints] of [[9, 100, 1.3599999999999
|
||||
})
|
||||
assert.equal(result.points, expectedPoints)
|
||||
assert.equal(result.description, `${Number(expectedPoints.toFixed(3))}/2 approval points (author 0).`)
|
||||
assert.equal(result.state, expectedPoints === 2 ? 'success' : 'action_required')
|
||||
assert.equal(result.state, expectedPoints === 2 ? 'success' : 'pending')
|
||||
assert.equal(measurements, 1)
|
||||
assert.deepEqual(result.approvals[0].ownership, { ownedLines, totalLines })
|
||||
})
|
||||
@@ -412,7 +408,7 @@ test('does not fetch ownership when a change request blocks approval', async ()
|
||||
})
|
||||
assert.equal(measurements, 0)
|
||||
assert.equal(result.points, 2)
|
||||
assert.equal(result.state, 'action_required')
|
||||
assert.equal(result.state, 'pending')
|
||||
})
|
||||
|
||||
test('does not fetch history when approvals already have two-point weights', async () => {
|
||||
@@ -424,7 +420,7 @@ test('does not fetch history when approvals already have two-point weights', asy
|
||||
assert.equal(result.points, 2)
|
||||
})
|
||||
|
||||
test('concludes failure when production attribution fails', async () => {
|
||||
test('publishes error when production attribution fails', async () => {
|
||||
const states = []
|
||||
await assert.rejects(runApprovalCheck({
|
||||
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1',
|
||||
@@ -433,46 +429,30 @@ test('concludes failure when production attribution fails', async () => {
|
||||
if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
|
||||
if (path.includes('/permission')) return { permission: 'write' }
|
||||
if (path.includes('/comments?')) return []
|
||||
if (isCheckRunsList(path)) return emptyCheckRuns
|
||||
states.push(publishedState(options.body))
|
||||
states.push(options.body.state)
|
||||
return {}
|
||||
},
|
||||
}), /incomplete history/u)
|
||||
assert.deepEqual(states, ['failure'])
|
||||
assert.deepEqual(states, ['pending', 'error'])
|
||||
})
|
||||
|
||||
|
||||
test('revokes a published success without returning a completed run to in progress', async () => {
|
||||
const calls = []
|
||||
const checkRuns = [
|
||||
{ id: 7, app: { slug: 'github-actions' }, conclusion: 'success' },
|
||||
{ id: 8, app: { slug: 'github-actions' }, conclusion: 'action_required' },
|
||||
{ id: 9, app: { slug: 'other-app' }, conclusion: 'success' },
|
||||
{ id: 10, app: { slug: 'github-actions' }, conclusion: 'skipped' },
|
||||
{ id: 11, app: { slug: 'github-actions' }, conclusion: 'neutral' },
|
||||
{ id: 12, app: { slug: 'github-actions' }, conclusion: null },
|
||||
]
|
||||
await publishApprovalPhase({
|
||||
event: pullRequestEvent(), runUrl: 'https://github.example/run/1', phase: 'revoke',
|
||||
test('revokes a previous success before starting expensive attribution', async () => {
|
||||
const states = []
|
||||
await runApprovalCheck({
|
||||
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {},
|
||||
getOwnership: async () => {
|
||||
assert.deepEqual(states, ['pending'])
|
||||
return { totalLines: 100, reviewerLines: { writer: 25 } }
|
||||
},
|
||||
api: async (path, options) => {
|
||||
calls.push({ path, method: options?.method, body: options?.body })
|
||||
return isCheckRunsList(path) ? { total_count: checkRuns.length, check_runs: checkRuns } : {}
|
||||
if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
|
||||
if (path.includes('/permission')) return { permission: 'write' }
|
||||
states.push(options.body.state)
|
||||
return {}
|
||||
},
|
||||
})
|
||||
assert.deepEqual(calls.map(({ path }) => path), [
|
||||
`/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',
|
||||
'/repos/deepseek-harness/deepseek-harness/check-runs/10',
|
||||
'/repos/deepseek-harness/deepseek-harness/check-runs/11',
|
||||
])
|
||||
for (const call of calls.slice(1)) {
|
||||
assert.deepEqual(call.body, {
|
||||
status: 'completed',
|
||||
conclusion: 'action_required',
|
||||
details_url: 'https://github.example/run/1',
|
||||
output: { title: 'Preparing approval evaluation.', summary: 'Preparing approval evaluation. [Workflow run](https://github.example/run/1)' },
|
||||
})
|
||||
}
|
||||
assert.deepEqual(states, ['pending', 'success'])
|
||||
})
|
||||
|
||||
for (const reviewers of [['first', 'second'], ['turtle1999', 'first']]) {
|
||||
@@ -500,69 +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: withEmptyReads(async (path, { body }) => { assert.match(path, /\/check-runs$/u); states.push(publishedState(body)) }),
|
||||
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: 'revoke' })
|
||||
await publishApprovalPhase({ ...options, phase: 'failure' })
|
||||
assert.deepEqual(states, ['in_progress', 'failure'])
|
||||
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('updates the head\'s GitHub Actions decision check run instead of creating another', async () => {
|
||||
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: 'action_required' },
|
||||
{ id: 8, app: { slug: 'other-app' }, conclusion: 'action_required' },
|
||||
{ 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: 'failure',
|
||||
api: async (path, options) => {
|
||||
calls.push({ path, method: options?.method })
|
||||
return { total_count: checkRuns.length, check_runs: checkRuns }
|
||||
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, [
|
||||
{ path: `/repos/deepseek-harness/deepseek-harness/commits/${HEAD_SHA}/check-runs?check_name=weighted%20approval%20decision&filter=all&per_page=100`, method: undefined },
|
||||
{ path: '/repos/deepseek-harness/deepseek-harness/check-runs/7', method: 'PATCH' },
|
||||
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 response of [{}, { total_count: 101, check_runs: [] }, { total_count: 1, check_runs: [{ id: '7', app: { slug: 'github-actions' } }] }]) {
|
||||
await assert.rejects(publishApprovalPhase({
|
||||
event: pullRequestEvent(), runUrl: 'https://github.example/run/1', phase: 'failure', api: async () => response,
|
||||
}), /check-run list response|valid id/u)
|
||||
for (const call of calls.slice(2)) {
|
||||
assert.equal(call.method, 'PATCH')
|
||||
assert.equal(call.body.conclusion, 'action_required')
|
||||
}
|
||||
})
|
||||
|
||||
test('records each published conclusion so the failure step never replaces it', async () => {
|
||||
const recorded = []
|
||||
const api = async (path, options) => {
|
||||
if (isCheckRunsList(path)) return emptyCheckRuns
|
||||
if (path.includes('/reviews?')) return [review('turtle2099', 'APPROVED')]
|
||||
if (path.includes('/permission')) return { permission: 'write' }
|
||||
if (path.includes('/comments?')) return []
|
||||
assert.match(path, /\/check-runs$/u)
|
||||
return {}
|
||||
}
|
||||
await runApprovalCheck({
|
||||
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', api, write: () => {},
|
||||
record: conclusion => recorded.push(conclusion),
|
||||
})
|
||||
await assert.rejects(runApprovalCheck({
|
||||
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/2', write: () => {},
|
||||
getMergedCount: async () => { throw new Error('history unavailable') },
|
||||
record: conclusion => recorded.push(conclusion),
|
||||
api: async path => {
|
||||
if (isCheckRunsList(path)) return emptyCheckRuns
|
||||
if (path.includes('/reviews?')) return []
|
||||
if (path.includes('/comments?')) return []
|
||||
return {}
|
||||
},
|
||||
}), /history unavailable/u)
|
||||
assert.deepEqual(recorded, ['success', 'failure'])
|
||||
})
|
||||
|
||||
|
||||
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({
|
||||
@@ -571,7 +576,7 @@ for (const [mergedCount, credit] of [[0, 0], [1, 0.011], [50, 0.55], [99, 1.089]
|
||||
})
|
||||
assert.equal(result.points, credit)
|
||||
assert.equal(result.authorCredit.points, credit)
|
||||
assert.equal(result.state, 'action_required')
|
||||
assert.equal(result.state, 'pending')
|
||||
assert.deepEqual(result.approvals, [])
|
||||
})
|
||||
}
|
||||
@@ -587,7 +592,7 @@ test('combines author credit and reviewer ownership at the passing threshold', a
|
||||
}
|
||||
})
|
||||
|
||||
for (const [mergedCount, state] of [[90, 'action_required'], [91, 'success'], [100, 'success']]) {
|
||||
for (const [mergedCount, state] of [[90, 'pending'], [91, 'success'], [100, 'success']]) {
|
||||
test(`one ordinary approval with ${mergedCount} merged PRs is ${state}`, async () => {
|
||||
const result = await evaluateApproval({
|
||||
event: pullRequestEvent(), policySource, getMergedCount: async () => mergedCount,
|
||||
@@ -610,14 +615,14 @@ test('blockers skip history and ignore non-write reviewers', async () => {
|
||||
? [review('writer', 'APPROVED'), review('blocker', 'CHANGES_REQUESTED'), review('reader', 'APPROVED')]
|
||||
: { permission: path.includes('/reader/') ? 'read' : 'write' },
|
||||
})
|
||||
assert.equal(result.state, 'action_required')
|
||||
assert.equal(result.state, 'pending')
|
||||
assert.equal(result.points, 1)
|
||||
assert.equal(result.authorCredit, null)
|
||||
assert.deepEqual(result.blockers, ['blocker'])
|
||||
assert.deepEqual(result.ignoredReviewers, ['reader'])
|
||||
})
|
||||
|
||||
test('drafts skip history and a failed history lookup concludes the check as failure', async () => {
|
||||
test('drafts skip history and history failures revoke success with error', async () => {
|
||||
await evaluateApproval({
|
||||
event: pullRequestEvent({ draft: true }), policySource,
|
||||
getMergedCount: async () => { throw new Error('draft must skip history') },
|
||||
@@ -628,20 +633,20 @@ test('drafts skip history and a failed history lookup concludes the check as fai
|
||||
getMergedCount: async () => { throw new Error('history unavailable') },
|
||||
api: async (path, options) => {
|
||||
if (path.includes('/reviews?')) return []
|
||||
states.push(publishedState(options.body))
|
||||
states.push(options.body.state)
|
||||
},
|
||||
}), /history unavailable/u)
|
||||
assert.deepEqual(states, ['failure'])
|
||||
assert.deepEqual(states, ['pending', 'error'])
|
||||
})
|
||||
|
||||
|
||||
test('a score just below the threshold requires action even if its display rounds to two', async () => {
|
||||
test('a score just below the threshold remains pending even if its display rounds to two', async () => {
|
||||
const result = await evaluateApproval({
|
||||
event: pullRequestEvent(), policySource, getMergedCount: async () => 50,
|
||||
getOwnership: async () => ({ totalLines: 1000000, reviewerLines: { writer: 112499 } }),
|
||||
api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' },
|
||||
})
|
||||
assert.equal(result.state, 'action_required')
|
||||
assert.equal(result.state, 'pending')
|
||||
assert.ok(result.points < 2)
|
||||
})
|
||||
|
||||
@@ -665,14 +670,14 @@ test('the publisher counts merged history through the production API path', asyn
|
||||
}
|
||||
if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
|
||||
if (path.includes('/permission')) return { permission: 'write' }
|
||||
states.push(publishedState(options.body))
|
||||
states.push(options.body.state)
|
||||
return {}
|
||||
},
|
||||
})
|
||||
assert.equal(result.points, 2)
|
||||
assert.equal(result.authorCredit.points, 0.55)
|
||||
assert.equal(result.approvals[0].points, 1.45)
|
||||
assert.deepEqual(states, ['success'])
|
||||
assert.deepEqual(states, ['pending', 'success'])
|
||||
assert.equal(output[0], 'Author credit: 0.55 (50 merged PRs).')
|
||||
})
|
||||
|
||||
@@ -690,7 +695,7 @@ test('sufficient reviewer points and drafts publish without querying author hist
|
||||
return {}
|
||||
},
|
||||
})
|
||||
assert.equal(result.state, draft ? 'action_required' : 'success')
|
||||
assert.equal(result.state, draft ? 'pending' : 'success')
|
||||
assert.equal(result.authorCredit, null)
|
||||
assert.equal(output[0], `Author credit: not evaluated (${draft ? 'draft' : 'reviewer points suffice'}).`)
|
||||
}
|
||||
@@ -756,7 +761,7 @@ test('delegates the sender fixed points once after the recipient approves, retai
|
||||
}) })
|
||||
const points = recipientState === 'APPROVED' ? 3 : 0
|
||||
assert.equal(result.points, points)
|
||||
assert.equal(result.state, points === 3 && senderState !== 'CHANGES_REQUESTED' ? 'success' : 'action_required')
|
||||
assert.equal(result.state, points === 3 && senderState !== 'CHANGES_REQUESTED' ? 'success' : 'pending')
|
||||
assert.deepEqual(result.blockers, [
|
||||
...(senderState === 'CHANGES_REQUESTED' ? ['turtle1999'] : []),
|
||||
...(recipientState === 'CHANGES_REQUESTED' ? ['writer'] : []),
|
||||
@@ -864,7 +869,7 @@ test('a sender submitted review takes back delegation, including comment-only an
|
||||
}) })
|
||||
assert.equal(result.delegations.length, state === 'PENDING' ? 1 : 0)
|
||||
assert.equal(result.points, state === 'APPROVED' || state === 'PENDING' ? 3 : 1)
|
||||
assert.equal(result.state, state === 'APPROVED' || state === 'PENDING' ? 'success' : 'action_required')
|
||||
assert.equal(result.state, state === 'APPROVED' || state === 'PENDING' ? 'success' : 'pending')
|
||||
if (state === 'APPROVED') assert.equal(result.approvals.find(({ login }) => login === 'Turtle1999').delegatedTo, undefined)
|
||||
}
|
||||
}
|
||||
@@ -907,8 +912,7 @@ test('an active delegate command requests review once and preserves other reques
|
||||
if (options?.method === 'POST') { requests.push(options.body); return {} }
|
||||
return { users: [{ login: 'another-reviewer' }, ...(alreadyRequested ? [{ login: 'WRITER' }] : [])], teams: [] }
|
||||
}
|
||||
if (isCheckRunsList(path)) return emptyCheckRuns
|
||||
if (path.includes('/check-runs')) return {}
|
||||
if (path.includes('/statuses/')) return {}
|
||||
return api(path, options)
|
||||
},
|
||||
})
|
||||
@@ -936,8 +940,7 @@ test('inactive commands and drafts do not request review', async () => {
|
||||
policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, write: () => {},
|
||||
api: async (path, options) => {
|
||||
assert.ok(!path.endsWith('/requested_reviewers'))
|
||||
if (isCheckRunsList(path)) return emptyCheckRuns
|
||||
if (path.includes('/check-runs')) return {}
|
||||
if (path.includes('/statuses/')) return {}
|
||||
return api(path, options)
|
||||
},
|
||||
})
|
||||
@@ -953,8 +956,7 @@ test('failed review requests preserve the computed score and report the retry',
|
||||
event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } },
|
||||
policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }), write: line => output.push(line),
|
||||
api: async (path, options) => {
|
||||
if (isCheckRunsList(path)) return emptyCheckRuns
|
||||
if (path.includes('/check-runs')) { statuses.push(publishedState(options.body)); return {} }
|
||||
if (path.includes('/statuses/')) { statuses.push(options.body.state); return {} }
|
||||
if (path.endsWith('/requested_reviewers')) {
|
||||
if (options?.method === 'POST') throw new Error('request rejected')
|
||||
return failedRead ? {} : { users: [] }
|
||||
@@ -964,7 +966,7 @@ test('failed review requests preserve the computed score and report the retry',
|
||||
})
|
||||
assert.match(output.at(-1), failedRead ? /no users array/u : /request rejected/u)
|
||||
assert.match(output.at(-1), /score is unchanged.*will retry/u)
|
||||
assert.deepEqual(statuses, ['success'])
|
||||
assert.deepEqual(statuses, ['pending', 'success'])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -976,8 +978,7 @@ test('delegation dismisses only the sender old decisions and stays active across
|
||||
const states = []
|
||||
const read = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')], reviews })
|
||||
const api = async (path, options) => {
|
||||
if (isCheckRunsList(path)) return emptyCheckRuns
|
||||
if (path.includes('/check-runs')) { states.push(publishedState(options.body)); return {} }
|
||||
if (path.includes('/statuses/')) { states.push(options.body.state); return {} }
|
||||
if (path.endsWith('/dismissals')) {
|
||||
calls.push(options)
|
||||
const id = Number(path.split('/').at(-2))
|
||||
@@ -1001,13 +1002,13 @@ test('delegation dismisses only the sender old decisions and stays active across
|
||||
assert.deepEqual(result.blockers, ['other'])
|
||||
assert.equal(result.delegations.length, 1)
|
||||
assert.deepEqual(result.delegations[0].reviewIds, [])
|
||||
assert.deepEqual(states, ['action_required'])
|
||||
assert.deepEqual(states, ['pending', 'pending'])
|
||||
reviews.find(review => review.id === 21).state = 'DISMISSED'
|
||||
for (const state of ['APPROVED', 'DISMISSED', 'COMMENTED', 'APPROVED']) {
|
||||
reviews.find(review => review.id === 20).state = state
|
||||
const next = await evaluateDelegation({ api })
|
||||
assert.equal(next.delegations.length, 1)
|
||||
assert.equal(next.state, state === 'APPROVED' ? 'success' : 'action_required')
|
||||
assert.equal(next.state, state === 'APPROVED' ? 'success' : 'pending')
|
||||
}
|
||||
reviews.push(review('turtle1999', 'COMMENTED', '2026-09-16T00:00:00Z', 30))
|
||||
const reclaimed = await evaluateDelegation({ api })
|
||||
@@ -1021,8 +1022,7 @@ test('a new sender review arriving during dismissal is not dismissed or delegate
|
||||
const result = await runWithHistory({ event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } },
|
||||
policySource, runUrl: 'https://github.example/run/1', write: () => {}, getMergedCount: async () => 0,
|
||||
api: async (path, options) => {
|
||||
if (isCheckRunsList(path)) return emptyCheckRuns
|
||||
if (path.includes('/check-runs')) return {}
|
||||
if (path.includes('/statuses/')) return {}
|
||||
if (path.endsWith('/dismissals')) {
|
||||
assert.ok(path.endsWith('/reviews/10/dismissals'))
|
||||
reviews[0].state = 'DISMISSED'
|
||||
@@ -1037,15 +1037,14 @@ test('a new sender review arriving during dismissal is not dismissed or delegate
|
||||
assert.deepEqual(result.blockers, ['turtle1999'])
|
||||
})
|
||||
|
||||
test('a failed or unconfirmed dismissal prevents review requests and concludes failure', async () => {
|
||||
test('a failed or unconfirmed dismissal prevents review requests and publishes error', async () => {
|
||||
for (const rejected of [false, true]) {
|
||||
const states = []
|
||||
const read = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')], reviews: [review('turtle1999', 'CHANGES_REQUESTED')] })
|
||||
await assert.rejects(runWithHistory({ event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } },
|
||||
policySource, runUrl: 'https://github.example/run/1', write: () => {},
|
||||
api: async (path, options) => {
|
||||
if (isCheckRunsList(path)) return emptyCheckRuns
|
||||
if (path.includes('/check-runs')) { states.push(publishedState(options.body)); return {} }
|
||||
if (path.includes('/statuses/')) { states.push(options.body.state); return {} }
|
||||
if (path.endsWith('/dismissals')) {
|
||||
if (rejected) throw new Error('dismissal denied')
|
||||
return { id: 10, state: 'CHANGES_REQUESTED' }
|
||||
@@ -1054,7 +1053,7 @@ test('a failed or unconfirmed dismissal prevents review requests and concludes f
|
||||
return read(path, options)
|
||||
},
|
||||
}), rejected ? /dismissal denied/u : /dismissal was not confirmed/u)
|
||||
assert.deepEqual(states, ['failure'])
|
||||
assert.deepEqual(states, ['pending', 'error'])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1067,8 +1066,7 @@ test('reads all comment pages, logs the score owner, and fails closed on missing
|
||||
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1',
|
||||
getMergedCount: async () => 0, write: line => output.push(line),
|
||||
api: async (path, options) => {
|
||||
if (isCheckRunsList(path)) return emptyCheckRuns
|
||||
if (path.includes('/check-runs')) { statuses.push(publishedState(options.body)); return {} }
|
||||
if (path.includes('/statuses/')) { statuses.push(options.body.state); return {} }
|
||||
if (path.includes('/comments?')) return comments(path)
|
||||
return api(path, options)
|
||||
},
|
||||
@@ -1081,7 +1079,7 @@ test('reads all comment pages, logs the score owner, and fails closed on missing
|
||||
})
|
||||
assert.equal(pages.length, 2)
|
||||
assert.ok(output.includes('- @turtle1999: 2 (delegated to @writer)'))
|
||||
assert.deepEqual(statuses.splice(0), ['success'])
|
||||
assert.deepEqual(statuses.splice(0), ['pending', 'success'])
|
||||
for (const [comments, message] of [
|
||||
[() => { throw new Error('comment API unavailable') }, /comment API unavailable/u],
|
||||
[() => ({}), /comments response is not an array/u],
|
||||
@@ -1091,7 +1089,7 @@ test('reads all comment pages, logs the score owner, and fails closed on missing
|
||||
[() => Array.from({ length: 100 }, () => comment('writer', 'text')), /comments exceed 3000/u],
|
||||
]) {
|
||||
await assert.rejects(run(comments), message)
|
||||
assert.deepEqual(statuses.splice(0), ['failure'])
|
||||
assert.deepEqual(statuses.splice(0), ['pending', 'error'])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1175,8 +1173,7 @@ test('later events reconcile all active delegations after a draft or replaced co
|
||||
const effects = []
|
||||
const requested = []
|
||||
const api = async (path, options) => {
|
||||
if (isCheckRunsList(path)) return emptyCheckRuns
|
||||
if (path.includes('/check-runs')) return {}
|
||||
if (path.includes('/statuses/')) return {}
|
||||
if (path.endsWith('/dismissals')) {
|
||||
const old = reviews.find(review => path.endsWith(`/reviews/${review.id}/dismissals`))
|
||||
effects.push(old.id)
|
||||
@@ -1208,8 +1205,7 @@ test('already approved delegates need no new request', async () => {
|
||||
})
|
||||
const result = await runWithHistory({ event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {},
|
||||
api: (path, options) => {
|
||||
if (isCheckRunsList(path)) return emptyCheckRuns
|
||||
if (path.includes('/check-runs')) return {}
|
||||
if (path.includes('/statuses/')) return {}
|
||||
assert.ok(!path.endsWith('/requested_reviewers'))
|
||||
return read(path, options)
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ permissions:
|
||||
checks: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
concurrency:
|
||||
group: weighted-approval-${{ (github.event.pull_request.number || github.event.issue.number) && format('weighted-approval-review-event:{0}', github.event.pull_request.number || github.event.issue.number) || github.event.workflow_run.display_title }}
|
||||
@@ -29,20 +30,20 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# SECURITY: the check-writing job executes policy from the trusted default
|
||||
# SECURITY: the status-writing job executes policy from the trusted default
|
||||
# branch and reads pull-request reviews and comments only as API data.
|
||||
- name: Check out trusted approval policy
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
- name: Revoke previous approval decision
|
||||
- name: Revoke previous approval status
|
||||
id: revoke
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: node .github/review-ownership/check-approval.mjs revoke
|
||||
# SECURITY: dependency setup runs in the check-writing job.
|
||||
run: node .github/review-ownership/check-approval.mjs pending
|
||||
# SECURITY: dependency setup runs in the status-writing job.
|
||||
- if: steps.revoke.outputs.active == 'true'
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
@@ -52,19 +53,15 @@ jobs:
|
||||
- name: Install production lexer
|
||||
if: steps.revoke.outputs.active == 'true'
|
||||
run: python3 -m pip install -r .github/review-ownership/requirements.txt
|
||||
- name: Publish weighted approval decision
|
||||
id: publish
|
||||
- name: Publish weighted approval status
|
||||
if: steps.revoke.outputs.active == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: node .github/review-ownership/check-approval.mjs
|
||||
# A failed or cancelled run must not leave an earlier conclusion standing. The publish step
|
||||
# records the conclusion it wrote, so a decision already published survives a later failure or
|
||||
# cancellation, and a cancelled publish step that wrote nothing still concludes failure.
|
||||
- name: Publish approval setup failure
|
||||
if: (failure() || cancelled()) && steps.publish.outputs.concluded == ''
|
||||
if: failure() && steps.revoke.outputs.active == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: node .github/review-ownership/check-approval.mjs failure
|
||||
run: node .github/review-ownership/check-approval.mjs error
|
||||
@@ -135,6 +135,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and
|
||||
- **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default.
|
||||
- **Waterfall listeners MUST call `next()`** to delegate; returning without it short-circuits the chain ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)).
|
||||
- **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event.
|
||||
- **Session projections:** follow [dsh-session-projection](.agents/skills/dsh-session-projection/SKILL.md) when creating, extending, or reviewing one, or migrating a fold helper to one.
|
||||
- **Plugins, not loop changes**: new behavior goes on documented extension points; changing `agent-loop` requires updating docs/architecture.md.
|
||||
- **A capability seam comprises Service Definition / Service Provider / Consumer roles.** It is complete, never one role; split only when roles evolve independently ([glossary](docs/glossary.md#capability-seam)).
|
||||
- **Prefer maintained dependencies over hand-rolling** when they genuinely delete owned code and tests ([policy](.agents/notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.md)).
|
||||
@@ -154,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.
|
||||
|
||||
@@ -78,6 +78,7 @@ External packages installed for runtime use or embedded in distributed Host or b
|
||||
| [`ajv`](https://github.com/ajv-validator/ajv) | MIT |
|
||||
| [`anser`](https://github.com/IonicaBizau/anser) | MIT |
|
||||
| [`big.js`](https://github.com/MikeMcl/big.js) | MIT |
|
||||
| [`birpc`](https://github.com/antfu-collective/birpc) | MIT |
|
||||
| [`buffer`](https://github.com/feross/buffer) | MIT |
|
||||
| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
|
||||
| [`chrome-devtools-frontend`](https://github.com/ChromeDevTools/devtools-frontend) | BSD-3-Clause |
|
||||
|
||||
@@ -40,8 +40,6 @@ flowchart LR
|
||||
cfg --> plugin_dsh_base_user_questions
|
||||
plugin_dsh_base_agent["agent<br/>@deepseek-ai/dsh-agent"]
|
||||
cfg --> plugin_dsh_base_agent
|
||||
plugin_dsh_base_agent_controller["agent-controller<br/>@deepseek-ai/dsh-agent-controller"]
|
||||
cfg --> plugin_dsh_base_agent_controller
|
||||
plugin_dsh_base_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek<br/>@deepseek-ai/dsh-plugin-package-inventory-deepseek"]
|
||||
cfg --> plugin_dsh_base_plugin_package_inventory_deepseek
|
||||
plugin_dsh_base_agent_default_model["agent-default-model<br/>@deepseek-ai/dsh-agent-default-model"]
|
||||
@@ -190,6 +188,10 @@ flowchart LR
|
||||
cfg --> plugin_dsh_base_mcp_resources
|
||||
plugin_dsh_base_tools["tools<br/>@deepseek-ai/dsh-tools"]
|
||||
cfg --> plugin_dsh_base_tools
|
||||
plugin_dsh_base_tool_executor_native["tool-executor-native<br/>@deepseek-ai/dsh-tool-executor-native"]
|
||||
cfg --> plugin_dsh_base_tool_executor_native
|
||||
plugin_dsh_base_tool_executor_ptc["tool-executor-ptc<br/>@deepseek-ai/dsh-tool-executor-ptc"]
|
||||
cfg --> plugin_dsh_base_tool_executor_ptc
|
||||
plugin_dsh_base_system_prompt["system-prompt<br/>@deepseek-ai/dsh-system-prompt"]
|
||||
cfg --> plugin_dsh_base_system_prompt
|
||||
plugin_dsh_base_agent_loop["agent-loop<br/>@deepseek-ai/dsh-agent-loop"]
|
||||
@@ -220,7 +222,6 @@ flowchart LR
|
||||
| `session-title-llm` | `@deepseek-ai/dsh-session-title-first-prompt-llm` |
|
||||
| `user-questions` | `@deepseek-ai/dsh-user-questions` |
|
||||
| `agent` | `@deepseek-ai/dsh-agent` |
|
||||
| `agent-controller` | `@deepseek-ai/dsh-agent-controller` |
|
||||
| `plugin-package-inventory-deepseek` | `@deepseek-ai/dsh-plugin-package-inventory-deepseek` |
|
||||
| `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` |
|
||||
| `jobs` | `@deepseek-ai/dsh-jobs-local` |
|
||||
@@ -295,6 +296,8 @@ flowchart LR
|
||||
| `tool-web` | `@deepseek-ai/dsh-tool-web` |
|
||||
| `mcp-resources` | `@deepseek-ai/dsh-mcp-resources` |
|
||||
| `tools` | `@deepseek-ai/dsh-tools` |
|
||||
| `tool-executor-native` | `@deepseek-ai/dsh-tool-executor-native` |
|
||||
| `tool-executor-ptc` | `@deepseek-ai/dsh-tool-executor-ptc` |
|
||||
| `system-prompt` | `@deepseek-ai/dsh-system-prompt` |
|
||||
| `agent-loop` | `@deepseek-ai/dsh-agent-loop` |
|
||||
| `fs-sandbox` | `@deepseek-ai/dsh-fs-sandbox` |
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
- id: tool-executor-native
|
||||
disabled: true
|
||||
|
||||
- id: tool-executor-ptc
|
||||
disabled: false
|
||||
@@ -155,6 +155,7 @@
|
||||
"@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:*",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-testkit": "workspace:*",
|
||||
"@deepseek-ai/dsh-tools": "workspace:*",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:*",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 8d2cfff9bab7327f
|
||||
zh: 21515ab6e6798504
|
||||
/dsh-cli-behavior-reference/web-profile:
|
||||
en: 850434e8ca162221
|
||||
zh: 4893613f97a21eb8
|
||||
en: 149c5571acd51baf
|
||||
zh: 300e3c2425273a92
|
||||
/dsh-cli-behavior-reference/shared-deployment-behavior:
|
||||
en: f40edcf903a51f3f
|
||||
zh: 8a9a915451047c8b
|
||||
|
||||
@@ -130,7 +130,7 @@ The base-backed modes treat the invoking directory as the default workspace root
|
||||
|
||||
New sessions in base-backed profiles default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. The standalone `sdk-minimal` tree instead pins `danger-full-access` and mounts no approval or permission-settings service.
|
||||
|
||||
`DSH_TOOLS_MODE` selects `native` or `ptc` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and provides the platform-selected persistent shell, `read_image`, and `working_directory`. It supports automatic context compaction and the human `/compact` command. Select 极简模式 when creating a Web session; the shared browser, workspace, persistence, sandbox, and permission host remains in place.
|
||||
Web, headless, and SDK inherit native tool execution from the base bundle; use a [PTC overlay](../config/examples/ptc.yml) to select PTC. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and provides the platform-selected persistent shell, `read_image`, and `working_directory`. It supports automatic context compaction and the human `/compact` command. Select 极简模式 when creating a Web session; the shared browser, workspace, persistence, sandbox, and permission host remains in place.
|
||||
|
||||
## Shared deployment behavior
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ dsh web --help
|
||||
|
||||
基于 base 的 profile 中,新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。独立的 `sdk-minimal` 配置树则固定为 `danger-full-access`,且不挂载 approval 或权限 settings 服务。
|
||||
|
||||
`DSH_TOOLS_MODE` 为进程选择 `native` 或 `ptc`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并提供按平台选择的持久 shell、`read_image` 和 `working_directory`。它支持自动上下文压缩和用户 `/compact` 命令。创建 Web 会话时请选择极简模式;共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。
|
||||
Web、headless 与 SDK 继承 base bundle 的原生工具执行;使用 [PTC overlay](../config/examples/ptc.yml) 可选择 PTC。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并提供按平台选择的持久 shell、`read_image` 和 `working_directory`。它支持自动上下文压缩和用户 `/compact` 命令。创建 Web 会话时请选择极简模式;共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。
|
||||
|
||||
## 共享部署行为
|
||||
|
||||
|
||||
@@ -1268,6 +1268,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
['session-title', '@deepseek-ai/dsh-session-title'],
|
||||
['system-prompt', '@deepseek-ai/dsh-system-prompt'],
|
||||
['tools', '@deepseek-ai/dsh-tools'],
|
||||
['tool-executor-native', '@deepseek-ai/dsh-tool-executor-native'],
|
||||
['mcp-resources', '@deepseek-ai/dsh-mcp-resources'],
|
||||
['agent', '@deepseek-ai/dsh-agent'],
|
||||
['llm-retry', '@deepseek-ai/dsh-llm-retry'],
|
||||
|
||||
+5
-3
@@ -1,3 +1,4 @@
|
||||
import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
@@ -5,7 +6,7 @@ import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { unsupportedModel, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
|
||||
const overlayPath = process.argv[2]
|
||||
if (overlayPath === undefined) throw new Error('dsh-badge snapshot requires an overlay path')
|
||||
@@ -31,6 +32,7 @@ try {
|
||||
id: agentId,
|
||||
options: {},
|
||||
session,
|
||||
model: unsupportedModel(),
|
||||
inbox: unsupportedInbox(),
|
||||
status: 'idle',
|
||||
send: () => {},
|
||||
@@ -43,7 +45,7 @@ try {
|
||||
}
|
||||
const decision = await agentEvents(ctx, agent).waterfall(
|
||||
'agent/pre-step',
|
||||
{ messages: [], turn: 1, step: 1, signal: new AbortController().signal },
|
||||
{ modelSelection: { provider: 'mock', model: 'mock' }, messages: [], turn: 1, step: 1, signal: new AbortController().signal },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
|
||||
)
|
||||
const catalog = decision.kind === 'enter'
|
||||
@@ -51,7 +53,7 @@ try {
|
||||
&& message.source.kind === 'skill-catalog')?.content
|
||||
: undefined
|
||||
const summary = (await ctx.skills.list()).find(skill => skill.name === 'dsh-badge')
|
||||
const result = await ctx.tools.execute({
|
||||
const result = await executeToolFixture(ctx, {
|
||||
callId: ToolCallId('dsh-badge-snapshot'),
|
||||
name: 'skill',
|
||||
arguments: { name: 'dsh-badge' },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Optional bundles through the shipped Web Loader, live package manager, and real Agent loop. */
|
||||
import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
@@ -232,7 +233,7 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option
|
||||
|
||||
it('loads the actual badge asset only while its bundle is selected', async () => {
|
||||
const owner = await agent()
|
||||
const load = (callId: string) => ctx.tools.execute({
|
||||
const load = (callId: string) => executeToolFixture(ctx, {
|
||||
callId: ToolCallId(callId), name: 'skill', arguments: { name: 'dsh-badge' },
|
||||
agent: owner.agent, signal: new AbortController().signal,
|
||||
})
|
||||
|
||||
+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}}
|
||||
|
||||
@@ -555,7 +555,7 @@ describe('headless stream-json snapshots', () => {
|
||||
'--permission-mode', 'auto', '--approval-policy', 'never', '--json', 'Run the CLI probe.',
|
||||
],
|
||||
tsconfigPath,
|
||||
env: { DSH_AUTO_REVIEW_TEST_MODE: mode, DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' },
|
||||
env: { DSH_AUTO_REVIEW_TEST_MODE: mode, DSH_TELEMETRY_DISABLED: '1' },
|
||||
inspect: async (cwd) => {
|
||||
const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions'))
|
||||
expect(logs).toHaveLength(1)
|
||||
@@ -605,7 +605,7 @@ describe('headless stream-json snapshots', () => {
|
||||
const first = await runLoaderSmoke({
|
||||
label: 'headless Auto denied wake', cwd, binScript: dshBinScript, configPath: autoReviewOverlayPath,
|
||||
binArgs: [...args, '--permission-mode', 'auto', '--approval-policy', 'never', 'Run the CLI probe.'], tsconfigPath,
|
||||
env: { DSH_AUTO_REVIEW_TEST_MODE: 'deny', DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' },
|
||||
env: { DSH_AUTO_REVIEW_TEST_MODE: 'deny', DSH_TELEMETRY_DISABLED: '1' },
|
||||
})
|
||||
const sessionId = parseJsonl(first.stdout)[0]?.sessionId
|
||||
if (typeof sessionId !== 'string') throw new Error('missing Auto Session identity')
|
||||
@@ -615,7 +615,7 @@ describe('headless stream-json snapshots', () => {
|
||||
const second = await runLoaderSmoke({
|
||||
label: 'headless Auto authorized wake', cwd, binScript: dshBinScript, configPath: autoReviewOverlayPath,
|
||||
binArgs: [...args, '--session-id', sessionId, 'I authorize the exact CLI probe. Retry it.'], tsconfigPath,
|
||||
env: { DSH_AUTO_REVIEW_TEST_MODE: 'allow', DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' },
|
||||
env: { DSH_AUTO_REVIEW_TEST_MODE: 'allow', DSH_TELEMETRY_DISABLED: '1' },
|
||||
inspect: async (directory) => {
|
||||
const logs = await persistedLogs(directory, join(directory, '.dsh', 'sessions'))
|
||||
expect(logs).toHaveLength(1)
|
||||
|
||||
@@ -40,7 +40,7 @@ interface ResolutionEvidence {
|
||||
sourceToolsCjs: string | null
|
||||
sourceLeafCjs: string
|
||||
sourceLeafExplicitCjs: string
|
||||
scheduler: boolean
|
||||
executor: boolean
|
||||
modules: string[]
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ export function testProfileResolution(mode: ExampleMode): void {
|
||||
const staleTools = join(root, 'stale-tools')
|
||||
const staleToolsLink = join(sharedModules, '@deepseek-ai', 'dsh-tools')
|
||||
await writePackage(staleTools, { name: '@deepseek-ai/dsh-tools', version: '0.0.0', exports }, {
|
||||
'index.mjs': "export default class Tools {}\nexport const TOOL_RUNTIME_SCHEDULER = Symbol()\nthrow new Error('STALE_DSH_TOOLS')",
|
||||
'index.mjs': "export default class Tools {}\nthrow new Error('STALE_DSH_TOOLS')",
|
||||
'index.cjs': "throw new Error('STALE_DSH_TOOLS')",
|
||||
})
|
||||
const sourceDir = join(sourcePackageDir, 'src')
|
||||
@@ -183,7 +183,7 @@ export function testProfileResolution(mode: ExampleMode): void {
|
||||
'probe.mjs': [
|
||||
"import { createRequire } from 'node:module'",
|
||||
"import { getEnvironmentData } from 'node:worker_threads'",
|
||||
"import Tools, { TOOL_RUNTIME_SCHEDULER } from '@deepseek-ai/dsh-tools'",
|
||||
"import Tools from '@deepseek-ai/dsh-tools'",
|
||||
`import { leaf } from '${leafName}'`,
|
||||
`import { leaf as bridgeLeaf } from '${bridgeName}'`,
|
||||
`import { external } from '${externalName}'`,
|
||||
@@ -214,7 +214,7 @@ export function testProfileResolution(mode: ExampleMode): void {
|
||||
' sourceToolsInstance: ctx.tools instanceof SourceTools,',
|
||||
` profileToolsCjs: ${toolsCjsExpression},`,
|
||||
' sourceToolsCjs, sourceLeafCjs, sourceLeafExplicitCjs,',
|
||||
" scheduler: typeof ctx.tools[TOOL_RUNTIME_SCHEDULER]?.prepare === 'function',",
|
||||
" executor: typeof ctx.tools.execute === 'function',",
|
||||
' modules: [...ctx.loader.internal.loadCache.keys()]',
|
||||
' .filter(url => /\\/packages\\/core\\/(?:tools|agent-loop)\\//.test(url)),',
|
||||
' }',
|
||||
@@ -292,7 +292,7 @@ export function testProfileResolution(mode: ExampleMode): void {
|
||||
expect(evidence.sourceLeafCjs).toBe(evidence.cjs.filename)
|
||||
expect(evidence.sourceLeafExplicitCjs).toBe(realpathSync.native(join(sourceDevLeaf, 'index.cjs')))
|
||||
expect(evidence.sourceLeafCjs).not.toBe(evidence.sourceLeafExplicitCjs)
|
||||
expect(evidence.scheduler).toBe(true)
|
||||
expect(evidence.executor).toBe(true)
|
||||
expect(evidence.execArgv).toEqual(mode === 'src' ? launch.args.slice(0, 2) : [])
|
||||
for (const name of ['tools', 'agent-loop']) {
|
||||
const selected = mode === 'src' ? 'src/index.ts' : 'lib/index.js'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -96,7 +97,7 @@ function runCode(
|
||||
signal: AbortSignal = testToolSignal,
|
||||
agent?: Agent,
|
||||
): Promise<ToolExecutionResult> {
|
||||
return harness.tools.execute({
|
||||
return executeModelToolFixture(harness, {
|
||||
callId: ToolCallId(`keyless-code-${++keylessCall}`),
|
||||
name: RUN_CODE_NAME,
|
||||
arguments: { code, description: 'Run the e2e program' },
|
||||
@@ -308,7 +309,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => {
|
||||
return { provider: provider.id, names: inspected.data.tools.map(tool => tool.name) };
|
||||
`, testToolSignal, agent))
|
||||
|
||||
expect(value).toEqual({ provider: 'Tool', names: ['cordis_inspect_list', 'cordis_inspect_query', 'run_code'] })
|
||||
expect(value).toEqual({ provider: 'Tool', names: ['cordis_inspect_list', 'cordis_inspect_query'] })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ describe.skipIf(!existsSync(join(repoRoot, 'apps/cli/lib/bin.js')))('dsh SOURCE
|
||||
DSH_HOME: join(root, 'home'),
|
||||
DSH_AGENTS_HOME: join(root, 'agents'),
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DSH_TOOLS_MODE: 'native',
|
||||
DSH_CLI_MOCK_FAILURE: '0',
|
||||
},
|
||||
input: '',
|
||||
|
||||
@@ -85,7 +85,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('SDK native tool updates with rea
|
||||
retryPolicy: { mode: 'normal', maxRetries: 0 } satisfies RetryPolicyConfig,
|
||||
models: [{ id: 'deepseek-flash', systemPromptUpdate: 'in-history', toolUpdate: 'addition-only' }],
|
||||
} },
|
||||
{ id: 'tools', config: { mode: 'native' } },
|
||||
{ id: 'session-log-deepseek', disabled: true },
|
||||
{ id: 'plugin-package-inventory-deepseek', disabled: true },
|
||||
{ insert: [{ id: 'sdk-dynamic-tool-cache-fixture', name: fixturePath, config: { evidencePath, callsPath, sampleLabel, updatePrompt } }] },
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
|
||||
{ label: 'reports max-token turns with mapping enabled through env', envValue: 'true', editorEnabled: false },
|
||||
{ label: 'reports max-token turns with mapping disabled through env', envValue: 'false', editorEnabled: false },
|
||||
{ label: 'allows an explicit patch to enable str_replace_editor', envValue: undefined, editorEnabled: true },
|
||||
{ label: 'uses desktop tools through the PTC environment switch', envValue: undefined, editorEnabled: false, ptc: true },
|
||||
{ label: 'uses the PTC executor overlay despite the retired environment selector', envValue: undefined, editorEnabled: false, ptc: true },
|
||||
])('$label', async (scenario) => {
|
||||
const { envValue, editorEnabled } = scenario
|
||||
const ptc = 'ptc' in scenario && scenario.ptc
|
||||
@@ -92,6 +92,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
|
||||
'--profile',
|
||||
'sdk',
|
||||
...(editorEnabled ? ['--patch', editorPatch] : []),
|
||||
...(ptc ? ['--patch', join(repoRoot, 'apps/cli/config/examples/ptc.yml')] : []),
|
||||
], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
@@ -99,7 +100,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
|
||||
DSH_HOME: join(root, '.dsh'),
|
||||
DSH_PERMISSION_MODE: 'danger-full-access',
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DSH_TOOLS_MODE: ptc ? 'ptc' : 'native',
|
||||
DSH_TOOLS_MODE: ptc ? 'native' : 'ptc',
|
||||
DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
|
||||
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
|
||||
...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }),
|
||||
|
||||
@@ -58,7 +58,7 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent
|
||||
], {
|
||||
cwd: root, env: {
|
||||
PATH: path, DSH_HOME: join(root, 'home'), DSH_AGENTS_HOME: join(root, 'agents'),
|
||||
DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DSH_TOOLS_MODE: 'native',
|
||||
DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
|
||||
DSH_PRIMARY_RUNTIME: carrier === 'sdk' ? undefined : '',
|
||||
},
|
||||
timeout: 110_000, killSignal: 'SIGKILL', reject: false,
|
||||
|
||||
@@ -49,7 +49,7 @@ it('delivers an overdue reminder with the SDK route and lets the client resume t
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('model fixture has no port')
|
||||
const env = { ...process.env, DSH_TOOLS_MODE: 'native', DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
|
||||
const env = { ...process.env, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
|
||||
DEEPSEEK_API_KEY: 'keyless-schedule-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` }
|
||||
clearAmbientProxyEnv(env)
|
||||
const first = new DeepSeekHarness({ cwd: root, dshHome: join(root, '.dsh'), patches: [patch],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createServer } from 'node:http'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { DeepSeekHarness, type SdkUserQuestionRequest } from '@deepseek-ai/dsh-sdk-client'
|
||||
import { clearAmbientProxyEnv } from '../../../../../scripts/test-proxy-environment.ts'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
@@ -47,13 +48,14 @@ it.each([
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('model fixture has no port')
|
||||
const env = { ...process.env, DSH_TOOLS_MODE: mode, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
|
||||
const env = { ...process.env, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
|
||||
DEEPSEEK_API_KEY: 'keyless-question-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` }
|
||||
clearAmbientProxyEnv(env)
|
||||
const seen: SdkUserQuestionRequest[] = []
|
||||
const entered = Promise.withResolvers<AbortSignal>()
|
||||
const harness = state.harness = new DeepSeekHarness({
|
||||
cwd: root, dshHome: join(root, '.dsh'), env, model: 'deepseek-v4-pro', maxTokens: 1234, initializeTimeoutMs: 30_000,
|
||||
patches: mode === 'ptc' ? [fileURLToPath(new URL('../../../config/examples/ptc.yml', import.meta.url))] : [],
|
||||
onUserQuestion: async (request, { signal }) => {
|
||||
expect(signal.aborted).toBe(false)
|
||||
seen.push(request)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/** Test-only IPC assertions over real Creator presets and profile management. */
|
||||
import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
|
||||
export const inject = ['agents', 'agentPresets', 'tools', 'pluginManager', 'permissionPresets']
|
||||
|
||||
export function apply(ctx, config) {
|
||||
@@ -30,7 +32,7 @@ export function apply(ctx, config) {
|
||||
try {
|
||||
const first = await make(`${phase}-first`)
|
||||
const before = names(first)
|
||||
const manage = args => ctx.tools.execute({ name: 'plugin_manager', arguments: args, agent: first,
|
||||
const manage = args => executeModelToolFixture(ctx, { name: 'plugin_manager', arguments: args, agent: first,
|
||||
callId: `${phase}-manage`, signal: new AbortController().signal })
|
||||
ctx.permissionPresets.set(first.session, 'workspace-write')
|
||||
first.session.append('turn/start', { turn: 1 })
|
||||
@@ -45,7 +47,7 @@ export function apply(ctx, config) {
|
||||
const second = await make(`${phase}-second`)
|
||||
const after = names(first)
|
||||
const other = names(second)
|
||||
const ping = await ctx.tools.execute({ name: 'mcp__demo__ping', arguments: {}, agent: first,
|
||||
const ping = await executeModelToolFixture(ctx, { name: 'mcp__demo__ping', arguments: {}, agent: first,
|
||||
callId: `${phase}-ping`, signal: new AbortController().signal })
|
||||
const removed = phase === 'restart'
|
||||
? JSON.parse((await manage({ action: 'remove_bundle', target: '@test/creator-mcp' })).value) : undefined
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry'
|
||||
import { subagentModelSelectionProjectionDefinition } from '@deepseek-ai/dsh-tool-subagent/src/model-selection-state.ts'
|
||||
import { createRequire } from 'node:module'
|
||||
@@ -300,7 +301,7 @@ describe('the shipped Web composition', () => {
|
||||
// depend on ripgrep being present on the machine.
|
||||
expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
|
||||
'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
|
||||
'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_subagent_models', 'present', 'read', 'read_image',
|
||||
'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_subagent_models', 'plugin_manager', 'present', 'read', 'read_image',
|
||||
'schedule_create', 'schedule_delete', 'schedule_list', 'schedule_update',
|
||||
'send_message', 'skill',
|
||||
'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_fetch', 'web_search',
|
||||
@@ -629,7 +630,7 @@ describe('the shipped Web composition', () => {
|
||||
expect(scoped).toContain('project-proof')
|
||||
|
||||
// The preset's own loader tool resolves the global-layer skill.
|
||||
const loaded = await ctx.tools.execute({
|
||||
const loaded = await executeToolFixture(ctx, {
|
||||
callId: ToolCallId('preset-skills-load'),
|
||||
name: 'skill',
|
||||
arguments: { name: 'dsh-badge' },
|
||||
@@ -787,7 +788,7 @@ describe('a user preset declared from the shipped cordis rows', () => {
|
||||
]))
|
||||
}
|
||||
const signal = new AbortController().signal
|
||||
const listed = await copyCtx.tools.execute({
|
||||
const listed = await executeToolFixture(copyCtx, {
|
||||
callId: ToolCallId('copied-preset-inspect-list'),
|
||||
name: 'cordis_inspect_list',
|
||||
arguments: {},
|
||||
@@ -802,7 +803,7 @@ describe('a user preset declared from the shipped cordis rows', () => {
|
||||
// The `Tool` provider is the one built over the host context: it
|
||||
// answers with the shared registry's view of the REQUESTING agent,
|
||||
// so the copy sees its own preset's tools, not the host's empty set.
|
||||
const queried = await copyCtx.tools.execute({
|
||||
const queried = await executeToolFixture(copyCtx, {
|
||||
callId: ToolCallId('copied-preset-inspect-tools'),
|
||||
name: 'cordis_inspect_query',
|
||||
arguments: { platform: 'host', provider: 'Tool', method: 'listTools' },
|
||||
@@ -819,12 +820,12 @@ describe('a user preset declared from the shipped cordis rows', () => {
|
||||
expect(queriedText).toContain(`"name": "${toolName}"`)
|
||||
}
|
||||
|
||||
// The `Config` provider reads the booted profile tree: the shipped `tools` row declares a Config,
|
||||
// The `Config` provider reads the booted profile tree: the Agent loop declares a Config,
|
||||
// and the bootstrap include row is a carrier. The name filter keeps each page small.
|
||||
type ConfigRow = { id: string; patchId: string; name: string; status: string }
|
||||
type ConfigPage = { data: { entries: ConfigRow[]; total: number; nextOffset: number | null } }
|
||||
const listConfigs = async (input: Record<string, string | number>, callId: string): Promise<ConfigPage['data']> => {
|
||||
const page = await copyCtx.tools.execute({
|
||||
const page = await executeToolFixture(copyCtx, {
|
||||
callId: ToolCallId(callId),
|
||||
name: 'cordis_inspect_query',
|
||||
arguments: { platform: 'host', provider: 'Config', method: 'listConfigs', input },
|
||||
@@ -834,16 +835,16 @@ describe('a user preset declared from the shipped cordis rows', () => {
|
||||
expect(page.isError, resultText(page)).toBe(false)
|
||||
return (JSON.parse(resultText(page)) as ConfigPage).data
|
||||
}
|
||||
const toolsRows = await listConfigs({ name: '@deepseek-ai/dsh-tools' }, 'copied-preset-inspect-configs')
|
||||
const toolsRows = await listConfigs({ name: '@deepseek-ai/dsh-agent-loop' }, 'copied-preset-inspect-configs')
|
||||
const toolsRow = toolsRows.entries[0]
|
||||
expect(toolsRow).toMatchObject({ patchId: 'tools', status: 'schema' })
|
||||
expect(toolsRow).toMatchObject({ patchId: 'agent-loop', status: 'schema' })
|
||||
expect(toolsRows).toMatchObject({ total: 1, nextOffset: null })
|
||||
const includes = await listConfigs({ name: 'cordis:include' }, 'copied-preset-inspect-includes')
|
||||
expect(includes.entries.find(entry => entry.patchId === 'include')).toMatchObject({ status: 'tree' })
|
||||
const firstPage = await listConfigs({ limit: 5 }, 'copied-preset-inspect-page')
|
||||
expect(firstPage.entries).toHaveLength(5)
|
||||
expect(firstPage.nextOffset).toBe(5)
|
||||
const projected = await copyCtx.tools.execute({
|
||||
const projected = await executeToolFixture(copyCtx, {
|
||||
callId: ToolCallId('copied-preset-inspect-config'),
|
||||
name: 'cordis_inspect_query',
|
||||
arguments: { platform: 'host', provider: 'Config', method: 'listConfigs', input: { entry: toolsRow!.id } },
|
||||
@@ -855,7 +856,7 @@ describe('a user preset declared from the shipped cordis rows', () => {
|
||||
const { data } = JSON.parse(resultText(projected)) as { data: Projected }
|
||||
expect(data.status).toBe('schema')
|
||||
expect(data.schema.$defs).toHaveProperty('loaderExpression')
|
||||
expect(JSON.stringify(data.schema)).toContain('"mode"')
|
||||
expect(JSON.stringify(data.schema)).toContain('"maxParallelToolCalls"')
|
||||
} finally {
|
||||
await copied.dispose()
|
||||
await shipped.dispose()
|
||||
@@ -954,7 +955,7 @@ describe('a delegated child', () => {
|
||||
] as const
|
||||
for (const [name, args] of calls) {
|
||||
expect(toolNames(ctx, child.agent)).toContain(name)
|
||||
const result = await ctx.tools.execute({
|
||||
const result = await executeToolFixture(ctx, {
|
||||
callId: ToolCallId(`child-${name}`), signal: new AbortController().signal,
|
||||
name, arguments: args, agent: child.agent,
|
||||
})
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:*",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:*",
|
||||
"@deepseek-ai/dsh-brand": "workspace:*"
|
||||
"@deepseek-ai/dsh-brand": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-executor-native": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,7 @@ async function main(): Promise<void> {
|
||||
if (process.connected) process.send?.({ type: 'platform-session', session })
|
||||
})
|
||||
const url = ctx.connection.authenticatedUrl(`${ctx.webServer.protocol}//127.0.0.1:${String(ctx.webServer.port)}`)
|
||||
if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections() }, (error) => { if (error !== null) console.error(error) })
|
||||
if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections(ctx.connection.browserPeer) }, (error) => { if (error !== null) console.error(error) })
|
||||
}
|
||||
|
||||
/** Upper bound of the startup diagnostic carried over IPC; the head holds the message and stack. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native'
|
||||
import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, sep } from 'node:path'
|
||||
@@ -29,7 +30,7 @@ it('loads Desktop Office CLI paths and removes them on disposal', async () => {
|
||||
expect('default' in desktopOffice).toBe(false)
|
||||
expect(ctx.loader.unwrapExports(desktopOffice)).toBe(desktopOffice)
|
||||
const modules = new Map<string, unknown>([
|
||||
['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime],
|
||||
['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime], ['toolExecutor', NativeToolExecutor],
|
||||
['skills', SkillRegistry], ['office', desktopOffice],
|
||||
])
|
||||
ctx.loader.internal = {
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 5f2d25a1d8472647
|
||||
zh: 2acb5c5d7d1917e2
|
||||
/deepseek-harness-desktop/closing-the-window-and-quitting:
|
||||
en: 24387a07ae4eb8e7
|
||||
zh: 0119352685cf273c
|
||||
en: bb35a022c37955c9
|
||||
zh: ab2ef22d398d83a0
|
||||
/deepseek-harness-desktop/key-technical-decisions:
|
||||
en: 3724c53da4f7647b
|
||||
zh: 76ae549d43c99bb4
|
||||
@@ -33,8 +33,8 @@
|
||||
en: 372297005b57a08b
|
||||
zh: 074a635ac17e4362
|
||||
/deepseek-harness-desktop/installation-ownership:
|
||||
en: d13b1404ca148177
|
||||
zh: 2f15c88083bd2e66
|
||||
en: 7cbb1f9e5006bd34
|
||||
zh: 01e9e15a75a90450
|
||||
/deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation:
|
||||
en: a1a47b09f4a72ba4
|
||||
zh: 7b1cf27f0b72cf31
|
||||
@@ -45,8 +45,8 @@
|
||||
en: faf7676ba18bbf2f
|
||||
zh: f1d7b75ad348c548
|
||||
/deepseek-harness-desktop/develop/startup-onboarding:
|
||||
en: a51346b8d880ddba
|
||||
zh: 7913214c1c66063c
|
||||
en: d87d51b901b72ecd
|
||||
zh: 525da1c4d56af659
|
||||
/deepseek-harness-desktop/develop/welcome-window-appearance:
|
||||
en: 617a89aeb28131b9
|
||||
zh: 4d84bd5d86501c86
|
||||
@@ -57,14 +57,14 @@
|
||||
en: 9b90a2651a6d2b52
|
||||
zh: 86d4d9c4358aad80
|
||||
/deepseek-harness-desktop/updates:
|
||||
en: 87db37529b51da62
|
||||
zh: 4fc0d752e437e7d6
|
||||
en: 67bf0a18faa7aa76
|
||||
zh: 782e14ce62bb939f
|
||||
/deepseek-harness-desktop/updates/local-desktop-settings:
|
||||
en: 2e57457c44c85b5f
|
||||
zh: 5b8510a3dc4c6c9d
|
||||
/deepseek-harness-desktop/updates/mandatory-update-policy:
|
||||
en: 54596bef0706d305
|
||||
zh: 48737302dcf3c939
|
||||
en: 182a9c6705213e13
|
||||
zh: 681762f83490f4c6
|
||||
/deepseek-harness-desktop/updates/local-updater-qualification:
|
||||
en: 68ec2f8bd5763ea6
|
||||
zh: 23b932dfe8503602
|
||||
@@ -72,8 +72,8 @@
|
||||
en: 8264b135871e8978
|
||||
zh: 0939aafb1b2961fc
|
||||
/deepseek-harness-desktop/known-limitations:
|
||||
en: c63d4dc7a9104954
|
||||
zh: a4c676e5b46a6ac0
|
||||
en: b1eeadfefb338056
|
||||
zh: 28683fbdf1e98685
|
||||
/deepseek-harness-desktop/dev-note:
|
||||
en: 8e26b460ba31f21b
|
||||
zh: fbcd0307b3867125
|
||||
+11
-7
@@ -70,7 +70,7 @@ Closing the main window (macOS close button and ⌘W; Windows ×, Alt+F4, and th
|
||||
|
||||
Windows keeps a tray icon for the whole run. Its tooltip is the product name, a single click shows and focuses the window, and the context menu offers Open DeepSeek Harness and Quit DeepSeek Harness in the shell locale. Before the first hide, the shared update-dialog overlay explains that running tasks continue and the window can be reopened from the system tray. Confirm hides the window and writes `background-close-confirmed` under Electron userData; Escape, dismissal, or dialog failure keeps the window visible and does not record acknowledgement. Repeated close requests focus the existing shell dialog. In-place updates retain the marker and uninstall removes it. The old `background-notice-shown` marker does not suppress this confirmation. Closing the window sends no system notification. The tray bitmaps are `resources/tray-windows.ico`, rendered from `resources/icon-windows.svg` at 16, 20, 24, 32, 40, 48, and 64 pixels by `pnpm run render:tray-icon`, and packaged as `resources/tray.ico`. macOS ships no menu bar icon.
|
||||
|
||||
Every ordinary quit entry — ⌘Q, the application menu, the Dock menu, the Windows tray and caption Application menu, and the quits caused by closing the mandatory-update or welcome window — asks the Host what it would interrupt. The Host answers over the private IPC channel with two facts: active tasks under the same rule as the update restart check (running agents including subagents and approval waits, queued messages, running or stopping jobs), and armed scheduled reminders reported by the `schedule` family of `workspace/session-activity` for the sessions loaded during this run. Neither fact, the quit proceeds silently. Otherwise a native message box without an owner window — hidden windows stay hidden — shows **Quit DeepSeek Harness?** with one of three localized explanations: running tasks will be interrupted, scheduled tasks will not run while the application is closed, or both. Quit is the default button and Cancel answers Esc; macOS places Cancel left of Quit, Windows places Quit left of Cancel, and the Windows task dialog shows the application icon and stays light regardless of the application theme. A Host that has not reached ready or has failed cannot run tasks, so the quit proceeds without asking. An inspection failure or a Host that misses the two-second deadline is treated as running tasks. While the box is open, another quit request joins it instead of stacking a second one (macOS also raises it; Electron exposes no handle to the Windows task dialog), the explanation does not change when work starts or ends, Quit stops the application without re-inspecting, and Cancel changes nothing. Cancelling a quit that closing the welcome window started shows the welcome window again.
|
||||
Every ordinary quit entry — ⌘Q, the application menu, the Dock menu, the Windows tray and caption Application menu, and the quit caused by closing the welcome window — asks the Host what it would interrupt. The Host answers over the private IPC channel with two facts: active tasks under the same rule as the update restart check (running agents including subagents and approval waits, queued messages, running or stopping jobs), and armed scheduled reminders reported by the `schedule` family of `workspace/session-activity` for the sessions loaded during this run. Neither fact, the quit proceeds silently. Otherwise a native message box without an owner window — hidden windows stay hidden — shows **Quit DeepSeek Harness?** with one of three localized explanations: running tasks will be interrupted, scheduled tasks will not run while the application is closed, or both. Quit is the default button and Cancel answers Esc; macOS places Cancel left of Quit, Windows places Quit left of Cancel, and the Windows task dialog shows the application icon and stays light regardless of the application theme. A Host that has not reached ready or has failed cannot run tasks, so the quit proceeds without asking. An inspection failure or a Host that misses the two-second deadline is treated as running tasks. While the box is open, another quit request joins it instead of stacking a second one (macOS also raises it; Electron exposes no handle to the Windows task dialog), the explanation does not change when work starts or ends, Quit stops the application without re-inspecting, and Cancel changes nothing. Cancelling a quit that closing the welcome window started shows the welcome window again.
|
||||
|
||||
The mandatory update confirmation includes the installation wait notice on Windows; macOS uses the shorter restart explanation.
|
||||
|
||||
@@ -152,7 +152,7 @@ The product UI retains Web actions, including "Open In..." through the shared au
|
||||
|
||||
Update copy preserves the complete version, including prerelease suffixes, without adding v or V. When no update is available, the dialog title reports the result and the body shows the current version.
|
||||
|
||||
Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. The macOS application bundle declares English and Simplified Chinese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources.
|
||||
Electron chooses typed English, Chinese, or Japanese shell copy from its application locale and falls back to English. The macOS application bundle declares English, Simplified Chinese, and Japanese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. Windows NSIS installer resources live in one JSON file per language under `installer/locales/`; packaging generates NSIS strings and discovers the installer language list from these files. English, Simplified Chinese, and Japanese resources must have matching keys and format placeholders; `pnpm run check:i18n` validates them. The native installer test builds and exercises each configured language. Platform account pages and onboarding artwork use English for languages other than Chinese. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources.
|
||||
|
||||
Windows and macOS relay native fullscreen transitions and the state after each page load to the renderer, so overlays can adapt their caption clearance.
|
||||
|
||||
@@ -220,7 +220,7 @@ Repeated launches and `dsh://open` keep the workspace hidden until the startup c
|
||||
|
||||
Desktop checks configured model API-key references after the Host starts and before opening the workspace. With no configured key, the welcome window offers the API-key page. Save and continue writes through the existing credential service using the official DeepSeek provider's configured reference, then opens the workspace. Set up later opens the workspace without saving the draft or a completion setting; the next process launch checks credentials again. Back to sign in returns to the entry and clears the unsaved key and validation message. Buttons keep their labels and block competing actions while saving or opening the workspace. The Desktop preload marker suppresses the Web credential dialog, while retaining the Models settings page and the welcome notice.
|
||||
|
||||
The welcome window reads the shared `locale.preference` before it appears. An explicit English or Chinese choice wins; otherwise Desktop picks the first supported OS language and falls back to English. The main UI reads the same preference and OS language order through its isolated preload before mounting. Language changes in Settings update the shell’s current dictionary and menu; automatic selection writes no preference. The welcome window has no language selector.
|
||||
The welcome window reads the shared `locale.preference` before it appears. An explicit English, Chinese, or Japanese choice wins; otherwise Desktop picks the first supported OS language and falls back to English. The main UI reads the same preference and OS language order through its isolated preload before mounting. Language changes in Settings update the shell’s current dictionary and menu; automatic selection writes no preference. The welcome window has no language selector.
|
||||
|
||||
The browser-login waiting page offers a copy-link action for the current pending authorization, a loading indicator, and cancellation. Clipboard failures leave the copy action available for retry. Copy feedback resets after two seconds; the copied state disables the link until it resets. Welcome text uses Montserrat Light with system fallbacks; large action buttons keep the system font, while text buttons use Montserrat Light. English introductory copy is 24px throughout. Chinese introductory copy is 24px with a 26px product name. Login action buttons are 240px wide with 14px labels. Authorization status headings use 20px Montserrat Regular text. The API-key page uses a 20px heading and a 14px back action, with 84px between the secondary button’s bottom edge and the window bottom.
|
||||
|
||||
@@ -273,9 +273,11 @@ On Windows, the downloaded-update confirmation explains that the application clo
|
||||
|
||||
On macOS, installation preparation waits for the product window to leave fullscreen before displaying the restart confirmation. Cancelling leaves the window in normal mode; destroying it during the native transition cancels installation before task teardown. If macOS does not report completion within five seconds, preparation fails without stopping the Host; the localized error asks the user to exit fullscreen and retry. Installation failure recovery uses the normal window.
|
||||
|
||||
Native update overlays wait for a ready document and a visible parent, and reappear when that parent is shown again. Closing an overlay releases its input interception and parent listeners. The [local window qualification](tests/README.md#verification-overlay) exercises these transitions without starting a workspace.
|
||||
Ordinary update dialogs use an isolated shell-origin WebContentsView inside the product window, so loading a result does not raise a separate native window. The document remains available before the workspace Host is ready. While attached, it intercepts product keyboard input; closing it releases its listeners and view. The Windows caption lies outside the view; its native and product Web controls remain operable. Caption clicks can toggle the sidebar, create or reuse a blank Session, and switch the underlying page. Application menu actions such as About or CLI command management can replace and cancel the current ordinary prompt; this does not approve installation or clear mandatory policy. Product keyboard shortcuts and editing-key forwarding remain blocked while the view is attached.
|
||||
|
||||
Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page. On Windows, dismissal hides the modal before destroying it so the parent is enabled before native window activation changes.
|
||||
Ordinary update dialogs, the Windows first-close notice, and mandatory-update documents share the UI design tokens bundled with the shell. Before displaying a card, each document resolves Electron's application theme through `prefers-color-scheme` and updates its dark-theme attribute when the palette changes. Welcome and update documents use the same initialization script. Explicit light/dark preferences and system appearance share this path; the isolated document keeps its background transparent, and dark mode adds a 1 CSS px token-based inset outline without changing the card size; light mode has no outline.
|
||||
|
||||
Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page.
|
||||
|
||||
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries.
|
||||
|
||||
@@ -332,10 +334,12 @@ When permitted, login is offered during user-initiated checks and the packaged a
|
||||
|
||||
Login and policy requests share an in-memory Session, separate from product windows and the updater; restarting requires a new login. Closing cancels login, and navigation failure provides localized retry guidance. Returning to the service triggers a fresh policy query; a redirect, cookie, or HTTP 422 is not a valid policy decision. Cancellation, expiry, and invalid responses retain any known mandatory block. Fixed login outcomes appear in process diagnostics and the optional update journal; cookies, OAuth parameters, and remote error text are not recorded by the login controller. Live Harness gateway/API integration and macOS login qualification remain unverified.
|
||||
|
||||
A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay fades in and out in place, keeps its backdrop during update-state changes, and redirects parent-window focus and keyboard input to the overlay. On Windows the isolated application preload mounts a shell-origin frame inside the main window, covering the content below the 40 DIP caption with a backdrop and dialog. It blocks background page input without creating another native window; moving and maximizing affect only the main window. Caption menus retain mouse and keyboard operation; Web controls in the caption remain reachable. The shared DOM is a presentation constraint, not a security boundary: product scripts can hide the overlay but cannot clear main-process policy or authorize installation through it. Update actions and state use a private MessageChannel between the isolated preload and shell frame. The parent remains enabled for native move, resize, minimize, maximize, and close controls. Closing the application performs cleanup without clearing the update requirement; Esc does not dismiss the overlay. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact.
|
||||
A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay uses an isolated shell-origin WebContentsView inside its native owner. It fades in and out in place, retains its backdrop across update-state changes, and blocks product keyboard input. Loading or updating the view never shows or activates a separate native window; returning to an already focused owner focuses the view. Closing its native owner follows the [normal window-close behavior](#closing-the-window-and-quitting). View loading failures retain policy and input blocking without changing the owner title. A native recovery dialog appears only while the owner is visible and focused, showing the policy explanation and available target version. Open download page uses the policy URL after the existing origin validation and is omitted when no approved URL is available; Quit uses normal application exit confirmation. No Cancel button is offered; non-action dismissals retain blocking and reopen recovery. Hiding the owner, policy clearance, successful loading, and disposal dismiss the recovery dialog; obsolete responses have no effect. This recovery covers the isolated view, not the Windows embedded frame. On Windows the isolated application preload mounts a shell-origin frame inside the main window, covering the content below the 40 DIP caption with a backdrop and dialog. It blocks background page input without creating another native window; moving and maximizing affect only the main window. Caption menus retain mouse and keyboard operation; Web controls in the caption remain reachable. The shared DOM is a presentation constraint, not a security boundary: product scripts can hide the overlay but cannot clear main-process policy or authorize installation through it. Update actions and state use a private MessageChannel between the isolated preload and shell frame. The parent remains enabled for native move, resize, minimize, maximize, and close controls. Closing the application performs cleanup without clearing the update requirement; Esc does not dismiss the overlay. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact.
|
||||
|
||||
Failures retain blocking, localized retry guidance, and folded diagnostics inside the modal. The allowed download-page action appears in recovery states, not beside normal download or installation. Requesting the browser immediately exposes a copy alternative even while the OS request is pending; a resolved request does not prove the page opened. Copy failure reveals the complete, read-only address for manual copying. Browser and clipboard outcomes do not overwrite updater errors. Only a fresh valid no-force response clears the block; the top-menu check remains available while blocked.
|
||||
|
||||
Selecting Check for Updates while mandatory policy is already blocking focuses its existing interface. If a manual check finishes with mandatory policy active, it also restores, shows, and focuses the owner even after the user has switched applications, minimized, or hidden it. This explicit foreground request is retained independently of the view carrier; the ordinary-result path does not make that request. Focusing the interface does not authorize downloading, installation, or task stopping. The asynchronous mandatory-result foreground path has not received a dedicated background/fullscreen manual qualification.
|
||||
|
||||
Background mandatory-installation confirmation requests Windows taskbar attention or an informational macOS Dock bounce plus one silent notification per readiness episode. It does not restore or focus the app. Notification clicks only return to current confirmation. Foreground return, installation, policy clearance, and shutdown clear owned reminders. System permissions and focus modes can suppress notifications; installed Windows and macOS notification qualification remains required.
|
||||
|
||||
### Local updater qualification
|
||||
@@ -377,7 +381,7 @@ When an expired account returns to Welcome, the main process retains a one-time
|
||||
|
||||
An expired login displays a timeout heading with explicit Sign in again and Add API Key actions. Opening the API-key form dismisses the authorization view; late account-state notifications do not replace an in-progress key entry.
|
||||
|
||||
Embedded Platform views remain hidden until document loading completes so the renderer loading indicator stays visible. Closing or replacing a pending view prevents it from appearing later. Reloading or replacing the owning application document, renderer termination, and window closure also destroy the native view without relying on React cleanup.
|
||||
Embedded Platform views remain hidden until document loading completes so the renderer loading indicator stays visible. Closing or replacing a pending view prevents it from appearing later. Update policy and ordinary update prompts refuse Platform admission, including a pending open whose storage cleanup completes after blocking starts. Reloading or replacing the owning application document, renderer termination, and window closure also destroy the native view without relying on React cleanup.
|
||||
|
||||
Private Platform deployment headers are injected by the embedded browser session only for its configured origin, including document and API requests. Cookie overrides merge by name. Cross-origin requests discard deployment headers; bootstrap exposes only origin, token, and resolved language.
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ macOS 安装会创建 `/usr/local/bin/dsh`;目录权限需要时,系统会
|
||||
|
||||
Windows 在整个运行期间常驻托盘图标。悬停提示为产品名,单击显示并聚焦窗口,右键菜单提供壳语言下的"打开 DeepSeek Harness"和"退出 DeepSeek Harness"。首次隐藏前复用更新弹窗,显示“正在运行的任务不会中断,可在系统托盘中重新打开窗口”和“确认”按钮。确认后隐藏窗口,并在 Electron userData 下写入 `background-close-confirmed`;Esc、关闭弹窗或加载失败均保持主窗口可见,不记录确认。重复关闭请求会聚焦已有壳弹窗。覆盖更新保留标记,卸载删除标记。旧的 `background-notice-shown` 标记不会跳过此确认。关闭窗口不发送系统通知。托盘位图是 `resources/tray-windows.ico`,由 `pnpm run render:tray-icon` 从 `resources/icon-windows.svg` 按 16、20、24、32、40、48、64 像素分别渲染,打包为 `resources/tray.ico`。macOS 不提供菜单栏图标。
|
||||
|
||||
所有普通退出入口——⌘Q、应用菜单、Dock 菜单、Windows 托盘和标题栏"应用程序"菜单,以及关闭强制更新窗口或欢迎窗口引起的退出——都先向 Host 查询退出会中断什么。Host 通过私有 IPC 通道回答两项事实:与更新重启检查同一口径的运行中任务(运行中的 agent,包括子代理和等待审批的回合、排队消息、运行中或停止中的后台任务),以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出,不弹框。否则弹出一个没有父窗口的原生消息框——隐藏的窗口保持隐藏——标题为**退出 DeepSeek Harness?**,正文为三种本地化说明之一:正在运行的任务将会中断、应用关闭期间定时任务不会运行,或两者兼有。"退出"是默认按钮,Esc 等同"取消";macOS 上"取消"在"退出"左侧,Windows 上"退出"在"取消"左侧,Windows 任务对话框显示应用图标且不跟随应用主题、始终为浅色。Host 尚未就绪或已失败时不可能有任务在跑,直接退出。查询失败或 Host 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会并入同一弹框而不叠加新弹框(macOS 上还会把它提到前面;Electron 不暴露 Windows 任务对话框的句柄);任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。取消由关闭欢迎窗口引起的退出时,欢迎窗口会重新显示。
|
||||
所有普通退出入口——⌘Q、应用菜单、Dock 菜单、Windows 托盘和标题栏"应用程序"菜单,以及关闭欢迎窗口引起的退出——都先向 Host 查询退出会中断什么。Host 通过私有 IPC 通道回答两项事实:与更新重启检查同一口径的运行中任务(运行中的 agent,包括子代理和等待审批的回合、排队消息、运行中或停止中的后台任务),以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出,不弹框。否则弹出一个没有父窗口的原生消息框——隐藏的窗口保持隐藏——标题为**退出 DeepSeek Harness?**,正文为三种本地化说明之一:正在运行的任务将会中断、应用关闭期间定时任务不会运行,或两者兼有。"退出"是默认按钮,Esc 等同"取消";macOS 上"取消"在"退出"左侧,Windows 上"退出"在"取消"左侧,Windows 任务对话框显示应用图标且不跟随应用主题、始终为浅色。Host 尚未就绪或已失败时不可能有任务在跑,直接退出。查询失败或 Host 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会并入同一弹框而不叠加新弹框(macOS 上还会把它提到前面;Electron 不暴露 Windows 任务对话框的句柄);任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。取消由关闭欢迎窗口引起的退出时,欢迎窗口会重新显示。
|
||||
|
||||
强制更新的安装确认在 Windows 上额外显示等待提示,macOS 只显示简短的重启说明。
|
||||
|
||||
@@ -152,7 +152,7 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm
|
||||
|
||||
升级文案完整展示版本号(包括预发布后缀),不额外添加 v 或 V。无可用更新时,弹窗标题显示检查结果,正文显示当前版本。
|
||||
|
||||
Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语和简体中文,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
|
||||
Electron 根据应用语言选择类型化的英文、中文或日文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语、简体中文和日语,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。 Windows NSIS 安装器在 `installer/locales/` 下按语言保存独立 JSON 资源;打包从这些文件生成 NSIS 文案和安装器语言清单。英文、简体中文和日语资源的键与格式占位符必须一致,`pnpm run check:i18n` 负责检查。原生安装器测试逐个构建并验证配置中的语言。账户平台页面和引导插图对中文以外的语言使用英文。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
|
||||
|
||||
Windows 和 macOS 会在原生全屏切换及每次页面加载后向渲染器同步全屏状态,让浮层调整顶栏避让距离。
|
||||
|
||||
@@ -220,7 +220,7 @@ API Key 输入框初始为空,并通过 `autocomplete="new-password"` 请求 C
|
||||
|
||||
Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否已配置。没有已配置的密钥时,欢迎窗口提供 API Key 页面。“保存并继续”通过现有凭证服务写入 DeepSeek 官方提供方配置的引用,然后打开工作区。“稍后配置”打开工作区,但不保存草稿或完成标记;下次进程启动时会重新检查凭证。“返回登录”回到入口并清空未保存的密钥和校验提示。保存或打开工作区期间,按钮保持原文案并禁用竞争操作。Desktop preload 标记使 Web 凭证弹窗不再显示,同时保留模型设置页和欢迎须知。
|
||||
|
||||
欢迎窗口在显示前读取共享的 `locale.preference`。用户明确选择的英文或中文优先;否则 Desktop 按系统语言顺序匹配支持的语言,并以英文兜底。主界面在挂载前通过隔离 preload 读取同一偏好和系统语言顺序。在设置中切换语言会更新桌面壳的当前词典和菜单;自动选择不会写入偏好。欢迎窗口不提供语言切换入口。
|
||||
欢迎窗口在显示前读取共享的 `locale.preference`。用户明确选择的英文、中文或日文优先;否则 Desktop 按系统语言顺序匹配支持的语言,并以英文兜底。主界面在挂载前通过隔离 preload 读取同一偏好和系统语言顺序。在设置中切换语言会更新桌面壳的当前词典和菜单;自动选择不会写入偏好。欢迎窗口不提供语言切换入口。
|
||||
|
||||
等待浏览器登录时,欢迎页提供当前待授权请求的链接复制入口、加载指示和取消操作;剪贴板写入失败后可以重试复制,复制结果提示在两秒后恢复;已复制状态下链接禁用,恢复后可再次点击。Welcome 文字使用 Montserrat Light 并回退到系统字体,底部大按钮保留系统字体,文字按钮使用 Montserrat Light。英文欢迎正文及产品名均为 24px,中文欢迎正文为 24px、产品名为 26px。登录操作按钮宽 240px,文字为 14px。授权状态标题使用 20px Montserrat Regular 字重。API Key 页的标题为 20px,返回操作为 14px,次级按钮底边距窗口底部 84px。
|
||||
|
||||
@@ -273,9 +273,11 @@ Windows 下载完成后的更新确认说明应用会在安装期间关闭、完
|
||||
|
||||
macOS 在安装准备阶段等待产品主窗口退出全屏,再显示重启确认。取消确认后窗口保持普通模式;原生切换期间销毁窗口会在任务停止前取消安装。如果 macOS 未在五秒内报告切换完成,安装准备会报错且不停止 Host;本地化错误提示要求退出全屏后重试。安装失败后的恢复使用普通窗口。
|
||||
|
||||
原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。
|
||||
普通更新弹窗使用产品窗口内、隔离的壳静态源 WebContentsView,检查结果加载不会抬升独立的原生窗口。工作区 Host 尚未就绪时,该文档仍可使用。视图挂载期间拦截产品键盘输入,关闭时释放监听和视图。Windows 顶栏位于视图覆盖范围外,其中的原生及产品 Web 控件仍可操作。点击顶栏可切换侧栏、创建或复用空 Session,并改变底层页面。“关于”或 CLI 命令管理等应用菜单操作可替换并取消当前普通提示,但不代表批准安装,也不会清除强更策略。视图挂载期间仍阻断产品键盘快捷键和编辑按键转发。
|
||||
|
||||
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。Windows 在销毁模态窗口前先隐藏它,确保原生窗口激活切换前父窗口已恢复启用。
|
||||
普通更新弹窗、Windows 首次关闭提示和强更文档共用随壳打包的 UI design token。各文档在显示卡片前通过 `prefers-color-scheme` 解析 Electron 的应用主题,配色变化时同步暗色主题属性。欢迎页与更新文档复用同一初始化脚本。明确选择浅色/暗色及跟随系统共用此路径,隔离文档保持透明背景,仅暗色模式以基于 token 的 1 CSS px 内描边区分卡片,不改变尺寸;浅色模式不描边。
|
||||
|
||||
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。
|
||||
|
||||
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。
|
||||
|
||||
@@ -332,10 +334,12 @@ Electron 主进程在启动时读取一次 `app.getPath('userData')/desktop/sett
|
||||
|
||||
登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。
|
||||
|
||||
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离 preload 和壳 frame 之间的私有 MessageChannel 传递。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
|
||||
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层使用原生窗口内、隔离的壳静态源 WebContentsView,原位渐入渐出,在更新状态切换时保留蒙层并拦截产品键盘输入。加载或更新视图不会显示或激活独立原生窗口;回到已获焦点的父窗口时聚焦视图。关闭原生父窗口遵循[常规关窗行为](#closing-the-window-and-quitting)。视图加载失败保留策略与输入阻断,不改写父窗口标题。仅当所属窗口可见且有焦点时显示原生恢复提示,包含策略说明与可用目标版本。打开下载页面使用现有来源校验后的策略 URL,缺少获准地址时不显示该按钮;退出走正常应用退出确认。不提供取消按钮,非操作性关闭保留阻断并重新显示恢复提示。隐藏窗口、策略解除、加载成功或释放控制器会撤销提示,过期响应无效。此恢复覆盖独立视图,不包含 Windows 内嵌 frame。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离 preload 和壳 frame 之间的私有 MessageChannel 传递。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
|
||||
|
||||
失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。
|
||||
|
||||
已处于强更阻断状态时,选择“检查更新”会聚焦现有界面。手动检查完成时若强更策略已生效,也会恢复、显示并聚焦所属窗口,即使用户已切换应用、最小化或隐藏窗口。此显式置前行为独立于视图承载方式保留;普通检查结果路径不发起该请求。聚焦界面不代表批准下载、安装或停止任务。异步强更结果置前路径尚未单独完成后台/全屏人工验证。
|
||||
|
||||
后台强更安装确认请求 Windows 任务栏提醒或 macOS 信息级 Dock 弹跳,并在每轮就绪时尝试一次无声通知,不还原窗口或抢焦点。点击通知只返回当前确认界面。回到前台、安装、策略解除和退出时清理提醒。系统权限和专注模式可能抑制通知;仍需完成 Windows 与 macOS 安装包通知验收。
|
||||
|
||||
### 本地 updater 验证
|
||||
@@ -377,7 +381,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd
|
||||
|
||||
登录超时后显示超时标题,并提供重新登录和添加 API Key 按钮。打开 API Key 表单会关闭授权视图;后续账号状态通知不会覆盖正在填写的密钥。
|
||||
|
||||
内嵌 Platform 视图在文档加载完成前保持隐藏,让渲染层加载图标可见。关闭或替换待加载视图后,该视图不会再次出现。 所属应用文档刷新或替换、渲染进程终止以及窗口关闭也会销毁原生视图,不依赖 React 清理。
|
||||
内嵌 Platform 视图在文档加载完成前保持隐藏,让渲染层加载图标可见。关闭或替换待加载视图后,该视图不会再次出现。强更策略或普通更新提示阻止打开 Platform 视图;清理存储期间开始阻断时,待打开视图也不会挂载。 所属应用文档刷新或替换、渲染进程终止以及窗口关闭也会销毁原生视图,不依赖 React 清理。
|
||||
|
||||
私有 Platform 部署请求头由内嵌浏览器会话注入,仅用于配置来源的文档和 API 请求。Cookie 覆盖按名称合并。跨来源请求移除部署请求头;bootstrap 仅暴露 origin、token 和已解析的语言。
|
||||
|
||||
|
||||
@@ -22,7 +22,10 @@ export interface DesktopElectronBuilderConfig {
|
||||
...{ readonly from: string, readonly to: 'tray.ico' }[],
|
||||
]
|
||||
readonly mac: {
|
||||
readonly extendInfo: { readonly NSMicrophoneUsageDescription: string }
|
||||
readonly extendInfo: {
|
||||
readonly CFBundleLocalizations: readonly string[]
|
||||
readonly NSMicrophoneUsageDescription: string
|
||||
}
|
||||
readonly entitlements: string
|
||||
readonly entitlementsInherit: string
|
||||
readonly identity: string | undefined
|
||||
@@ -50,7 +53,7 @@ export interface DesktopElectronBuilderConfig {
|
||||
readonly perMachine: false
|
||||
readonly allowElevation: false
|
||||
readonly allowToChangeInstallationDirectory: false
|
||||
readonly installerLanguages: readonly ['en_US', 'zh_CN']
|
||||
readonly installerLanguages: readonly string[]
|
||||
}
|
||||
readonly beforeBuild: () => Promise<boolean>
|
||||
readonly beforePack: (context: BeforePackContext) => Promise<void>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"nsisLanguage": "ENGLISH",
|
||||
"strings": {
|
||||
"INSTALLER_INSTALL": "Install now",
|
||||
"INSTALLER_FINISH": "Finish",
|
||||
"INSTALLER_NATIVE_PROGRESS": "Preparing installation… %d%%",
|
||||
"INSTALLER_PROGRESS_EXTRACT": "Extracting files… %d%%",
|
||||
"INSTALLER_PROGRESS_COPY": "Installing files… %d%%",
|
||||
"INSTALLER_PROGRESS_REGISTER": "Finishing installation… %d%%",
|
||||
"INSTALLER_PROGRESS_CLEAN": "Cleaning temporary files… %d%%",
|
||||
"INSTALLER_CHOOSE_PATH": "Choose location",
|
||||
"INSTALLER_BROWSE": "Browse",
|
||||
"INSTALLER_LAUNCH": "Launch now",
|
||||
"INSTALLER_LAUNCH_FAILED": "DeepSeek Harness could not be launched. Retry, or clear Launch now and close the installer.",
|
||||
"INSTALLER_UI_ERROR": "The installer could not initialize its window. Close it and try again.",
|
||||
"INSTALLER_THEME_ERROR": "Use /THEME=auto, /THEME=light or /THEME=dark.",
|
||||
"INSTALLER_FAILED": "Installation did not finish. Close this installer and retry. If the problem persists, download the installer again.",
|
||||
"INSTALLER_RUNNING": "DeepSeek Harness is running. Quit it from the system tray and try again.",
|
||||
"INSTALLER_DIRECTORY_FAILED": "The installation directory could not be replaced.",
|
||||
"INSTALLER_DIRECTORY_HINT": "Check access to the installation directory and close programs using its files, then run the installer again. Copy the error details if the problem persists.",
|
||||
"INSTALLER_DIRECTORY_RESTORE_HINT": "The previous installation could not be restored. Keep the backup directory shown in the error details and contact support before installing again.",
|
||||
"INSTALLER_PATH_INVALID": "Choose a full local folder path. Drive roots, system folders, links and special characters are not supported.",
|
||||
"INSTALLER_PATH_OWNERSHIP": "Choose an empty folder or the registered DeepSeek Harness installation folder.",
|
||||
"INSTALLER_PATH_WRITABLE": "This location is not writable. Choose a folder available to the current user.",
|
||||
"INSTALLER_DISK_SPACE": "There is not enough free disk space. Choose another location.",
|
||||
"INSTALLER_PER_USER": "This installer supports the current user only. Uninstall the existing all-users installation first.",
|
||||
"INSTALLER_EXTRACT_FAILED": "The application files could not be extracted.",
|
||||
"INSTALLER_EXTRACT_HINT": "Security software may have blocked a file, or the disk may be full. Close this installer and try again. If it keeps failing, send us the error details below.",
|
||||
"INSTALLER_EXTRACT_COPY": "Copy error details",
|
||||
"INSTALLER_EXTRACT_EXPAND": "Show details",
|
||||
"INSTALLER_EXTRACT_COLLAPSE": "Hide details",
|
||||
"INSTALLER_EXTRACT_SAVED": "The full report was saved to %s",
|
||||
"INSTALLER_EXTRACT_UNSAVED": "The full report could not be saved. Use Copy error details to keep it.",
|
||||
"INSTALLER_EXTRACT_COPIED": "Error details were copied to the clipboard."
|
||||
},
|
||||
"uninstaller": {
|
||||
"welcome": "Welcome",
|
||||
"finished": "has been uninstalled"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"nsisLanguage": "JAPANESE",
|
||||
"strings": {
|
||||
"INSTALLER_INSTALL": "今すぐインストール",
|
||||
"INSTALLER_FINISH": "完了",
|
||||
"INSTALLER_NATIVE_PROGRESS": "インストールを準備しています… %d%%",
|
||||
"INSTALLER_PROGRESS_EXTRACT": "ファイルを展開しています… %d%%",
|
||||
"INSTALLER_PROGRESS_COPY": "ファイルをインストールしています… %d%%",
|
||||
"INSTALLER_PROGRESS_REGISTER": "インストールを完了しています… %d%%",
|
||||
"INSTALLER_PROGRESS_CLEAN": "一時ファイルを削除しています… %d%%",
|
||||
"INSTALLER_CHOOSE_PATH": "インストール先を選択",
|
||||
"INSTALLER_BROWSE": "参照",
|
||||
"INSTALLER_LAUNCH": "今すぐ起動",
|
||||
"INSTALLER_LAUNCH_FAILED": "DeepSeek Harness を起動できませんでした。再試行するか、「今すぐ起動」のチェックを外してインストーラーを閉じてください。",
|
||||
"INSTALLER_UI_ERROR": "インストーラーのウィンドウを初期化できませんでした。閉じてから再試行してください。",
|
||||
"INSTALLER_THEME_ERROR": "/THEME=auto、/THEME=light、/THEME=dark のいずれかを指定してください。",
|
||||
"INSTALLER_FAILED": "インストールが完了しませんでした。インストーラーを閉じて再試行してください。問題が解決しない場合は、インストーラーを再度ダウンロードしてください。",
|
||||
"INSTALLER_RUNNING": "DeepSeek Harness が実行中です。システムトレイから終了して再試行してください。",
|
||||
"INSTALLER_DIRECTORY_FAILED": "インストール先のディレクトリを置き換えられませんでした。",
|
||||
"INSTALLER_DIRECTORY_HINT": "インストール先へのアクセス権限を確認し、そのファイルを使用しているプログラムを閉じてから、インストーラーを再実行してください。問題が続く場合はエラーの詳細をコピーしてください。",
|
||||
"INSTALLER_DIRECTORY_RESTORE_HINT": "以前のインストールを復元できませんでした。エラーの詳細に表示されたバックアップディレクトリを保持し、再インストール前にサポートへお問い合わせください。",
|
||||
"INSTALLER_PATH_INVALID": "ローカルフォルダーの完全なパスを選択してください。ドライブのルート、システムフォルダー、リンク、特殊文字を含むパスは使用できません。",
|
||||
"INSTALLER_PATH_OWNERSHIP": "空のフォルダー、または登録済みの DeepSeek Harness インストール先を選択してください。",
|
||||
"INSTALLER_PATH_WRITABLE": "この場所には書き込めません。現在のユーザーが書き込めるフォルダーを選択してください。インストーラーは管理者権限を要求しません。",
|
||||
"INSTALLER_DISK_SPACE": "ディスクの空き容量が不足しています。別の場所を選択してください。",
|
||||
"INSTALLER_PER_USER": "このインストーラーは現在のユーザーのみを対象としています。先にすべてのユーザー向けの既存のインストールを削除してください。",
|
||||
"INSTALLER_EXTRACT_FAILED": "アプリケーションのファイルを展開できませんでした。",
|
||||
"INSTALLER_EXTRACT_HINT": "セキュリティソフトがファイルをブロックしたか、ディスクの空き容量が不足している可能性があります。インストーラーを閉じて再試行してください。問題が続く場合は、以下のエラーの詳細をお送りください。",
|
||||
"INSTALLER_EXTRACT_COPY": "エラーの詳細をコピー",
|
||||
"INSTALLER_EXTRACT_EXPAND": "詳細を表示",
|
||||
"INSTALLER_EXTRACT_COLLAPSE": "詳細を非表示",
|
||||
"INSTALLER_EXTRACT_SAVED": "完全なレポートを %s に保存しました",
|
||||
"INSTALLER_EXTRACT_UNSAVED": "完全なレポートを保存できませんでした。「エラーの詳細をコピー」で保存してください。",
|
||||
"INSTALLER_EXTRACT_COPIED": "エラーの詳細をクリップボードにコピーしました。"
|
||||
},
|
||||
"uninstaller": {
|
||||
"welcome": "アンインストール ウィザードへようこそ",
|
||||
"finished": "アンインストールされました"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"nsisLanguage": "SIMPCHINESE",
|
||||
"strings": {
|
||||
"INSTALLER_INSTALL": "立即安装",
|
||||
"INSTALLER_FINISH": "完成",
|
||||
"INSTALLER_NATIVE_PROGRESS": "正在准备安装… %d%%",
|
||||
"INSTALLER_PROGRESS_EXTRACT": "正在解压文件… %d%%",
|
||||
"INSTALLER_PROGRESS_COPY": "正在安装文件… %d%%",
|
||||
"INSTALLER_PROGRESS_REGISTER": "正在完成安装… %d%%",
|
||||
"INSTALLER_PROGRESS_CLEAN": "正在清理临时文件… %d%%",
|
||||
"INSTALLER_CHOOSE_PATH": "选择安装位置",
|
||||
"INSTALLER_BROWSE": "选择",
|
||||
"INSTALLER_LAUNCH": "立即启动",
|
||||
"INSTALLER_LAUNCH_FAILED": "无法启动 DeepSeek Harness。请重试,或取消勾选立即启动后关闭安装程序。",
|
||||
"INSTALLER_UI_ERROR": "无法初始化安装窗口,请关闭后重试。",
|
||||
"INSTALLER_THEME_ERROR": "主题参数须为 /THEME=auto、/THEME=light 或 /THEME=dark。",
|
||||
"INSTALLER_FAILED": "安装未完成,请关闭安装程序后重试。如仍然失败,请重新下载安装包。",
|
||||
"INSTALLER_RUNNING": "DeepSeek Harness 正在运行,请先在系统托盘中退出应用后重试。",
|
||||
"INSTALLER_DIRECTORY_FAILED": "无法替换安装目录。",
|
||||
"INSTALLER_DIRECTORY_HINT": "请检查安装目录的访问权限,关闭占用其中内容的程序后重新运行安装器。如仍失败,请复制错误详情。",
|
||||
"INSTALLER_DIRECTORY_RESTORE_HINT": "未能恢复原安装目录。请保留错误详情中列出的备份目录,联系支持人员后再安装。",
|
||||
"INSTALLER_PATH_INVALID": "请选择本地磁盘上的完整文件夹路径,不能使用磁盘根目录、系统目录、链接目录或包含特殊字符的路径。",
|
||||
"INSTALLER_PATH_OWNERSHIP": "请选择空文件夹,或 DeepSeek Harness 原来的安装目录。",
|
||||
"INSTALLER_PATH_WRITABLE": "无法写入此位置。请选择当前用户可写入的文件夹;安装程序不会申请管理员权限。",
|
||||
"INSTALLER_DISK_SPACE": "此磁盘的可用空间不足,请选择其他位置。",
|
||||
"INSTALLER_PER_USER": "此安装程序仅支持当前用户。请先卸载已有的所有用户安装版本。",
|
||||
"INSTALLER_EXTRACT_FAILED": "无法解压应用程序文件。",
|
||||
"INSTALLER_EXTRACT_HINT": "可能是安全软件拦截了文件,或磁盘空间不足。请关闭安装程序后重试;如仍然失败,请将下方错误信息发送给我们。",
|
||||
"INSTALLER_EXTRACT_COPY": "复制错误信息",
|
||||
"INSTALLER_EXTRACT_EXPAND": "显示详情",
|
||||
"INSTALLER_EXTRACT_COLLAPSE": "隐藏详情",
|
||||
"INSTALLER_EXTRACT_SAVED": "完整错误报告已保存到 %s",
|
||||
"INSTALLER_EXTRACT_UNSAVED": "无法保存完整错误报告,请使用“复制错误信息”保留。",
|
||||
"INSTALLER_EXTRACT_COPIED": "错误信息已复制到剪贴板。"
|
||||
},
|
||||
"uninstaller": {
|
||||
"welcome": "欢迎",
|
||||
"finished": "已从你"
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
; NSIS owns installer localization independently of the Electron application locale.
|
||||
LangString INSTALLER_INSTALL ${LANG_ENGLISH} "Install now"
|
||||
LangString INSTALLER_INSTALL ${LANG_SIMPCHINESE} "立即安装"
|
||||
LangString INSTALLER_FINISH ${LANG_ENGLISH} "Finish"
|
||||
LangString INSTALLER_FINISH ${LANG_SIMPCHINESE} "完成"
|
||||
LangString INSTALLER_NATIVE_PROGRESS ${LANG_ENGLISH} "Preparing installation… %d%%"
|
||||
LangString INSTALLER_NATIVE_PROGRESS ${LANG_SIMPCHINESE} "正在准备安装… %d%%"
|
||||
LangString INSTALLER_PROGRESS_EXTRACT ${LANG_ENGLISH} "Extracting files… %d%%"
|
||||
LangString INSTALLER_PROGRESS_EXTRACT ${LANG_SIMPCHINESE} "正在解压文件… %d%%"
|
||||
LangString INSTALLER_PROGRESS_COPY ${LANG_ENGLISH} "Installing files… %d%%"
|
||||
LangString INSTALLER_PROGRESS_COPY ${LANG_SIMPCHINESE} "正在安装文件… %d%%"
|
||||
LangString INSTALLER_PROGRESS_REGISTER ${LANG_ENGLISH} "Finishing installation… %d%%"
|
||||
LangString INSTALLER_PROGRESS_REGISTER ${LANG_SIMPCHINESE} "正在完成安装… %d%%"
|
||||
LangString INSTALLER_PROGRESS_CLEAN ${LANG_ENGLISH} "Cleaning temporary files… %d%%"
|
||||
LangString INSTALLER_PROGRESS_CLEAN ${LANG_SIMPCHINESE} "正在清理临时文件… %d%%"
|
||||
LangString INSTALLER_CHOOSE_PATH ${LANG_ENGLISH} "Choose location"
|
||||
LangString INSTALLER_CHOOSE_PATH ${LANG_SIMPCHINESE} "选择安装位置"
|
||||
LangString INSTALLER_BROWSE ${LANG_ENGLISH} "Browse"
|
||||
LangString INSTALLER_BROWSE ${LANG_SIMPCHINESE} "选择"
|
||||
LangString INSTALLER_LAUNCH ${LANG_ENGLISH} "Launch now"
|
||||
LangString INSTALLER_LAUNCH ${LANG_SIMPCHINESE} "立即启动"
|
||||
LangString INSTALLER_LAUNCH_FAILED ${LANG_ENGLISH} "DeepSeek Harness could not be launched. Retry, or clear Launch now and close the installer."
|
||||
LangString INSTALLER_LAUNCH_FAILED ${LANG_SIMPCHINESE} "无法启动 DeepSeek Harness。请重试,或取消勾选立即启动后关闭安装程序。"
|
||||
LangString INSTALLER_UI_ERROR ${LANG_ENGLISH} "The installer could not initialize its window. Close it and try again."
|
||||
LangString INSTALLER_UI_ERROR ${LANG_SIMPCHINESE} "无法初始化安装窗口,请关闭后重试。"
|
||||
LangString INSTALLER_THEME_ERROR ${LANG_ENGLISH} "Use /THEME=auto, /THEME=light or /THEME=dark."
|
||||
LangString INSTALLER_THEME_ERROR ${LANG_SIMPCHINESE} "主题参数须为 /THEME=auto、/THEME=light 或 /THEME=dark。"
|
||||
LangString INSTALLER_FAILED ${LANG_ENGLISH} "Installation did not finish. Close this installer and retry. If the problem persists, download the installer again."
|
||||
LangString INSTALLER_FAILED ${LANG_SIMPCHINESE} "安装未完成,请关闭安装程序后重试。如仍然失败,请重新下载安装包。"
|
||||
LangString INSTALLER_RUNNING ${LANG_ENGLISH} "DeepSeek Harness is running. Quit it from the system tray and try again."
|
||||
LangString INSTALLER_RUNNING ${LANG_SIMPCHINESE} "DeepSeek Harness 正在运行,请先在系统托盘中退出应用后重试。"
|
||||
LangString INSTALLER_DIRECTORY_FAILED ${LANG_ENGLISH} "The installation directory could not be replaced."
|
||||
LangString INSTALLER_DIRECTORY_FAILED ${LANG_SIMPCHINESE} "无法替换安装目录。"
|
||||
LangString INSTALLER_DIRECTORY_HINT ${LANG_ENGLISH} "Check access to the installation directory and close programs using its files, then run the installer again. Copy the error details if the problem persists."
|
||||
LangString INSTALLER_DIRECTORY_HINT ${LANG_SIMPCHINESE} "请检查安装目录的访问权限,关闭占用其中内容的程序后重新运行安装器。如仍失败,请复制错误详情。"
|
||||
LangString INSTALLER_DIRECTORY_RESTORE_HINT ${LANG_ENGLISH} "The previous installation could not be restored. Keep the backup directory shown in the error details and contact support before installing again."
|
||||
LangString INSTALLER_DIRECTORY_RESTORE_HINT ${LANG_SIMPCHINESE} "未能恢复原安装目录。请保留错误详情中列出的备份目录,联系支持人员后再安装。"
|
||||
LangString INSTALLER_PATH_INVALID ${LANG_ENGLISH} "Choose a full local folder path. Drive roots, system folders, links and special characters are not supported."
|
||||
LangString INSTALLER_PATH_INVALID ${LANG_SIMPCHINESE} "请选择本地磁盘上的完整文件夹路径,不能使用磁盘根目录、系统目录、链接目录或包含特殊字符的路径。"
|
||||
LangString INSTALLER_PATH_OWNERSHIP ${LANG_ENGLISH} "Choose an empty folder or the registered DeepSeek Harness installation folder."
|
||||
LangString INSTALLER_PATH_OWNERSHIP ${LANG_SIMPCHINESE} "请选择空文件夹,或 DeepSeek Harness 原来的安装目录。"
|
||||
LangString INSTALLER_PATH_WRITABLE ${LANG_ENGLISH} "This location is not writable. Choose a folder available to the current user."
|
||||
LangString INSTALLER_PATH_WRITABLE ${LANG_SIMPCHINESE} "无法写入此位置。请选择当前用户可写入的文件夹;安装程序不会申请管理员权限。"
|
||||
LangString INSTALLER_DISK_SPACE ${LANG_ENGLISH} "There is not enough free disk space. Choose another location."
|
||||
LangString INSTALLER_DISK_SPACE ${LANG_SIMPCHINESE} "此磁盘的可用空间不足,请选择其他位置。"
|
||||
LangString INSTALLER_PER_USER ${LANG_ENGLISH} "This installer supports the current user only. Uninstall the existing all-users installation first."
|
||||
LangString INSTALLER_PER_USER ${LANG_SIMPCHINESE} "此安装程序仅支持当前用户。请先卸载已有的所有用户安装版本。"
|
||||
LangString INSTALLER_EXTRACT_FAILED ${LANG_ENGLISH} "The application files could not be extracted."
|
||||
LangString INSTALLER_EXTRACT_FAILED ${LANG_SIMPCHINESE} "无法解压应用程序文件。"
|
||||
LangString INSTALLER_EXTRACT_HINT ${LANG_ENGLISH} "Security software may have blocked a file, or the disk may be full. Close this installer and try again. If it keeps failing, send us the error details below."
|
||||
LangString INSTALLER_EXTRACT_HINT ${LANG_SIMPCHINESE} "可能是安全软件拦截了文件,或磁盘空间不足。请关闭安装程序后重试;如仍然失败,请将下方错误信息发送给我们。"
|
||||
LangString INSTALLER_EXTRACT_COPY ${LANG_ENGLISH} "Copy error details"
|
||||
LangString INSTALLER_EXTRACT_COPY ${LANG_SIMPCHINESE} "复制错误信息"
|
||||
LangString INSTALLER_EXTRACT_EXPAND ${LANG_ENGLISH} "Show details"
|
||||
LangString INSTALLER_EXTRACT_EXPAND ${LANG_SIMPCHINESE} "显示详情"
|
||||
LangString INSTALLER_EXTRACT_COLLAPSE ${LANG_ENGLISH} "Hide details"
|
||||
LangString INSTALLER_EXTRACT_COLLAPSE ${LANG_SIMPCHINESE} "隐藏详情"
|
||||
LangString INSTALLER_EXTRACT_SAVED ${LANG_ENGLISH} "The full report was saved to %s"
|
||||
LangString INSTALLER_EXTRACT_SAVED ${LANG_SIMPCHINESE} "完整错误报告已保存到 %s"
|
||||
LangString INSTALLER_EXTRACT_UNSAVED ${LANG_ENGLISH} "The full report could not be saved. Use Copy error details to keep it."
|
||||
LangString INSTALLER_EXTRACT_UNSAVED ${LANG_SIMPCHINESE} "无法保存完整错误报告,请使用“复制错误信息”保留。"
|
||||
LangString INSTALLER_EXTRACT_COPIED ${LANG_ENGLISH} "Error details were copied to the clipboard."
|
||||
LangString INSTALLER_EXTRACT_COPIED ${LANG_SIMPCHINESE} "错误信息已复制到剪贴板。"
|
||||
@@ -1,16 +1,19 @@
|
||||
#status, #version { margin-top: 12px; color: #555962; }
|
||||
#status, #version { margin-top: 12px; color: var(--dsw-alias-label-secondary); }
|
||||
#status:empty, #version:empty { display: none; }
|
||||
#error { margin-top: 12px; color: #d93025; }
|
||||
#address { display: block; width: 100%; margin-top: 4px; padding: 8px; border: 1px solid #e6e7eb; border-radius: 8px; background: #f5f5f5; font: 12px/20px monospace; color: #555962; overflow-wrap: anywhere; resize: vertical; }
|
||||
#actions button:not(.primary) { min-height: 32px; padding: 5px 8px; color: #555962; background: transparent; }
|
||||
#error { margin-top: 12px; color: var(--dsw-alias-state-error-primary); }
|
||||
#address { display: block; width: 100%; margin-top: 4px; padding: 8px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-markdown-code-block); font: 12px/20px monospace; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; resize: vertical; }
|
||||
#actions button:not(.primary) { min-height: 32px; padding: 5px 8px; color: var(--dsw-alias-label-secondary); background: transparent; }
|
||||
#update, #refresh { border-radius: 100px; }
|
||||
#fallback { border-top: 1px solid #e6e7eb; margin-top: 12px; padding-top: 12px; }
|
||||
#fallback p, #manual-copy { font-size: 12px; line-height: 20px; color: #555962; }
|
||||
#copy { min-height: 32px; padding: 3px 0; background: transparent; color: #4d6bfe; font-size: 12px; }
|
||||
#copy-message { color: #d93025 !important; }
|
||||
#fallback { border-top: 1px solid var(--dsw-alias-border-l2); margin-top: 12px; padding-top: 12px; }
|
||||
#fallback p, #manual-copy { font-size: 12px; line-height: 20px; color: var(--dsw-alias-label-secondary); }
|
||||
#copy { min-height: 32px; padding: 3px 0; background: transparent; color: var(--dsw-alias-link); font-size: 12px; }
|
||||
#copy-message { color: var(--dsw-alias-state-error-primary) !important; }
|
||||
#manual-copy { display: block; margin-top: 8px; }
|
||||
#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; appearance: none; -webkit-appearance: none; background: #e6e7eb; accent-color: #0f1115; }
|
||||
#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; appearance: none; -webkit-appearance: none; background: var(--dsw-alias-border-l2); accent-color: var(--dsw-alias-button-primary-fill); }
|
||||
|
||||
#progress::-webkit-progress-bar { background: #e6e7eb; }
|
||||
#progress::-webkit-progress-value { background: #0f1115; }
|
||||
#progress::-moz-progress-bar { background: #0f1115; }
|
||||
#progress::-webkit-progress-bar { background: var(--dsw-alias-border-l2); }
|
||||
#progress::-webkit-progress-value { background: var(--dsw-alias-button-primary-fill); }
|
||||
#progress::-moz-progress-bar { background: var(--dsw-alias-button-primary-fill); }
|
||||
@media (hover: hover) {
|
||||
#actions button:not(.primary):hover:not(:disabled), #copy:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
}
|
||||
@@ -5,10 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'">
|
||||
<title></title>
|
||||
<link rel="stylesheet" href="design-platform.css">
|
||||
<link rel="stylesheet" href="update-dialog.css">
|
||||
<link rel="stylesheet" href="mandatory-update.css">
|
||||
</head>
|
||||
<body class="update-dialog">
|
||||
<script src="shell-theme.js"></script>
|
||||
<main role="alertdialog" aria-modal="true" aria-labelledby="title" aria-describedby="detail">
|
||||
<header><h1 id="title"></h1></header>
|
||||
<p id="detail"></p>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Shell documents follow Electron's app-resolved palette before displaying their content. */
|
||||
const palette = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const applyPalette = () => { document.body.toggleAttribute('data-ds-dark-theme', palette.matches) }
|
||||
applyPalette()
|
||||
palette.addEventListener('change', applyPalette)
|
||||
window.addEventListener('pagehide', () => { palette.removeEventListener('change', applyPalette) }, { once: true })
|
||||
@@ -1,31 +1,37 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
color-scheme: light dark;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
color: #0f1115;
|
||||
background: transparent;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; height: 100vh; display: grid; place-items: center; isolation: isolate; }
|
||||
body::before { content: ""; position: fixed; inset: 0; z-index: -1; background: rgb(0 0 0 / 24%); }
|
||||
main { width: min(380px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; padding: 22px 24px 24px; border-radius: 24px; background: #fff; box-shadow: 0 0 1px rgb(0 0 0 / 20%), 0 0 4px rgb(0 0 0 / 2%), 0 12px 32px rgb(0 0 0 / 8%); }
|
||||
body { margin: 0; height: 100vh; display: grid; place-items: center; isolation: isolate; background: transparent; color: var(--dsw-alias-label-primary); color-scheme: light; }
|
||||
body[data-ds-dark-theme] { color-scheme: dark; }
|
||||
body::before { content: ""; position: fixed; inset: 0; z-index: -1; background: var(--dsw-alias-bg-mask-1); }
|
||||
main { width: min(380px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; padding: 22px 24px 24px; border-radius: 24px; background: var(--dsw-alias-bg-layer-1); box-shadow: none; }
|
||||
body[data-ds-dark-theme] main { box-shadow: inset 0 0 0 1px var(--dsw-alias-border-l2); }
|
||||
header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
|
||||
main:focus { outline: none; }
|
||||
h1 { margin: 0; min-height: 28px; font-size: 16px; font-weight: 500; line-height: 24px; overflow-wrap: anywhere; }
|
||||
p { margin: 0; font-size: 14px; line-height: 22px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
#actions { display: flex; flex-direction: column; gap: 8px; margin-top: 20px; position: sticky; bottom: 0; background: #fff; }
|
||||
button { min-height: 44px; padding: 9px 16px; border: 0; border-radius: 12px; font-family: inherit; font-size: 14px; font-weight: 500; line-height: 22px; cursor: pointer; }
|
||||
.primary { background: #0f1115; color: #fff; }
|
||||
.secondary { background: #f5f5f5; color: inherit; }
|
||||
#actions { display: flex; flex-direction: column; gap: 8px; margin-top: 20px; position: sticky; bottom: 0; background: var(--dsw-alias-bg-layer-1); }
|
||||
button { min-height: 44px; padding: 9px 16px; border: 0; border-radius: 12px; font-family: inherit; font-size: 14px; font-weight: 500; line-height: 22px; color: inherit; cursor: pointer; }
|
||||
.primary { background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-inverted); }
|
||||
|
||||
.secondary { background: var(--dsw-alias-button-primary-dimmed); color: inherit; }
|
||||
.close { display: grid; place-items: center; flex: none; width: 28px; min-height: 28px; padding: 7px; border-radius: 50%; background: transparent; }
|
||||
.close img { width: 14px; height: 14px; }
|
||||
button:hover { filter: brightness(0.95); }
|
||||
button:focus-visible, summary:focus-visible, pre:focus-visible { outline: 2px solid #4d6bfe; outline-offset: 3px; }
|
||||
.close::before { content: ""; width: 14px; height: 14px; background: currentColor; mask: url("update-close.svg") center / contain no-repeat; }
|
||||
|
||||
button:focus-visible, summary:focus-visible, pre:focus-visible { outline: 2px solid var(--dsw-alias-link); outline-offset: 3px; }
|
||||
details { margin-top: 12px; font-size: 13px; line-height: 20px; }
|
||||
summary { cursor: pointer; color: #5b6472; }
|
||||
pre { margin: 8px 0 0; padding: 10px; max-height: min(180px, 30vh); overflow: auto; border-radius: 8px; background: #f5f5f5; font: 12px/18px monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
summary { cursor: pointer; color: var(--dsw-alias-label-secondary); }
|
||||
pre { margin: 8px 0 0; padding: 10px; max-height: min(180px, 30vh); overflow: auto; border-radius: 8px; background: var(--dsw-alias-markdown-code-block); font: 12px/18px monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
[hidden] { display: none !important; }
|
||||
body.update-dialog { opacity: 0; transition: opacity 150ms ease; }
|
||||
body.update-dialog.visible { opacity: 1; }
|
||||
@starting-style { body.update-dialog.visible { opacity: 0; } }
|
||||
@media (prefers-reduced-motion: reduce) { body.update-dialog { transition: none; } }
|
||||
@media (hover: hover) {
|
||||
.primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); }
|
||||
.secondary:hover:not(:disabled), .close:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
}
|
||||
@@ -5,11 +5,13 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'self'">
|
||||
<title></title>
|
||||
<link rel="stylesheet" href="design-platform.css">
|
||||
<link rel="stylesheet" href="update-dialog.css">
|
||||
</head>
|
||||
<body class="update-dialog">
|
||||
<script src="shell-theme.js"></script>
|
||||
<main id="dialog" role="dialog" tabindex="-1" aria-modal="true" aria-labelledby="title" aria-describedby="detail" hidden>
|
||||
<header><h1 id="title"></h1><button id="close" class="close" type="button"><img src="update-close.svg" alt=""></button></header>
|
||||
<header><h1 id="title"></h1><button id="close" class="close" type="button"></button></header>
|
||||
<p id="detail"></p>
|
||||
<details id="technical-details" hidden>
|
||||
<summary id="technical-details-label"></summary>
|
||||
|
||||
Loaded 100 of 2247 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user