Merge remote-tracking branch 'github/master' into xtr/session-store-write-lifecycle

# Conflicts:
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	docs/persistence-schema.json
This commit is contained in:
_Kerman committed 2026-10-21 13:45:24 +08:00
commit 28af21a294
558 files changed
+20051 -666365

No files matched your search

@@ -9,8 +9,8 @@
en: 6bb727a659ba1503
zh: d2ebf5c8164a807e
/agent-note-current-profile-plugin-management-shares-cli-transactions/decision:
en: 863b4369c39f3933
zh: 7dd44a91ccaaef8f
en: d2e13562b5aef903
zh: 07face30189c07fc
/agent-note-current-profile-plugin-management-shares-cli-transactions/alternatives-considered:
en: 30c609de89084351
zh: 27f2747ad9d9b752
@@ -16,7 +16,7 @@ Configuration watches use Chokidar write stabilization by default. Its ordinary
Profile files remain the persisted state: entry toggles edit only `disabled` in the last override matching the entry id and any module-name assertion, appending when none matches, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; a failed removal preserves the actual partial state and a diagnostic path, while a failed or cancelled installation restores the profile files it snapshotted.
This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service. Management operations return results to callers without adding messages to live Agents. The agent tool is enabled in Creator mode and disabled by default in the base bundle and other shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.
This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service. Management operations return results to callers without adding messages to live Agents. The agent tool is enabled in Standard and Creator modes and disabled by default in the base bundle and other shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.
CLI calls inherit the terminal and authentication environment; service calls retain the subprocess credential scrub and bounded diagnostics. Management records carry error codes and parameters for locale-owned Web presentation. Reconciliation compares entry identity, fiber identity, configuration and diagnostics before and after updating: unchanged inactive entries remain warnings, while newly affected failures reject the operation. Explicit enablement targets must activate.
@@ -16,7 +16,7 @@ Web 和 Agent 控件需要修改运行中的 profile,同时避免另建包安
profile 文件保持为持久状态:条目开关只修改最后一条符合条目 id 及模块名称断言的覆盖项中的 `disabled`,没有匹配项时追加,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败的删除保留实际的部分状态与诊断路径,失败或被取消的安装则恢复它快照的 profile 文件。
这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service。管理操作向调用方返回结果,不向存活 Agent 添加消息。创造模式启用该 Agent 工具;base 组合包和其他内置预设默认禁用。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。
这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service。管理操作向调用方返回结果,不向存活 Agent 添加消息。标准模式和创造模式启用该 Agent 工具;base 组合包和其他内置预设默认禁用。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。
CLI 调用继承终端和认证环境;service 调用保留子进程凭据清理与有界诊断。管理结果提供错误码和参数,由 Web 词典呈现文案。重载前后比较 entry、fiber、配置与诊断:未变化的已有故障保留为警告,本次影响到的新故障使操作失败。显式启用的目标必须成功激活。
@@ -9,8 +9,8 @@
en: 44615a7a0939054e
zh: b8efa721321ba0d7
/agent-note-creator-mode-installs-persistent-plugin-bundles/decision:
en: c0d74db1b2b16576
zh: b8713a6aadf007f9
en: 38ecabc170c418c7
zh: d380f53fa508328e
/agent-note-creator-mode-installs-persistent-plugin-bundles/alternatives-considered:
en: af527d8aabdfd59c
zh: 34388da992d2ab56
@@ -12,6 +12,8 @@ Agents need to install capabilities and use them during the same conversation. G
Creator mode enables the existing `plugin_manager` tool. Agents author packages and Loader YAML patches as workspace files, then install them through `install_bundle`. An MCP connection is a configuration-only bundle inserting the installed `dsh-mcp-client`; a UI bundle includes a Host entry and a Client artifact. Profile locking, package installation, enablement and HMR remain owned by the existing manager. The entire management tool requires `danger-full-access` or approval for one call: profile changes can load code with host permissions and affect other sessions. Each execution, including inventory reads, uses the shared sandbox escalation helper and approval service before accessing the manager. Under lower sandbox modes, `ask` requests approval and `never` denies; a full-access session needs no additional approval. A grant does not change session permissions, but its profile changes persist. This keeps shell-equivalent Host access explicit without requiring a permanent session-wide elevation. Human-operated Web and CLI controls retain their existing behavior.
The default Standard mode enables the same tool under the same profile condition and approval gate. Without it, Standard sessions did not install plugins directly and spent extra turns and tokens working around the missing tool; the change also moves Standard and Creator toward one capability set. A full-access Standard session can therefore install Host code without a separate prompt, as its shell already can. PTC and Minimal keep the tool disabled.
The model sees two read-only Cordis inspection tools. Generated-code define/run/stop/undefine and dynamic self-inspection tool APIs are absent. Existing runtime and Client consumers retain their services; historical session cards remain readable. This supersedes only the model-facing mutation workflow in the [self-referential toolset reference](../../../../packages/extensions/cordis-host-runner/README.md); its runtime ownership and sandbox rationale remain independently relevant. The [profile transaction decision](2026-09-14-current-profile-plugin-management.md) continues to govern locking, package installation, and partial failures.
Visual creation requests default to an installed Client plugin rendered in the current Web page unless the user names another destination. The development skill supplies a minimal package and effect-owned Client registration. Discovery ends when the required APIs are known; a working first version is installed before optional visual refinement. Verification uses the connected page where available. Browser authentication or operating-system setup is not a prerequisite for plugin installation, and a mock preview cannot establish an in-app result.
@@ -12,6 +12,8 @@ agent 需要安装能力并在同一段对话中使用。生成代码工具在
创造模式启用现有的 `plugin_manager` 工具。agent 将包和 Loader YAML patch 写入工作区文件,再通过 `install_bundle` 安装。MCP 连接是插入已安装 `dsh-mcp-client` 的纯配置组合包;UI 组合包包含 Host 入口和 Client 产物。profile 锁、包安装、启停和 HMR 继续由现有管理器负责。 整个管理工具要求 `danger-full-access` 或单次调用的批准:profile 变更能以宿主权限加载代码,并影响其他会话。每次执行(包括查询列表)都会先使用共享沙箱提权函数和审批服务,再访问管理器。在较低沙箱模式下,`ask` 请求审批,`never` 拒绝;完整权限会话无需额外审批。批准不改变会话权限,但本次操作的 profile 变更会持久化。这让与 shell 等价的宿主访问需要明确授权,同时不必永久提升整个会话的权限。由用户直接操作的 Web 和 CLI 控件保持原有行为。
默认的标准模式以相同的 profile 条件和审批门槛启用同一工具。缺少该工具时,标准模式会话不会直接安装插件,而要额外花费轮次和 token 绕过;此改动也让标准模式和创造模式朝同一能力集收敛。因此,完整权限的标准模式会话无需单独审批即可安装 Host 代码,与其 shell 已有的能力一致。PTC 和 Minimal 仍禁用该工具。
模型可见两个只读 Cordis 检查工具,不再提供生成代码的 define/run/stop/undefine 和动态自省工具 API。现有运行时和 Client 消费者保留其服务;历史会话卡片仍然可读。这仅取代[自引用工具集参考](../../../../packages/extensions/cordis-host-runner/README.zh.md)中的模型侧变更流程;其运行时所有权和沙箱依据仍有独立价值。[profile 事务决策](2026-09-14-current-profile-plugin-management.zh.md)继续规定锁、包安装和部分失败行为。
除非用户指定其他目标,视觉创建请求默认通过已安装的 Client 插件显示在当前 Web 页面。开发 skill 提供最小包和由 effect 管理的 Client 注册示例。已知所需 API 后结束探查,在可选视觉优化之前先安装能工作的初版。有条件时使用已连接页面验证。浏览器认证或操作系统设置不是安装插件的前提,mock 预览不能证明应用内结果。
@@ -9,8 +9,8 @@
en: 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
@@ -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.
@@ -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 特有的激活行为。
@@ -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
@@ -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.
@@ -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()` 之前,这个阶段还不是完整的强制执行点。
@@ -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.
@@ -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 序列化器的输出。
@@ -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 检查对已安装入口包跳过这些边,并要求列表中的每个组合包都已安装。
@@ -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.
@@ -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。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-10-20-weighted-approval-status-gate-with-check-run-label.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-10-20-weighted-approval-status-gate-with-check-run-label.md
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run:
en: a1f0f9fd657c7d35
zh: e8fa02fb5bde6075
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/problem:
en: 8e854872edfc0729
zh: 6f3ed495fcb690a4
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/decision:
en: 03de94946d7532d7
zh: cf2f585f715d3295
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/verification:
en: 8298cff64cce6467
zh: a121d509c2302c00
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/alternatives-considered:
en: 197255cd82aabef7
zh: dc03f4bf77150f22
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/consequences:
en: 25e1ac1c8cab089a
zh: 84e10cccb1b9eda7
@@ -0,0 +1,37 @@
# Agent Note: Weighted approval keeps its commit status gate and adds a decision check run
Status: implemented
English | [中文](2026-10-20-weighted-approval-status-gate-with-check-run-label.zh.md)
## Problem
The `weighted approval` commit status is the record this repository's branch rules require, and below the approval threshold it stays `pending`. A commit status has no action-required state, so readers cannot tell an approval wait from running work. Moving the gate to a check run does not work: after that change, the check run completed as `success` on the pull-request head and both the REST API and `gh pr checks` listed it, while the merge box kept reporting the same `weighted approval decision` context as `Expected — Waiting for status to be reported`, and two pull requests needed an administrator bypass. That change was reverted.
## Decision
The publisher keeps writing the `weighted approval` commit status exactly as before: `pending` while the policy is unmet, `success` when it holds, and `error` when setup or evaluation fails. Branch rules keep requiring that status. That behavior, including keeping an unmet policy `pending`, is what the [archived decision](../../archived/process/2026-09-09-blocked-weighted-approvals-remain-pending.md) records; this change adds the label beside it instead of changing the gate.
The publisher additionally writes a `weighted approval decision` check run for the same evaluation: `action_required` while the policy is unmet, `success` when it holds, and `failure` when setup or evaluation fails. The check run's title carries the score or the blocker. Branch rules do not require it, so it never blocks a merge; it names the blocked state beside the status that does.
A head keeps one GitHub Actions run with the check-run name: the publisher updates the head's existing run and creates one only when none exists. The setup phase creates the run as `in_progress` on a head without one and deals with a published success by rewriting its conclusion to `action_required`, because GitHub accepts but does not apply a return to `in_progress`. A failed check-run write is logged and never changes the status, so a token without the checks permission, or a check-run API failure, leaves the gate as the status wrote it. The publisher identifies its own runs by the `external_id` marker it sets on creation, which excludes check runs that workflow jobs produce for a same-named job. It revokes a published `success` before dependency setup, because GitHub accepts but does not apply a return to `in_progress`.
## Verification
[Approval policy tests](../../../../.github/review-ownership/check-approval.test.mjs) pin the unchanged status states, the check-run conclusion for a met and an unmet policy, the phase writes, revoking a published success through its conclusion, one run per head, and invalid list responses. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the `checks: write` and `statuses: write` permissions and the setup phase commands.
The check-run limit was measured on 2026-10-20 with the reverted attempt on this repository: the `weighted approval decision` check run completed as `success` on the pull-request head, the REST API and `gh pr checks` listed it, and the merge box kept reporting the same context as expected.
## Alternatives considered
**Require the check run instead of the commit status.** Measured above: the ruleset does not match a check run published through the default-branch workflow's token, so the gate cannot be satisfied.
**Publish the commit status as `failure` while the policy is unmet.** The status would complete, but this changes the required record's meaning and would report an unmet, reversible condition as a malfunction.
**Publish only the check run and leave the status untouched.** The status would keep its last state from an earlier evaluation, so the gate and the label could disagree.
**Publish the required check run from a dedicated GitHub App.** A check run whose app the ruleset names would let the required entry carry the action-required conclusion itself. The repository has no App with the checks permission; with one, the label could move onto the required record and the status could be retired.
## Consequences
A job that ends between the `pending` write and the fallback step, such as a lost runner or a cancellation that skips the final step, leaves the status `pending`; that is the original symptom of this status, not a regression from the added label. While reviewers are pending, the merge box reports `weighted approval` as pending (the gate) and the `weighted approval decision` entry as `action_required`, with the score or blocker in its title; `gh pr checks` lists both. A published success is revoked by rewriting the check run's conclusion, and the status follows the unchanged policy states.
@@ -0,0 +1,37 @@
# Agent Note: Weighted approval 保留其 commit status 门禁并新增决策 check run
Status: implemented
[English](2026-10-20-weighted-approval-status-gate-with-check-run-label.md) | 中文
## 问题
`weighted approval` commit status 是本仓库分支规则要求的记录,在批准阈值下方时保持 `pending`。commit status 没有 action-required 状态,读者无法区分等待批准与正在运行的工作。把门禁改成 check run 并不可行:该改动后 check run 在 PR head 上以 `success` 完成,REST API 与 `gh pr checks` 都能列出它,而合并框始终把同一上下文报告为 `Expected — Waiting for status to be reported`,两个 PR 需要管理员绕过才能合并。该改动已回退。
## 决定
publisher 继续完全按原样写 `weighted approval` commit status:策略未满足时为 `pending`,满足时为 `success`,安装或评估失败时为 `error`。分支规则继续要求该 status。该行为——包括未满足的策略保持 `pending`——正是[已归档决定](../../archived/process/2026-09-09-blocked-weighted-approvals-remain-pending.md)记录的;本次变更在该门禁旁边新增标签,而不改变门禁。
publisher 为同一次评估额外写一个 `weighted approval decision` check run:策略未满足时为 `action_required`,满足时为 `success`,安装或评估失败时为 `failure`。check run 的标题携带分数或 blocker。分支规则不要求它,因此它从不阻止合并;它在会阻止合并的那条 status 旁边命名被阻止的状态。
每个 head 只保留一个同名 GitHub Actions run:publisher 更新 head 上已有的 run,仅在不存在时创建。setup 阶段对没有 run 的 head 以 `in_progress` 创建,并把已发布的 success 的 conclusion 改写为 `action_required`,因为 GitHub 接受但不执行改回 `in_progress` 的更新。check run 写入失败会被记录,且从不改变 status,因此没有 checks 权限的 token 或 check-run API 失败时,门禁保持 status 写入的结果。publisher 用创建时设置的 `external_id` 标记识别自己的 run,从而排除 workflow job 为同名 job 产生的 check run。它在依赖安装前撤销已发布的 `success`,因为 GitHub 接受但不执行改回 `in_progress` 的更新。
## 验证
[批准策略测试](../../../../.github/review-ownership/check-approval.test.mjs)锁定未改动的 status 状态、策略满足与未满足两种情况下 check run 的 conclusion、阶段写入、通过 conclusion 撤销已发布的成功、每个 head 只有一个 run,以及无效的列表响应。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定 `checks: write` 与 `statuses: write` 权限以及 setup 阶段命令。
check run 的限制于 2026-10-20 在本仓库用被回退的尝试实测:`weighted approval decision` check run 在 PR head 上以 `success` 完成,REST API 与 `gh pr checks` 都能列出它,而合并框始终把同一个 `weighted approval decision` 上下文报告为 expected。
## 考虑过的替代方案
**把 check run 作为必需检查。** 见上面的实测:ruleset 不匹配默认分支 workflow 的 token 发布的 check run,门禁无法满足。
**策略未满足时把 commit status 发布为 `failure`。** status 会完成,但改变了必需记录的含义,并把尚未满足且可撤销的条件报告为故障。
**只发布 check run 而不动 status。** status 会保留上一次评估的状态,门禁与标签可能不一致。
**由专用 GitHub App 发布必需 check run。** 由 ruleset 指定 app 的 check run 可以让必需条目本身携带 action-required 结论。本仓库没有具备 checks 权限的 App;有了之后可以把标签移到必需记录上并停用 status。
## 后果
job 在 `pending` 写入与兜底步骤之间结束(例如 runner 丢失,或取消导致最后一步未执行)会让 status 保持 `pending`;这是该 status 既有的表现,不是新增标签带来的回归。评审未完成时,合并框把 `weighted approval` 报告为 pending(门禁),并把 `weighted approval decision` 条目报告为 `action_required`,标题里是分数或 blocker;`gh pr checks` 会列出两者。已发布的 success 通过改写 check run 的 conclusion 撤销,status 继续按未改动的策略状态变化。
+1 -1
View File
@@ -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
+1
View File
@@ -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.
+1 -1
View File
@@ -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:
+10 -5
View File
@@ -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.
+6 -6
View File
@@ -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>
+75 -40
View File
@@ -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`)
},
})
}
+156 -160
View File
@@ -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)
},
+8 -11
View File
@@ -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
+1
View File
@@ -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)).
-3
View File
@@ -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"]
@@ -220,7 +218,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` |
+1 -1
View File
@@ -300,7 +300,7 @@ describe('the shipped Web composition', () => {
// depend on ripgrep being present on the machine.
expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_subagent_models', 'present', 'read', 'read_image',
'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_subagent_models', 'plugin_manager', 'present', 'read', 'read_image',
'schedule_create', 'schedule_delete', 'schedule_list', 'schedule_update',
'send_message', 'skill',
'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_fetch', 'web_search',
+2 -2
View File
@@ -9,8 +9,8 @@
en: 1f9c004f3eb465fb
zh: 3e45ef8014bad48d
/apps-web-browser-e2e/these-are-host-face-tests:
en: 8c406d423c8e2137
zh: a66a144fd146f56c
en: 101d687b1d98c5b7
zh: f8c31115e8a15be7
/apps-web-browser-e2e/do-not-import-deepseek-ai-dsh-client-here:
en: 7cc75a6413647142
zh: a8c541f9b93e9f7f
+2
View File
@@ -32,6 +32,8 @@ Explicit scrolling uses `scrollIntoView` from `support.ts`: it resolves the loca
They type-check in the root `tsconfig.host.json`, not in the Client aggregate, because they read Host services directly: `ctx.connection`, the Host `SessionStore`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not make a file part of the Client program — the two faces merge Cordis `Context` under the same keys with different services, so one program cannot see both. Moving these files into the Client aggregate makes every Host-service access fail to compile.
A new scenario joins both program lists in the same change: its file in the root `tsconfig.host.json` include and in the Client aggregate's `apps/web/tsconfig.json` exclude. That project includes all of `tests`, so a file missing from the exclude compiles as Client code, and the Client compiler gate of `pnpm run typecheck` keeps failing with Host-service errors until it is listed.
## Do not import `@deepseek-ai/dsh-client-*` here
Importing a Client package — a value or a type — pulls its TypeScript project and referenced projects into the Host build graph. Generated Remote declarations are available before package compilation, but this does not make the Host and Client Cordis `Context` declarations compatible. Keep these tests on the Host face and use only the explicit built-client harness exception below.
+2
View File
@@ -32,6 +32,8 @@ pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps
它们在根 `tsconfig.host.json` 中做类型检查,而不在 Client aggregate 中,因为它们直接读取 Host 服务:`ctx.connection`、Host 侧 `SessionStore` 与 `ctx.sessionProjectionCache`。运行时驱动浏览器并不使一个文件成为 Client 程序的一部分——两个 face 在相同的键上以不同服务合并 Cordis `Context`,因此单个程序无法同时看见两者。把这些文件挪进 Client aggregate 会让每一处 Host 服务访问都无法编译。
新增场景要在同一次改动里登记到两份工程清单:根 `tsconfig.host.json` 的 include,以及 Client aggregate `apps/web/tsconfig.json` 的 exclude。该工程包含整个 `tests` 目录,漏登记 exclude 的文件会按 Client 代码编译,`pnpm run typecheck` 的 Client compiler 关卡会一直以 Host 服务错误失败,直到它被列入。
## 不要在此 import `@deepseek-ai/dsh-client-*`
import 一个 Client 包——无论值还是类型——都会把它的 TypeScript 工程及其引用的工程拉进 Host 构建图。生成的 Remote 声明在包编译之前就绪,但这并不使 Host 与 Client 的 Cordis `Context` 声明兼容。保留这些测试的 Host face 归属,只使用下文明确的 built-client harness 例外。
+11
View File
@@ -112,6 +112,17 @@ describe.each(MODE === 'record' ? ['button'] as const : ['button', 'keyboard'] a
await panel.waitFor({ timeout: MODE === 'record' ? 180_000 : 60_000 })
const scroll = panel.locator('[data-approval-scroll]')
await expect.poll(() => scroll.getByText(/tok/).count(), { timeout: 15_000 }).toBeGreaterThan(0)
const defaults = await panel.evaluate((root: HTMLElement) => {
const before = root.style.textAlign
root.style.textAlign = 'center'
try {
return {
alignment: getComputedStyle(root.querySelector('[data-approval-scroll]')!).textAlign,
wrapping: getComputedStyle(root.querySelector('button')!.parentElement!).flexWrap,
}
} finally { root.style.textAlign = before }
})
expect(defaults).toEqual({ alignment: 'center', wrapping: 'nowrap' })
if (MODE !== 'record') {
const snapshot = await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd)
+22 -3
View File
@@ -8,8 +8,8 @@ import { readProfileManifest } from '@deepseek-ai/dsh-app-boot'
import { SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-ptc-runtime'
import type {} from '@deepseek-ai/dsh-agent-preset-registry'
import { expect, it } from 'vitest'
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
import { expect, it, vi } from 'vitest'
import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, webSnapshotMode, type WebScaffold } from './scaffold.ts'
import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts'
it.each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserving default %s until the explicit persistent-runtime action', async (selectedDefault) => {
@@ -54,9 +54,28 @@ it.each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserv
const toggle = card.getByRole('switch', { name: '启用 Computer Use', exact: true })
expect(await toggle.getAttribute('aria-checked')).toBe('false')
expect(await card.textContent()).toContain('请切换到 PTC 模式')
const enabling = Promise.withResolvers<undefined>()
const setEnabled = scaffold.ctx.pluginManager.setBundleEnabled.bind(scaffold.ctx.pluginManager)
let applying: ReturnType<typeof setEnabled> | undefined
// Hold the Host request so the pending UI does not depend on machine speed; release into the real operation.
const enableCall = vi.spyOn(scaffold.ctx.pluginManager, 'setBundleEnabled').mockImplementationOnce((name, enabled) => {
applying = enabling.promise.then(() => setEnabled(name, enabled))
return applying
})
try {
await toggle.click()
await card.getByRole('status').getByText('正在应用 Computer Use 的更改', { exact: true }).waitFor()
expect(await toggle.isEnabled()).toBe(false)
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/cua-permissions/enabling.expected.md', import.meta.url)),
await captureStableAria(page, '[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]', scaffold.workspaceCwd), webSnapshotMode())
} finally {
enabling.resolve(undefined)
enableCall.mockRestore()
await applying
}
// Bundle transitions await native cleanup under the browser action deadline.
await toggle.click()
await toggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' })
await card.getByRole('status').getByText('正在应用 Computer Use 的更改', { exact: true }).waitFor({ state: 'hidden' })
const setup = page.getByRole('dialog', { name: '使用 Computer Use 前需要设置', exact: true })
await setup.waitFor()
expect(await setup.getByText('Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。', { exact: true }).count()).toBe(1)
+107 -7
View File
@@ -7,9 +7,9 @@ import { ToolCallId } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-user-approval'
import type {} from '@deepseek-ai/dsh-agent-preset-registry'
import { chromium, type Browser } from 'playwright'
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
import { expect, it, onTestFailed, onTestFinished, vi } from 'vitest'
import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, webSnapshotMode, watchConsole, type WebScaffold } from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, PLUGIN_TOGGLE_SETTLE_MS, saveFailureShot } from './support.ts'
import { installNativeFixture, startFixtureHost } from '../../../packages/experimental/cua-macos/tests/native-fixture.ts'
const bundle = fileURLToPath(new URL('../../../packages/experimental/cua-bundle', import.meta.url))
@@ -59,13 +59,20 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
callId: ToolCallId('cua-app-approval'), name: 'run_code',
arguments: { code: 'return await tools.cua({ method: "getApp", params: { selector: "Fixture" } })',
description: 'Request application access' } })
const approval = page.locator('[data-question-key]')
const approval = page.locator('[data-approval-key]')
await Promise.race([approval.waitFor(), grant.then((result) => {
throw new Error(`Application selection completed before consent: ${JSON.stringify(result)}`)
})])
expect(await approval.textContent()).toContain('Fixture')
await approval.getByRole('radio', { name: 'Always allow', exact: true }).click()
await approval.getByRole('button', { name: 'Submit', exact: true }).click()
expect(await approval.getByText('Fixture', { exact: true }).count()).toBe(1)
expect(await approval.getByRole('textbox').count()).toBe(0)
expect(await approval.locator('[data-approval-scroll]').evaluate(element => getComputedStyle(element).textAlign)).toBe('left')
expect(await approval.getByRole('button', { name: 'Always allow', exact: true })
.evaluate(button => getComputedStyle(button.parentElement!).flexWrap)).toBe('wrap')
await expect.poll(() => approval.locator('img').evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1)
expect(await approval.getByText('/Applications/Fixture.app', { exact: true }).count()).toBe(0)
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/cua-permissions/application-approval.expected.md', import.meta.url)),
await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd), webSnapshotMode())
await approval.getByRole('button', { name: 'Always allow', exact: true }).click()
expect((await grant).isError).toBe(false)
await approval.waitFor({ state: 'hidden' })
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
@@ -146,5 +153,98 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
{ method: 'openPermissionSettings', params: { permission: 'accessibility' } },
])
expect(events.every(event => ['permissionStatus', 'requestPermission', 'openPermissionSettings', 'applicationIcons', 'getApp'].includes(event.method))).toBe(true)
const preview = plugins.locator('[data-detail-document] img')
await preview.waitFor()
await preview.evaluate(async (image: HTMLImageElement) => { await image.decode() })
const trace = await page.evaluateHandle(() => {
const errors: string[] = []
const stable = [...document.querySelectorAll('[data-cua-settings] [role="alert"]')].map(node => node.textContent?.trim())
const layouts: { height: number; sections: number[]; groups: number[] }[] = []
const documents: number[] = []
let frame = 0
const measure = () => {
const documentHeight = document.querySelector('[data-plugin-detail] [data-detail-document]')?.getBoundingClientRect().height ?? 0
if (documents.at(-1) !== documentHeight) documents.push(documentHeight)
const settings = document.querySelector('[data-cua-settings]')
if (settings) {
const height = settings.getBoundingClientRect().height
if (layouts.at(-1)?.height !== height) layouts.push({ height,
sections: [...settings.querySelectorAll('section')].map(node => node.getBoundingClientRect().height),
groups: [...settings.querySelectorAll('[role="group"]')].map(node => node.getBoundingClientRect().height),
})
}
frame = requestAnimationFrame(measure)
}
measure()
const observer = new MutationObserver(() => {
for (const node of document.querySelectorAll('[data-cua-settings] [role="alert"]')) {
const text = node.textContent?.trim()
if (text && !stable.includes(text) && errors.at(-1) !== text) errors.push(text)
}
})
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
return { errors, layouts, documents, stop: () => { observer.disconnect(); cancelAnimationFrame(frame) } }
})
try {
const toggle = page.locator('[data-plugin-panel]').getByRole('switch', { name: 'Enable Computer Use', exact: true })
for (const width of [1680, 390]) {
await page.setViewportSize({ width, height: 1000 })
if (width === 390) await page.locator('[data-sidebar-collapsed]').waitFor({ state: 'attached' })
await page.locator('[data-animating]').waitFor({ state: 'hidden' })
await trace.evaluate((value) => { value.layouts.length = 0; value.documents.length = 0 })
for (let cycle = 0; cycle < 3; cycle++) {
await toggle.click()
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false')
await expect.poll(() => toggle.isEnabled(), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe(true)
await toggle.click()
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true')
await expect.poll(() => panel.getAttribute('aria-busy'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false')
expect(await trace.evaluate(value => value.errors)).toEqual([])
}
const layouts = await trace.evaluate(value => value.layouts)
expect(layouts.length).toBeGreaterThan(0)
const documentHeights = await trace.evaluate(value => value.documents)
expect(documentHeights.length).toBeGreaterThan(0)
expect(Math.max(...documentHeights) - Math.min(...documentHeights)).toBeLessThanOrEqual(1)
const heights = layouts.map(layout => layout.height)
expect(Math.max(...heights) - Math.min(...heights), JSON.stringify(layouts)).toBeLessThanOrEqual(1)
}
} finally {
await trace.evaluate((value) => { value.stop() })
await trace.dispose()
}
await writeFile(state, JSON.stringify({ accessibility: 'unavailable', screenRecording: false }))
await page.evaluate(() => { window.dispatchEvent(new Event('focus')) })
const diagnostic = panel.getByRole('alert')
await diagnostic.waitFor()
const diagnosticText = await diagnostic.innerText()
const height = await panel.evaluate(element => element.getBoundingClientRect().height)
const rowToggle = plugins.getByRole('switch', { name: 'Enable component @deepseek-ai/dsh-experimental-cua-macos', exact: true })
const release = Promise.withResolvers<undefined>()
const changeRow = scaffold.ctx.pluginManager.setPluginEnabled.bind(scaffold.ctx.pluginManager)
let changing: ReturnType<typeof changeRow> | undefined
const hold = vi.spyOn(scaffold.ctx.pluginManager, 'setPluginEnabled').mockImplementationOnce((entryId, enabled) => {
changing = release.promise.then(() => changeRow(entryId, enabled))
return changing
})
try {
await rowToggle.click()
await expect.poll(() => hold.mock.calls.length, { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe(1)
await expect.poll(() => panel.getAttribute('aria-busy')).toBe('true')
expect(await diagnostic.innerText()).toBe(diagnosticText)
expect(await diagnostic.getByRole('button', { name: 'Retry', exact: true }).isEnabled()).toBe(false)
expect(await panel.evaluate(element => element.getBoundingClientRect().height)).toBe(height)
} finally {
release.resolve(undefined)
hold.mockRestore()
await changing
}
await expect.poll(() => rowToggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false')
await expect.poll(() => rowToggle.isEnabled(), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe(true)
await writeFile(state, JSON.stringify({ accessibility: false, screenRecording: false, launchApplication }))
await rowToggle.click()
await expect.poll(() => rowToggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true')
await expect.poll(() => panel.getAttribute('aria-busy'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false')
await diagnostic.waitFor({ state: 'hidden' })
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
})
@@ -1,5 +1,6 @@
- button "返回 实验性插件": 实验性插件
- button "卸载 思考过程机器翻译" [disabled]: 卸载
- status
- switch "启用 思考过程机器翻译" [checked]
- heading "思考过程机器翻译" [level=3]
- text: v{{version}} 实验性
@@ -1,5 +1,6 @@
- button "返回 实验性插件": 实验性插件
- button "卸载 思考过程机器翻译" [disabled]: 卸载
- status
- switch "启用 思考过程机器翻译" [checked]
- heading "思考过程机器翻译" [level=3]
- text: v{{version}} 实验性
@@ -0,0 +1,8 @@
- text: Waiting for approval
- group "Approval details":
- strong: Fixture
- text: Allow Computer Use to launch, read, capture and control this application?
- paragraph: Session access lasts until DSH restarts or Computer Use is disabled.
- button "Reject"
- button "Allow for this session"
- button "Always allow"
@@ -0,0 +1,5 @@
- listitem:
- button "查看 Computer Use": Computer Use
- text: 实验性 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。
- status: 正在应用 Computer Use 的更改
- switch "启用 Computer Use" [disabled]
@@ -16,7 +16,7 @@
- strong: Screen Recording
- paragraph: In System Settings, allow Visual Studio Code.app to capture application windows.
- button "Request permission"
- paragraph: Enable the application in System Settings. Follow the macOS prompt if it names another application.
- paragraph: Manage permissions in System Settings for the application named by macOS.
- region "Manage applications Computer Use can access":
- heading "Manage applications Computer Use can access" [level=3]
- heading "Always-allowed applications" [level=4]
@@ -1,6 +1,7 @@
- button "返回插件": 插件
- button "夹具操作"
- button "卸载 @fixture/live-client": 卸载
- status
- switch "启用 @fixture/live-client" [checked]
- heading "@fixture/live-client" [level=3]
- text: v0.0.1 夹具标签
@@ -25,24 +25,30 @@
- listitem:
- button "查看 智能体团队": 智能体团队
- text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。
- status
- switch "启用 智能体团队"
- listitem:
- button "查看 语音输入": 语音输入
- text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
- status
- switch "启用 语音输入"
- listitem:
- button "查看 自动授权审查": 自动授权审查
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
- status
- switch "启用 自动授权审查"
- listitem:
- button "查看 开发者工具": 开发者工具
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
- status
- switch "启用 开发者工具"
- heading "已安装插件" [level=3]
- list:
- listitem:
- button "查看 @fixture/live-client": "@fixture/live-client"
- status
- switch "启用 @fixture/live-client"
- listitem:
- button "查看 dsh-web-scaffold-defaults": dsh-web-scaffold-defaults
- status
- switch "启用 dsh-web-scaffold-defaults" [checked]
@@ -1,5 +1,6 @@
- listitem:
- button "查看 slow-package": slow-package
- status
- switch "启用 slow-package" [checked]
[
@@ -1,5 +1,6 @@
- button "Back to plugins": Plugins
- button "Uninstall @fixture/bundle": Uninstall
- status
- switch "Enable @fixture/bundle"
- heading "@fixture/bundle" [level=3]
- text: v0.0.1
@@ -1,5 +1,6 @@
- button "返回插件": 插件
- button "卸载 @fixture/bundle": 卸载
- status
- switch "启用 @fixture/bundle"
- heading "@fixture/bundle" [level=3]
- text: v0.0.1
@@ -25,25 +25,31 @@
- listitem:
- button "查看 智能体团队": 智能体团队
- text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。
- status
- switch "启用 智能体团队"
- listitem:
- button "查看 语音输入": 语音输入
- text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
- status
- switch "启用 语音输入"
- listitem:
- button "查看 自动授权审查": 自动授权审查
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
- status
- switch "启用 自动授权审查"
- listitem:
- button "查看 开发者工具": 开发者工具
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
- status
- switch "启用 开发者工具"
- heading "已安装插件" [level=3]
- list:
- listitem:
- button "查看 @fixture/bundle": "@fixture/bundle"
- text: Registry description for the fixture bundle.
- status
- switch "启用 @fixture/bundle" [checked]
- listitem:
- button "查看 dsh-web-scaffold-defaults": dsh-web-scaffold-defaults
- status
- switch "启用 dsh-web-scaffold-defaults" [checked]
@@ -25,25 +25,31 @@
- listitem:
- button "查看 智能体团队": 智能体团队
- text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。
- status
- switch "启用 智能体团队"
- listitem:
- button "查看 语音输入": 语音输入
- text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
- status
- switch "启用 语音输入"
- listitem:
- button "查看 自动授权审查": 自动授权审查
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
- status
- switch "启用 自动授权审查"
- listitem:
- button "查看 开发者工具": 开发者工具
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
- status
- switch "启用 开发者工具"
- heading "已安装插件" [level=3]
- list:
- listitem:
- button "查看 @fixture/bundle": "@fixture/bundle"
- text: Registry description for the fixture bundle.
- status
- switch "启用 @fixture/bundle"
- listitem:
- button "查看 dsh-web-scaffold-defaults": dsh-web-scaffold-defaults
- status
- switch "启用 dsh-web-scaffold-defaults" [checked]
@@ -1,5 +1,6 @@
- button "返回插件": 插件
- button "卸载 @fixture/missing-bundle": 卸载
- status
- switch "启用 @fixture/missing-bundle" [checked]
- heading "@fixture/missing-bundle" [level=3]
- text: 异常 未安装
@@ -1,4 +1,5 @@
- button "返回 实验性插件": 实验性插件
- status
- switch "启用 DSH 徽章技能"
- heading "DSH 徽章技能" [level=3]
- text: v{{version}} 实验性
@@ -0,0 +1,8 @@
# Touch tooltips
Touch refresh: one refresh request; no tooltip
Touch explanation: visible within the 390px viewport
- tooltip "在这里配置官方插件,安装和管理其他插件。内置插件列表及运行状态可在「设置 → 内置插件」中查看"
Explanation closes on another tap or an outside touch
Keyboard focus after touch: refresh tooltip visible
Mouse hover after touch: refresh tooltip visible
@@ -72,7 +72,7 @@
- region "Approval area":
- heading "Approval area" [level=3]
- list:
- listitem: Allow once Enter
- listitem: Allow Enter
- listitem: Reject Esc
- contentinfo:
- button "Restore all defaults" [disabled]
@@ -72,7 +72,7 @@
- region "Approval area":
- heading "Approval area" [level=3]
- list:
- listitem: Allow once Enter
- listitem: Allow Enter
- listitem: Reject Esc
- contentinfo:
- button "Restore all defaults" [disabled]
@@ -72,7 +72,7 @@
- region "审批区域":
- heading "审批区域" [level=3]
- list:
- listitem: 允许一次 Enter
- listitem: 允许 Enter
- listitem: 拒绝 Esc
- contentinfo:
- button "恢复全部默认" [disabled]
@@ -0,0 +1,5 @@
# Touch copy feedback
Copy succeeds and the button returns from Copied to Copy.
Tooltips during copy feedback: 0
Tooltips after emulated mouse entry: 0
+242
View File
@@ -0,0 +1,242 @@
/** Pointer, keyboard, and resize behavior in the assembled client. */
import { fileURLToPath } from 'node:url'
import { chromium, type Locator } from 'playwright'
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
import { launchWebScaffold, seedSession, watchConsole } from './scaffold.ts'
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
it.each([false, true])('shows the new-session shortcut on keyboard focus with touch=%s', async (hasTouch) => {
const scaffold = await launchWebScaffold()
onTestFinished(() => scaffold.close())
const browser = await chromium.launch()
onTestFinished(() => browser.close())
const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, hasTouch, locale: 'en-US' })
await context.addInitScript(() => { Object.defineProperty(navigator, 'platform', { value: 'MacIntel' }) })
const page = await context.newPage()
onTestFailed(() => saveFailureShot(page, `new-session-focus-${String(hasTouch)}`))
await page.goto(scaffold.authenticatedUrl)
const button = page.getByRole('button', { name: 'New session', exact: true }).filter({ has: page.locator('kbd') })
await button.waitFor()
const shortcut = button.locator('[class*="newSessionShortcut"]')
const mask = button.locator('[class*="newSessionLabelMask"]')
const opacity = () => shortcut.evaluate(element => getComputedStyle(element).opacity)
const maskImage = () => mask.evaluate(element => getComputedStyle(element).maskImage)
expect(await page.evaluate(() => matchMedia('(hover: none)').matches)).toBe(hasTouch)
expect(await opacity()).toBe('0')
expect(await maskImage()).toBe('none')
await button.focus()
await page.keyboard.press('Shift+Tab')
await page.keyboard.press('Tab')
expect(await button.evaluate(element => element === document.activeElement && element.matches(':focus-visible'))).toBe(true)
expect(await opacity()).toBe('1')
expect(await maskImage()).not.toBe('none')
await page.keyboard.press('Tab')
expect(await opacity()).toBe('0')
expect(await maskImage()).toBe('none')
if (hasTouch) {
await button.tap()
expect(await button.evaluate(element => element.matches(':focus-visible'))).toBe(false)
expect(await opacity()).toBe('0')
expect(await maskImage()).toBe('none')
} else {
await button.hover()
expect(await opacity()).toBe('1')
expect(await maskImage()).not.toBe('none')
}
})
it.each([false, true])('keeps workspace and archived search actions reachable with touch=%s', async (hasTouch) => {
const scaffold = await launchWebScaffold()
onTestFinished(() => scaffold.close())
const fixture = createChatScrollFixture({ markerPrefix: 'ROW_ACTIONS', title: 'Archived row actions', turns: 1 })
const id = await seedSession(scaffold, fixture.log, 'row-actions')
const browser = await chromium.launch()
onTestFinished(() => browser.close())
const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, hasTouch, locale: 'en-US' })
await page.addInitScript(() => { Object.defineProperty(navigator, 'platform', { value: 'MacIntel' }) })
onTestFailed(() => saveFailureShot(page, `row-actions-${String(hasTouch)}`))
const activate = async (target: Locator) => {
if (hasTouch) await target.tap()
else await target.click()
}
const openFixture = async () => {
await activate(page.getByRole('button', { name: 'Search sessions', exact: true }))
await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixture.markers.user(1))
await activate(page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem'))
await expect.poll(() => row.getAttribute('aria-selected')).toBe('true')
}
const row = page.locator(`[data-row-key="session:${id}"]`)
await page.goto(scaffold.authenticatedUrl)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
await openFixture()
await activate(page.getByRole('button', { name: 'View options', exact: true }))
await activate(page.getByRole('menuitem', { name: 'WorkSpace', exact: true }))
const menu = page.getByRole('button', { name: 'Workspace actions for workspace', exact: true, includeHidden: true })
const project = page.locator('[data-row-key^="workspace:"]').filter({ has: menu })
await page.mouse.move(0, 0)
expect(await page.evaluate(() => matchMedia('(hover: none)').matches)).toBe(hasTouch)
expect(await menu.isVisible()).toBe(hasTouch)
if (!hasTouch) await project.hover()
await activate(menu)
await page.getByRole('menuitem', { name: 'Rename', exact: true }).waitFor()
await page.keyboard.press('Escape')
if (!hasTouch) await project.hover()
await activate(page.getByRole('button', { name: 'New session in workspace', exact: true }))
await expect.poll(() => row.getAttribute('aria-selected')).not.toBe('true')
expect(await project.getAttribute('aria-expanded')).toBe('true')
await openFixture()
if (!hasTouch) await row.hover()
const actions = row.getByRole('button', { name: /^Session actions for / })
await activate(actions)
const archive = page.getByRole('menuitem', { name: 'Archive session', exact: true })
const shortcut = archive.locator('[aria-hidden="true"]').filter({ has: page.locator('kbd') })
await archive.waitFor()
expect(await shortcut.count()).toBe(1)
expect(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)).toBe(hasTouch)
expect(await shortcut.isVisible()).toBe(!hasTouch)
for (const key of ['ArrowDown', 'Tab']) {
await page.keyboard.press(key)
expect(await page.locator('html').getAttribute('data-input-modality')).toBe('keyboard')
expect(await page.getByRole('menu').evaluate(menu => menu.contains(document.activeElement))).toBe(true)
expect(await shortcut.isVisible()).toBe(true)
if (hasTouch) {
await page.setViewportSize({ width: 390, height: 900 })
await expect.poll(() => shortcut.evaluate((element) => {
const bounds = element.getBoundingClientRect()
return bounds.left >= 0 && bounds.right <= window.innerWidth
})).toBe(true)
await page.setViewportSize({ width: 1280, height: 900 })
}
await page.keyboard.press('Escape')
if (!hasTouch) await row.hover()
await activate(actions)
await archive.waitFor()
expect(await shortcut.isVisible()).toBe(!hasTouch)
}
await activate(archive)
await row.waitFor({ state: 'detached' })
await activate(page.getByRole('button', { name: 'View options', exact: true }))
await activate(page.getByRole('menuitem', { name: 'All conversations (show archived)', exact: true }))
await activate(page.getByRole('button', { name: 'Search sessions', exact: true }))
await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixture.markers.user(1))
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await result.waitFor()
const restore = result.getByRole('button', { name: 'Unarchive session', exact: true, includeHidden: true })
expect(await restore.isVisible()).toBe(hasTouch)
if (!hasTouch) await result.hover()
await activate(restore)
await expect.poll(() => scaffold.ctx.workspaceRegistry.archivedSessionIds.includes(id)).toBe(false)
await restore.waitFor({ state: 'detached' })
await activate(result)
await expect.poll(() => row.getAttribute('aria-selected')).toBe('true')
})
it.each([false, true])('keeps actions and focus usable with touch=%s across layout widths', async (hasTouch) => {
const scaffold = await launchWebScaffold()
onTestFinished(() => scaffold.close())
const fixture = createChatScrollFixture({ markerPrefix: 'HOVER', title: 'Hover input', turns: 1 })
const id = await seedSession(scaffold, fixture.log, 'hover-input')
const browser = await chromium.launch()
onTestFinished(() => browser.close())
const context = await browser.newContext({
viewport: { width: 1280, height: 900 }, hasTouch, locale: 'en-US', timezoneId: 'Asia/Shanghai',
})
const page = await context.newPage()
const consoleWatch = watchConsole(page)
onTestFailed(() => saveFailureShot(page, `hover-input-${String(hasTouch)}`))
await page.goto(scaffold.authenticatedUrl)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
expect(await page.evaluate(() => matchMedia('(hover: hover)').matches)).toBe(!hasTouch)
await page.getByRole('button', { name: 'Search sessions' }).click()
await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixture.markers.user(1))
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => result.count()).toBe(1)
await result.click()
await page.getByText(fixture.markers.assistant(1), { exact: false }).last().waitFor()
const row = page.locator(`[data-row-key="session:${id}"]`)
await row.waitFor({ state: 'visible' })
expect(await row.getAttribute('aria-selected')).toBe('true')
const copy = page.locator('[data-clock="end"] > button').first()
const background = () => copy.evaluate(element => getComputedStyle(element).backgroundColor)
await page.mouse.move(0, 0)
const resting = await background()
if (hasTouch) {
await copy.tap()
expect(await background()).toBe(resting)
expect(await row.getAttribute('aria-selected')).toBe('true')
expect(await page.getByRole('tooltip').count()).toBe(0)
} else {
await copy.hover()
await expect.poll(background).not.toBe(resting)
await page.mouse.move(0, 0)
await expect.poll(background).toBe(resting)
}
await page.keyboard.press('Tab')
await copy.focus()
expect(await copy.evaluate(element => element.matches(':focus-visible'))).toBe(true)
await row.focus()
expect(await row.evaluate(element => document.activeElement === element)).toBe(true)
await page.keyboard.press('Enter')
expect(await row.getAttribute('aria-selected')).toBe('true')
const image = fileURLToPath(new URL('../../../snapshots/session/read-image/workspace/red.png', import.meta.url))
await page.locator('input[type="file"]').setInputFiles(image)
const remove = page.getByRole('button', { name: 'Remove image red.png', exact: true })
await remove.waitFor({ state: 'visible' })
if (hasTouch) {
expect(await remove.evaluate(element => getComputedStyle(element).opacity)).toBe('1')
await remove.tap()
} else {
await remove.focus()
await expect.poll(() => remove.evaluate(element => getComputedStyle(element).opacity)).toBe('1')
await page.keyboard.press('Enter')
}
await remove.waitFor({ state: 'detached' })
const input = page.locator('[data-composer-input]')
await input.fill('keep this draft')
await input.press('Home')
await input.press('Shift+End')
const element = await input.elementHandle()
const selection = () => input.evaluate(node => ({
focused: document.activeElement === node,
text: node.textContent,
selected: window.getSelection()?.toString(),
}))
const before = await selection()
expect(before.focused).toBe(true)
for (const width of [390, 768, 1280]) {
await page.setViewportSize({ width, height: 900 })
expect(await element!.evaluate(node => node.isConnected)).toBe(true)
expect(await selection()).toEqual(before)
}
await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click()
const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]')
await panel.getByRole('button', { name: 'Fullscreen', exact: true }).click()
await panel.locator('[data-dockkit-split-button]').click()
await expect.poll(() => panel.locator('[data-dockkit-pane]').count()).toBe(2)
await page.mouse.move(0, 0)
await page.keyboard.press('Escape')
await page.getByRole('tooltip').waitFor({ state: 'detached' })
const blockedSplit = panel.locator('[data-dockkit-split-button]').last()
expect(await blockedSplit.isDisabled()).toBe(true)
const hit = await blockedSplit.evaluate((button) => {
const bounds = button.getBoundingClientRect()
const x = bounds.x + bounds.width / 2
const y = bounds.y + bounds.height / 2
const target = document.elementFromPoint(x, y)
return { x, y, wrapperReceivesInput: target === button.parentElement, cursor: target && getComputedStyle(target).cursor }
})
expect(hit.wrapperReceivesInput).toBe(true)
expect(['auto', 'default']).toContain(hit.cursor)
// Coordinate input exercises CSS hit testing over the disabled button itself.
if (hasTouch) await page.touchscreen.tap(hit.x, hit.y)
else await page.mouse.click(hit.x, hit.y)
await page.getByRole('tooltip').filter({ hasText: 'Two panes is the limit' }).waitFor()
expect(await panel.locator('[data-dockkit-pane]').count()).toBe(2)
expect(consoleWatch.pageErrors).toEqual([])
})
+40 -2
View File
@@ -20,8 +20,8 @@
// NO_ADAPTER.
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { chromium, webkit } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest'
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import {
SESSION_FORMAT_VERSION,
@@ -374,6 +374,44 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro
expect(tripwire.pageErrors).toEqual([])
}, 120_000)
it.each(['chromium', 'webkit'] as const)('keeps wide tables horizontally scrollable without hover in %s', async (engine) => {
const touchBrowser = await ({ chromium, webkit })[engine].launch()
onTestFinished(() => touchBrowser.close())
// Mobile WebKit has no wheel driver; touch emulation also disables hover in its desktop viewport.
const context = await touchBrowser.newContext({
viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: engine === 'chromium', locale: 'en-US',
})
const touchPage = await context.newPage()
const touchConsole = watchConsole(touchPage)
onTestFailed(() => saveFailureShot(touchPage, `markdown-wide-table-touch-${engine}`))
await touchPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
const group = touchPage.getByRole('treeitem').first()
await touchPage.getByRole('button', { name: 'Open sidebar', exact: true }).first().click()
await group.click()
await touchPage.getByRole('treeitem').filter({ hasText: 'Markdown wide tables' }).click()
await touchPage.getByText(TAIL_MARKER, { exact: true }).waitFor()
expect(await touchPage.evaluate(() => matchMedia('(hover: hover)').matches)).toBe(false)
const wide = touchPage.locator('[class*="tableScroll"]', { hasText: WIDE_MARKER })
await wide.scrollIntoViewIfNeeded()
expect(await wide.evaluate(element => ({
overflow: getComputedStyle(element).overflowX,
overflowing: element.scrollWidth > element.clientWidth,
focused: element.matches(':focus-visible'),
}))).toEqual({ overflow: 'auto', overflowing: true, focused: false })
const box = await wide.boundingBox()
if (box === null) throw new Error('Wide table is not visible')
// A wheel input must scroll the native overflow container without hover or focus.
// Assigning scrollLeft would also work with overflow:hidden and miss this regression.
await touchPage.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await touchPage.mouse.wheel(5_000, 0)
await expect.poll(() => wide.evaluate(element => element.scrollLeft)).toBeGreaterThan(0)
await expect.poll(() => wide.evaluate((element) => {
const last = element.querySelector('thead th:last-child')!
return last.getBoundingClientRect().right <= element.getBoundingClientRect().right + 1
})).toBe(true)
expect(touchConsole.pageErrors).toEqual([])
})
it('reveals the wide table scrollbar on hover only', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-scrollbar'))
await sweep()
+3 -2
View File
@@ -9,7 +9,6 @@ import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
import { ON_DEMAND_BUNDLES, OPTIONAL_BUNDLES } from '@deepseek-ai/dsh-app-boot'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { join } from 'node:path'
import {
@@ -111,8 +110,10 @@ describe('web e2e: plugin configuration pages', () => {
await more.focus()
await page.keyboard.press('Enter')
await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor()
const bundles = await scaffold.ctx.pluginManager.listBundles()
expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count())
.toBe(OPTIONAL_BUNDLES.length + ON_DEMAND_BUNDLES.length)
.toBe(bundles.filter(bundle => bundle.name !== '@deepseek-ai/dsh-experimental-terminal-bundle'
&& bundle.official && (bundle.listedByDefault !== false || bundle.enabled || bundle.installed)).length)
expect(await basic.count()).toBe(0)
expect(await extensions.count()).toBe(0)
expect(await panel.locator('[data-plugin-group="bundles"]').count()).toBe(0)
+125 -7
View File
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url'
import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
import { FiberState } from '@deepseek-ai/cordis'
import { ON_DEMAND_BUNDLES, OPTIONAL_BUNDLES } from '@deepseek-ai/dsh-app-boot'
import { readProfileManifest } from '@deepseek-ai/dsh-app-boot'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest'
import { join } from 'node:path'
@@ -298,7 +298,7 @@ describe('web e2e: plugin manager', () => {
width: node.getBoundingClientRect().width,
height: node.getBoundingClientRect().height,
})))
expect(blankActions).toEqual(Array.from({ length: 4 }, () => ({ children: 0, background: 'rgba(0, 0, 0, 0)', width: 36, height: 20 })))
expect(blankActions).toEqual(Array.from({ length: 4 }, () => ({ children: 0, background: 'rgba(0, 0, 0, 0)', width: 58, height: 20 })))
if (shots !== undefined) {
for (const colorScheme of ['light', 'dark'] as const) {
await probe.emulateMedia({ colorScheme })
@@ -488,6 +488,88 @@ describe('web e2e: plugin manager', () => {
}
})
it('keeps touch actions direct before keyboard focus and mouse hover', async () => {
const context = await browser.newContext({
viewport: { width: 1280, height: 900 }, locale: ZH_BROWSER_LOCALE,
})
try {
const probe = await context.newPage()
const input = await context.newCDPSession(probe)
// Native touch input preserves desktop hover capability for mixed-input devices.
const tap = async (target: Locator): Promise<void> => {
await target.scrollIntoViewIfNeeded()
const bounds = await target.boundingBox()
if (bounds === null) throw new Error('Touch target is not visible')
await input.send('Input.dispatchTouchEvent', {
type: 'touchStart', touchPoints: [{ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }],
})
await input.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
}
const consoleWatch = watchConsole(probe)
onTestFailed(() => saveFailureShot(probe, 'web-e2e-touch-tooltips'))
await probe.clock.install()
await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await probe.getByRole('button', { name: '插件', exact: true }).click()
const panel = probe.locator('[data-plugin-panel]')
const refresh = panel.getByRole('button', { name: '刷新', exact: true })
const info = panel.getByRole('button', { name: '插件说明', exact: true })
await expect.poll(() => refresh.isEnabled()).toBe(true)
await probe.setViewportSize({ width: 390, height: 844 })
await probe.evaluate(() => document.fonts.ready)
const calls = vi.spyOn(scaffold.ctx.pluginManager, 'listBundles')
try {
await tap(refresh)
await expect.poll(() => calls.mock.calls.length).toBe(1)
await expect.poll(() => refresh.isEnabled()).toBe(true)
} finally {
calls.mockRestore()
}
await probe.clock.pauseAt(await probe.evaluate(() => Date.now() + 1000))
await probe.clock.runFor(500)
expect(await probe.getByRole('tooltip').count()).toBe(0)
const facts = ['Touch refresh: one refresh request; no tooltip']
await tap(info)
const help = probe.getByRole('tooltip')
await help.waitFor()
const bounds = await help.boundingBox()
if (bounds === null) throw new Error('Tapped plugin explanation is not visible')
expect(bounds.x).toBeGreaterThanOrEqual(12)
expect(bounds.x + bounds.width).toBeLessThanOrEqual(390 - 12)
expect(bounds.y).toBeGreaterThanOrEqual(12)
expect(bounds.y + bounds.height).toBeLessThanOrEqual(844 - 12)
facts.push('Touch explanation: visible within the 390px viewport', await help.ariaSnapshot())
await tap(info)
expect(await help.count()).toBe(0)
await tap(info)
await help.waitFor()
await tap(panel.getByRole('heading', { name: '插件', exact: true }))
expect(await help.count()).toBe(0)
facts.push('Explanation closes on another tap or an outside touch')
await probe.keyboard.press('Tab')
await refresh.focus()
await probe.clock.runFor(500)
await probe.getByRole('tooltip').filter({ hasText: '刷新' }).waitFor()
facts.push('Keyboard focus after touch: refresh tooltip visible')
await probe.keyboard.press('Tab')
expect(await probe.evaluate(() => matchMedia('(any-hover: hover)').matches)).toBe(true)
await refresh.hover()
await probe.clock.runFor(500)
await probe.getByRole('tooltip').filter({ hasText: '刷新' }).waitFor()
facts.push('Mouse hover after touch: refresh tooltip visible')
await probe.mouse.move(0, 843)
expect(await help.count()).toBe(0)
await input.detach()
await probe.clock.resume()
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'touch-tooltips.expected.md'),
['# Touch tooltips', '', ...facts].join('\n'), MODE)
expect(consoleWatch.pageErrors).toEqual([])
} finally {
await context.close()
}
})
it('starts with an unavailable selected bundle and lets the user clear its error', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-missing-bundle'))
const panel = await openPluginsPanel()
@@ -582,8 +664,17 @@ describe('web e2e: plugin manager', () => {
expect(await panel.getByText(/Cordis|Chrome DevTools/).count()).toBe(0)
await panel.getByRole('button', { name: '更多', exact: true }).click()
await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor()
const bundles = await scaffold.ctx.pluginManager.listBundles()
expect(bundles.some(bundle => bundle.name === '@deepseek-ai/dsh-experimental-terminal-bundle')).toBe(true)
expect(await panel.getByRole('switch', { name: '启用 持久终端', exact: true }).count()).toBe(0)
expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count())
.toBe(OPTIONAL_BUNDLES.length + ON_DEMAND_BUNDLES.length)
.toBe(bundles.filter(bundle => bundle.name !== '@deepseek-ai/dsh-experimental-terminal-bundle'
&& bundle.official && (bundle.listedByDefault !== false || bundle.enabled || bundle.installed)).length)
for (const name of ['@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-ralph-bundle']) {
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name))
.toMatchObject({ enabled: false, optional: true, availability: 'installation', listedByDefault: false })
expect(await panel.locator(`[data-plugin-package="${name}"]`).count()).toBe(0)
}
const cards = await panel.locator('[data-plugin-group="more"] [data-plugin-package]').all()
expect(await Promise.all(cards.map(async card => [
await card.getAttribute('data-plugin-package'),
@@ -596,11 +687,8 @@ describe('web e2e: plugin manager', () => {
['@deepseek-ai/dsh-cua-bundle', 1],
['@deepseek-ai/dsh-experimental-tool-worktree', 1],
['@deepseek-ai/dsh-experimental-cot-translation-bundle', 1],
['@deepseek-ai/dsh-experimental-terminal-bundle', 1],
['@deepseek-ai/dsh-experimental-session-search', 1],
['@deepseek-ai/dsh-experimental-session-titles-bundle', 1],
['@deepseek-ai/dsh-experimental-badge-skill-bundle', 1],
['@deepseek-ai/dsh-experimental-ralph-bundle', 1],
['@deepseek-ai/dsh-experimental-plugin-market-bundle', 1],
['@deepseek-ai/dsh-subagent-codex', 0],
['@deepseek-ai/dsh-subagent-claude-code', 0],
@@ -621,6 +709,36 @@ describe('web e2e: plugin manager', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('enables both default-hidden shipped bundles by name without adding a profile dependency', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-bundled-enable'))
const profileDir = join(scaffold.harnessHome, 'profiles', 'scaffold')
const before = readProfileManifest('dsh', profileDir).dependencies
for (const [name, title] of [
['@deepseek-ai/dsh-experimental-session-search', '会话搜索'],
['@deepseek-ai/dsh-experimental-ralph-bundle', 'Ralph 循环'],
] as const) {
const panel = await openPluginsPanel()
expect(await panel.locator(`[data-plugin-package="${name}"]`).count()).toBe(0)
await panel.getByRole('button', { name: '添加插件', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '添加插件', exact: true })
await dialog.getByRole('textbox', { name: '包名或地址', exact: true }).fill(name)
await dialog.getByRole('button', { name: '安装', exact: true }).click()
const ready = page.getByRole('dialog', { name: '插件已随 DSH 提供', exact: true })
await ready.waitFor()
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name)?.enabled).toBe(false)
expect(await ready.getByText('无需下载,启用即可使用', { exact: true }).count()).toBe(1)
await ready.getByRole('button', { name: '立即启用', exact: true }).click()
const card = panel.locator(`[data-plugin-package="${name}"]`)
await card.waitFor()
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name)?.enabled).toBe(true)
expect(readProfileManifest('dsh', profileDir).dependencies).toEqual(before)
await card.getByRole('switch', { name: `启用 ${title}`, exact: true }).click()
await card.waitFor({ state: 'hidden' })
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name)?.enabled).toBe(false)
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps off-state switch thumbs light in both themes', async () => {
// The off-state thumb must stay light so an operable toggle cannot read as
// disabled, whose only other difference is reduced opacity.
@@ -1139,7 +1257,7 @@ describe('web e2e: plugin manager', () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [
'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', 'loading.expected.md', 'refresh.expected.md',
'ime-enter.expected.md', 'header-responsive.expected.md', 'preview.expected.md',
'ime-enter.expected.md', 'header-responsive.expected.md', 'preview.expected.md', 'touch-tooltips.expected.md',
])
})
})
+25
View File
@@ -175,6 +175,31 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
expect(await opened()).toHaveLength(count + 1)
expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, name)), content: bytes })
}
const success = row.locator('[data-presented-file]').filter({ hasText: '说明.txt' }).locator('[data-success]')
await success.waitFor()
const animation = await success.evaluate((element) => {
const style = getComputedStyle(element)
const fade = element.getAnimations()[0]
if (fade === undefined) throw new Error('Successful file feedback has no fade animation')
// Seek the CSS animation so the 200 ms fade does not race the assertion.
fade.pause()
fade.currentTime = 5100
return {
delay: style.animationDelay, duration: style.animationDuration,
fill: style.animationFillMode, opacity: Number(getComputedStyle(element).opacity),
}
})
expect(animation.delay).toBe('5s')
expect(animation.duration).toBe('0.2s')
expect(animation.fill).toBe('forwards')
expect(animation.opacity).toBeGreaterThan(0)
expect(animation.opacity).toBeLessThan(1)
try {
await page.emulateMedia({ reducedMotion: 'reduce' })
expect(await success.evaluate(element => getComputedStyle(element).animationName)).toBe('none')
} finally {
await page.emulateMedia({ reducedMotion: 'no-preference' })
}
}
expect(downloads).toEqual([])
const response = await page.request.get(new URL(`/api/session.export?sessionId=${sessionId}`, scaffold.authenticatedUrl).href)
+35 -1
View File
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { TIMED_WAIT_PARAMETER } from '@deepseek-ai/dsh-user-questions'
@@ -700,3 +700,37 @@ describe.skipIf(MODE === 'record')('web e2e: late-answered question transcript',
])
})
})
it.each(['another-feature', 'cua-app-access'])('answers %s approval without a specialized composer', async (id) => {
const resources: { scaffold?: WebScaffold; browser?: Browser; waiting?: Promise<unknown> } = {}
const controller = new AbortController()
onTestFinished(async () => {
controller.abort()
await resources.waiting
await resources.browser?.close()
await resources.scaffold?.close()
})
const scaffold = await launchWebScaffold()
resources.scaffold = scaffold
const browser = await chromium.launch()
resources.browser = browser
const page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
onTestFailed(() => saveFailureShot(page, `web-approval-fallback-${id}`))
await page.goto(scaffold.authenticatedUrl)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const agent = scaffold.ctx.agents.list()[0]!
const result = scaffold.ctx.userQuestions.ask({ agent, signal: controller.signal, questions: [{
id, question: 'Allow fixture access?', detail: 'Only this request.',
options: [{ label: 'Allow' }, { label: 'Deny' }],
intent: { kind: 'approval', approve: 'Allow', reject: 'Deny' },
}] })
resources.waiting = Promise.allSettled([result])
const question = page.locator('[data-question-key]')
await question.getByRole('radio', { name: 'Deny', exact: true }).click()
await question.getByRole('button', { name: 'Submit', exact: true }).click()
expect(await result).toEqual({ answers: [{ id, selected: ['Deny'] }] })
await question.waitFor({ state: 'hidden' })
expect(tripwire.pageErrors).toEqual([])
})
+21 -20
View File
@@ -47,12 +47,13 @@ const AUTO_PARENT_ADJUST = 'AUTO_PARENT_ADJUST'
/** Identify the one-shot Auto Review request and check its request-only outer input. */
function isAutoReviewRequest(options: GenerateOptions): boolean {
if (options.system?.startsWith('REVIEW_POLICY\n') !== true) return false
expect(options.messages).toHaveLength(1)
const message = options.messages[0]
expect(message).toMatchObject({ role: 'user', content: [{ type: 'text' }] })
expect(message?.content).toHaveLength(1)
expect(message).not.toHaveProperty('id')
expect(message).not.toHaveProperty('source')
expect(options.messages.length).toBeGreaterThanOrEqual(2)
for (const message of options.messages) {
expect(message).toMatchObject({ role: 'user', content: [{ type: 'text' }] })
expect(message.content).toHaveLength(1)
expect(message).not.toHaveProperty('id')
expect(message).not.toHaveProperty('source')
}
return true
}
@@ -150,21 +151,19 @@ interface ChildScriptState {
phase: number
}
/** Parse the fixed review request sections emitted by the production plugin. */
/** Parse the history items and pending action emitted by the production plugin. */
function childReviewSections(options: GenerateOptions): Record<string, unknown> {
const block = options.messages[0]?.content[0]
if (block?.type !== 'text') throw new Error('shipped child review request has no text body')
const labels = ['ENVIRONMENT', 'PROJECT_INSTRUCTIONS', 'FILTERED_HISTORY', 'PENDING_ACTION'] as const
const sections: Record<string, unknown> = {}
for (const [index, label] of labels.entries()) {
const prefix = `${label}\n`
const start = block.text.indexOf(prefix)
if (start < 0) throw new Error(`shipped child review request is missing ${label}`)
const next = labels[index + 1]
const end = next === undefined ? block.text.length : block.text.indexOf(`\n\n${next}\n`, start)
sections[label] = JSON.parse(block.text.slice(start + prefix.length, end)) as unknown
const items = options.messages.slice(1).map((message) => {
const block = message.content[0]
if (block?.type !== 'text') throw new Error('shipped child review message has no text body')
return JSON.parse(block.text) as Record<string, unknown>
})
const call = items.at(-1)
if (call?.kind !== 'tool-call') throw new Error('shipped child review request does not end with its pending call')
return {
FILTERED_HISTORY: items.slice(0, -1).filter(item => item.kind !== 'tool-schema'),
PENDING_ACTION: { name: call.name, arguments: JSON.parse(call.arguments as string) as Record<string, unknown> },
}
return sections
}
/** One native tool-call completion in the provider-neutral stream vocabulary. */
@@ -485,6 +484,7 @@ const EXPECTED_TOOLS = [
'job_output',
'list_agents',
'list_subagent_models',
'plugin_manager',
'present',
'read',
'read_image',
@@ -790,7 +790,8 @@ it('routes one browser-authored Auto request through the same model and asks the
.filter(block => block.type === 'text')
.map(block => block.text)
.join('') ?? ''
expect(reviewInput).toContain('PENDING_ACTION')
expect(reviewInput).toContain('ENVIRONMENT')
expect(reviewInput).toContain('"kind": "tool-schema"')
expect(reviewInput).toContain(requestId)
expect(reviewInput).toContain(targetPath)
expect(approvalReasons).toEqual([`Auto review denied tool "bash": ${AUTO_RAW_REASON}`])
+8 -3
View File
@@ -71,17 +71,22 @@ function measureList(page: Page): Promise<ListMetrics> {
probe.remove()
return value
}
// Computed pseudo style folds in hover even at rest, so inspect the cascade.
// Computed pseudo style folds in hover even at rest, so inspect active CSS groups.
// Cross-origin sheets may throw and cannot contain the app-owned rule.
const styleRules = (rules: CSSRuleList): CSSStyleRule[] => [...rules].flatMap((rule) => {
if (rule instanceof CSSStyleRule) return [rule]
if (rule instanceof CSSMediaRule && !matchMedia(rule.conditionText).matches) return []
if (rule instanceof CSSSupportsRule && !CSS.supports(rule.conditionText)) return []
return rule instanceof CSSGroupingRule ? styleRules(rule.cssRules) : []
})
const hoverRules = [...document.styleSheets]
.flatMap((sheet) => {
try {
return [...sheet.cssRules]
return styleRules(sheet.cssRules)
} catch {
return []
}
})
.filter((rule): rule is CSSStyleRule => rule instanceof CSSStyleRule)
.filter(rule => rule.selectorText === '::-webkit-scrollbar-thumb:hover')
.map(rule => rule.style.getPropertyValue('background-color'))
const style = getComputedStyle(list)
+50 -3
View File
@@ -12,14 +12,15 @@ import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterEach, describe, expect, it, onTestFailed, vi } from 'vitest'
import { chromium, devices, webkit } from 'playwright'
import { afterEach, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest'
import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
import { openSettings, connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/turn-tail-actions', import.meta.url))
@@ -347,3 +348,49 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
)
})
})
it('keeps copy tooltips hidden during a WebKit touch copy', async () => {
const scaffold = await launchWebScaffold({})
const browserPromise = webkit.launch()
onTestFinished(async () => {
try { await (await browserPromise).close() }
finally { await scaffold.close() }
})
const browser = await browserPromise
const context = await browser.newContext({
...devices['iPhone 13'], locale: 'en-US', viewport: { width: 1280, height: 900 },
})
const page = await context.newPage()
// LAN HTTP pages use the clipboard fallback on iOS.
await page.addInitScript(() => {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
})
const fixture = createChatScrollFixture({ markerPrefix: 'TOOLTIP_TOUCH', title: 'Touch tooltips', turns: 1 })
await seedSession(scaffold, fixture.log, 'tooltip-touch-web-e2e')
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'tooltip-touch')
await page.getByRole('button', { name: 'Search sessions' }).click()
await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixture.markers.user(1))
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => result.count(), { timeout: 60_000 }).toBe(1)
await result.click()
await page.getByText(fixture.markers.assistant(1), { exact: false }).last().waitFor()
await page.setViewportSize({ width: 390, height: 844 })
expect(await page.evaluate(() => matchMedia('(any-hover: hover)').matches)).toBe(false)
const copy = page.getByRole('button', { name: 'Copy', exact: true }).last()
await copy.tap()
await expect.poll(() => page.getByRole('button', { name: 'Copied', exact: true }).count()).toBe(1)
const duringCopy = await page.getByRole('tooltip').count()
expect(duringCopy).toBe(0)
await expect.poll(() => page.getByRole('button', { name: 'Copied', exact: true }).count(), { timeout: 10_000 }).toBe(0)
// iOS may synthesize mouse entry when the checkmark is replaced by the copy icon.
await copy.dispatchEvent('pointerover', { pointerType: 'mouse', bubbles: true })
const afterCopy = await page.getByRole('tooltip').count()
expect(afterCopy).toBe(0)
await compareOrRefreshGolden(
fileURLToPath(new URL('./expected/tooltip-touch/copy.expected.md', import.meta.url)),
['# Touch copy feedback', '', 'Copy succeeds and the button returns from Copied to Copy.',
`Tooltips during copy feedback: ${duringCopy}`, `Tooltips after emulated mouse entry: ${afterCopy}`].join('\n'),
webSnapshotMode(),
)
})
+1 -1
View File
@@ -32,7 +32,7 @@ it('guides a newly enabled voice plugin to installation and lets the user postpo
await dialog.waitFor({ state: 'hidden' })
expect(await toggle.getAttribute('aria-checked')).toBe('true')
const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]')
expect(await card.getByRole('status').count()).toBe(0)
expect(await card.getByRole('status').textContent()).toBe('')
await toggle.click()
await page.getByRole('switch', { name: 'Enable Voice input', exact: true, checked: false }).click()
await dialog.getByRole('button', { name: 'Go to setup', exact: true }).click()
+1
View File
@@ -23,6 +23,7 @@
// this client-registered project must not also hold them (one program
// cannot see both sides of the cordis Context merges).
"exclude": [
"tests/hover-input.e2e.ts",
"tests/collapse-timing.e2e.ts",
"tests/notice-wake-tail.e2e.ts",
"tests/fold-blank-matrix.e2e.ts",
+4 -4
View File
@@ -18,11 +18,11 @@
en: b9eeb18645121a8b
zh: 70d87a12c43e042a
/deepseek-harness-architecture/core-packages:
en: f4dde8809a713bad
zh: ccbee814b4e8b4b7
en: 20ef882e44b06a10
zh: c10c3934ed5570e9
/deepseek-harness-architecture/events:
en: c00467ff3a210ef5
zh: 7619d7cca723fa6b
en: e89802f066e4955d
zh: ea2fb4bef94e086d
/deepseek-harness-architecture/turn-flow:
en: 7a2a9d68e4fa1fe4
zh: 67f40bac3d1959cc
-2
View File
@@ -61,7 +61,6 @@ Electron starts the private Desktop Host in Electron Node mode. The Host invokes
| [`core/tools`](subsystems/tools.md) | The scoped tool registry and guarded execution pipeline | `ctx.tools` |
| [`core/agent`](subsystems/core.md) | The `Agent` interface, live registry, and `agent/*` events | `ctx.agents` |
| [`core/agent-loop`](subsystems/core.md) | The default driver implementing that interface | `ctx.agentLoop` |
| [`core/agent-controller`](subsystems/core.md#messages-from-outside-an-agent) | Admission and delivery of outside messages | `ctx.agentController` |
| [`core/scope`](subsystems/scope.md) | The per-agent scoped-registration primitive | library, no key |
| [`llm/llm`](subsystems/llm-streaming.md) | Message and stream vocabulary plus the adapter seam | `ctx.llm` |
@@ -71,7 +70,6 @@ Events are the extension points, and picking the right domain is the first decis
- **Session events** are durable facts appended to the log and broadcast through `session/event`. Use one when the fact must survive a reload.
- **Agent events** (`agent/*`) carry a live `Agent`: inbox, step, status, request, validation, continuation. Use one to observe or intercept work in flight.
- **Delivery admission** (`agent-controller/admission`) holds or refuses outside input before it enters the inbox.
- **Capability events** attach policy and adapters to a seam (`fs/*`, `tools/*`, `telemetry/*`) without importing the loop.
AgentLoop awaits serial `agent/created` before queued work and rolls back failed initialization. Unload retains resources through Agent teardown; [agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) defines ordering.
-2
View File
@@ -61,7 +61,6 @@ Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共
| [`core/tools`](subsystems/tools.zh.md) | 作用域化的工具注册表和带把关的执行流水线 | `ctx.tools` |
| [`core/agent`](subsystems/core.zh.md) | `Agent` 接口、活跃 agent 注册表和 `agent/*` 事件 | `ctx.agents` |
| [`core/agent-loop`](subsystems/core.zh.md) | 实现该接口的默认驱动器 | `ctx.agentLoop` |
| [`core/agent-controller`](subsystems/core.zh.md#messages-from-outside-an-agent) | 外部消息的准入与投递 | `ctx.agentController` |
| [`core/scope`](subsystems/scope.zh.md) | 按 agent 划分作用域的注册原语 | 库,无 ctx 键 |
| [`llm/llm`](subsystems/llm-streaming.zh.md) | 消息与流式词汇表,以及适配器 seam | `ctx.llm` |
@@ -73,7 +72,6 @@ Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共
- **会话事件**是追加到日志并通过 `session/event` 广播的持久事实。当某个事实必须在重新加载后仍然存在时,使用它。
- **Agent 事件**(`agent/*`)携带活跃 `Agent`:inbox、步骤、状态、请求、验证、续跑。要观察或拦截进行中的工作时,使用它。
- **投递准入**(`agent-controller/admission`)在外部输入进入收件箱之前暂留或拒绝它。
- **能力事件**无需导入循环即可向某个 seam(`fs/*`、`tools/*`、`telemetry/*`)附加策略和适配器。
AgentLoop 在启动已排队工作前等待串行 `agent/created`,初始化失败则回滚。卸载在 Agent teardown 期间保留所需资源;[agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation)定义顺序。
+2 -2
View File
@@ -6,5 +6,5 @@
en: 41d772192075e133
zh: 5ef3da69b4de1d8a
/capability-seams-and-core-services:
en: 3e87315977a89b5a
zh: 626a75f3bebaa39f
en: 753b3da3abc6aeea
zh: aab02ec16c3f744b
-5
View File
@@ -191,8 +191,6 @@ flowchart LR
pkg_skill_office["skill-office"]
svc_agents["ctx.agents<br/>Agent service"]
pkg_acp["acp"]
pkg_agent_controller["agent-controller"]
svc_agentController["ctx.agentController<br/>Outside-message delivery"]
svc_agentDefaultModel["ctx.agentDefaultModel<br/>Default Agent model selection"]
svc_agentLoop["ctx.agentLoop<br/>Concrete loop driver"]
pkg_base["base"]
@@ -300,7 +298,6 @@ flowchart LR
svc_dynamicCordisRunner["ctx.dynamicCordisRunner<br/>Dynamic Cordis package host runner"]
svc_cordisInspect["ctx.cordisInspect<br/>Dynamic Cordis inspect registry"]
pkg_agent --> svc_agents
pkg_agent_controller --> svc_agentController
pkg_agent_default_model --> svc_agentDefaultModel
pkg_agent_loop --> svc_agentLoop
pkg_agent_preset_registry --> svc_agentPresets
@@ -459,7 +456,6 @@ flowchart LR
pkg_working_directory --> svc_workingDirectory
pkg_workspace --> svc_workspaceRegistry
pkg_workspace_changes --> svc_workspaceChanges
svc_agentController --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_headless
svc_agentLoop --> pkg_base
@@ -690,7 +686,6 @@ flowchart LR
| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points), serves cached projection views, and accelerates prepared-Session projection hydration. |
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl), [`skill-badge`](../packages/experimental/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent`](../packages/subagent/subagent) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
| `ctx.agentController` | `core` | [`agent-controller`](../packages/core/agent-controller) | - | [`api-session-controller`](../packages/api/session-controller) | - | Prepares, admits through the agent-controller/admission stage, and appends messages that senders bring on someone's behalf, one at a time per Agent. |
| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Reads the default ModelSelection from volatile Config and saves selections through the profile editor. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
| `ctx.schedule` | `core` | [`schedule`](../packages/schedule/schedule) | - | - | - | Stores tasks independently of Session activation and queues due messages in the original Session. |
-5
View File
@@ -193,8 +193,6 @@ flowchart LR
pkg_skill_office["skill-office"]
svc_agents["ctx.agents<br/>Agent service"]
pkg_acp["acp"]
pkg_agent_controller["agent-controller"]
svc_agentController["ctx.agentController<br/>Outside-message delivery"]
svc_agentDefaultModel["ctx.agentDefaultModel<br/>Default Agent model selection"]
svc_agentLoop["ctx.agentLoop<br/>Concrete loop driver"]
pkg_base["base"]
@@ -302,7 +300,6 @@ flowchart LR
svc_dynamicCordisRunner["ctx.dynamicCordisRunner<br/>Dynamic Cordis package host runner"]
svc_cordisInspect["ctx.cordisInspect<br/>Dynamic Cordis inspect registry"]
pkg_agent --> svc_agents
pkg_agent_controller --> svc_agentController
pkg_agent_default_model --> svc_agentDefaultModel
pkg_agent_loop --> svc_agentLoop
pkg_agent_preset_registry --> svc_agentPresets
@@ -461,7 +458,6 @@ flowchart LR
pkg_working_directory --> svc_workingDirectory
pkg_workspace --> svc_workspaceRegistry
pkg_workspace_changes --> svc_workspaceChanges
svc_agentController --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_headless
svc_agentLoop --> pkg_base
@@ -692,7 +688,6 @@ flowchart LR
| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),提供缓存投影视图,并加速 prepared Session 的投影恢复。 |
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl), [`skill-badge`](../packages/experimental/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent`](../packages/subagent/subagent) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 |
| `ctx.agentController` | `core` | [`agent-controller`](../packages/core/agent-controller) | - | [`api-session-controller`](../packages/api/session-controller) | - | 准备发送方代表他人送来的消息,经 agent-controller/admission 阶段接纳后放入收件箱,每个 Agent 逐条处理。 |
| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Reads the default ModelSelection from volatile Config and saves selections through the profile editor. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 |
| `ctx.schedule` | `core` | [`schedule`](../packages/schedule/schedule) | - | - | - | 独立于 Session 的加载状态存储任务,并将到期消息排入原 Session。 |
+1 -2
View File
@@ -225,7 +225,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-session-controller`
- `inject`: `agentController` · `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts)
```ts config-catalog
@@ -4698,7 +4698,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
| --- | --- | --- |
| `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) |
| `@deepseek-ai/dsh-agent` | `sessionProjections` | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) |
| `@deepseek-ai/dsh-agent-controller` | `agents` | [`packages/core/agent-controller/src/index.ts`](../packages/core/agent-controller/src/index.ts) |
| `@deepseek-ai/dsh-api-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) |
| `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) |
| `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts) |
+1 -2
View File
@@ -227,7 +227,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-session-controller`
- `inject`: `agentController` · `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts)
```ts config-catalog
@@ -4700,7 +4700,6 @@ export interface Config {
| --- | --- | --- |
| `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) |
| `@deepseek-ai/dsh-agent` | `sessionProjections` | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) |
| `@deepseek-ai/dsh-agent-controller` | `agents` | [`packages/core/agent-controller/src/index.ts`](../packages/core/agent-controller/src/index.ts) |
| `@deepseek-ai/dsh-api-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) |
| `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) |
| `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts) |
@@ -15,8 +15,8 @@
en: 5fca19ac2bb1ddac
zh: 00d78fe7e80ba4f7
/cookbook-reviewing-persistence-type-changes/optional-inspect-the-change:
en: 14049617e3de750d
zh: 200456cf72bef77b
en: 841a75ed33db17bd
zh: ed4e80aae708bfb9
/cookbook-reviewing-persistence-type-changes/1-record-the-change:
en: de16b6e1ac883317
zh: 21d367bb78e799d1
@@ -43,7 +43,7 @@ A recorded decision does not hide the transition from this comparison. `--check`
Record the commits supplying those files with the report. Add `--json` for structured output. This read-only comparison groups shared changes with their affected roots and uses actual literal `kind`/`form` values instead of union positions. Ambiguous alternatives remain separate additions and removals. Its compatibility section copies every root's authoritative classifier result; the structural explanation does not replace acknowledgement checks. Current catalog labels and declaration names are descriptive metadata; structural anchors and fingerprints identify types.
The current machine inventory stores complete graphs in `roots`. A `types` entry contains `digest`, `names`, and `sources`, plus an explicit `schema` when roots cannot reconstruct that graph exactly. Readers resolve omitted graphs from root subgraphs by digest and validate explicit graphs directly, preserving every type and its metadata. Historical full entries remain readable. `formatVersion` identifies normalization rules; storage compaction does not change root fingerprints or require an acknowledgement.
Every inventory uses the [node-table layout](../persistence-changes/README.md#files-and-ownership); both comparison inputs must be generated by the current tooling. `formatVersion` identifies normalization rules; the storage layout does not change root fingerprints or require an acknowledgement.
<a id="acknowledge"></a>
## 1. Record the change
@@ -43,7 +43,7 @@ pnpm --silent run persistence-review --before .artifacts/base.schema.json --afte
在报告旁记录这些文件对应的 commit。添加 `--json` 可获取结构化输出。此只读比较将共享变更与受影响的根类型归组,使用实际字面量 `kind`/`form` 值代替联合类型位置。无法唯一匹配的候选项保留为独立的新增与删除。兼容性部分复制每个根类型的权威分类结果;结构说明不替代确认检查。当前目录标签和声明名称是描述元数据;结构锚点和指纹标识类型。
当前机器清单在 `roots` 中保存完整图。每个 `types` 条目包含 `digest`、`names` 和 `sources`;若根无法精确重建该图,条目还会保存显式 `schema`。读取器按摘要从根的子图恢复省略的图,并直接验证显式图,保留每个类型及其元数据。历史完整条目仍然可读。`formatVersion` 标识规范化规则;存储压缩不改变根指纹,也不需要确认记录。
所有清单都使用[节点表布局](../persistence-changes/README.zh.md#files-and-ownership);两个比较输入都必须由当前工具生成。`formatVersion` 标识规范化规则;存储布局不改变根指纹,也不需要确认记录。
<a id="acknowledge"></a>
## 1. 记录变更
+10 -11
View File
@@ -8,7 +8,6 @@ This matrix shows which packages dispatch each harness-owned event and which pac
<!-- BEGIN GENERATED event-producer-consumer:events -->
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-controller/admission` | `waterfall` | [`packages/core/agent-controller/src/types.ts:79`](../packages/core/agent-controller/src/types.ts) | [`agent-controller`](../packages/core/agent-controller) (`waterfall`) | - |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:194`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
@@ -23,11 +22,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:632`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:639`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:625`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:633`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
@@ -57,10 +56,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:392`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:398`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:405`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:412`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:394`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:400`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:407`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:414`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
| `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` |
@@ -83,7 +82,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:154`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
+10 -11
View File
@@ -10,7 +10,6 @@
<!-- BEGIN GENERATED event-producer-consumer:events -->
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-controller/admission` | `waterfall` | [`packages/core/agent-controller/src/types.ts:79`](../packages/core/agent-controller/src/types.ts) | [`agent-controller`](../packages/core/agent-controller) (`waterfall`) | - |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:194`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
@@ -25,11 +24,11 @@
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:632`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:639`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:625`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:633`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
@@ -59,10 +58,10 @@
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:392`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:398`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:405`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:412`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:394`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:400`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:407`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:414`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
| `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` |
@@ -85,7 +84,7 @@
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:154`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
+1 -7
View File
@@ -41,7 +41,6 @@ flowchart TD
end
subgraph group_core["packages/core"]
pkg_agent["agent"]
pkg_agent_controller["agent-controller"]
pkg_agent_default_model["agent-default-model"]
pkg_agent_loop["agent-loop"]
pkg_agent_tool_presentation["agent-tool-presentation"]
@@ -641,9 +640,6 @@ flowchart TD
pkg_llm_retry --> pkg_session
pkg_llm_retry --> pkg_session_projection
pkg_llm_retry --> pkg_timeout
pkg_agent_controller --> pkg_agent
pkg_agent_controller --> pkg_llm
pkg_agent_controller --> pkg_scope
pkg_agent_default_model --> pkg_agent
pkg_agent_default_model --> pkg_llm
pkg_goal --> pkg_agent
@@ -1276,7 +1272,6 @@ flowchart TD
pkg_tool_subagent_control --> pkg_subagent
pkg_tool_subagent_control --> pkg_tools
pkg_api_session_controller --> pkg_agent
pkg_api_session_controller --> pkg_agent_controller
pkg_api_session_controller --> pkg_agent_default_model
pkg_api_session_controller --> pkg_agent_preset_registry
pkg_api_session_controller --> pkg_api_gateway
@@ -1657,7 +1652,6 @@ flowchart TD
| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key) | `llm` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
| [`agent-controller`](../packages/core/agent-controller) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
@@ -1776,7 +1770,7 @@ flowchart TD
| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) |
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-controller`](../packages/core/agent-controller), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) |
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
+1 -7
View File
@@ -43,7 +43,6 @@ flowchart TD
end
subgraph group_core["packages/core"]
pkg_agent["agent"]
pkg_agent_controller["agent-controller"]
pkg_agent_default_model["agent-default-model"]
pkg_agent_loop["agent-loop"]
pkg_agent_tool_presentation["agent-tool-presentation"]
@@ -643,9 +642,6 @@ flowchart TD
pkg_llm_retry --> pkg_session
pkg_llm_retry --> pkg_session_projection
pkg_llm_retry --> pkg_timeout
pkg_agent_controller --> pkg_agent
pkg_agent_controller --> pkg_llm
pkg_agent_controller --> pkg_scope
pkg_agent_default_model --> pkg_agent
pkg_agent_default_model --> pkg_llm
pkg_goal --> pkg_agent
@@ -1278,7 +1274,6 @@ flowchart TD
pkg_tool_subagent_control --> pkg_subagent
pkg_tool_subagent_control --> pkg_tools
pkg_api_session_controller --> pkg_agent
pkg_api_session_controller --> pkg_agent_controller
pkg_api_session_controller --> pkg_agent_default_model
pkg_api_session_controller --> pkg_agent_preset_registry
pkg_api_session_controller --> pkg_api_gateway
@@ -1659,7 +1654,6 @@ flowchart TD
| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key) | `llm` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
| [`agent-controller`](../packages/core/agent-controller) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
@@ -1778,7 +1772,7 @@ flowchart TD
| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) |
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-controller`](../packages/core/agent-controller), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) |
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
+6 -6
View File
@@ -1347,8 +1347,8 @@
en: 2786fef560851bb4
zh: 5d787475356b2606
/session-persistence-event-catalog/resolved-persistence-types/string:
en: 324d7ebffee10aad
zh: 31f7b8a18571665a
en: 41c03b03d1bd5a44
zh: 1b0e430af6c140b3
/session-persistence-event-catalog/resolved-persistence-types/string~2:
en: 0089620bf4638263
zh: 1922bef1713094bb
@@ -1833,8 +1833,8 @@
en: 809a23c25ded8497
zh: e9cd23e281f8d31f
/session-persistence-event-catalog/resolved-persistence-types/kind-user:
en: db30e659a1cc4c06
zh: 8585ea4c8178ab37
en: 53b7abac679ee252
zh: 63eb6a015986ef6d
/session-persistence-event-catalog/resolved-persistence-types/kind-user-approval:
en: a52d2dfbaf844127
zh: 4c5306ca40f20873
@@ -1857,8 +1857,8 @@
en: 993991e8f05665bb
zh: a03931839e647bac
/session-persistence-event-catalog/resolved-persistence-types/kind-user-question-reply:
en: 77a8fc6a2f3a9319
zh: 4dc1de1358b51a8d
en: 246940ea354e5493
zh: 9f3f462ec3bc2000
/session-persistence-event-catalog/resolved-persistence-types/kind-webhook-form-notice:
en: 0b74295f7c5c8f11
zh: 5f1213cec87f6be0
+3 -3
View File
@@ -5583,7 +5583,7 @@ Array of `number`.
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
Sources: [`packages/api/session-controller/src/types.ts:412`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:406`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -7554,7 +7554,7 @@ Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/inde
SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def`
Sources: [`packages/api/session-controller/src/types.ts:421`](../packages/api/session-controller/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:415`](../packages/api/session-controller/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -7647,7 +7647,7 @@ SHA-256: `5654685d7557ec747e74887237065e626a5bdfa426ca43f7fbb0b6fee9b0916c`
SHA-256: `c55827fba7018198772c50e37cb7d3f60a3d8bb6e94b07de72dee0d9f8968005`
Sources: [`packages/interaction/user-questions/src/types.ts:117`](../packages/interaction/user-questions/src/types.ts)
Sources: [`packages/interaction/user-questions/src/types.ts:134`](../packages/interaction/user-questions/src/types.ts)
| Property | Presence | Type |
|---|---|---|
+3 -3
View File
@@ -5585,7 +5585,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78`
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
来源:[`packages/api/session-controller/src/types.ts:412`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:406`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -7556,7 +7556,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543`
SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def`
来源:[`packages/api/session-controller/src/types.ts:421`](../packages/api/session-controller/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:415`](../packages/api/session-controller/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -7649,7 +7649,7 @@ SHA-256: `5654685d7557ec747e74887237065e626a5bdfa426ca43f7fbb0b6fee9b0916c`
SHA-256: `c55827fba7018198772c50e37cb7d3f60a3d8bb6e94b07de72dee0d9f8968005`
来源:[`packages/interaction/user-questions/src/types.ts:117`](../packages/interaction/user-questions/src/types.ts)
来源:[`packages/interaction/user-questions/src/types.ts:134`](../packages/interaction/user-questions/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
File diff suppressed because it is too large. Load diff
@@ -1,910 +1,58 @@
{
"formatVersion": 1,
"roots": [
{
"key": "event:tool/ptc-dispatch",
"kind": "event",
"event": "tool/ptc-dispatch",
"surface": false,
"digest": "b5d66eaebed4da391b13498623b11142222149fbf5e025975dbc6d94f0d06796",
"schema": {
"root": 0,
"nodes": [
{
"kind": "object",
"properties": [
{
"name": "data",
"type": 1,
"optional": false
},
{
"name": "ignorable",
"type": 29,
"optional": true
},
{
"name": "seq",
"type": 17,
"optional": false
},
{
"name": "time",
"type": 17,
"optional": false
},
{
"name": "type",
"type": 30,
"optional": false
}
],
"indices": []
},
{
"kind": "object",
"properties": [
{
"name": "arguments",
"type": 2,
"optional": false
},
{
"name": "content",
"type": 3,
"optional": false
},
{
"name": "error",
"type": 28,
"optional": true
},
{
"name": "isError",
"type": 6,
"optional": false
},
{
"name": "name",
"type": 7,
"optional": false
},
{
"name": "parentCallId",
"type": 7,
"optional": false
},
{
"name": "rootCallId",
"type": 7,
"optional": false
},
{
"name": "subCallId",
"type": 7,
"optional": false
}
],
"indices": []
},
{
"kind": "opaque",
"reason": "unknown"
},
{
"kind": "array",
"element": 4
},
{
"kind": "union",
"types": [
5,
9,
11,
13,
15,
25
]
},
{
"kind": "object",
"properties": [
{
"name": "content",
"type": 3,
"optional": false
},
{
"name": "isError",
"type": 6,
"optional": true
},
{
"name": "toolCallId",
"type": 7,
"optional": false
},
{
"name": "type",
"type": 8,
"optional": false
}
],
"indices": []
},
{
"kind": "primitive",
"type": "boolean"
},
{
"kind": "primitive",
"type": "string"
},
{
"kind": "literal",
"value": "tool-result"
},
{
"kind": "object",
"properties": [
{
"name": "text",
"type": 7,
"optional": false
},
{
"name": "type",
"type": 10,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "reasoning"
},
{
"kind": "object",
"properties": [
{
"name": "text",
"type": 7,
"optional": false
},
{
"name": "type",
"type": 12,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "text"
},
{
"kind": "object",
"properties": [
{
"name": "arguments",
"type": 7,
"optional": false
},
{
"name": "id",
"type": 7,
"optional": false
},
{
"name": "name",
"type": 7,
"optional": false
},
{
"name": "type",
"type": 14,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "tool-call"
},
{
"kind": "object",
"properties": [
{
"name": "attachment",
"type": 16,
"optional": false
},
{
"name": "type",
"type": 24,
"optional": false
}
],
"indices": []
},
{
"kind": "object",
"properties": [
{
"name": "attachmentId",
"type": 7,
"optional": false
},
{
"name": "bytes",
"type": 17,
"optional": false
},
{
"name": "height",
"type": 17,
"optional": false
},
{
"name": "mediaType",
"type": 18,
"optional": false
},
{
"name": "name",
"type": 7,
"optional": true
},
{
"name": "originalDimensions",
"type": 23,
"optional": true
},
{
"name": "width",
"type": 17,
"optional": false
}
],
"indices": []
},
{
"kind": "primitive",
"type": "number"
},
{
"kind": "union",
"types": [
19,
20,
21,
22
]
},
{
"kind": "literal",
"value": "image/gif"
},
{
"kind": "literal",
"value": "image/jpeg"
},
{
"kind": "literal",
"value": "image/png"
},
{
"kind": "literal",
"value": "image/webp"
},
{
"kind": "object",
"properties": [
{
"name": "height",
"type": 17,
"optional": false
},
{
"name": "width",
"type": 17,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "image"
},
{
"kind": "object",
"properties": [
{
"name": "attachment",
"type": 26,
"optional": false
},
{
"name": "type",
"type": 27,
"optional": false
}
],
"indices": []
},
{
"kind": "object",
"properties": [
{
"name": "attachmentId",
"type": 7,
"optional": false
},
{
"name": "bytes",
"type": 17,
"optional": false
},
{
"name": "name",
"type": 7,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "file"
},
{
"kind": "object",
"properties": [
{
"name": "code",
"type": 7,
"optional": false
},
{
"name": "name",
"type": 7,
"optional": false
},
{
"name": "reason",
"type": 7,
"optional": true
}
],
"indices": []
},
{
"kind": "literal",
"value": true
},
{
"kind": "literal",
"value": "tool/ptc-dispatch"
}
]
}
},
{
"key": "event:tool/result",
"kind": "event",
"event": "tool/result",
"surface": true,
"digest": "3a803805bdeb805f32b229e399fb7258be8e32a89f89063a7c99957cfea942f7",
"schema": {
"root": 0,
"nodes": [
{
"kind": "object",
"properties": [
{
"name": "data",
"type": 1,
"optional": false
},
{
"name": "ignorable",
"type": 37,
"optional": true
},
{
"name": "seq",
"type": 17,
"optional": false
},
{
"name": "sourceEventSeqs",
"type": 38,
"optional": true
},
{
"name": "surfaceOp",
"type": 39,
"optional": false
},
{
"name": "time",
"type": 17,
"optional": false
},
{
"name": "type",
"type": 43,
"optional": false
}
],
"indices": []
},
{
"kind": "object",
"properties": [
{
"name": "error",
"type": 2,
"optional": true
},
{
"name": "message",
"type": 4,
"optional": false
},
{
"name": "meta",
"type": 33,
"optional": true
},
{
"name": "step",
"type": 17,
"optional": false
},
{
"name": "turn",
"type": 17,
"optional": false
}
],
"indices": []
},
{
"kind": "object",
"properties": [
{
"name": "code",
"type": 3,
"optional": false
},
{
"name": "name",
"type": 3,
"optional": false
},
{
"name": "reason",
"type": 3,
"optional": true
}
],
"indices": []
},
{
"kind": "primitive",
"type": "string"
},
{
"kind": "object",
"properties": [
{
"name": "content",
"type": 5,
"optional": false
},
{
"name": "id",
"type": 3,
"optional": false
},
{
"name": "role",
"type": 30,
"optional": false
},
{
"name": "source",
"type": 31,
"optional": false
}
],
"indices": []
},
{
"kind": "tuple",
"elements": [
{
"type": 6,
"optional": false,
"rest": false
}
]
},
{
"kind": "object",
"properties": [
{
"name": "content",
"type": 7,
"optional": false
},
{
"name": "isError",
"type": 28,
"optional": true
},
{
"name": "toolCallId",
"type": 3,
"optional": false
},
{
"name": "type",
"type": 29,
"optional": false
}
],
"indices": []
},
{
"kind": "array",
"element": 8
},
{
"kind": "union",
"types": [
6,
9,
11,
13,
15,
25
]
},
{
"kind": "object",
"properties": [
{
"name": "text",
"type": 3,
"optional": false
},
{
"name": "type",
"type": 10,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "text"
},
{
"kind": "object",
"properties": [
{
"name": "text",
"type": 3,
"optional": false
},
{
"name": "type",
"type": 12,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "reasoning"
},
{
"kind": "object",
"properties": [
{
"name": "arguments",
"type": 3,
"optional": false
},
{
"name": "id",
"type": 3,
"optional": false
},
{
"name": "name",
"type": 3,
"optional": false
},
{
"name": "type",
"type": 14,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "tool-call"
},
{
"kind": "object",
"properties": [
{
"name": "attachment",
"type": 16,
"optional": false
},
{
"name": "type",
"type": 24,
"optional": false
}
],
"indices": []
},
{
"kind": "object",
"properties": [
{
"name": "attachmentId",
"type": 3,
"optional": false
},
{
"name": "bytes",
"type": 17,
"optional": false
},
{
"name": "height",
"type": 17,
"optional": false
},
{
"name": "mediaType",
"type": 18,
"optional": false
},
{
"name": "name",
"type": 3,
"optional": true
},
{
"name": "originalDimensions",
"type": 23,
"optional": true
},
{
"name": "width",
"type": 17,
"optional": false
}
],
"indices": []
},
{
"kind": "primitive",
"type": "number"
},
{
"kind": "union",
"types": [
19,
20,
21,
22
]
},
{
"kind": "literal",
"value": "image/gif"
},
{
"kind": "literal",
"value": "image/jpeg"
},
{
"kind": "literal",
"value": "image/png"
},
{
"kind": "literal",
"value": "image/webp"
},
{
"kind": "object",
"properties": [
{
"name": "height",
"type": 17,
"optional": false
},
{
"name": "width",
"type": 17,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "image"
},
{
"kind": "object",
"properties": [
{
"name": "attachment",
"type": 26,
"optional": false
},
{
"name": "type",
"type": 27,
"optional": false
}
],
"indices": []
},
{
"kind": "object",
"properties": [
{
"name": "attachmentId",
"type": 3,
"optional": false
},
{
"name": "bytes",
"type": 17,
"optional": false
},
{
"name": "name",
"type": 3,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "file"
},
{
"kind": "primitive",
"type": "boolean"
},
{
"kind": "literal",
"value": "tool-result"
},
{
"kind": "literal",
"value": "user"
},
{
"kind": "object",
"properties": [
{
"name": "callId",
"type": 3,
"optional": false
},
{
"name": "kind",
"type": 32,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "tool"
},
{
"kind": "union",
"types": [
3,
17,
28,
34,
35,
36
]
},
{
"kind": "array",
"element": 33
},
{
"kind": "primitive",
"type": "null"
},
{
"kind": "object",
"properties": [],
"indices": [
{
"key": 3,
"value": 33
}
]
},
{
"kind": "literal",
"value": true
},
{
"kind": "array",
"element": 17
},
{
"kind": "union",
"types": [
40,
41
]
},
{
"kind": "literal",
"value": "append"
},
{
"kind": "object",
"properties": [
{
"name": "endSeq",
"type": 17,
"optional": false
},
{
"name": "op",
"type": 42,
"optional": false
},
{
"name": "startSeq",
"type": 17,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "replace"
},
{
"kind": "literal",
"value": "tool/result"
}
]
}
}
{"key":"event:tool/ptc-dispatch","kind":"event","event":"tool/ptc-dispatch","surface":false,"digest":"b5d66eaebed4da391b13498623b11142222149fbf5e025975dbc6d94f0d06796"},
{"key":"event:tool/result","kind":"event","event":"tool/result","surface":true,"digest":"3a803805bdeb805f32b229e399fb7258be8e32a89f89063a7c99957cfea942f7"}
],
"types": []
"types": [],
"nodes": {
"0657d3ca5ac4e7bf": {"kind":"primitive","type":"number"},
"0e560aad8fa9f338": {"kind":"array","element":"e1161343902a8da6"},
"0f46153645c29784": {"kind":"object","properties":[{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"1f0133217eeeffd8": {"kind":"literal","value":"tool-call"},
"277b61f9c6f81704": {"kind":"literal","value":"file"},
"28aa673866746f44": {"kind":"object","properties":[{"name":"error","type":"a81059ca06999da1","optional":true},{"name":"message","type":"966fad765834f620","optional":false},{"name":"meta","type":"c592ce75aab73fca","optional":true},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"2f1e6a7ebace43d7": {"kind":"union","types":["3f74ba74acd10644","58f94390b982f8a5","de30b1fa12c75f81","ebb768d85ec87fb6"]},
"335e242de1fcc17b": {"kind":"union","types":["bcf0caf62d964b2f","e438a9d4fb883b3e"]},
"3a803805bdeb805f": {"kind":"object","properties":[{"name":"data","type":"28aa673866746f44","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"570e850224346133","optional":false}],"indices":[]},
"3c2535ca0a2062f1": {"kind":"literal","value":"reasoning"},
"3f74ba74acd10644": {"kind":"literal","value":"image/jpeg"},
"4c12ebe118c00be8": {"kind":"object","properties":[{"name":"arguments","type":"bbc5696e3f13c758","optional":false},{"name":"content","type":"0e560aad8fa9f338","optional":false},{"name":"error","type":"a81059ca06999da1","optional":true},{"name":"isError","type":"aa93010d0e521ad7","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"parentCallId","type":"4cd48f3e5108bd6e","optional":false},{"name":"rootCallId","type":"4cd48f3e5108bd6e","optional":false},{"name":"subCallId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"4cd48f3e5108bd6e": {"kind":"primitive","type":"string"},
"4e44c9118152f682": {"kind":"literal","value":"text"},
"4e88776858652856": {"kind":"object","properties":[{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"4e44c9118152f682","optional":false}],"indices":[]},
"529e0ccd0e34079a": {"kind":"object","properties":[],"indices":[{"key":"4cd48f3e5108bd6e","value":"c592ce75aab73fca"}]},
"570e850224346133": {"kind":"literal","value":"tool/result"},
"58f94390b982f8a5": {"kind":"literal","value":"image/png"},
"5d03ba38734809bc": {"kind":"array","element":"0657d3ca5ac4e7bf"},
"5eedf09e88b5ed9c": {"kind":"tuple","elements":[{"type":"9bdd77f2b39d9ae4","optional":false,"rest":false}]},
"68f44f59e91c4139": {"kind":"literal","value":"user"},
"72ec79127a9c0d02": {"kind":"array","element":"c592ce75aab73fca"},
"814d9434e1ddf907": {"kind":"object","properties":[{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"3c2535ca0a2062f1","optional":false}],"indices":[]},
"8ef06dd236128246": {"kind":"object","properties":[{"name":"attachment","type":"b0b0eb1b16490f6b","optional":false},{"name":"type","type":"fb8b6a2e53cb9cf4","optional":false}],"indices":[]},
"938f7c7357bc403e": {"kind":"primitive","type":"null"},
"966fad765834f620": {"kind":"object","properties":[{"name":"content","type":"5eedf09e88b5ed9c","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"68f44f59e91c4139","optional":false},{"name":"source","type":"d9c462b9f62fdd3e","optional":false}],"indices":[]},
"9bdd77f2b39d9ae4": {"kind":"object","properties":[{"name":"content","type":"0e560aad8fa9f338","optional":false},{"name":"isError","type":"aa93010d0e521ad7","optional":true},{"name":"toolCallId","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"e5244812c495a80c","optional":false}],"indices":[]},
"a13d8f379222ece8": {"kind":"literal","value":"tool"},
"a81059ca06999da1": {"kind":"object","properties":[{"name":"code","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"reason","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]},
"aa93010d0e521ad7": {"kind":"primitive","type":"boolean"},
"b0b0eb1b16490f6b": {"kind":"object","properties":[{"name":"attachmentId","type":"4cd48f3e5108bd6e","optional":false},{"name":"bytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"mediaType","type":"2f1e6a7ebace43d7","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":true},{"name":"originalDimensions","type":"0f46153645c29784","optional":true},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"b5d66eaebed4da39": {"kind":"object","properties":[{"name":"data","type":"4c12ebe118c00be8","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"fb5e0517050d2c6d","optional":false}],"indices":[]},
"bbc5696e3f13c758": {"kind":"opaque","reason":"unknown"},
"bcf0caf62d964b2f": {"kind":"object","properties":[{"name":"endSeq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"op","type":"f6cb462f808309e3","optional":false},{"name":"startSeq","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"c1f0f17b6493b85d": {"kind":"object","properties":[{"name":"arguments","type":"4cd48f3e5108bd6e","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"1f0133217eeeffd8","optional":false}],"indices":[]},
"c592ce75aab73fca": {"kind":"union","types":["0657d3ca5ac4e7bf","4cd48f3e5108bd6e","529e0ccd0e34079a","72ec79127a9c0d02","938f7c7357bc403e","aa93010d0e521ad7"]},
"d9c462b9f62fdd3e": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"a13d8f379222ece8","optional":false}],"indices":[]},
"dacf3d5f3e4ff619": {"kind":"literal","value":true},
"de30b1fa12c75f81": {"kind":"literal","value":"image/gif"},
"e1161343902a8da6": {"kind":"union","types":["4e88776858652856","814d9434e1ddf907","8ef06dd236128246","9bdd77f2b39d9ae4","c1f0f17b6493b85d","f2da0f27c1e6add5"]},
"e438a9d4fb883b3e": {"kind":"literal","value":"append"},
"e5244812c495a80c": {"kind":"literal","value":"tool-result"},
"ebb768d85ec87fb6": {"kind":"literal","value":"image/webp"},
"f19bee84bf3d2090": {"kind":"object","properties":[{"name":"attachmentId","type":"4cd48f3e5108bd6e","optional":false},{"name":"bytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"f2da0f27c1e6add5": {"kind":"object","properties":[{"name":"attachment","type":"f19bee84bf3d2090","optional":false},{"name":"type","type":"277b61f9c6f81704","optional":false}],"indices":[]},
"f6cb462f808309e3": {"kind":"literal","value":"replace"},
"fb5e0517050d2c6d": {"kind":"literal","value":"tool/ptc-dispatch"},
"fb8b6a2e53cb9cf4": {"kind":"literal","value":"image"}
}
}
@@ -1,76 +1,15 @@
{
"formatVersion": 1,
"roots": [
{
"key": "event:working-directory/change",
"kind": "event",
"event": "working-directory/change",
"surface": false,
"digest": "124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3",
"schema": {
"root": 0,
"nodes": [
{
"kind": "object",
"properties": [
{
"name": "data",
"type": 1,
"optional": false
},
{
"name": "ignorable",
"type": 3,
"optional": true
},
{
"name": "seq",
"type": 4,
"optional": false
},
{
"name": "time",
"type": 4,
"optional": false
},
{
"name": "type",
"type": 5,
"optional": false
}
],
"indices": []
},
{
"kind": "object",
"properties": [
{
"name": "cwd",
"type": 2,
"optional": false
}
],
"indices": []
},
{
"kind": "primitive",
"type": "string"
},
{
"kind": "literal",
"value": true
},
{
"kind": "primitive",
"type": "number"
},
{
"kind": "literal",
"value": "working-directory/change"
}
]
}
}
{"key":"event:working-directory/change","kind":"event","event":"working-directory/change","surface":false,"digest":"124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3"}
],
"types": []
"types": [],
"nodes": {
"0657d3ca5ac4e7bf": {"kind":"primitive","type":"number"},
"124aa62078ca3311": {"kind":"object","properties":[{"name":"data","type":"da60ed58a8fa23d7","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"d4c799e3d51cae10","optional":false}],"indices":[]},
"4cd48f3e5108bd6e": {"kind":"primitive","type":"string"},
"d4c799e3d51cae10": {"kind":"literal","value":"working-directory/change"},
"da60ed58a8fa23d7": {"kind":"object","properties":[{"name":"cwd","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"dacf3d5f3e4ff619": {"kind":"literal","value":true}
}
}
File diff suppressed because it is too large. Load diff
Loaded 100 of 558 files, more files were not shown because too many files have changed in this diff. Show more